- tasks.tsx:顶栏任务中心全局 Provider(SSE 任务进度,供各插件页共享) - 设置页与 AppLayout / client / router 调整,BF2042 插件页路由注册 - web_hub:dashboard / bot_settings 同步更新 - 视频解析页配合后端(缓存 tab / 任务槽进度) Co-Authored-By: Claude Code <noreply@anthropic.com>
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
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>
|
|
}
|