Files
UHD_Media_Tool/apps/web/app/components/tasks/task-stream.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

308 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQueryClient } from "@tanstack/react-query";
import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts";
import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { useEngineRuns } from "~/api/engine.ts";
import { useScheduleBoard } from "~/api/schedules.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
import { Button } from "~/components/ui/button.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import {
BUSINESS_KIND_LABEL,
BUSINESS_KINDS,
businessLink,
ENGINE_ACTIVE,
engineRunStatus,
RUN_STATUS_OPTIONS,
runTitle,
SOURCE_LABEL,
SOURCE_TRIGGERED_BY,
sourceOf,
type TaskSource,
workflowLabel,
} from "./labels.ts";
export type StreamTab = "all" | "active" | "failed";
/** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新 */
const TASK_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"download.threshold",
"organization.changed",
"feed.fetched",
"decision.created",
"decision.updated",
"agent.run_status",
"schedule.changed",
];
export function useLiveTasks() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(TASK_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
}
const SINCE: { value: string; label: string; ms: number }[] = [
{ value: "1h", label: "1 小时内", ms: 3600_000 },
{ value: "24h", label: "24 小时内", ms: 86400_000 },
{ value: "7d", label: "7 天内", ms: 7 * 86400_000 },
{ value: "30d", label: "30 天内", ms: 30 * 86400_000 },
];
export type StreamFilters = {
status: string;
kind: string;
ref: string;
source: string;
since: string;
workflow: string;
/** "1" = 显示系统任务 */
system: string;
};
function SourceBadge({ r }: { r: EngineRun }) {
const s = sourceOf(r.business?.triggeredBy);
return s ? <span className="text-caption">{SOURCE_LABEL[s]}</span> : <span className="text-muted-foreground">—</span>;
}
/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */
function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void }) {
const board = useScheduleBoard();
const job = board.data?.items.find((j) => j.id === jobId) ?? null;
const [open, setOpen] = useState<ScheduledJob | null>(null);
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 truncate">
定时任务 <span className="font-medium text-foreground">{job?.name ?? jobId}</span> 触发的任务
</span>
<span className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="xs" disabled={!job} onClick={() => setOpen(job)}>
<History />
运行记录
</Button>
<Button asChild variant="ghost" size="xs">
<Link to={`/console/schedules?job=${encodeURIComponent(jobId)}`}>
<ExternalLink />
计划
</Link>
</Button>
<Button variant="ghost" size="icon-xs" aria-label="清除定时任务筛选" onClick={onClear}>
<X />
</Button>
</span>
<RunsSheet job={open} onClose={() => setOpen(null)} />
</div>
);
}
export function TaskStream({
tab,
filters,
setFilter,
resetFilters,
onOpen,
selectedId,
}: {
tab: StreamTab;
filters: StreamFilters;
setFilter: (k: keyof StreamFilters, v: string) => void;
resetFilters: () => void;
onOpen: (id: string) => void;
selectedId: string | null;
}) {
useLiveTasks();
// 时间下界按分钟取整,查询 key 稳定
const from = useMemo(() => {
const ms = SINCE.find((s) => s.value === filters.since)?.ms;
return ms ? new Date(Math.floor((Date.now() - ms) / 60_000) * 60_000).toISOString() : undefined;
}, [filters.since]);
const base = {
businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined,
businessRefId: filters.ref || undefined,
workflowName: filters.workflow || undefined,
triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined,
from,
};
const active = tab === "active";
// 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做
const list = useEngineRuns(
{
...base,
status: active ? "running,pending" : tab === "failed" ? "failed" : filters.status || undefined,
},
true,
active ? 10_000 : false,
);
const all = list.data?.pages.flatMap((p) => p.items);
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
const showSystem = filters.system === "1" || !!filters.ref || filters.kind === "schedule" || !!filters.workflow;
const isSystem = (r: EngineRun) => !r.business || (r.business.kind === "schedule" && r.status === "completed");
const rows = showSystem ? all : all?.filter((r) => !isSystem(r));
const hidden = (all?.length ?? 0) - (rows?.length ?? 0);
const filtered = Object.entries(filters).some(([k, v]) => k !== "system" && !!v);
return (
<>
{filters.kind === "schedule" && filters.ref ? (
<ScheduleBanner
jobId={filters.ref}
onClear={() => {
setFilter("ref", "");
setFilter("kind", "");
}}
/>
) : filters.ref ? (
<div className="mb-3 flex items-center gap-2 text-caption text-muted-foreground">
只看对象 <code className="font-mono text-foreground">{filters.ref}</code>
<Button variant="ghost" size="icon-xs" aria-label="清除对象筛选" onClick={() => setFilter("ref", "")}>
<X />
</Button>
</div>
) : null}
<FilterBar onReset={filtered ? resetFilters : undefined}>
<FilterSelect
label="来源"
value={filters.source}
onChange={(v) => setFilter("source", v)}
options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))}
/>
<FilterSelect
label="业务"
value={filters.kind}
onChange={(v) => setFilter("kind", v)}
options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))}
/>
{tab === "all" ? (
<FilterSelect
label="状态"
value={filters.status}
onChange={(v) => setFilter("status", v)}
options={RUN_STATUS_OPTIONS}
/>
) : null}
<FilterSelect
label="时间"
value={filters.since}
onChange={(v) => setFilter("since", v)}
options={SINCE}
allLabel="不限"
/>
<Button
variant={filters.system === "1" ? "secondary" : "ghost"}
size="sm"
aria-pressed={filters.system === "1"}
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
>
{filters.system === "1" ? "隐藏系统任务" : hidden ? `显示系统任务(${hidden})` : "显示系统任务"}
</Button>
{filters.workflow ? (
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
{workflowLabel(filters.workflow)}
<X />
</Button>
) : null}
</FilterBar>
<DataTable<EngineRun>
paginationKey={JSON.stringify({ tab, filters })}
rows={rows}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => void list.refetch()}
onRowClick={(r) => onOpen(r.id)}
selectedKey={selectedId}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
empty={{
icon: ListTodo,
title: filtered
? "没有符合条件的任务"
: tab === "active"
? "没有进行中的任务"
: tab === "failed"
? "没有失败的任务"
: "还没有任务",
description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可翻页继续查找。" : undefined,
}}
columns={[
{
key: "title",
header: "任务",
cell: (r) => (
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
{r.business?.title ? (
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
) : null}
</div>
),
},
{ key: "source", header: "来源", hideBelow: "md", cell: (r) => <SourceBadge r={r} /> },
{
key: "status",
header: "状态",
cell: (r) => {
const s = engineRunStatus(r);
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
},
},
{
key: "time",
header: "开始",
hideBelow: "sm",
cell: (r) => (
<div className="whitespace-nowrap">
<p className="text-muted-foreground" title={r.startedAt ?? r.createdAt}>
{r.startedAt
? formatRelative(r.startedAt)
: ENGINE_ACTIVE.has(r.status)
? `排队 ${formatRelative(r.createdAt)}`
: `未开始 · ${formatRelative(r.createdAt)}`}
</p>
</div>
),
},
{
key: "link",
header: "关联",
hideBelow: "lg",
cell: (r) => {
const l = businessLink(r.business);
return l ? (
<Link
to={l.to}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
);
},
},
]}
/>
</>
);
}