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:
1 parent
6af60a7d55
commit
968b3d1cfe
10 files changed
+803
-8
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。
|
/** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。
|
||||||
*
|
*
|
||||||
* 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。
|
* 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user