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 = { 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({ tasks: [], running: [], finished: [], status: 'connecting', }) /** * 全局任务中心数据源:常驻一条 /hub/api/tasks/stream SSE。 * * 顶栏徽章与抽屉共用同一份数据,所以必须放在 provider 里(各页面自己订阅会开 * 多条 SSE)。与 connection.tsx 同款做法。 */ export function TasksProvider({ children }: { children: ReactNode }) { const [tasks, setTasks] = useState([]) const [status, setStatus] = useState('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(() => ({ tasks, running: tasks.filter(t => t.active), finished: tasks.filter(t => !t.active), status, }), [tasks, status]) return {children} } 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([]) // 服务端已确认消失、正在播退场动画的快照 const [leaving, setLeaving] = useState([]) // 上一帧的 tasks:用来在渲染期发现"谁被服务端删掉了" const [seen, setSeen] = useState([]) const scheduled = useRef>(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() 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 ( <> 任务中心 {status === 'open' ? `${running.length} 个进行中 · ${finished.length} 个已结束` : status === 'reconnecting' ? '重连中…' : '已断开'} {tasks.length === 0 ? (

{status === 'open' ? '暂无任务。长任务(清缓存、批量下载等)会出现在这里。' : '正在连接任务流…'}

) : (
{running.length > 0 && (
进行中
{running.map(t => ( ))}
)} {finished.length > 0 && (
已结束
{finished.map(t => ( ))}
)}
)}
) } /** 退场动画时长(毫秒),与 `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 (
{children}
) } /** 抽屉里的一行:标题 + 来源 + 进度条 + 状态;按状态给「取消」或「删除」。 */ 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 (
{t.title} {dimmed ? '删除中…' : taskPercentText(t)}
{taskOrigin(t)} {p.phase ? ` · ${p.phase}` : ''} {t.active && t.duration ? ` · 已运行 ${t.duration.toFixed(0)}s` : ''} {onCancel && t.active && ( )} {onDismiss && !t.active && ( )}
{(p.message || t.error) && (
{t.status === 'failed' ? (t.error || '未知错误') : p.message}
)} {t.warnings.length > 0 && (
{t.warnings.length} 条警告
    {t.warnings.map((w, i) =>
  • {w}
  • )}
)}
) } /** 抽屉里的加载态(预留给以后按需拉取列表的场景)。 */ export function TaskCenterLoading() { return
}