feat(web): 任务中心全局 Provider / 设置·布局·路由调整 / 视频解析页适配

- tasks.tsx:顶栏任务中心全局 Provider(SSE 任务进度,供各插件页共享)
- 设置页与 AppLayout / client / router 调整,BF2042 插件页路由注册
- web_hub:dashboard / bot_settings 同步更新
- 视频解析页配合后端(缓存 tab / 任务槽进度)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:27 +08:00
1 parent 6af60a7d55
commit 968b3d1cfe
10 files changed
+803 -8

No files matched your search

+21
View File
@@ -58,6 +58,12 @@ export const hubPlugins = async () => await request('/hub/api/plugins')
export const hubDashboard = async () => await request('/hub/api/dashboard') export const hubDashboard = async () => await request('/hub/api/dashboard')
export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog') export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog')
export const hubGroups = async () => await request('/hub/api/groups') export const hubGroups = async () => await request('/hub/api/groups')
// 请求取消一个在跑的任务(协作式:任务体在下一个检查点收尾,状态走 SSE 回来)
export const hubTaskCancel = async (id: string) => await request(`/hub/api/tasks/${id}/cancel`, { method: 'POST' })
// 从任务列表里删掉一条已结束的任务(在跑的服务端会拒绝)
export const hubTaskDismiss = async (id: string) => await request(`/hub/api/tasks/${id}/dismiss`, { method: 'POST' })
// 清掉所有已结束的任务
export const hubTasksClear = async () => await request('/hub/api/tasks/clear', { method: 'POST' })
export const hubPluginSetGlobal = async (id: string, body: any) => export const hubPluginSetGlobal = async (id: string, body: any) =>
await request('/hub/api/plugins/' + encodeURIComponent(id) + '/global', { method: 'POST', body }) await request('/hub/api/plugins/' + encodeURIComponent(id) + '/global', { method: 'POST', body })
export const hubPluginSetGroup = async (id: string, gid: string, body: any) => export const hubPluginSetGroup = async (id: string, gid: string, body: any) =>
@@ -160,6 +166,21 @@ export function hubLogStream(opts: {
}) })
} }
/** 任务槽 SSE:每帧都是全量任务快照(服务端按百分比桶节流后才推)。 */
export function hubTasksStream(opts: {
onTasks: (tasks: any[]) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void {
return makeSSEStream({
url: () => '/hub/api/tasks/stream',
onData: (obj) => {
if (obj && Array.isArray(obj.tasks)) opts.onTasks(obj.tasks)
},
onStatus: opts.onStatus,
retryMs: 2000,
})
}
export function hubDashboardStream(opts: { export function hubDashboardStream(opts: {
onData: (data: any) => void onData: (data: any) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
+2
View File
@@ -11,6 +11,7 @@ import { useAuth } from '../auth/auth'
import { hubPlugins } from '../api/client' import { hubPlugins } from '../api/client'
import { ErrorBoundary } from '../components/ErrorBoundary' import { ErrorBoundary } from '../components/ErrorBoundary'
import { useConnection } from '../connection' import { useConnection } from '../connection'
import { TaskCenterButton } from '../tasks'
import { BOT_ACTION_TEXT } from '../lib/botAction' import { BOT_ACTION_TEXT } from '../lib/botAction'
import { BotActionOverlay, useBotAction } from '../botAction' import { BotActionOverlay, useBotAction } from '../botAction'
@@ -197,6 +198,7 @@ export function AppLayout() {
)} )}
</div> </div>
<div className="flex shrink-0 items-center gap-2 md:gap-3"> <div className="flex shrink-0 items-center gap-2 md:gap-3">
<TaskCenterButton />
<span <span
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'} title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '已连接' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')} className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '已连接' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
+23
View File
@@ -20,6 +20,8 @@ interface BotConfigData {
log_level_options: string[] log_level_options: string[]
log_max_mb: number log_max_mb: number
log_max_mb_default: number log_max_mb_default: number
media_mode: string
media_mode_options: string[]
source: string source: string
notice?: string notice?: string
applied?: string[] applied?: string[]
@@ -27,6 +29,12 @@ interface BotConfigData {
} }
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const MEDIA_MODES = ['http', 'base64', 'file']
const MEDIA_MODE_HINTS: Record<string, string> = {
http: '协议端按 /media 链接来拉(推荐,跨机部署)',
base64: '字节内联在消息里(协议端拨不通本机时用,体积膨胀 1/3)',
file: '不改写、直接给本地路径(只有协议端就在本机时才正确)',
}
const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none' const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
export default function SettingsPage() { export default function SettingsPage() {
@@ -49,6 +57,7 @@ export default function SettingsPage() {
const [cfgDebug, setCfgDebug] = useState(false) const [cfgDebug, setCfgDebug] = useState(false)
const [cfgLogLevel, setCfgLogLevel] = useState('INFO') const [cfgLogLevel, setCfgLogLevel] = useState('INFO')
const [cfgLogMaxMb, setCfgLogMaxMb] = useState('20') const [cfgLogMaxMb, setCfgLogMaxMb] = useState('20')
const [cfgMediaMode, setCfgMediaMode] = useState('http')
const [cfgBusy, setCfgBusy] = useState(false) const [cfgBusy, setCfgBusy] = useState(false)
const [cfgLoaded, setCfgLoaded] = useState(false) const [cfgLoaded, setCfgLoaded] = useState(false)
@@ -68,6 +77,7 @@ export default function SettingsPage() {
setCfgDebug(!!c.debug) setCfgDebug(!!c.debug)
setCfgLogLevel(c.log_level || 'INFO') setCfgLogLevel(c.log_level || 'INFO')
setCfgLogMaxMb(String(c.log_max_mb ?? c.log_max_mb_default ?? 20)) setCfgLogMaxMb(String(c.log_max_mb ?? c.log_max_mb_default ?? 20))
setCfgMediaMode(c.media_mode || 'http')
setCfgLoaded(true) setCfgLoaded(true)
} catch (e: any) { } catch (e: any) {
setCfgLoaded(false) setCfgLoaded(false)
@@ -103,6 +113,7 @@ export default function SettingsPage() {
nickname: (cfgNickname || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean), nickname: (cfgNickname || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean),
debug: cfgDebug, debug: cfgDebug,
log_level: cfgLogLevel, log_level: cfgLogLevel,
media_mode: cfgMediaMode,
} }
const logMaxMbRaw = cfgLogMaxMb.trim() const logMaxMbRaw = cfgLogMaxMb.trim()
if (logMaxMbRaw !== '') { if (logMaxMbRaw !== '') {
@@ -117,6 +128,7 @@ export default function SettingsPage() {
setCfgDebug(!!r.debug) setCfgDebug(!!r.debug)
setCfgLogLevel(r.log_level || 'INFO') setCfgLogLevel(r.log_level || 'INFO')
setCfgLogMaxMb(String(r.log_max_mb ?? r.log_max_mb_default ?? 20)) setCfgLogMaxMb(String(r.log_max_mb ?? r.log_max_mb_default ?? 20))
setCfgMediaMode(r.media_mode || 'http')
toast.success('BOT 配置已保存') toast.success('BOT 配置已保存')
if (r.notice) toast.info(r.notice) if (r.notice) toast.info(r.notice)
} catch (e: any) { } catch (e: any) {
@@ -167,6 +179,7 @@ export default function SettingsPage() {
SUPERUSERS / NICKNAME 支持逗号或空格分隔;保存后会写入 .env 并尽量热应用。 SUPERUSERS / NICKNAME 支持逗号或空格分隔;保存后会写入 .env 并尽量热应用。
部分插件在启动时缓存了这些值,完全生效建议保存后重启 bot。 部分插件在启动时缓存了这些值,完全生效建议保存后重启 bot。
日志清理大小保存后仅在下一个检查周期(约 1 分钟)内生效,无需重启。 日志清理大小保存后仅在下一个检查周期(约 1 分钟)内生效,无需重启。
媒体发送方式保存后立即生效,无需重启。
</p> </p>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<TextField> <TextField>
@@ -203,6 +216,16 @@ export default function SettingsPage() {
<Input type="number" min={1} value={cfgLogMaxMb} onChange={e => setCfgLogMaxMb(e.target.value)} placeholder="如: 20" /> <Input type="number" min={1} value={cfgLogMaxMb} onChange={e => setCfgLogMaxMb(e.target.value)} placeholder="如: 20" />
<span className="text-xs text-slate-400">_bot_run.log 超过该大小即清空(不归档)</span> <span className="text-xs text-slate-400">_bot_run.log 超过该大小即清空(不归档)</span>
</TextField> </TextField>
<div>
<Label>媒体发送方式</Label>
<Select selectedKey={cfgMediaMode} onSelectionChange={(k) => setCfgMediaMode(String(k))}>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover><ListBox>
{(botCfg?.media_mode_options || MEDIA_MODES).map(o => <ListBox.Item key={o} id={o}>{o}</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
<span className="text-xs text-slate-400">{MEDIA_MODE_HINTS[cfgMediaMode] || '图片/视频/文件的发送方式'}</span>
</div>
</div> </div>
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex items-center gap-3">
<Button isDisabled={cfgBusy || !cfgLoaded || !!botPhase} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button> <Button isDisabled={cfgBusy || !cfgLoaded || !!botPhase} onPress={saveBotConfig}>{cfgBusy ? '保存中…' : '保存 BOT 配置'}</Button>
+254 -4
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Button, Card, Chip, Drawer, Input, Label, ListBox, Select, Spinner, Switch, TextArea, TextField, Button, Card, Chip, Drawer, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextArea,
Tooltip, TooltipContent, toast, useOverlayState, TextField, Tooltip, TooltipContent, toast, useOverlayState,
} from '@heroui/react' } from '@heroui/react'
import { ArrowDownToLine, ArrowUpRightFromSquare, CirclePlay, TrashBin, Xmark } from '@gravity-ui/icons' import { ArrowDownToLine, ArrowUpRightFromSquare, CirclePlay, TrashBin, Xmark } from '@gravity-ui/icons'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
@@ -16,7 +16,8 @@ import 'yet-another-react-lightbox/styles.css'
import 'yet-another-react-lightbox/plugins/captions.css' import 'yet-another-react-lightbox/plugins/captions.css'
import 'yet-another-react-lightbox/plugins/counter.css' import 'yet-another-react-lightbox/plugins/counter.css'
import 'yet-another-react-lightbox/plugins/thumbnails.css' import 'yet-another-react-lightbox/plugins/thumbnails.css'
import { api, hubGroups } from '../../api/client' import { api, hubGroups, hubTaskCancel } from '../../api/client'
import { taskOrigin, useTasks } from '../../tasks'
import { PageHead, Tabs, selectCls, tdCls, thCls, type TabItem } from '../../components/ui' import { PageHead, Tabs, selectCls, tdCls, thCls, type TabItem } from '../../components/ui'
import { fmtBytes, fmtTime } from '../../lib/format' import { fmtBytes, fmtTime } from '../../lib/format'
@@ -140,10 +141,11 @@ function toSlide(file: JobFile, src: string): Slide {
return { src, alt: file.name, title } return { src, alt: file.name, title }
} }
type MainTab = 'parse' | 'policy' type MainTab = 'parse' | 'policy' | 'temp'
const MAIN_TABS: TabItem[] = [ const MAIN_TABS: TabItem[] = [
{ key: 'parse', label: '链接解析' }, { key: 'parse', label: '链接解析' },
{ key: 'policy', label: '群策略' }, { key: 'policy', label: '群策略' },
{ key: 'temp', label: '缓存' },
] ]
export default function VideoAnalysisPage() { export default function VideoAnalysisPage() {
@@ -164,6 +166,7 @@ export default function VideoAnalysisPage() {
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
{tab === 'parse' && <ParseView />} {tab === 'parse' && <ParseView />}
{tab === 'policy' && <PolicyView />} {tab === 'policy' && <PolicyView />}
{tab === 'temp' && <TempView />}
</div> </div>
</div> </div>
) )
@@ -504,6 +507,253 @@ function BlacklistCard({ ov, onSaved }: { ov: Overview; onSaved: () => Promise<v
} }
interface TempStats {
files: number
bytes: number
dirs: number
mode: 'auto' | 'manual'
max_age_days: number
hour: number
}
/** 与 cleanup.py 的 CLEANUP_SLOT 对齐;命令/定时/Web 三条路共用同一个槽。 */
const CLEANUP_SLOT = 'video_analysis:temp_cleanup'
/** 缓存:temp 目录占用 + 手动清理(与群里的 /清理temp、每日定时任务同一套逻辑)。
*
* 清理是几十秒级的阻塞任务,提交后立刻返回,进度由 hub 的任务槽 SSE 推过来;
* 同槽只允许一个任务(服务端去重),所以按钮在运行期间禁用只是"礼貌",不是保证。
*/
function TempView() {
const [st, setSt] = useState<TempStats | null>(null)
const [days, setDays] = useState('7')
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const wasRunning = useRef(false)
const confirmState = useOverlayState()
// 任务状态来自顶栏任务的全局 provider(同一条 SSE,别自己再订阅一条)
const { tasks } = useTasks()
const task = useMemo(
() => tasks.find(t => t.slot === CLEANUP_SLOT) || null,
[tasks],
)
const load = useCallback(async () => {
setLoading(true)
try {
const d: TempStats = data(await va.get('temp/stats'))
setSt(d)
setDays(String(d?.max_age_days ?? 7))
} catch (e: any) {
toast.danger(e.message || '缓存信息加载失败')
} finally { setLoading(false) }
}, [])
useEffect(() => { void load() }, [load])
// 「运行中 → 终态」这一次跳变才刷统计 + 弹提示(页面打开时带出的历史终态任务
// 不该再弹一次 toast,所以只认跳变)。
useEffect(() => {
const running = !!task?.active
if (wasRunning.current && !running && task) {
void load()
const removed = (task.result as [number, number] | undefined)?.[0] ?? 0
if (task.status === 'failed') toast.danger(`清理失败:${task.error || '未知错误'}`)
else if (task.status === 'canceled') toast.info(`清理已取消(已删除 ${removed} 个)`)
else toast.success(`已删除 ${removed} 个文件`)
}
wasRunning.current = running
}, [task, load])
const clean = async (rawDays: number) => {
setBusy(true)
try {
const d = data(await va.post('temp/cleanup', { days: rawDays }))
// 任务状态由全局 SSE 推来(这里不再本地维护一份),提交成功即视为已开始
if (d?.task) wasRunning.current = true
if (d?.reused) toast.info('已有清理任务在运行,进度见下方')
else toast.success('清理已开始,进度实时更新')
} catch (e: any) {
toast.danger(e.message || '清理提交失败')
} finally { setBusy(false) }
}
const cleanByDays = () => {
const n = parseInt(days, 10)
if (!(n >= 0)) { toast.warning('保留天数需为不小于 0 的整数'); return }
void clean(n)
}
const confirmClear = async () => {
confirmState.close()
await clean(0)
}
const cancelTask = async () => {
if (!task) return
try {
await hubTaskCancel(task.id)
toast.info('已请求取消,任务会在当前文件处理完后收尾')
} catch (e: any) {
toast.danger(e.message || '取消失败')
}
}
const isRunning = !!task?.active
const p = task?.progress
// 正在写入的文件 mtime 很新,两种清理都不会碰(见 cleanup.py 的 60s 目录下限)
const autoText = st
? st.mode === 'manual'
? '自动清理:已关闭(manual,仅手动清理)'
: `自动清理:每天 ${st.hour}:00 清理 ${st.max_age_days} 天前的文件`
: '自动清理:读取中…'
return (
<div className="space-y-4">
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>
缓存占用
<span className="ml-2 text-xs font-normal text-slate-400">
hexi/data/temp —— 下载的中转区,发送后仍保留供取用(含各插件的子目录)
</span>
</Card.Title>
</Card.Header>
<Card.Content className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
{([['文件', st ? String(st.files) : '—'],
['占用', st ? fmtBytes(st.bytes) : '—'],
['目录', st ? String(st.dirs) : '—']] as const).map(([label, value]) => (
<div key={label} className="rounded-xl border border-slate-200 px-4 py-3">
<div className="text-xs text-slate-400">{label}</div>
<div className="mt-1 text-xl font-semibold text-slate-800">{value}</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-slate-400">{autoText}(在插件配置页可改)</span>
<Button variant="tertiary" isDisabled={loading || busy} onPress={() => void load()}>
{loading ? '加载中…' : '刷新'}
</Button>
</div>
</Card.Content>
</Card>
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>
手动清理
<span className="ml-2 text-xs font-normal text-slate-400">
正在写入的文件不会被误删;空目录会被一并收掉
</span>
</Card.Title>
</Card.Header>
<Card.Content className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="w-36">
<TextField>
<Label>保留天数</Label>
<Input type="number" min={0} value={days} onChange={e => setDays(e.target.value)} />
</TextField>
</div>
<Button isDisabled={busy || loading || isRunning} onPress={cleanByDays}>
{isRunning ? '清理中…' : '清理过期文件'}
</Button>
<Button
variant="tertiary"
className="text-red-500"
isDisabled={busy || loading || isRunning || (st?.files ?? 0) === 0}
onPress={confirmState.open}
>
全部清空
</Button>
</div>
{task && (
<div className="rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-slate-600">
{task.title}
{p?.phase ? ` · ${p.phase}` : ''}
<span className="ml-2 text-slate-400">
{taskOrigin(task)}
{task.actor ? ` · ${task.actor}` : ''}
</span>
</span>
<span className="shrink-0 text-slate-400">
{task.active
? `${p?.percent == null ? '…' : p.percent + '%'}${p?.total ? ` (${p.done}/${p.total}${p.unit})` : ''} · 已运行 ${task.duration.toFixed(0)}s`
: task.status === 'done'
? `完成 · 用时 ${task.duration.toFixed(0)}s`
: task.status === 'canceled' ? '已取消' : '失败'}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-100">
<div
className={
'h-full transition-all ' +
(task.status === 'failed'
? 'bg-red-400'
: task.status === 'canceled' ? 'bg-slate-300' : 'bg-indigo-500')
}
style={{ width: p?.percent == null ? (task.active ? '100%' : '0%') : p.percent + '%' }}
/>
</div>
<div className="mt-1 flex items-center justify-between gap-3">
<span className="text-xs text-slate-400">
{task.status === 'failed'
? task.error || '未知错误'
: task.status === 'canceled'
? `已删除 ${(task.result as [number, number] | undefined)?.[0] ?? 0} 个后取消`
: p?.message || ''}
</span>
{task.active && (
<Button size="sm" variant="tertiary" isDisabled={task.cancel_requested} onPress={cancelTask}>
{task.cancel_requested ? '正在收尾…' : '取消'}
</Button>
)}
</div>
{task.warnings.length > 0 && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-amber-600">
{task.warnings.length} 个文件被跳过(点击看详情)
</summary>
<ul className="mt-1 max-h-32 list-disc overflow-y-auto pl-5 text-xs text-slate-400">
{task.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</details>
)}
</div>
)}
<p className="text-xs text-slate-400">
等效于群里的「/清理temp [天数]」(需管理权限);保留 0 天 = 全部清空。
清理在后台线程跑,页面其它操作不受影响。
</p>
</Card.Content>
</Card>
<Modal isOpen={confirmState.isOpen} onOpenChange={confirmState.setOpen}>
<Modal.Backdrop><Modal.Container><Modal.Dialog>
<Modal.Header><Modal.Heading>全部清空缓存</Modal.Heading></Modal.Header>
<Modal.Body>
<p className="text-sm text-slate-600">
将删除 temp 目录下全部 {st?.files ?? 0} 个文件({st ? fmtBytes(st.bytes) : '—'})。
已归档到 S3 的媒体不受影响,但 temp 里这份就没有了 —— 页面上的预览链接会失效。
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={confirmState.close}>取消</Button>
<Button isDisabled={busy} onPress={confirmClear}>{busy ? '清理中…' : '确定清空'}</Button>
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
</div>
)
}
/** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。 /** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。
* *
* 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。 * 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。
+2 -1
View File
@@ -8,6 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import { ConnectionProvider } from './connection' import { ConnectionProvider } from './connection'
import { LogProvider } from './logs' import { LogProvider } from './logs'
import { BotActionProvider } from './botAction' import { BotActionProvider } from './botAction'
import { TasksProvider } from './tasks'
import { pluginModules } from './plugins' import { pluginModules } from './plugins'
import HomePage from './pages/home' import HomePage from './pages/home'
import PluginsPage from './pages/plugins' import PluginsPage from './pages/plugins'
@@ -23,7 +24,7 @@ export function AppRoutes() {
<Routes> <Routes>
<Route path="/" element={<Navigate to="/hub" replace />} /> <Route path="/" element={<Navigate to="/hub" replace />} />
<Route path="/hub/login" element={<LoginPage />} /> <Route path="/hub/login" element={<LoginPage />} />
<Route path="/hub" element={<ConnectionProvider><LogProvider><BotActionProvider><AppLayout /></BotActionProvider></LogProvider></ConnectionProvider>}> <Route path="/hub" element={<ConnectionProvider><LogProvider><BotActionProvider><TasksProvider><AppLayout /></TasksProvider></BotActionProvider></LogProvider></ConnectionProvider>}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="plugins" element={<PluginsPage />} /> <Route path="plugins" element={<PluginsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
+380
View File
@@ -0,0 +1,380 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Button, Drawer, Spinner, toast, useOverlayState } from '@heroui/react'
import { CircleStop, ListCheck, TrashBin } from '@gravity-ui/icons'
import { hubTaskCancel, hubTaskDismiss, hubTasksClear, hubTasksStream } from './api/client'
/** 任务槽里的一条任务(后端 hexi.core.task_slots 的 Task.to_dict())。 */
export interface SlotTask {
id: string
slot: string
title: string
plugin_id: string
trigger: string
actor: string
status: 'running' | 'done' | 'failed' | 'canceled'
active: boolean
cancel_requested: boolean
progress: {
percent: number | null
done: number
total: number
unit: string
phase: string
message: string
}
result?: unknown
warnings: string[]
error: string
created_at: number
started_at: number
finished_at: number
duration: number
}
const TRIGGER_LABEL: Record<string, string> = { web: '网页', command: '群命令', schedule: '定时' }
/** 任务来源的一句话:`网页 · admin` / `群命令 · 12345`。 */
export function taskOrigin(t: SlotTask): string {
const kind = TRIGGER_LABEL[t.trigger] || t.trigger || '未知来源'
return t.actor ? `${kind} · ${t.actor}` : kind
}
/** 进度百分比文案:总数未知时给阶段名,别显示 0% 骗人。 */
export function taskPercentText(t: SlotTask): string {
const p = t.progress
if (t.active) return p.percent == null ? (p.phase || '进行中') : `${p.percent}%`
if (t.status === 'done') return '完成'
if (t.status === 'canceled') return '已取消'
return '失败'
}
interface TasksState {
tasks: SlotTask[]
running: SlotTask[]
finished: SlotTask[]
status: 'connecting' | 'open' | 'reconnecting' | 'closed'
}
const Ctx = createContext<TasksState>({
tasks: [], running: [], finished: [], status: 'connecting',
})
/**
* 全局任务中心数据源:常驻一条 /hub/api/tasks/stream SSE。
*
* 顶栏徽章与抽屉共用同一份数据,所以必须放在 provider 里(各页面自己订阅会开
* 多条 SSE)。与 connection.tsx 同款做法。
*/
export function TasksProvider({ children }: { children: ReactNode }) {
const [tasks, setTasks] = useState<SlotTask[]>([])
const [status, setStatus] = useState<TasksState['status']>('connecting')
useEffect(() => {
const dispose = hubTasksStream({
onTasks: (list: SlotTask[]) => { setTasks(list || []); setStatus('open') },
onStatus: (s) => setStatus(s === 'open' ? 'open' : s === 'reconnecting' ? 'reconnecting' : 'closed'),
})
return dispose
}, [])
const value = useMemo<TasksState>(() => ({
tasks,
running: tasks.filter(t => t.active),
finished: tasks.filter(t => !t.active),
status,
}), [tasks, status])
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export const useTasks = () => useContext(Ctx)
/**
* 顶栏「任务中心」按钮 + 抽屉。
*
* 有任务在跑时按钮上挂数字徽章(不弹 toast 打扰,用户自己点开看)。
* 在跑的只能取消(删掉就没人回传状态了),已结束的可以逐条删除 / 一键清空。
*/
export function TaskCenterButton() {
const { tasks, status } = useTasks()
const drawer = useOverlayState()
const [dealId, setDealId] = useState('')
// 点了删除、等 SSE 确认的 id(行变灰 + 按钮禁用)
const [pending, setPending] = useState<string[]>([])
// 服务端已确认消失、正在播退场动画的快照
const [leaving, setLeaving] = useState<SlotTask[]>([])
// 上一帧的 tasks:用来在渲染期发现"谁被服务端删掉了"
const [seen, setSeen] = useState<SlotTask[]>([])
const scheduled = useRef<Set<string>>(new Set())
const aliveIds = useMemo(() => new Set(tasks.map(t => t.id)), [tasks])
const pendingIds = useMemo(() => new Set(pending), [pending])
// 渲染期对齐(React 官方推荐的"props 变了就同步调状态"模式):
// **退场动画只能在服务端确认消失之后播** —— 点击就播的话,动画放完服务端还没删完,
// 行会闪回来、等 SSE 到了再瞬间消失,比不做动画还难受。所以这里等它真的从 tasks
// 里消失,才把快照挪进 leaving(同一帧内换处理,DOM 节点不重建 → CSS 过渡才跑得起来)。
if (tasks !== seen) {
const gone = seen.filter(t => !aliveIds.has(t.id) && pendingIds.has(t.id))
setSeen(tasks)
if (gone.length) {
setLeaving(prev => [...prev, ...gone.filter(t => !prev.some(x => x.id === t.id))])
setPending(prev => prev.filter(id => !gone.some(t => t.id === id)))
}
}
// 退场动画播完就把快照丢掉(每个快照只排一次定时器)
useEffect(() => {
leaving.forEach((t, i) => {
if (scheduled.current.has(t.id)) return
scheduled.current.add(t.id)
window.setTimeout(() => {
scheduled.current.delete(t.id)
setLeaving(prev => prev.filter(x => x.id !== t.id))
}, LEAVE_MS + Math.min(i * 30, 150))
})
}, [leaving])
// 兜底:请求成功了但 SSE 一直没确认(比如连接断了)→ 8 秒后解除"删除中",别让行永远灰着
useEffect(() => {
if (!pending.length) return
const ids = pending
const timer = window.setTimeout(() => {
setPending(prev => prev.filter(x => !ids.includes(x)))
toast.warning('服务端还没确认删除,稍后刷新看看')
}, 8000)
return () => window.clearTimeout(timer)
}, [pending])
// 服务端已经删掉的,靠 leaving 里的快照继续渲染到动画结束
const visible = useMemo(() => {
const ids = new Set(tasks.map(t => t.id))
return [...tasks, ...leaving.filter(t => !ids.has(t.id))]
}, [tasks, leaving])
const running = visible.filter(t => t.active)
const finished = visible.filter(t => !t.active)
const leavingIds = useMemo(() => new Set(leaving.map(t => t.id)), [leaving])
const leaveDelay = useMemo(() => {
const m = new Map<string, number>()
leaving.forEach((t, i) => m.set(t.id, Math.min(i * 30, 150)))
return m
}, [leaving])
const cancel = async (t: SlotTask) => {
setDealId(t.id)
try {
await hubTaskCancel(t.id)
toast.info('已请求取消,任务会在下一个检查点收尾')
} catch (e: any) {
toast.danger(e.message || '取消失败')
} finally { setDealId('') }
}
const dismiss = async (t: SlotTask) => {
setPending(prev => [...new Set([...prev, t.id])]) // 先变灰,等服务端确认再滑走
try {
await hubTaskDismiss(t.id) // 列表由 SSE 刷新,本地不用改
} catch (e: any) {
setPending(prev => prev.filter(x => x !== t.id)) // 失败:恢复可点
toast.danger(e.message || '删除失败')
}
}
const clearFinished = async () => {
const ids = finished.map(t => t.id)
setPending(prev => [...new Set([...prev, ...ids])])
try {
const d = await hubTasksClear()
toast.success(`已清理 ${d?.removed ?? 0} 条结束的任务`)
} catch (e: any) {
setPending(prev => prev.filter(x => !ids.includes(x)))
toast.danger(e.message || '清理失败')
}
}
return (
<>
<Button size="sm" variant="tertiary" onPress={drawer.open} aria-label="任务中心">
<span className="flex items-center gap-1.5">
<ListCheck className="size-4" />
<span className="hidden text-xs sm:inline">任务</span>
{running.length > 0 && (
<span className="rounded-full bg-indigo-500 px-1.5 text-[10px] font-medium text-white">
{running.length}
</span>
)}
</span>
</Button>
<Drawer state={drawer}>
<Drawer.Backdrop>
<Drawer.Content placement="right">
<Drawer.Dialog className="w-[560px] max-w-[92vw]">
<Drawer.Header>
<Drawer.Heading>
任务中心
<span className="ml-2 text-xs font-normal text-slate-400">
{status === 'open'
? `${running.length} 个进行中 · ${finished.length} 个已结束`
: status === 'reconnecting' ? '重连中…' : '已断开'}
</span>
</Drawer.Heading>
<Drawer.CloseTrigger />
</Drawer.Header>
<Drawer.Body>
{tasks.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-400">
{status === 'open' ? '暂无任务。长任务(清缓存、批量下载等)会出现在这里。' : '正在连接任务流…'}
</p>
) : (
<div>
{running.length > 0 && (
<div>
<div className="pb-3 text-xs font-medium text-slate-400">进行中</div>
{running.map(t => (
<Collapsible key={t.id} leaving={false}>
<TaskRow t={t} busy={dealId === t.id} onCancel={cancel} />
</Collapsible>
))}
</div>
)}
{finished.length > 0 && (
<div>
<div className="flex items-center justify-between pb-3">
<span className="text-xs font-medium text-slate-400">已结束</span>
<Button size="sm" variant="tertiary" className="text-xs" onPress={clearFinished}>
清空已结束
</Button>
</div>
{finished.map(t => (
<Collapsible
key={t.id}
leaving={leavingIds.has(t.id)}
delayMs={leaveDelay.get(t.id) ?? 0}
>
<TaskRow
t={t}
busy={dealId === t.id}
dimmed={pendingIds.has(t.id)}
onDismiss={dismiss}
/>
</Collapsible>
))}
</div>
)}
</div>
)}
</Drawer.Body>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
</>
)
}
/** 退场动画时长(毫秒),与 `beginLeave` 的定时器对齐。 */
const LEAVE_MS = 200
/**
* 列表项的退场动画:左滑淡出 + 高度收起。
*
* HeroUI v3 没有 exit 动画原语(88 个组件里只有 accordion/disclosure 那种展开
* 折叠,也没有 framer-motion),而任务列表是**服务端驱动的**——不能靠卸载触发
* 动画,所以由 `leaving` 快照多留一帧,这里用 CSS 过渡演:外层 grid 的
* `grid-rows` 1fr→0fr 负责收高度(不用猜 max-height),内层 overflow-hidden。
*/
function Collapsible({
leaving, delayMs = 0, children,
}: {
leaving: boolean
delayMs?: number
children: ReactNode
}) {
return (
<div
style={{ transitionDelay: leaving ? `${delayMs}ms` : undefined }}
className={
'grid transition-all duration-200 ease-out ' +
(leaving
? '-translate-x-6 grid-rows-[0fr] opacity-0'
: 'translate-x-0 grid-rows-[1fr] opacity-100')
}
>
<div className="overflow-hidden">
<div className="pb-3">{children}</div>
</div>
</div>
)
}
/** 抽屉里的一行:标题 + 来源 + 进度条 + 状态;按状态给「取消」或「删除」。 */
function TaskRow({
t, busy, dimmed = false, onCancel, onDismiss,
}: {
t: SlotTask
busy: boolean
/** 已点删除、等服务端确认(变灰 + 禁按钮,不播动画) */
dimmed?: boolean
onCancel?: (t: SlotTask) => void
onDismiss?: (t: SlotTask) => void
}) {
const p = t.progress
const barCls = t.status === 'failed'
? 'bg-red-400'
: t.status === 'canceled' ? 'bg-slate-300'
: t.status === 'done' ? 'bg-emerald-500' : 'bg-indigo-500'
return (
<div className={'rounded-xl border border-slate-200 px-3 py-2.5 transition-opacity ' + (dimmed ? 'opacity-50' : '')}>
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate text-sm text-slate-700">{t.title}</span>
<span className="shrink-0 text-xs text-slate-400">{dimmed ? '删除中…' : taskPercentText(t)}</span>
</div>
<div className="mt-1 flex items-center justify-between gap-3 text-xs text-slate-400">
<span className="min-w-0 truncate">
{taskOrigin(t)}
{p.phase ? ` · ${p.phase}` : ''}
{t.active && t.duration ? ` · 已运行 ${t.duration.toFixed(0)}s` : ''}
</span>
<span className="flex shrink-0 items-center gap-1">
{onCancel && t.active && (
<Button size="sm" variant="tertiary" isDisabled={busy || t.cancel_requested} onPress={() => onCancel(t)}>
<CircleStop className="size-4" />
</Button>
)}
{onDismiss && !t.active && (
<Button size="sm" variant="tertiary" isDisabled={busy || dimmed} onPress={() => onDismiss(t)} aria-label="删除任务">
<TrashBin className="size-4" />
</Button>
)}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className={'h-full transition-all ' + barCls}
style={{ width: p.percent == null ? (t.active ? '100%' : '0%') : p.percent + '%' }}
/>
</div>
{(p.message || t.error) && (
<div className={'mt-1 truncate text-xs ' + (t.status === 'failed' ? 'text-red-500' : 'text-slate-400')}>
{t.status === 'failed' ? (t.error || '未知错误') : p.message}
</div>
)}
{t.warnings.length > 0 && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-amber-600">{t.warnings.length} 条警告</summary>
<ul className="mt-1 max-h-28 list-disc overflow-y-auto pl-5 text-xs text-slate-400">
{t.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</details>
)}
</div>
)
}
/** 抽屉里的加载态(预留给以后按需拉取列表的场景)。 */
export function TaskCenterLoading() {
return <div className="flex justify-center py-6"><Spinner /></div>
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/botaction.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/botaction.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/tasks.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/bf_bot/index.tsx","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"}
+57
View File
@@ -41,6 +41,7 @@ from .web_plugin_registry import get_web_plugin_builder, list_web_plugins
from .dashboard import collect_dashboard from .dashboard import collect_dashboard
# hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件 # hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件
basic_path = Path(__file__).resolve().parent basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web # hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
@@ -333,6 +334,10 @@ async def _first_bot():
def build_hub_app() -> FastAPI: def build_hub_app() -> FastAPI:
# 延迟导入:hexi.core 是插件侧基础设施,模块级导入会触发它整套初始化
# (见本模块文档"对 core 的引用一律函数内导入"的约定)
from hexi.core.task_slots import TASKS
sync_admin() sync_admin()
app = FastAPI(title="HeXi Web Hub") app = FastAPI(title="HeXi Web Hub")
@@ -575,6 +580,58 @@ def build_hub_app() -> FastAPI:
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
@app.get("/api/tasks")
async def list_tasks(_: dict = Depends(get_current_user)):
"""任务槽的当前快照(新的在前);SSE 首帧同款,作轮询兜底用。"""
return {"ok": True, "tasks": TASKS.snapshot()}
@app.get("/api/tasks/stream")
async def tasks_stream(_: dict = Depends(get_current_user)):
"""任务槽 SSE:连上先发一帧全量快照,之后每次任务变化再发一帧。
变化不频繁(按百分比桶节流),所以直接推送全量快照,不做增量协议。
"""
queue = TASKS.subscribe()
async def gen():
try:
yield f"data: {json.dumps({'ok': True, 'tasks': TASKS.snapshot()}, ensure_ascii=False)}\n\n"
while True:
try:
tasks = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield ": ping\n\n" # ~15s 心跳保活
continue
yield f"data: {json.dumps({'ok': True, 'tasks': tasks}, ensure_ascii=False)}\n\n"
finally:
TASKS.unsubscribe(queue)
return StreamingResponse(gen(), media_type="text/event-stream")
@app.post("/api/tasks/clear")
async def clear_tasks(_: dict = Depends(get_current_user)):
"""清掉所有已结束的任务(在跑的不动)。"""
removed = TASKS.clear_finished()
return {"ok": True, "removed": removed}
@app.post("/api/tasks/{task_id}/dismiss")
async def dismiss_task(task_id: str, _: dict = Depends(get_current_user)):
"""从列表里删掉一条已结束的任务(在跑的先取消,删不了)。"""
if not TASKS.dismiss(task_id):
return {"ok": False, "msg": "任务不存在,或还在运行(在跑的只能先取消)"}
return {"ok": True}
@app.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, _: dict = Depends(get_current_user)):
"""请求取消一个在跑的任务。
协作式:只置标志,任务体在下一个检查点收尾(见 task_slots 模块文档),
所以接口马上返回不代表任务已经停了 —— 状态变化会从 SSE 推出去。
"""
if not TASKS.cancel(task_id):
return {"ok": False, "msg": "任务不存在或已结束"}
return {"ok": True}
@app.get("/api/logs/stream") @app.get("/api/logs/stream")
async def logs_stream( async def logs_stream(
since: int = 0, since: int = 0,
+37
View File
@@ -32,6 +32,10 @@ _KEY_ENV: dict[str, str] = {
# LOG_LEVEL 可选项(写入时建议大写) # LOG_LEVEL 可选项(写入时建议大写)
LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# 资源发送方式(写入 .env 的 HEXI_MEDIA_MODE;语义见 hexi/core/outbound_media._mode)
MEDIA_MODE_OPTIONS = ["http", "base64", "file"]
_MEDIA_MODE_ENV = "HEXI_MEDIA_MODE"
# 日志清理大小(MB)默认值(未在 .env 配置时) # 日志清理大小(MB)默认值(未在 .env 配置时)
_LOG_MAX_MB_DEFAULT = 20 _LOG_MAX_MB_DEFAULT = 20
@@ -103,6 +107,22 @@ def _current_log_max_mb() -> int:
return _LOG_MAX_MB_DEFAULT return _LOG_MAX_MB_DEFAULT
def _current_media_mode() -> str:
"""当前资源发送方式(HEXI_MEDIA_MODE > 旧的两个开关 > 默认 http)。
推断规则必须与 `hexi.core.outbound_media._mode` 一致(那边运行时读
os.environ,这边多读一层 .env 文件,好在手改 .env 未重启时也能显示正确)。
"""
raw = _env_get(_MEDIA_MODE_ENV).strip().lower()
if raw in MEDIA_MODE_OPTIONS:
return raw
if _env_get("HEXI_INLINE_MEDIA").strip().lower() in ("0", "false", "no", "off"):
return "file"
if _env_get("HEXI_INLINE_MEDIA_URL").strip().lower() in ("0", "false", "no", "off"):
return "base64"
return "http"
def current_values() -> dict[str, Any]: def current_values() -> dict[str, Any]:
"""返回当前生效值(读取优先级:driver.config > .env)。""" """返回当前生效值(读取优先级:driver.config > .env)。"""
try: try:
@@ -129,6 +149,8 @@ def current_values() -> dict[str, Any]:
"log_level_options": LOG_LEVEL_OPTIONS, "log_level_options": LOG_LEVEL_OPTIONS,
"log_max_mb": _current_log_max_mb(), "log_max_mb": _current_log_max_mb(),
"log_max_mb_default": _LOG_MAX_MB_DEFAULT, "log_max_mb_default": _LOG_MAX_MB_DEFAULT,
"media_mode": _current_media_mode(),
"media_mode_options": MEDIA_MODE_OPTIONS,
"source": "config" if cfg is not None else "env", "source": "config" if cfg is not None else "env",
} }
@@ -190,6 +212,14 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
if log_max_mb <= 0: if log_max_mb <= 0:
raise ValueError("日志清理大小必须是正整数(MB)") raise ValueError("日志清理大小必须是正整数(MB)")
# 资源发送方式(http/base64/file,写入 HEXI_MEDIA_MODE)
media_mode: str | None = None
_media_mode_raw = values.get("media_mode")
if _media_mode_raw is not None and str(_media_mode_raw).strip() != "":
media_mode = str(_media_mode_raw).strip().lower()
if media_mode not in MEDIA_MODE_OPTIONS:
raise ValueError(f"资源发送方式必须是 {'/'.join(MEDIA_MODE_OPTIONS)} 之一")
# 1. 写 os.environ(即时可读,进程内 getenv 立刻生效) # 1. 写 os.environ(即时可读,进程内 getenv 立刻生效)
os.environ["SUPERUSERS"] = _serialize_list(superusers) os.environ["SUPERUSERS"] = _serialize_list(superusers)
os.environ["NICKNAME"] = _serialize_list(nickname) os.environ["NICKNAME"] = _serialize_list(nickname)
@@ -197,6 +227,9 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
os.environ["LOG_LEVEL"] = log_level os.environ["LOG_LEVEL"] = log_level
if log_max_mb is not None: if log_max_mb is not None:
os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb) os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb)
if media_mode is not None:
# outbound_media._mode() 每次发送现读 os.environ → 保存即生效,无需重启
os.environ[_MEDIA_MODE_ENV] = media_mode
# 2. 落盘 .env(重启后依然生效) # 2. 落盘 .env(重启后依然生效)
_set_env_line("SUPERUSERS", _serialize_list(superusers)) _set_env_line("SUPERUSERS", _serialize_list(superusers))
@@ -205,6 +238,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
_set_env_line("LOG_LEVEL", log_level) _set_env_line("LOG_LEVEL", log_level)
if log_max_mb is not None: if log_max_mb is not None:
_set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb)) _set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb))
if media_mode is not None:
_set_env_line(_MEDIA_MODE_ENV, media_mode)
# 3. 热应用到 driver.config # 3. 热应用到 driver.config
applied: list[str] = [] applied: list[str] = []
@@ -232,6 +267,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
_apply_log_level(log_level) _apply_log_level(log_level)
if log_max_mb is not None: if log_max_mb is not None:
applied.append("HEXI_LOG_MAX_MB") applied.append("HEXI_LOG_MAX_MB")
if media_mode is not None:
applied.append(_MEDIA_MODE_ENV)
return { return {
"ok": True, "ok": True,
+26 -2
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import platform import platform
import socket
import time import time
import psutil import psutil
@@ -216,8 +217,26 @@ def _is_private_ip(ip: str) -> bool:
return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31) return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31)
def _route_ip() -> str | None:
"""默认路由出口 IP —— 连外网时内核会挑的那张网卡。
VPN / 虚拟机 / WSL / EasyTier 的地址同样是私有段,纯按字符串排序会让
虚拟网卡赢(10.0.0.129 < 192.168.2.186),首页「协议端配对」就会展示一个
协议端连不上的地址(2026-10-08 实测)。逻辑与 hexi.core.outbound_media
的 `_route_ip` 同源,改这里记得同步那边;无网络时返回 None。
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return str(sock.getsockname()[0])
except OSError:
return None
finally:
sock.close()
def _local_ips() -> list[str]: def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。""" """本机可被协议端访问的 IPv4 列表(默认路由网卡最前,其次私有网段优先)。"""
ips: list[str] = [] ips: list[str] = []
try: try:
addrs = psutil.net_if_addrs() addrs = psutil.net_if_addrs()
@@ -237,7 +256,12 @@ def _local_ips() -> list[str]:
continue continue
if ip not in ips: if ip not in ips:
ips.append(ip) ips.append(ip)
return sorted(ips, key=lambda ip: (not _is_private_ip(ip), ip)) ips.sort(key=lambda ip: (not _is_private_ip(ip), ip))
route = _route_ip()
if route in ips:
ips.remove(route)
ips.insert(0, route)
return ips
def _ws_path() -> str: def _ws_path() -> str: