Files
HeXi/hexi/web/src/tasks.tsx
T
sansenhoshiandClaude Code 968b3d1cfe 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>
2026-10-13 17:53:27 +08:00

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>
}