diff --git a/apps/web/app/api/decisions.ts b/apps/web/app/api/decisions.ts index adf33f80..2897e227 100644 --- a/apps/web/app/api/decisions.ts +++ b/apps/web/app/api/decisions.ts @@ -25,8 +25,9 @@ export { DecisionSummary }; const Page = cursorPage(Decision); -export function useDecisions(filters: DecisionFilters) { +export function useDecisions(filters: DecisionFilters, enabled = true) { return useInfiniteQuery({ + enabled, queryKey: decisionKeys.list(filters), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx new file mode 100644 index 00000000..d0bd0ff8 --- /dev/null +++ b/apps/web/app/components/console/inbox-bell.tsx @@ -0,0 +1,136 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { Decision, DecisionKind } from "@umt/contracts"; +import { ArrowRight, Bell, CheckCheck } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts"; +import { SEVERITY } from "~/components/library/labels.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; +import { decisionKindLabel, formatRelative } from "~/lib/labels.ts"; +import { useSse } from "~/lib/sse.ts"; +import { cn } from "~/lib/utils.ts"; + +// 顶栏收件箱(IA-3):待处理不进侧栏,铃铛 + 未决角标;下拉按问题类型列最近事项,点开进待处理页。 + +const RECENT = 8; +const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const; + +/** 未决数与最近事项;SSE decision.* 到达时刷新 */ +export function useLiveDecisions() { + const qc = useQueryClient(); + const timer = useRef | null>(null); + useSse(["decision.created", "decision.updated"], () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + void qc.invalidateQueries({ queryKey: decisionKeys.all }); + }, 800); + }); + return useDecisionSummary(); +} + +export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}` : "/console/inbox"); + +export function InboxBell() { + const summary = useLiveDecisions(); + const [open, setOpen] = useState(false); + const list = useDecisions({}, open); + const navigate = useNavigate(); + const total = summary.data?.total ?? 0; + + const groups = useMemo(() => { + const items = (list.data?.pages[0]?.items ?? []).slice(0, RECENT); + const m = new Map(); + for (const d of items) m.set(d.kind, [...(m.get(d.kind) ?? []), d]); + const counts = new Map(); + for (const g of summary.data?.groups ?? []) counts.set(g.kind, (counts.get(g.kind) ?? 0) + g.count); + return [...m.entries()].map(([kind, ds]) => ({ kind, items: ds, count: counts.get(kind) ?? ds.length })); + }, [list.data, summary.data]); + + const go = (to: string) => { + setOpen(false); + navigate(to); + }; + + return ( + + + + + +
+

+ 待处理 + {total ? {total} : null} +

+ +
+ {list.isPending ? ( +

加载中…

+ ) : list.isError ? ( +

加载失败,稍后再试

+ ) : groups.length === 0 ? ( +
+ + 没有需要处理的事项 +
+ ) : ( +
+ {groups.map((g) => ( +
+ +
    + {g.items.map((d) => ( +
  • + setOpen(false)} + className="flex items-start gap-2.5 px-4 py-2 hover:bg-foreground/[0.04]" + > + + + {d.title} + + {SEVERITY[d.severity].label} · {formatRelative(d.createdAt)} + + + +
  • + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index d75cafa5..3a3db02c 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { LEGACY_REDIRECTS, legacyRedirect } from "../../routes/console/redirects.ts"; -import { consolePath, findNavItem, NAV_GROUPS, NAV_ITEMS, settingsPath } from "./nav.ts"; +import { consolePath, findNavItem, NAV_GROUPS, NAV_ITEMS, pageTitle, settingsPath } from "./nav.ts"; /** routes.ts 里登记的控制台路由(相对 /console) */ const ROUTE_PATTERNS = [ @@ -16,13 +16,26 @@ const matchesRoute = (pathname: string) => { }; describe("控制台导航", () => { - it("7 组 18 项(另加系统组的定时任务),路径唯一且都在 /console 下", () => { - expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "下载", "整理", "Agent", "系统"]); - const core = NAV_ITEMS.filter((i) => i.to !== "/console/schedules"); - expect(core).toHaveLength(18); + it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => { + expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]); + expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([ + ["首页", "任务", "Agent 会话"], + ["站点", "RSS 订阅"], + ["订阅", "规则"], + ["下载面板", "整理记录", "文件管理", "媒体概览"], + ["设置", "审计", "运行状态"], + ]); + // 定稿文字写"共 12 项",逐项清单实际是 14 项;以清单为准 + expect(NAV_ITEMS).toHaveLength(14); expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); - for (const g of NAV_GROUPS) expect(g.items.length).toBeLessThanOrEqual(4); + }); + + it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 定时任务 / 身份并入别处", () => { + const paths = NAV_ITEMS.map((i) => i.to); + for (const p of ["/console/inbox", "/console/engine", "/console/schedules", "/console/identity"]) + expect(paths).not.toContain(p); + expect(pageTitle("/console/inbox")).toBe("待处理"); }); it("每个导航项都有路由", () => { @@ -32,13 +45,13 @@ describe("控制台导航", () => { it("按最长前缀匹配当前项", () => { expect(findNavItem("/console")?.label).toBe("首页"); expect(findNavItem("/console/")?.label).toBe("首页"); - expect(findNavItem("/console/library/abc")?.label).toBe("媒体库"); - expect(findNavItem("/console/engine/r1")?.label).toBe("引擎面板"); - expect(findNavItem("/console/downloads")?.label).toBe("下载队列"); - expect(findNavItem("/console/downloads/plans")?.label).toBe("下载计划"); - expect(findNavItem("/console/downloads/abc")?.label).toBe("下载队列"); - expect(findNavItem("/console/tasks/abc")?.label).toBe("业务任务"); + expect(findNavItem("/console/library/abc")?.label).toBe("媒体概览"); + expect(findNavItem("/console/tasks/r1")?.label).toBe("任务"); + expect(findNavItem("/console/downloads")?.label).toBe("下载面板"); + expect(findNavItem("/console/downloads/plans")?.label).toBe("下载面板"); + expect(findNavItem("/console/downloads/abc")?.label).toBe("下载面板"); expect(findNavItem("/console/sites/abc")?.label).toBe("站点"); + expect(findNavItem("/console/status")?.label).toBe("运行状态"); expect(findNavItem("/nope")).toBeUndefined(); }); @@ -64,12 +77,15 @@ describe("旧链接重定向", () => { for (const { to } of LEGACY_REDIRECTS) { const u = new URL(to, "http://x"); expect(matchesRoute(u.pathname), to).toBe(true); - if (u.pathname !== "/console/sites/abc") expect(legacyRedirect(u.pathname, u.search)).toBeNull(); + if (u.pathname !== "/console/sites/abc") expect(legacyRedirect(u.pathname, u.search), to).toBeNull(); } }); it("不相关的 query 不跳转", () => { expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBeNull(); + expect(legacyRedirect("/console/tasks", "?tab=plan&job=trash.purge")).toBeNull(); + expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull(); + expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull(); expect(legacyRedirect("/console/subscriptions", "")).toBeNull(); expect(legacyRedirect("/console/sources", "?health=failing")).toBeNull(); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 6133eca0..5be82b7d 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -1,15 +1,12 @@ import { - Activity, Bot, CalendarClock, - ClipboardList, Download, FileStack, - Fingerprint, FolderTree, + Gauge, Globe, House, - Inbox, Library, ListChecks, ListTodo, @@ -17,8 +14,6 @@ import { Rss, ScrollText, Settings, - Tags, - Timer, } from "lucide-react"; export type NavItem = { @@ -26,8 +21,6 @@ export type NavItem = { label: string; icon: LucideIcon; description: string; - /** 侧栏角标的数据来源 */ - badge?: "decisions"; }; export type NavGroup = { title: string; items: NavItem[] }; @@ -41,8 +34,8 @@ export function consolePath(...segments: (string | number)[]): string { } /** - * 侧栏 7 组(docs/design/console-ia.md §3.2),按业务流程排:概览 → 资源 → 订阅 → 下载 → 整理 → Agent → 系统。 - * 移动端抽屉用同一份表。 + * 侧栏 5 组 14 项(IA-3 定稿清单;定稿文字写"共 12 项",以逐项清单为准,docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。 + * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ { @@ -52,14 +45,19 @@ export const NAV_GROUPS: NavGroup[] = [ to: "/console", label: "首页", icon: House, - description: "自动化概览、当前工作、待处理、缓存、来源健康、最近产出", + description: "正在进行、需要处理、计划、最近结果与运行状态", }, { - to: "/console/inbox", - label: "待处理", - icon: Inbox, - description: "按作品和问题类型合并的待处理事项", - badge: "decisions", + to: "/console/tasks", + label: "任务", + icon: ListTodo, + description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划", + }, + { + to: "/console/agent", + label: "Agent 会话", + icon: Bot, + description: "交代目标,Agent 在授权内执行并汇报", }, ], }, @@ -72,64 +70,20 @@ export const NAV_GROUPS: NavGroup[] = [ icon: Globe, description: "站点、凭证、账号数据与健康;资源、快照与 Feed", }, - { - to: "/console/sources", - label: "来源健康", - icon: Rss, - description: "全部 Feed 与站点的抓取状态", - }, + { to: "/console/sources", label: "RSS 订阅", icon: Rss, description: "全部 Feed 与抓取状态" }, ], }, { title: "订阅", items: [ - { - to: "/console/subscriptions", - label: "订阅", - icon: CalendarClock, - description: "单次收录与持续追踪", - }, - { - to: "/console/rules", - label: "收录策略", - icon: ListChecks, - description: "新作品收录条件、默认版本目标与策略版本", - }, - { - to: "/console/preferences", - label: "偏好标签", - icon: Tags, - description: "软偏好影响排序,硬规则直接排除", - }, - ], - }, - { - title: "下载", - items: [ - { - to: "/console/downloads", - label: "下载队列", - icon: Download, - description: "下载任务与缓存水位", - }, - { - to: "/console/downloads/plans", - label: "下载计划", - icon: ClipboardList, - description: "选中候选后生成的计划与空间预留", - }, - { - to: "/console/tasks", - label: "业务任务", - icon: ListTodo, - description: "提交下载、核验入库、清理缓存等后台任务", - }, + { to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" }, + { to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" }, ], }, { title: "整理", items: [ - { to: "/console/library", label: "媒体库", icon: Library, description: "作品、季 / 集、版本目标与实际路径" }, + { to: "/console/downloads", label: "下载面板", icon: Download, description: "下载任务、下载计划与缓存水位" }, { to: "/console/organize", label: "整理记录", @@ -143,21 +97,10 @@ export const NAV_GROUPS: NavGroup[] = [ description: "浏览登记目录,改名、移动、删除与整理", }, { - to: "/console/identity", - label: "身份与元数据", - icon: Fingerprint, - description: "外部身份、待识别资源与别名", - }, - ], - }, - { - title: "Agent", - items: [ - { - to: "/console/agent", - label: "Agent 会话", - icon: Bot, - description: "交代目标,Agent 在授权内执行并汇报", + to: "/console/library", + label: "媒体概览", + icon: Library, + description: "作品、季 / 集、版本目标、外部身份与待识别资源", }, ], }, @@ -170,28 +113,29 @@ export const NAV_GROUPS: NavGroup[] = [ icon: Settings, description: "下载器、媒体服务器、库根、站点账号、Agent 上游与自动化", }, - { - to: "/console/schedules", - label: "定时任务", - icon: Timer, - description: "周期任务与 Agent 巡检的开关、间隔和运行记录", - }, - { - to: "/console/engine", - label: "引擎面板", - icon: Activity, - description: "排查用,业务进度看业务任务。仅管理员", - }, { to: "/console/audit", - label: "审计日志", + label: "审计", icon: ScrollText, - description: "登录、设置、密钥与实例变更的操作留痕", + description: "登录、设置、密钥、任务操作的留痕", + }, + { + to: "/console/status", + label: "运行状态", + icon: Gauge, + description: "进程心跳、事件循环、数据库、磁盘与挂载根", }, ], }, ]; +/** 不在侧栏但有页面的路由:顶栏标题用 */ +const EXTRA_TITLES: Record = { + "/console/inbox": "待处理", + "/console/preferences": "偏好标签", + "/console/search": "搜索", +}; + export const NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items); /** 设置页深链(IA-2):`/console/settings#分区[/节]`。调用方只认新分区名 */ @@ -218,3 +162,9 @@ export function findNavItem(pathname: string): NavItem | undefined { )[0] ); } + +/** 顶栏标题:导航项名,或不在侧栏的页面名 */ +export function pageTitle(pathname: string): string | undefined { + const path = pathname.replace(/\/+$/, ""); + return findNavItem(pathname)?.label ?? EXTRA_TITLES[path]; +} diff --git a/apps/web/app/components/downloads/downloads-tabs.tsx b/apps/web/app/components/downloads/downloads-tabs.tsx new file mode 100644 index 00000000..26e64ef2 --- /dev/null +++ b/apps/web/app/components/downloads/downloads-tabs.tsx @@ -0,0 +1,33 @@ +import { NavLink } from "react-router"; +import { cn } from "~/lib/utils.ts"; + +// 下载面板的两个页签(IA-3:下载计划并入下载面板,路由保持 /downloads 与 /downloads/plans) + +const TABS = [ + { to: "/console/downloads", label: "下载任务", end: true }, + { to: "/console/downloads/plans", label: "下载计划", end: true }, +]; + +export function DownloadsTabs() { + return ( + + ); +} diff --git a/apps/web/app/components/library/identity-views.tsx b/apps/web/app/components/library/identity-views.tsx new file mode 100644 index 00000000..bf9ccb6f --- /dev/null +++ b/apps/web/app/components/library/identity-views.tsx @@ -0,0 +1,261 @@ +import type { + ExternalIdentity, + IdentityCandidateReason, + IdentityCandidateStatus, + IdentityPoolItem, + IdentityStatus, +} from "@umt/contracts"; +import { ArrowRight, Fingerprint, Lock, SearchX } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Link } from "react-router"; +import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts"; +import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; +import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; +import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx"; +import { inboxLink, inInbox, PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx"; +import { + EVIDENCE_LEVEL, + IDENTITY_STATUS, + opts, + POOL_REASON, + POOL_STATUS, + VERIFY_STATUS, +} from "~/components/library/labels.ts"; +import { relTime, Tone } from "~/components/library/parts.tsx"; + +// 媒体概览的身份视图(原"身份与元数据"页,IA-3 并入 /console/library):待识别资源、外部身份。 +// 待识别资源只读;选候选、忽略在待处理里做(docs/design/console-ia.md §3.1 判定只在待处理)。 + +function useDebounced(v: string, ms = 250) { + const [d, setD] = useState(v); + useEffect(() => { + const t = setTimeout(() => setD(v), ms); + return () => clearTimeout(t); + }, [v, ms]); + return d; +} + +export function PoolTab() { + const [q, setQ] = useState(""); + const [status, setStatus] = useState("pending"); + const [reason, setReason] = useState(""); + const dq = useDebounced(q.trim()); + const pool = useIdentityPool({ + q: dq || undefined, + status: (status || undefined) as IdentityCandidateStatus | undefined, + reason: (reason || undefined) as IdentityCandidateReason | undefined, + }); + const [openId, setOpenId] = useState(null); + const rows = pool.data?.pages.flatMap((p) => p.items); + const open = rows?.find((r) => r.id === openId) ?? null; + + const columns: DataColumn[] = [ + { + key: "title", + header: "原始标题", + className: "max-w-0 w-full", + cell: (r) => ( +
+

{r.release.title}

+

+ {r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)} +

+
+ ), + }, + { + key: "reason", + header: "原因", + cell: (r) => ( +
+ + {inInbox(r) ? ( + e.stopPropagation()} + className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" + > + 已进待处理 + + + ) : null} +
+ ), + }, + { + key: "cands", + header: "候选", + hideBelow: "sm", + align: "right", + cell: (r) => {r.candidates.length}, + }, + { + key: "attempts", + header: "尝试", + hideBelow: "md", + cell: (r) => ( + + {r.attempts} 次 · {relTime(r.lastAttemptAt)} + + ), + }, + { + key: "next", + header: "下次复查", + hideBelow: "lg", + cell: (r) => ( + + {r.status === "pending" ? ( + r.nextReviewAt ? ( + relTime(r.nextReviewAt) + ) : ( + "不自动复查" + ) + ) : ( + + )} + + ), + }, + ]; + + const filtered = !!q || status !== "pending" || !!reason; + return ( + <> + { + setQ(""); + setStatus("pending"); + setReason(""); + } + : undefined + } + > + + + + r.id} + loading={pool.isPending} + error={pool.error} + onRetry={() => pool.refetch()} + onRowClick={(r) => setOpenId(r.id)} + selectedKey={openId} + empty={{ + icon: SearchX, + title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源", + description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。", + }} + hasMore={pool.hasNextPage} + onLoadMore={() => pool.fetchNextPage()} + loadingMore={pool.isFetchingNextPage} + /> + setOpenId(null)} /> + + ); +} + +export function ExternalTab() { + const [provider, setProvider] = useState(""); + const [status, setStatus] = useState(""); + const list = useExternalIdentities({ + provider: provider || undefined, + status: (status || undefined) as IdentityStatus | undefined, + }); + const [openId, setOpenId] = useState(null); + const rows = list.data?.pages.flatMap((p) => p.items); + const open = rows?.find((r) => r.id === openId) ?? null; + + const columns: DataColumn[] = [ + { + key: "ref", + header: "外部身份", + cell: (r) => ( + + {r.locked ? : null} + {identityRef(r)} + + ), + }, + { + key: "entity", + header: "作品", + hideBelow: "sm", + cell: (r) => ( + + {r.mediaEntityTitle ?? 未关联} + + ), + }, + { key: "status", header: "状态", cell: (r) => }, + { + key: "level", + header: "证据", + hideBelow: "sm", + cell: (r) => {EVIDENCE_LEVEL[r.evidenceLevel]}, + }, + { key: "verify", header: "外部校验", hideBelow: "md", cell: (r) => }, + { + key: "rev", + header: "修订", + hideBelow: "lg", + align: "right", + cell: (r) => ( + + r{r.revision} · {relTime(r.updatedAt)} + + ), + }, + ]; + const filtered = !!provider || !!status; + return ( + <> + { + setProvider(""); + setStatus(""); + } + : undefined + } + > + + + + r.id} + loading={list.isPending} + error={list.error} + onRetry={() => list.refetch()} + onRowClick={(r) => setOpenId(r.id)} + selectedKey={openId} + empty={{ + icon: Fingerprint, + title: "没有外部身份", + description: "没有外部身份的作品也能建档入库。", + }} + hasMore={list.hasNextPage} + onLoadMore={() => list.fetchNextPage()} + loadingMore={list.isFetchingNextPage} + /> + setOpenId(null)} /> + + ); +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 51f260ad..e8a7efb5 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -35,6 +35,7 @@ export default [ route("audit", "routes/console/audit.tsx"), route("settings", "routes/console/settings.tsx"), route("schedules", "routes/console/schedules.tsx"), + route("status", "routes/console/status.tsx"), ]), ]), route("*", "routes/not-found.tsx"), diff --git a/apps/web/app/routes/console/downloads.$id.tsx b/apps/web/app/routes/console/downloads.$id.tsx index 38522c66..6d297964 100644 --- a/apps/web/app/routes/console/downloads.$id.tsx +++ b/apps/web/app/routes/console/downloads.$id.tsx @@ -22,7 +22,7 @@ export default function DownloadTaskPage() { } diff --git a/apps/web/app/routes/console/downloads.plans.tsx b/apps/web/app/routes/console/downloads.plans.tsx index 137e29c1..73f821b1 100644 --- a/apps/web/app/routes/console/downloads.plans.tsx +++ b/apps/web/app/routes/console/downloads.plans.tsx @@ -12,13 +12,14 @@ import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useQueryState } from "~/components/console/use-query-state.ts"; +import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx"; import { PRIORITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { planReasonText } from "~/lib/labels.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; -export const meta = () => [{ title: "下载计划 · UHD 媒体自动化控制台" }]; +export const meta = () => [{ title: "下载计划 · 下载面板 · UHD 媒体自动化控制台" }]; const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const; @@ -77,7 +78,8 @@ export default function DownloadPlansPage() { return ( <> - + + [{ title: "下载队列 · UHD 媒体自动化控制台" }]; +export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制台" }]; export default function DownloadsPage() { const f = useQueryState(["status", "managed"] as const); @@ -89,7 +90,7 @@ export default function DownloadsPage() { return ( <> void qc.invalidateQueries({ queryKey: ["downloads"] })}> @@ -98,6 +99,7 @@ export default function DownloadsPage() { } /> + [{ title: "运行详情 · 引擎面板" }]; +// IA-3:引擎运行详情改为任务页抽屉(/console/tasks?run=) +export const clientLoader = legacyRedirectLoader; -const KIND_LABEL: Record = { - function: "函数", - sleep: "休眠", - workflow: "子工作流", - "signal-send": "发送信号", - "signal-wait": "等待信号", -}; - -function stepTone(s: string) { - if (s === "completed" || s === "succeeded") return "success" as const; - if (s === "failed") return "destructive" as const; - return "neutral" as const; -} - -function stepItems(steps: EngineStepAttempt[]): TimelineItem[] { - const seen = new Map(); - return steps.map((st) => { - const n = (seen.get(st.stepName) ?? 0) + 1; - seen.set(st.stepName, n); - const status = - st.status === "running" - ? "运行中" - : st.status === "failed" - ? "失败" - : st.status === "completed" || st.status === "succeeded" - ? "完成" - : st.status; - const hasError = st.error !== null && st.error !== undefined; - return { - id: st.id, - at: st.startedAt ?? st.createdAt, - tone: stepTone(st.status), - title: ( - - {st.stepName} - - {KIND_LABEL[st.kind] ?? st.kind} · {status} - {n > 1 ? ` · 第 ${n} 次` : ""} - - - ), - meta: formatDuration(runDurationSec(st)), - description: ( - <> - {st.waitSignal ? ( -

- 等待信号 {st.waitSignal} - {st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""} -

- ) : null} - {st.childWorkflowRunId ? ( -

- 子工作流{" "} - - {st.childWorkflowRunId} - -

- ) : null} -

