- 侧栏与命令面板没有「待处理」;/console/inbox?kind= 按问题类型跳到整理记录「需要处理」、下载面板、运行状态或订阅 - 整理记录加「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 替换·保留两者·跳过 / 忽略), 详情页同样可处理;处理按钮从原待处理页抽成 components/decisions/decision-actions.tsx 复用 - 铃铛只放等你批准的智能体操作与系统健康;首页「要我处理」与运行状态「需要关注」放汇总计数,运行状态加智能体用量 - 设置 → 智能体:预算卡改为「用量与限制」(只剩单次工具调用与时长,附花费统计),自动调查卡加「整理识别不到作品时叫智能体识别」 - 集数待确认的原因显示在作品页集数映射(可确认建议规则 / 智能体建议)与订阅详情;未识别资源在媒体库「未识别」里直接选候选或指定 - 下载、站点、任务里指向待处理的链接改到对应页面 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { Dashboard } from "@umt/contracts";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
Bot,
|
|
CircleCheck,
|
|
Download,
|
|
Library as LibraryIcon,
|
|
type LucideIcon,
|
|
Pause,
|
|
Play,
|
|
} from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { useRef } from "react";
|
|
import { Link } from "react-router";
|
|
import { toast } from "sonner";
|
|
import { useAttention } from "~/api/attention.ts";
|
|
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
|
import { useEngineRunsPage } from "~/api/engine.ts";
|
|
import { useUpdateSettings } from "~/api/settings.ts";
|
|
import { useSystemHealth } from "~/api/system.ts";
|
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
|
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
|
|
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
|
|
import { runTitle } from "~/components/tasks/labels.ts";
|
|
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { formatRelative } from "~/lib/labels.ts";
|
|
import { qk } from "~/lib/query-keys.ts";
|
|
import { useSse } from "~/lib/sse.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
|
|
// 首页概览:自动化、存储与下载器状态,以及当前工作、要我处理的汇总计数(整理需要处理 / 等你批准 / 系统健康)和最近入库。
|
|
|
|
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
|
const DASHBOARD_EVENTS = [
|
|
"task.created",
|
|
"task.status_changed",
|
|
"download.submitted",
|
|
"download.status_changed",
|
|
"download.threshold",
|
|
"organization.changed",
|
|
"agent.run_status",
|
|
"decision.created",
|
|
"decision.updated",
|
|
"settings.changed",
|
|
];
|
|
|
|
function useLiveDashboard() {
|
|
const qc = useQueryClient();
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useSse(DASHBOARD_EVENTS, () => {
|
|
if (timer.current) return;
|
|
timer.current = setTimeout(() => {
|
|
timer.current = null;
|
|
qc.invalidateQueries({ queryKey: qk.dashboard });
|
|
}, 1500);
|
|
});
|
|
return useDashboard();
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
icon: Icon,
|
|
to,
|
|
children,
|
|
className,
|
|
}: {
|
|
title: string;
|
|
icon: LucideIcon;
|
|
to?: string;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
|
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
|
{to ? (
|
|
<Link
|
|
to={to}
|
|
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
|
>
|
|
全部
|
|
<ArrowRight className="h-3.5 w-3.5" />
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Row({ children }: { children: ReactNode }) {
|
|
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
|
}
|
|
|
|
/** 非零项拼一句:[[3, "个下载"], [0, "个入库中"]] → "3 个下载" */
|
|
function nonZero(parts: [number, string][]): string {
|
|
return parts
|
|
.filter(([n]) => n > 0)
|
|
.map(([n, s]) => `${n} ${s}`)
|
|
.join(" · ");
|
|
}
|
|
|
|
// ─── 顶部一行:自动化状态 + 需要知道的异常 ───
|
|
function StatusLine({ d }: { d: Dashboard }) {
|
|
const a = d.automation;
|
|
const update = useUpdateSettings();
|
|
const health = useSystemHealth(60_000);
|
|
const sites = useSitesWithAccounts();
|
|
const setEnabled = async (enabled: boolean) => {
|
|
try {
|
|
await update.mutateAsync({ automation: { enabled } });
|
|
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, "切换失败"));
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
|
|
const problems: { text: string; to: string }[] = [];
|
|
const lv = health.data?.level;
|
|
if (lv === "critical" || lv === "warn")
|
|
problems.push({ text: lv === "critical" ? "服务异常" : "服务需要关注", to: consolePath("status") });
|
|
for (const v of d.cache.volumes)
|
|
if (v.usedPercent !== null && v.usedPercent >= v.hardThresholdPercent)
|
|
problems.push({ text: `下载盘已用 ${Math.round(v.usedPercent)}%,新下载已暂停`, to: consolePath("downloads") });
|
|
const badAccounts = (sites.data?.items ?? []).filter(
|
|
(s) => s.enabled && s.accountStatus && ["invalid", "expired", "challenge"].includes(s.accountStatus),
|
|
).length;
|
|
if (badAccounts) problems.push({ text: `${badAccounts} 个站点账号需要重新登录`, to: consolePath("sites") });
|
|
const offline = d.sources.downloaders.filter((x) => x.enabled && ["offline", "failing"].includes(x.health)).length;
|
|
if (offline) problems.push({ text: `${offline} 个下载器连不上`, to: settingsPath("downloaders") });
|
|
|
|
const paused = !a.enabled;
|
|
const warn = paused || a.readOnlyReconcile || problems.length > 0;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"pg-slab mb-4 flex flex-col gap-3 rounded-xl px-4 py-3 sm:flex-row sm:items-center sm:px-5",
|
|
warn && "border border-warning/40",
|
|
)}
|
|
>
|
|
<p className="min-w-0 flex-1 text-sm text-foreground">
|
|
<span className={cn("mr-2 inline-block size-2 rounded-full", warn ? "bg-warning" : "bg-success")} aria-hidden />
|
|
{paused ? (
|
|
"自动化已暂停,手动操作仍可用"
|
|
) : a.readOnlyReconcile ? (
|
|
<>
|
|
只读模式:不会自动下载和整理。
|
|
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
|
|
去设置关闭
|
|
</Link>
|
|
</>
|
|
) : a.activeSubscriptions > 0 ? (
|
|
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
|
|
) : (
|
|
<>
|
|
自动化已开启 · 还没有订阅。
|
|
<Link to={consolePath("search")} className="underline underline-offset-4">
|
|
去搜索添加
|
|
</Link>
|
|
</>
|
|
)}
|
|
{problems.map((p) => (
|
|
<span key={p.text}>
|
|
{" · "}
|
|
<Link to={p.to} className="text-warning underline-offset-4 hover:underline">
|
|
{p.text}
|
|
</Link>
|
|
</span>
|
|
))}
|
|
</p>
|
|
{paused ? (
|
|
<Button size="sm" disabled={update.isPending} onClick={() => setEnabled(true).catch(() => {})}>
|
|
<Play />
|
|
恢复
|
|
</Button>
|
|
) : (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="outline" size="sm" disabled={update.isPending}>
|
|
<Pause />
|
|
暂停
|
|
</Button>
|
|
}
|
|
title="暂停全部自动化?"
|
|
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
|
confirmLabel="暂停自动化"
|
|
onConfirm={() => setEnabled(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
|
|
function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
|
const running = useEngineRunsPage({ status: "running" }, 10, 10_000);
|
|
const runs = (running.data?.items ?? []).filter((r) => r.business);
|
|
const downloads = w.items.slice(0, 4);
|
|
const agents = w.agentRuns.slice(0, 2);
|
|
const summary = nonZero([
|
|
[w.downloading, "个下载中"],
|
|
[w.importing, "个入库中"],
|
|
[w.stalled, "个没有进展"],
|
|
[w.paused, "个已暂停"],
|
|
]);
|
|
const empty = !downloads.length && !agents.length && !runs.length;
|
|
return (
|
|
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
|
|
{running.isPending && !downloads.length ? (
|
|
<LoadingState rows={3} />
|
|
) : empty ? (
|
|
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
|
|
) : (
|
|
<ul className="divide-y divide-border/60">
|
|
{downloads.map((t) => (
|
|
<Row key={t.downloadTaskId}>
|
|
<Link to={`/console/downloads/${t.downloadTaskId}`} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">{t.name}</span>
|
|
{t.status === "stalled" ? <span className="block text-meta text-warning">没有进展</span> : null}
|
|
</Link>
|
|
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
|
<Download className="mr-1 inline h-3 w-3" aria-hidden />
|
|
{Math.round(t.progress * 100)}%
|
|
</span>
|
|
</Row>
|
|
))}
|
|
{agents.map((r) => (
|
|
<Row key={r.id}>
|
|
<Link to={`/console/agent/${r.id}`} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">{r.goal}</span>
|
|
</Link>
|
|
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="智能体" />
|
|
</Row>
|
|
))}
|
|
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
|
|
<Row key={r.id}>
|
|
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">{runTitle(r)}</span>
|
|
</Link>
|
|
</Row>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 要我处理什么:汇总计数(整理需要处理、等你批准、系统健康),点进去到对应页面 ───
|
|
function NeedsMe() {
|
|
const q = useAttention();
|
|
const rows = attentionRows(q.data);
|
|
return (
|
|
<Section title="要我处理" icon={AlertTriangle}>
|
|
{q.isPending ? (
|
|
<LoadingState rows={2} />
|
|
) : rows.length === 0 ? (
|
|
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
|
|
) : (
|
|
<AttentionList rows={rows} />
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 最近入库了什么:成功 / 部分成功的整理 ───
|
|
function RecentImports({ d }: { d: Dashboard }) {
|
|
const rows = d.recentOutput.filter((r) => r.status === "succeeded" || r.status === "partial").slice(0, 6);
|
|
const today = d.automation.today.filesImported;
|
|
return (
|
|
<Section title="最近入库" icon={LibraryIcon} to={consolePath("organize")}>
|
|
{rows.length === 0 ? (
|
|
<EmptyState plain icon={LibraryIcon} title="最近没有新入库的作品" className="py-6" />
|
|
) : (
|
|
<ul className="divide-y divide-border/60">
|
|
{rows.map((r) => (
|
|
<Row key={r.recordId}>
|
|
<Link
|
|
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : `/console/organize/${r.recordId}`}
|
|
className="min-w-0 flex-1 hover:underline"
|
|
>
|
|
<span className="block truncate">{r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"}</span>
|
|
{r.status === "partial" ? (
|
|
<span className="block text-meta text-warning">
|
|
{r.counts.succeeded}/{r.counts.total} 个文件入库
|
|
</span>
|
|
) : null}
|
|
</Link>
|
|
<span className="shrink-0 text-meta text-muted-foreground">{formatRelative(r.finishedAt)}</span>
|
|
</Row>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{today ? <p className="mt-auto pt-3 text-meta text-muted-foreground">今天入库 {today} 个文件</p> : null}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
export default function Home() {
|
|
const q = useLiveDashboard();
|
|
useLiveTasks();
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="首页"
|
|
actions={
|
|
q.data ? (
|
|
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
|
|
) : null
|
|
}
|
|
/>
|
|
{q.data ? <StatusLine d={q.data} /> : null}
|
|
<HomeRuntime downloaders={q.data?.sources.downloaders} />
|
|
{q.isPending ? (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{["a", "b", "c"].map((k) => (
|
|
<div key={k} className="pg-slab rounded-xl p-5">
|
|
<LoadingState rows={3} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : q.isError ? (
|
|
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<Busy w={q.data.currentWork} />
|
|
<NeedsMe />
|
|
<RecentImports d={q.data} />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|