{st.id}

- - ), - children: hasError ? ( - - ) : st.output !== null && st.output !== undefined ? ( - - ) : undefined, - }; - }); -} - -export default function EngineRunPage() { - const { runId } = useParams(); - const q = useEngineRun(runId); - const cancel = useCancelEngineRun(); - const retry = useRetryEngineRun(); - const navigate = useNavigate(); - - const back = ( - - - 返回列表 - - ); - - if (q.isPending) { - return ( - <> - {back} - - - ); - } - if (q.isError) { - return ( - <> - {back} - q.refetch()} /> - - ); - } - const r = q.data; - const st = engineRunLabel(r.status); - const terminal = ENGINE_TERMINAL.has(r.status); - const hasError = r.error !== null && r.error !== undefined; - - return ( - <> - {back} - - {st.label} - {r.version ? {r.version} : null} - {r.id} - - } - actions={ - <> - - {!terminal ? ( - - - 取消运行 - - } - title="取消这次运行?" - description="正在执行的步骤会在下一个检查点退出。" - confirmLabel="取消运行" - cancelLabel="不取消" - destructive - onConfirm={async () => { - try { - const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" }); - if (res.canceled) toast.success("已取消"); - else toast.warning("运行已结束"); - } catch (err) { - toast.error(errorMessage(err, "取消失败")); - throw err; - } - }} - /> - ) : ( - - - 重跑 - - } - title="重跑这次运行?" - description="用原始输入新建一次运行,原记录不变。已完成的文件操作不会重复执行。" - confirmLabel="重跑" - cancelLabel="不重跑" - onConfirm={async () => { - try { - const res = await retry.mutateAsync({ id: r.id }); - toast.success(`已重新入队(第 ${res.attempt} 次重跑)`); - navigate(consolePath("engine", res.runId)); - } catch (err) { - toast.error(errorMessage(err, "重跑失败")); - throw err; - } - }} - /> - )} - - } - /> - -
-
-
- - - -
-
- {hasError ? : null} - - {!hasError ? : null} -
-
- - {r.signals.length === 0 ? ( -

没有信号。

- ) : ( -
    - {r.signals.map((sg) => ( -
  • -

    - {sg.signal} - {formatDateTime(sg.createdAt)} -

    - -
  • - ))} -
- )} -
-
-
-
- - {r.stepAttempts.length === 0 ? ( - - ) : ( - - )} - -
-
- - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/engine.tsx b/apps/web/app/routes/console/engine.tsx index 13e13816..03a70f7a 100644 --- a/apps/web/app/routes/console/engine.tsx +++ b/apps/web/app/routes/console/engine.tsx @@ -1,225 +1,8 @@ -import { useQueryClient } from "@tanstack/react-query"; -import type { EngineRun } from "@umt/contracts"; -import { Ban, CheckCircle2, Clock, Cpu, LoaderCircle, RotateCw, XCircle } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router"; -import { useEngineRuns, useEngineStats } from "~/api/engine.ts"; -import { DataTable } from "~/components/console/data-table.tsx"; -import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; -import { consolePath } from "~/components/console/nav.ts"; -import { PageHeader } from "~/components/console/page-header.tsx"; -import { StatTile } from "~/components/console/stat-tile.tsx"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts"; -import { qk } from "~/lib/query-keys.ts"; -import { useSse } from "~/lib/sse.ts"; +import { legacyRedirectLoader } from "./redirects.ts"; -export const meta = () => [{ title: "引擎面板 · UHD 媒体自动化控制台" }]; +// IA-3:引擎面板并入任务页 +export const clientLoader = legacyRedirectLoader; -const STAT_ORDER = [ - { status: "pending", label: "待执行", icon: Clock, tone: "neutral" }, - { status: "running", label: "运行中", icon: LoaderCircle, tone: "neutral" }, - { status: "completed", label: "完成", icon: CheckCircle2, tone: "success" }, - { status: "failed", label: "失败", icon: XCircle, tone: "destructive" }, - { status: "canceled", label: "已取消", icon: Ban, tone: "warning" }, -] as const; - -const STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled", "sleeping", "succeeded"].map((s) => ({ - value: s, - label: engineRunLabel(s).label, -})); - -/** 持续任务事件到达时节流刷新引擎视图(引擎本身不发事件,业务任务事件足以代表它在动) */ -const ENGINE_EVENTS = [ - "task.created", - "task.status_changed", - "download.status_changed", - "organization.changed", - "agent.run_status", -]; - -function useDebounced(value: T, ms = 350): T { - const [v, setV] = useState(value); - useEffect(() => { - const t = setTimeout(() => setV(value), ms); - return () => clearTimeout(t); - }, [value, ms]); - return v; -} - -export default function EnginePage() { - const navigate = useNavigate(); - const qc = useQueryClient(); - const [params, setParams] = useSearchParams(); - const status = params.get("status") ?? ""; - const [name, setName] = useState(params.get("workflow") ?? ""); - const workflowName = useDebounced(name.trim()); - - useEffect(() => { - setParams( - (p) => { - if (workflowName) p.set("workflow", workflowName); - else p.delete("workflow"); - return p; - }, - { replace: true }, - ); - }, [workflowName, setParams]); - - const setStatus = (s: string) => - setParams( - (p) => { - if (s) p.set("status", s); - else p.delete("status"); - return p; - }, - { replace: true }, - ); - - const runs = useEngineRuns({ status: status || undefined, workflowName: workflowName || undefined }); - const stats = useEngineStats(); - - const timer = useRef | null>(null); - useSse(ENGINE_EVENTS, () => { - if (timer.current) return; - timer.current = setTimeout(() => { - timer.current = null; - qc.invalidateQueries({ queryKey: qk.all("engine") }); - }, 2000); - }); - - const rows = runs.data?.pages.flatMap((p) => p.items); - const s = stats.data; - - return ( - <> - - 排查用,只读。业务进度看 - - 业务任务 - - - } - actions={ - - } - /> - -
- {STAT_ORDER.map((t) => ( - 0 ? t.tone : "neutral"} - value={stats.isPending ? "…" : stats.isError ? "—" : (s?.byStatus[t.status] ?? 0)} - active={status === t.status} - onClick={() => setStatus(status === t.status ? "" : t.status)} - /> - ))} -
-
- - 最老待执行: - {s?.oldestPending ? ( - - ) : ( - "无" - )} - - - - 持有租约的 worker: - {s && s.activeWorkers.length > 0 - ? s.activeWorkers.map((w) => `${w.workerId}(${w.running})`).join("、") - : "无"} - (快照) - -
- - { - setName(""); - setStatus(""); - } - : undefined - } - > - - - - - rows={rows} - rowKey={(r) => r.id} - loading={runs.isPending} - error={runs.error} - onRetry={() => runs.refetch()} - empty={{ - title: status || workflowName ? "没有符合条件的运行" : "引擎还没有运行记录", - description: status || workflowName ? undefined : "任务入队后出现在这里。", - }} - onRowClick={(r) => navigate(consolePath("engine", r.id))} - hasMore={!!runs.hasNextPage} - onLoadMore={() => runs.fetchNextPage()} - loadingMore={runs.isFetchingNextPage} - columns={[ - { - key: "status", - header: "状态", - cell: (r) => { - const l = engineRunLabel(r.status); - return {l.label}; - }, - }, - { - key: "name", - header: "工作流", - cell: (r) => ( -
-

- {r.workflowName} - {r.version ? {r.version} : null} -

-

{r.id}

-
- ), - }, - { key: "attempts", header: "尝试", hideBelow: "md", align: "right", cell: (r) => r.attempts }, - { - key: "dur", - header: "耗时", - hideBelow: "sm", - cell: (r) => {formatDuration(runDurationSec(r))}, - }, - { - key: "created", - header: "创建", - hideBelow: "md", - cell: (r) => {formatRelative(r.createdAt)}, - }, - ]} - /> - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/identity.tsx b/apps/web/app/routes/console/identity.tsx index e51b4576..39885f1c 100644 --- a/apps/web/app/routes/console/identity.tsx +++ b/apps/web/app/routes/console/identity.tsx @@ -1,323 +1,8 @@ -import type { - ExternalIdentity, - IdentityCandidateReason, - IdentityCandidateStatus, - IdentityPoolItem, - IdentityStatus, -} from "@umt/contracts"; -import { ArrowRight, Fingerprint, Inbox, Lock, SearchX } from "lucide-react"; -import { useEffect, useState } from "react"; -import { Link, useSearchParams } from "react-router"; -import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts"; -import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; -import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; -import { PageHeader } from "~/components/console/page-header.tsx"; -import { AliasesPanel } from "~/components/library/identity-aliases.tsx"; -import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx"; -import { - INBOX_IDENTITY, - inboxLink, - inInbox, - PoolSheet, - querySummary, -} from "~/components/library/identity-pool-sheet.tsx"; -import { - EVIDENCE_LEVEL, - IDENTITY_STATUS, - opts, - POOL_REASON, - POOL_STATUS, - VERIFY_STATUS, -} from "~/components/library/labels.ts"; -import { relTime, Tone } from "~/components/library/parts.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; +import { legacyRedirectLoader } from "./redirects.ts"; -// 身份与元数据(REQ §7.2、§7.5):待识别资源、外部身份映射与修订、别名。 -// 待识别资源只读;选候选、忽略在待处理里做(docs/design/console-ia.md §3.1 判定只在待处理)。 +// IA-3:"身份与元数据"取消,待识别 / 外部身份 / 别名并入媒体概览(/console/library?filter=unidentified 等) +export const clientLoader = legacyRedirectLoader; -export const meta = () => [{ title: "身份与元数据 · UHD 媒体自动化控制台" }]; - -function useDebounced(v: string, ms = 250) { - const [d, setD] = useState(v); - useEffect(() => { - const t = setTimeout(() => setD(v), ms); - return () => clearTimeout(t); - }, [v, ms]); - return d; -} - -function PoolTab() { - const [q, setQ] = useState(""); - const [status, setStatus] = useState("pending"); - const [reason, setReason] = useState(""); - const dq = useDebounced(q.trim()); - const pool = useIdentityPool({ - q: dq || undefined, - status: (status || undefined) as IdentityCandidateStatus | undefined, - reason: (reason || undefined) as IdentityCandidateReason | undefined, - }); - const [openId, setOpenId] = useState(null); - const rows = pool.data?.pages.flatMap((p) => p.items); - const open = rows?.find((r) => r.id === openId) ?? null; - - const columns: DataColumn[] = [ - { - key: "title", - header: "原始标题", - className: "max-w-0 w-full", - cell: (r) => ( -
-

{r.release.title}

-

- {r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)} -

-
- ), - }, - { - key: "reason", - header: "原因", - cell: (r) => ( -
- - {inInbox(r) ? ( - e.stopPropagation()} - className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" - > - 已进待处理 - - - ) : null} -
- ), - }, - { - key: "cands", - header: "候选", - hideBelow: "sm", - align: "right", - cell: (r) => {r.candidates.length}, - }, - { - key: "attempts", - header: "尝试", - hideBelow: "md", - cell: (r) => ( - - {r.attempts} 次 · {relTime(r.lastAttemptAt)} - - ), - }, - { - key: "next", - header: "下次复查", - hideBelow: "lg", - cell: (r) => ( - - {r.status === "pending" ? ( - r.nextReviewAt ? ( - relTime(r.nextReviewAt) - ) : ( - "不自动复查" - ) - ) : ( - - )} - - ), - }, - ]; - - const filtered = !!q || status !== "pending" || !!reason; - return ( - <> - { - setQ(""); - setStatus("pending"); - setReason(""); - } - : undefined - } - > - - - - r.id} - loading={pool.isPending} - error={pool.error} - onRetry={() => pool.refetch()} - onRowClick={(r) => setOpenId(r.id)} - selectedKey={openId} - empty={{ - icon: SearchX, - title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源", - description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。", - }} - hasMore={pool.hasNextPage} - onLoadMore={() => pool.fetchNextPage()} - loadingMore={pool.isFetchingNextPage} - /> - setOpenId(null)} /> - - ); -} - -function ExternalTab() { - const [provider, setProvider] = useState(""); - const [status, setStatus] = useState(""); - const list = useExternalIdentities({ - provider: provider || undefined, - status: (status || undefined) as IdentityStatus | undefined, - }); - const [openId, setOpenId] = useState(null); - const rows = list.data?.pages.flatMap((p) => p.items); - const open = rows?.find((r) => r.id === openId) ?? null; - - const columns: DataColumn[] = [ - { - key: "ref", - header: "外部身份", - cell: (r) => ( - - {r.locked ? : null} - {identityRef(r)} - - ), - }, - { - key: "entity", - header: "作品", - hideBelow: "sm", - cell: (r) => ( - - {r.mediaEntityTitle ?? 未关联} - - ), - }, - { key: "status", header: "状态", cell: (r) => }, - { - key: "level", - header: "证据", - hideBelow: "sm", - cell: (r) => {EVIDENCE_LEVEL[r.evidenceLevel]}, - }, - { key: "verify", header: "外部校验", hideBelow: "md", cell: (r) => }, - { - key: "rev", - header: "修订", - hideBelow: "lg", - align: "right", - cell: (r) => ( - - r{r.revision} · {relTime(r.updatedAt)} - - ), - }, - ]; - const filtered = !!provider || !!status; - return ( - <> - { - setProvider(""); - setStatus(""); - } - : undefined - } - > - - - - r.id} - loading={list.isPending} - error={list.error} - onRetry={() => list.refetch()} - onRowClick={(r) => setOpenId(r.id)} - selectedKey={openId} - empty={{ - icon: Fingerprint, - title: "没有外部身份", - description: "没有外部身份的作品也能建档入库。", - }} - hasMore={list.hasNextPage} - onLoadMore={() => list.fetchNextPage()} - loadingMore={list.isFetchingNextPage} - /> - setOpenId(null)} /> - - ); -} - -const TABS = ["pool", "external", "aliases"] as const; - -export default function IdentityPage() { - const [params, setParams] = useSearchParams(); - const tab = (TABS as readonly string[]).includes(params.get("tab") ?? "") ? params.get("tab")! : "pool"; - return ( - <> - - - - 确认身份 - - - } - /> - - setParams( - (p) => { - p.set("tab", v); - return p; - }, - { replace: true }, - ) - } - > - - 待识别 - 外部身份 - 别名 - - - - - - - - - - - - - ); +export default function IdentityRedirect() { + return null; } diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx index 24cb84db..c8246d36 100644 --- a/apps/web/app/routes/console/layout.tsx +++ b/apps/web/app/routes/console/layout.tsx @@ -3,12 +3,12 @@ import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "luc import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link, Outlet, useLocation } from "react-router"; import { useSession } from "~/api/auth.ts"; -import { useDecisionSummary } from "~/api/decisions.ts"; import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx"; import { AccountMenu } from "~/components/console/account-menu.tsx"; import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx"; import { ErrorState } from "~/components/console/empty-state.tsx"; -import { findNavItem, NAV_GROUPS, type NavGroup, type NavItem } from "~/components/console/nav.ts"; +import { InboxBell } from "~/components/console/inbox-bell.tsx"; +import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts"; import PrismLeak from "~/components/prism-leak.tsx"; import { ThemeToggle } from "~/components/theme-toggle.tsx"; import { Button } from "~/components/ui/button.tsx"; @@ -18,31 +18,10 @@ import { cn } from "~/lib/utils.ts"; const PORTAL_NAME = "媒体自动化"; -/** 侧栏角标:待处理未决数 */ -function useNavBadges(): Record, number> { - const summary = useDecisionSummary(); - return { decisions: summary.data?.total ?? 0 }; -} - -function NavBadge({ n, className }: { n: number; className?: string }) { - if (!n) return null; - return ( - - {n > 99 ? "99+" : n} - - ); -} - // ─── 展开态导航:选中项由一枚玻璃滑块(seg-thumb)滑到激活链接背后 ─── function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: () => void }) { const location = useLocation(); const activeTo = findNavItem(location.pathname)?.to; - const badges = useNavBadges(); const navRef = useRef(null); const [pill, setPill] = useState<{ x: number; y: number; w: number; h: number } | null>(null); @@ -102,7 +81,6 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: > {item.label} - {item.badge ? : null} ); })} @@ -117,7 +95,6 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: function CollapsedNav() { const location = useLocation(); const activeTo = findNavItem(location.pathname)?.to; - const badges = useNavBadges(); return (