Merge branch 'deai2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	apps/server/src/modules/acquisition-organize/services/organize.ts
#	apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
root committed 2026-09-29 21:26:16 +08:00
commit 12bc4664b6
247 files changed
+2658 -2148

No files matched your search

+3 -3
View File
@@ -3,7 +3,7 @@ import { Link, useParams } from "react-router";
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { splitContextGoal } from "~/components/agent/agent-store.ts";
import { shortGoalTitle } from "~/components/agent/agent-store.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -17,11 +17,11 @@ export default function AgentRunPage() {
return (
<>
<PageHeader
title="Agent 会话"
title="智能体会话"
description={
run.data ? (
<span className="line-clamp-2 break-words" title={run.data.run.goal}>
{splitContextGoal(run.data.run.goal).text}
{shortGoalTitle(run.data.run.goal)}
</span>
) : (
"加载中…"
+1 -4
View File
@@ -15,10 +15,7 @@ export default function AgentPage() {
return (
<>
<PageHeader
title="Agent 会话"
description="交代目标,Agent 列出步骤后在授权范围内执行并汇报;需要审批的动作停在对应步骤等你批准。"
/>
<PageHeader title="智能体会话" />
{mock ? (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
<FlaskConical className="h-4 w-4 shrink-0" />
+33 -11
View File
@@ -1,4 +1,4 @@
import { AuditAction, type AuditEvent } from "@umt/contracts";
import { AuditAction, type AuditEvent, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
@@ -40,10 +40,11 @@ const ACTION_LABEL: Record<string, string> = {
"schedule.skip": "跳过定时任务",
"acquisition_plan.manual_submit": "手动提交下载计划",
"engine.run_retry": "重跑任务",
"capability.invoke": "Agent 调用能力",
"capability.invoke": "智能体操作",
"capability.denied": "智能体操作被拒绝",
"site.created": "登记站点",
"site.updated": "修改站点",
"feed_source.created": "新建 Feed",
"feed_source.created": "新建 RSS",
"identity.release_assigned": "指定资源身份",
"schedule.update": "修改定时任务",
"files.mkdir": "新建目录",
@@ -56,14 +57,37 @@ const ACTION_LABEL: Record<string, string> = {
};
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
const NOUN: Record<string, string> = {
library_root: "库根",
library_root: "媒体库目录",
downloader_instance: "下载器",
media_server_instance: "媒体服务器",
path_mapping: "路径映射",
pi_upstream: "Agent 上游",
pi_upstream: "模型服务",
schedule: "定时任务",
automation_grant: "持续授权",
};
const TARGET_LABEL: Record<string, string> = {
...NOUN,
admin_user: "管理员",
api_key: "API Key",
settings: "设置",
secret: "凭证",
site: "站点",
site_account: "站点账号",
feed_source: "RSS",
engine_run: "任务",
acquisition_plan: "下载计划",
download_task: "下载任务",
media_entity: "作品",
source_release: "资源",
organization_record: "整理记录",
file: "文件",
capability: "能力",
};
/** 对象列:能力显示中文名,其余显示对象类型;ID 只在详情里 */
function targetText(type: string, id: string | null): string {
if (type === "capability" && id) return CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
return TARGET_LABEL[type] ?? type;
}
function actionLabel(a: string): string {
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
const [noun, verb] = a.split(".");
@@ -132,7 +156,7 @@ export default function AuditPage() {
return (
<>
<PageHeader title="审计" description="登录、设置、密钥与实例变更,新的在前。不记录凭证内容。" />
<PageHeader title="审计" />
<FilterBar
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
onReset={
@@ -188,7 +212,6 @@ export default function AuditPage() {
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{actionLabel(r.action)}</p>
<p className="font-mono text-meta text-muted-foreground">{r.action}</p>
</div>
),
},
@@ -199,10 +222,9 @@ export default function AuditPage() {
hideBelow: "md",
cell: (r) =>
r.targetType ? (
<div className="min-w-0 max-w-[18rem]">
<p className="truncate">{r.targetType}</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.targetId}</p>
</div>
<p className="min-w-0 max-w-[18rem] truncate" title={`${r.targetType} · ${r.targetId ?? ""}`}>
{targetText(r.targetType, r.targetId)}
</p>
) : (
"—"
),
@@ -78,7 +78,7 @@ export default function DownloadPlansPage() {
return (
<>
<PageHeader title="下载面板" description="候选被选中后生成计划,同时预留版本目标、空间与预算。" />
<PageHeader title="下载面板" />
<DownloadsTabs />
<FilterBar onReset={f.active ? f.reset : undefined}>
<FilterSelect
@@ -134,7 +134,7 @@ export default function DownloadPlansPage() {
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
{ label: "版本目标", value: openPlan.slotId, mono: true },
{ label: "收录版本", value: openPlan.slotId, mono: true },
{ label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true },
]}
/>
+3 -4
View File
@@ -91,7 +91,6 @@ export default function DownloadsPage() {
<>
<PageHeader
title="下载面板"
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
actions={
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
@@ -109,12 +108,12 @@ export default function DownloadsPage() {
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="纳管"
label="来源"
value={f.values.managed}
onChange={(v) => f.set("managed", v)}
options={[
{ value: "true", label: "纳管任务" },
{ value: "false", label: "未纳管" },
{ value: "true", label: "本工具" },
{ value: "false", label: "其他" },
]}
/>
</FilterBar>
+4 -4
View File
@@ -212,14 +212,14 @@ export default function FilesTrashPage() {
},
{
key: "asset",
header: "关联资产",
header: "媒体库记录",
hideBelow: "lg",
cell: (r) => {
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
return (
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
<span className="block max-w-48 text-meta" title="删除时已从媒体库移除;还原不会自动恢复">
{snap.mediaEntityId ? (
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
{label}
@@ -274,7 +274,7 @@ export default function FilesTrashPage() {
<>
<PageHeader
title="回收站"
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
description="到期后自动清除,到期前可以还原。还原不会恢复媒体库记录,需要时重新整理。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
@@ -419,7 +419,7 @@ function RestoreToDialog({
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>还原到…</DialogTitle>
<DialogDescription>{ids.length} 项 · 选一个可写库根下的目录。同名项已存在时不覆盖。</DialogDescription>
<DialogDescription>{ids.length} 项 · 选一个可写媒体库目录下的目录。同名项已存在时不覆盖。</DialogDescription>
</DialogHeader>
<DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
<DialogFooter>
+8 -6
View File
@@ -97,7 +97,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
const HEALTH_REASON: Record<string, string> = {
root_missing: "挂载点不存在",
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
sentinel_missing: "目录里没有标记文件,挂载可能没生效",
timeout: "探测超时",
permission_denied: "没有读取权限",
};
@@ -398,7 +398,9 @@ export default function FilesPage() {
onSuccess: (r) => {
const ok = r.items.filter((i) => i.status === "ok").length;
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
description: r.items.some((i) => i.savedToAsset) ? "媒体信息已写入资产" : "未登记文件的结果缓存 24 小时",
description: r.items.some((i) => i.savedToAsset)
? "媒体信息已写入媒体库记录"
: "未登记文件的结果缓存 24 小时",
});
},
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
@@ -540,7 +542,7 @@ export default function FilesPage() {
if (items.every((i) => i.root === target.root && (i.path === target.path || parentRel(i.path) === target.path)))
return;
const tRoot = rootList.find((r) => r.id === target.root);
if (!tRoot?.writable) return toast.error("只能拖到可写的库根");
if (!tRoot?.writable) return toast.error("只能拖到可写的媒体库目录");
setDialog({ kind: "transfer", items, mode: ev.altKey ? "copy" : "move", target });
};
@@ -567,7 +569,7 @@ export default function FilesPage() {
plain
icon={FolderTree}
title="还没有登记目录"
description="在设置里添加库根或下载器路径映射。"
description="在设置里添加媒体库目录或下载器路径映射。"
className="px-2 py-8"
/>
) : null}
@@ -596,7 +598,7 @@ export default function FilesPage() {
</span>
<p className="text-lead font-medium text-foreground">{root.name} 离线</p>
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间资产状态不变。
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间文件状态不变。
</p>
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
<Button
@@ -693,7 +695,7 @@ export default function FilesPage() {
key: "scrape",
label: "刷新元数据",
icon: RefreshCw,
disabled: selected.every((e) => e.asset) ? null : "只对已登记项",
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
onSelect: () => act("scrape", selected),
});
items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) });
+165 -516
View File
@@ -5,18 +5,12 @@ import {
AlertTriangle,
ArrowRight,
Bot,
CalendarClock,
CalendarCog,
CircleCheck,
Gauge,
HardDrive,
Download,
Library as LibraryIcon,
ListTodo,
type LucideIcon,
Pause,
Play,
Radio,
ShieldAlert,
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef } from "react";
@@ -24,31 +18,27 @@ import { Link } from "react-router";
import { toast } from "sonner";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.ts";
import { useEngineRunsPage, useEngineStats } from "~/api/engine.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 { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
import { formatUsd, planPath } from "~/components/schedules/labels.ts";
import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx";
import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
"schedule.changed",
"task.created",
"task.status_changed",
"download.submitted",
@@ -56,8 +46,6 @@ const DASHBOARD_EVENTS = [
"download.threshold",
"organization.changed",
"agent.run_status",
"feed.fetched",
"feed.health_changed",
"decision.created",
"decision.updated",
"settings.changed",
@@ -82,28 +70,24 @@ function Section({
to,
children,
className,
extra,
}: {
title: string;
icon: LucideIcon;
to?: string;
children: ReactNode;
className?: string;
/** 标题右侧的摘要(计数、状态) */
extra?: ReactNode;
}) {
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" />
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
{extra}
{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}
@@ -117,144 +101,170 @@ 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>;
}
// ─── 自动化总开关:显眼状态 + 切换 ───
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
/** 非零项拼一句:[[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 setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
const health = useSystemHealth(60_000);
const sites = useSitesWithAccounts();
const setEnabled = async (enabled: boolean) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(ok);
await update.mutateAsync({ automation: { enabled } });
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
};
const paused = !a.enabled;
const readOnly = a.readOnlyReconcile;
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
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-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
(paused || readOnly) && "border border-warning/40",
"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",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
)}
>
{readOnly ? (
<ShieldAlert className="h-5 w-5" />
) : paused ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-lead font-semibold text-foreground">
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
</p>
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
</div>
<p className="mt-0.5 text-caption text-muted-foreground">
{paused
? "自动收录、下载、整理已停止,手动操作仍可用。"
: readOnly
? "只核对,不写入。确认无误后关闭。"
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2 text-caption text-foreground">
<label htmlFor="home-readonly">只读对账</label>
<Switch
id="home-readonly"
checked={readOnly}
disabled={update.isPending}
onCheckedChange={(v) =>
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
}
aria-label="只读对账模式"
/>
</div>
<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 ? (
<Button
size="sm"
disabled={update.isPending}
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
>
<Play />
恢复自动化
</Button>
"自动化已暂停,手动操作仍可用"
) : a.readOnlyReconcile ? (
<>
只读模式:不会自动下载和整理。
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
去设置关闭
</Link>
</>
) : a.activeSubscriptions > 0 ? (
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
) : (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={update.isPending}>
<Pause />
暂停自动化
</Button>
}
title="暂停全部自动化?"
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
confirmLabel="暂停自动化"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
/>
<>
自动化已开启 · 还没有订阅。
<Link to={consolePath("search")} className="underline underline-offset-4">
去搜索添加
</Link>
</>
)}
</div>
{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>
);
}
// ─── 正在进行:任务流 top N ───
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
const stats = useEngineStats();
const rows = running.data?.items ?? [];
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
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 ? (
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
{running.isPending && !downloads.length ? (
<LoadingState rows={3} />
) : running.isError ? (
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
) : empty ? (
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{rows.slice(0, 5).map((r) => (
{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>
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
</Link>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{formatDuration(spanSec(r))}
</span>
</Row>
))}
</ul>
)}
<p className="mt-auto pt-3 text-meta text-muted-foreground">
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
</p>
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
</Section>
);
}
// ─── 需要我处理:计数 + 前 3 条 ───
// ─── 要我处理什么:计数 + 前 3 条 ───
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
const list = useDecisions({});
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
return (
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
) : (
<>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
@@ -268,9 +278,7 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
<Row key={d.id}>
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{d.title}</span>
<span className="block truncate text-meta text-muted-foreground">
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
</span>
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
</Link>
</Row>
))}
@@ -281,387 +289,39 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
);
}
// ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ───
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
const c = a.slotCounts;
const total = Object.values(c).reduce((x, y) => x + y, 0);
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
const lib = a.library;
// ─── 最近入库了什么:成功 / 部分成功的整理 ───
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("library")}>
<div className="grid grid-cols-3 gap-2">
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
</div>
<div className="mt-3">
<div className="flex items-baseline justify-between text-sm">
<span>版本目标满足率</span>
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
</div>
<p className="mt-1.5 text-meta text-muted-foreground">
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
</p>
</div>
<p className="mt-auto pt-3 text-meta text-muted-foreground">
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
升级 {a.today.upgrades}
</p>
</Section>
);
}
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
function PlanCount({
to,
label,
icon: Icon,
children,
}: {
to: string;
label: string;
icon: LucideIcon;
children: ReactNode;
}) {
return (
<Link
to={to}
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
</span>
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
{children}
</span>
</Link>
);
}
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
return (
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
{children}
</span>
);
}
function PlanStrip() {
const board = useLiveSchedules();
const s = planStats(board.data?.items ?? []);
return (
<Section
title="计划"
icon={CalendarCog}
to={planPath()}
extra={
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
}
>
{board.isPending ? (
<LoadingState rows={2} />
) : board.isError ? (
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
) : (
<div className="grid grid-cols-2 gap-2">
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
<span>
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
</span>
<span>
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
</span>
</PlanCount>
<PlanCount to={planPath({ section: "checks" })} label="Agent 巡检" icon={Bot}>
<span>
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
</span>
<span title="近 7 天巡检会话费用合计">
近 7 天 <Num>{formatUsd(s.checks.weekCost)}</Num>
</span>
</PlanCount>
</div>
)}
</Section>
);
}
// ─── 最近完成 / 失败 ───
function RecentRuns() {
const done = useEngineRunsPage({ status: "completed" }, 6);
const failed = useEngineRunsPage({ status: "failed" }, 6);
const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])]
.sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt))
.slice(0, 6);
const loading = done.isPending || failed.isPending;
const err = done.error ?? failed.error;
return (
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
{loading ? (
<LoadingState rows={3} />
) : err ? (
<ErrorState
plain
error={err}
onRetry={() => {
void done.refetch();
void failed.refetch();
}}
/>
) : rows.length === 0 ? (
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
<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) => {
const s = engineRunStatus(r);
return (
<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>
<span className="block truncate text-meta text-muted-foreground">
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
{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>
</Link>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
);
})}
) : 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>
);
}
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
unconfigured: { label: "未配置账号", tone: "neutral" },
unknown: { label: "账号未验证", tone: "neutral" },
active: { label: "账号正常", tone: "success" },
invalid: { label: "账号失效", tone: "destructive" },
expired: { label: "登录过期", tone: "warning" },
challenge: { label: "需人工验证", tone: "warning" },
};
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
const sites = useSitesWithAccounts();
const siteRows = (sites.data?.items ?? s.sites).map((x) => {
const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined;
return {
id: x.id,
kind: "站点",
name: x.name,
enabled: x.enabled,
health: "healthStatus" in x ? x.healthStatus : x.health,
account: acc,
to: `/console/sites/${x.id}`,
};
});
const rows = [
...siteRows,
...s.feeds.map((x) => ({
id: x.id,
kind: "Feed",
name: x.name,
enabled: x.enabled,
health: x.health,
account: undefined,
to: consolePath("sources"),
})),
...s.downloaders.map((x) => ({
id: x.id,
kind: "下载器",
name: x.name,
enabled: x.enabled,
health: x.health,
account: undefined,
to: settingsPath("downloaders"),
})),
];
const bad = (r: (typeof rows)[number]) =>
(r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) +
(r.account && r.account.tone !== "success" ? 1 : 0);
rows.sort((x, y) => bad(y) - bad(x));
const accountIssues = siteRows.filter(
(r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"),
).length;
return (
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
{rows.length === 0 ? (
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
) : (
<>
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
<ul className="divide-y divide-border/60">
{rows.slice(0, 7).map((r) => {
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
return (
<Row key={`${r.kind}-${r.id}`}>
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
{r.name}
</span>
{r.account ? (
<span
className={cn(
"block truncate text-meta",
r.account.tone === "destructive"
? "text-destructive"
: r.account.tone === "warning"
? "text-warning"
: "text-muted-foreground",
)}
>
{r.account.label}
</span>
) : null}
</Link>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
</Row>
);
})}
</ul>
</>
)}
</Section>
);
}
// ─── 运行状态摘要(/system/health) ───
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" },
warn: { label: "需要关注", tone: "warning" },
critical: { label: "需要处理", tone: "destructive" },
unknown: { label: "未知", tone: "neutral" },
};
function StatusSummary() {
const h = useSystemHealth(30_000);
const d = h.data;
const stale = d?.processes.filter((p) => p.stale) ?? [];
const alive = (d?.processes.length ?? 0) - stale.length;
const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0;
const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown;
return (
<Section
title="运行状态"
icon={Gauge}
to={consolePath("status")}
className="md:col-span-2 xl:col-span-3"
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
>
{h.isPending ? (
<LoadingState rows={1} />
) : h.isError ? (
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
) : d ? (
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
<span>
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
{stale.length ? (
<span className="text-warning">
{" "}
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
</span>
) : null}
</span>
<span>
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
</span>
<span>
24 小时失败率{" "}
<span className="font-medium tabular-nums">
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
</span>
</span>
<span>
数据库{" "}
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
</span>
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
</div>
) : null}
</Section>
);
}
function CacheSection({ c }: { c: Dashboard["cache"] }) {
const volumeLabel = useVolumeLabel();
return (
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
{c.volumes.length === 0 ? (
<EmptyState
plain
icon={HardDrive}
title="还没有下载器"
description="添加下载器后显示缓存水位。"
className="py-6"
/>
) : (
<ul className="space-y-4">
{c.volumes.map((v) => {
const pct = v.usedPercent;
const over = pct !== null && pct >= v.hardThresholdPercent;
const near = pct !== null && pct >= v.resumePercent;
return (
<li key={v.volumeKey} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{volumeLabel(v)}</span>
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
</StatusBadge>
</div>
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${pct ?? 0}%` }}
/>
<span
aria-hidden
className="absolute inset-y-0 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
/>
</div>
<p className="mt-1.5 text-meta text-muted-foreground">
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
</p>
{v.notes.map((n) => (
<p key={n} className="text-meta text-muted-foreground">
{n}
</p>
))}
</li>
);
})}
</ul>
)}
<Link
to={settingsPath("automation", "retention")}
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
调整阈值与保留天数
</Link>
</Section>
);
}
function LiveIndicator() {
const s = useSseStatus();
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
return <StatusBadge tone={tone}>{label}</StatusBadge>;
}
export default function Home() {
const q = useLiveDashboard();
useLiveTasks();
@@ -669,21 +329,15 @@ export default function Home() {
<>
<PageHeader
title="首页"
description="正在进行的任务、需要处理的事项、计划和最近结果。"
actions={
<div className="flex items-center gap-2">
<LiveIndicator />
{q.data ? (
<span className="hidden text-meta text-muted-foreground sm:inline">
更新于 {formatRelative(q.data.generatedAt)}
</span>
) : null}
</div>
q.data ? (
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
) : null
}
/>
{q.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c", "d", "e", "f"].map((k) => (
{["a", "b", "c"].map((k) => (
<div key={k} className="pg-slab rounded-xl p-5">
<LoadingState rows={3} />
</div>
@@ -693,16 +347,11 @@ export default function Home() {
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<AutomationBanner a={q.data.automation} />
<StatusLine d={q.data} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<InProgress w={q.data.currentWork} />
<Busy w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} />
<MediaOverview a={q.data.automation} />
<PlanStrip />
<RecentRuns />
<CacheSection c={q.data.cache} />
<SourcesSection s={q.data.sources} />
<StatusSummary />
<RecentImports d={q.data} />
</div>
</>
)}
+130 -35
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw, Search } from "lucide-react";
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
@@ -12,17 +12,20 @@ import { FormField } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.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 { TechInfo } from "~/components/console/tech-info.tsx";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatDateTime } from "~/lib/utils.ts";
@@ -78,7 +81,7 @@ function GroupReleases({ d }: { d: Decision }) {
))}
{count > list.length ? (
<li className="text-meta text-muted-foreground">
另有 {count - list.length} 条,可在媒体概览 → 待识别按标题检索
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
</li>
) : null}
</ul>
@@ -94,8 +97,8 @@ function entityOf(d: Decision): string | null {
}
const IMPACT_KEY: Record<string, string> = {
slots: "版本目标",
units: "内容单元",
slots: "收录版本",
units: "季与集",
files: "文件",
releases: "资源",
tasks: "任务",
@@ -140,14 +143,21 @@ function Evidence({ d }: { d: Decision }) {
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-meta text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Agent 已检查
智能体已检查
</span>
{checked.map((c) => (
<Chip key={c}>{c}</Chip>
))}
</div>
) : null}
<JsonView label="原始证据" value={e} defaultOpen={false} maxHeight="14rem" />
<TechInfo
items={[
{ label: "事项 ID", value: d.id },
{ label: "类型", value: d.kind },
]}
>
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
</TechInfo>
</div>
);
}
@@ -246,6 +256,82 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
);
}
/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */
function subjectName(d: Decision): string | null {
const e = d.evidence;
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
return (
str(first?.title) ??
str(e.title) ??
str(e.name) ??
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
);
}
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
function queryOf(name: string | null): string {
if (!name) return "";
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
return (m?.[1]?.trim() || base)
.replace(/[[\]()]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 60);
}
const IDENTITY_KINDS: DecisionKind[] = ["identity_ambiguous", "identity_unresolved"];
/** 「没识别出作品」的主操作:搜作品并指定。资源类事项走 resolve(本地作品 / TMDB),整理类事项打开重新整理 */
function AssignWorkButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
const [open, setOpen] = useState(false);
const [wizard, setWizard] = useState(false);
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
const recordId = d.subjectType === "organization_record" ? d.subjectId : null;
if (!local && !tmdb && !recordId) return null;
const name = subjectName(d);
const assign = (w: AssignedWork) => {
const option = "mediaEntityId" in w ? local : tmdb;
if (!option) {
toast.error("mediaEntityId" in w ? "这条事项只能按 TMDB 指定" : "这条事项只能选媒体库里的作品");
return Promise.reject(new Error("unsupported"));
}
return resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
})
.then(
() => toast.success(`已指定为「${w.title}」`),
(err) => {
toast.error(errorMessage(err, "指定失败"));
throw err;
},
);
};
return (
<>
<Button size="sm" onClick={() => (recordId && !local && !tmdb ? setWizard(true) : setOpen(true))}>
<Search />
指定作品
</Button>
<WorkAssignDialog
open={open}
onOpenChange={setOpen}
subject={name}
initialQuery={queryOf(name)}
onAssign={assign}
/>
{wizard && recordId ? (
<OrganizeWizard source={{ kind: "record", recordId }} onClose={() => setWizard(false)} />
) : null}
</>
);
}
function DismissButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
return (
@@ -274,26 +360,34 @@ function DismissButton({ d }: { d: Decision }) {
function DecisionCard({ d }: { d: Decision }) {
const open = OPEN.includes(d.status);
const options = d.options.filter((o) => o.key !== "dismiss");
const identity = IDENTITY_KINDS.includes(d.kind);
// 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」
const options = d.options.filter(
(o) =>
o.key !== "dismiss" && !(identity && (o.input?.requires === "tmdb" || o.input?.requires === "mediaEntityId")),
);
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
return (
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
<div className="flex flex-wrap items-center gap-2">
<Tone v={SEVERITY[d.severity]} />
<Tone v={DECISION_STATUS[d.status]} />
{d.severity === "critical" ? <Tone v={SEVERITY[d.severity]} /> : null}
{d.status === "investigating" ? <Tone v={DECISION_STATUS[d.status]} /> : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
{relTime(d.createdAt)}
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
</span>
</div>
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
{d.summary ? <p className="mt-1 text-caption break-words text-muted-foreground">{d.summary}</p> : null}
{d.summary ? (
<p className="mt-1 text-caption break-words text-muted-foreground">{humanizeReason(d.summary)}</p>
) : null}
<GroupReleases d={d} />
<div className="mt-3">
<Evidence d={d} />
</div>
{open ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{identity ? <AssignWorkButton d={d} /> : null}
{options.map((o) => (
<OptionButton key={o.key} d={d} option={o} />
))}
@@ -303,7 +397,7 @@ function DecisionCard({ d }: { d: Decision }) {
to={consolePath("agent", d.agentRunId)}
className="ml-auto inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
>
Agent 调查记录
智能体调查记录
<ChevronRight className="h-3 w-3" />
</Link>
) : null}
@@ -404,13 +498,15 @@ export default function InboxPage() {
<>
<PageHeader
title="待处理"
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
description="需要你决定的事"
actions={
<>
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
</span>
{live === "reconnecting" ? (
<span className="hidden items-center gap-1.5 text-meta text-warning sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
重连中
</span>
) : null}
<Button
variant="outline"
size="sm"
@@ -426,27 +522,26 @@ export default function InboxPage() {
{summary.isError ? (
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
) : (
<div className="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<StatTile
label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
value={summary.isPending ? "…" : (summary.data?.total ?? 0)}
hint={isOpenView ? "含调查中、等你决定" : undefined}
icon={Inbox}
active={!kind}
onClick={() => set("kind", "")}
/>
<fieldset className="mb-4 flex min-w-0 flex-wrap items-center gap-1.5 border-0 p-0" aria-label="按问题类型筛选">
<Button variant={kind ? "ghost" : "secondary"} size="sm" onClick={() => set("kind", "")} aria-pressed={!kind}>
<Inbox />
{isOpenView ? "全部" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
<span className="tabular-nums">{summary.isPending ? "…" : (summary.data?.total ?? 0)}</span>
</Button>
{byKind.map(([k, v]) => (
<StatTile
<Button
key={k}
label={DECISION_KIND[k]}
value={v.count}
hint={`最高级别:${SEVERITY[v.sev].label}`}
tone={SEVERITY[v.sev].tone}
active={kind === k}
variant={kind === k ? "secondary" : "ghost"}
size="sm"
aria-pressed={kind === k}
onClick={() => set("kind", kind === k ? "" : k)}
/>
className={v.sev === "critical" ? "text-destructive" : undefined}
>
{DECISION_KIND[k]}
<span className="tabular-nums">{v.count}</span>
</Button>
))}
</div>
</fieldset>
)}
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
@@ -510,7 +605,7 @@ export default function InboxPage() {
</span>
) : (
<span className="text-body font-semibold text-muted-foreground">
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
+85 -57
View File
@@ -44,6 +44,7 @@ import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
import {
CONTENT_TYPE,
@@ -86,11 +87,12 @@ import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useUiPrefs } from "~/lib/ui-prefs.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
// 内容单元、下载历史与校验结果,同一页同时展示。
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按版本目标),
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按收录版本),
// 头部 / 主创 / 季集组件与仅 TMDB 的作品详情页共用(components/media/media-detail)。
const pad = (n: number) => String(n).padStart(2, "0");
@@ -127,13 +129,13 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
const rootOf = (id: string | null) => roots.data?.items.find((r) => r.id === id);
const bindings = detail.pathBindings.filter((b) => b.status !== "superseded");
return (
<Panel title="实际路径与库根" description="路径绑定建立后不随译名变化">
<Panel title="文件位置">
{bindings.length === 0 && detail.assets.length === 0 ? (
<EmptyState
plain
icon={FolderTree}
title="还没有入库文件"
description="版本目标满足后,文件位置显示在这里。"
description="收录版本满足后,文件位置显示在这里。"
className="py-8"
/>
) : (
@@ -146,7 +148,9 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<Chip>{b.bindingKind === "season" ? `第 ${b.seasonNumber} 季目录` : "作品目录"}</Chip>
<span className="text-meta text-muted-foreground">库根 {root?.name ?? "未知库根"}</span>
<span className="text-meta text-muted-foreground">
媒体库目录 {root?.name ?? "未知媒体库目录"}
</span>
{b.status === "conflict" ? <StatusBadge tone="destructive">绑定冲突</StatusBadge> : null}
</div>
<p className="mt-1 font-mono text-caption break-all text-foreground">
@@ -163,22 +167,19 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
{a.asset.verifiedAt ? (
<StatusBadge tone="success">已核验 {relTime(a.asset.verifiedAt)}</StatusBadge>
) : (
<StatusBadge>未核验</StatusBadge>
)}
{a.asset.verifiedAt ? null : <StatusBadge>未核验</StatusBadge>}
</div>
<ul className="mt-2 space-y-1.5">
{a.locations.map((l) => (
<li key={l.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
{l.isPrimary ? <Chip>主位置</Chip> : null}
<Tone v={LOCATION_STATUS[l.status]} />
{l.locationKind !== "library" ? (
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
) : null}
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
{l.libraryRootId ? (
<span className="text-meta text-muted-foreground">
{rootOf(l.libraryRootId)?.name ?? "库根"}
{rootOf(l.libraryRootId)?.name ?? "媒体库目录"}
</span>
) : null}
</div>
@@ -197,6 +198,8 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
);
}
const PROVIDER_LABEL: Record<string, string> = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb", douban: "豆瓣" };
// ── 外部身份映射 ──
function IdentityPanel({ detail }: { detail: LibraryDetail }) {
const [open, setOpen] = useState(false);
@@ -204,8 +207,7 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted");
return (
<Panel
title="外部身份"
description="以本地 ID 为准,外部身份可修改"
title="匹配的 TMDB 条目"
actions={
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<PencilLine />
@@ -245,10 +247,13 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
return (
<li className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-caption break-all text-foreground">
{i.provider}:{i.entityType}:{i.externalId}
<span
className="text-caption break-all text-foreground"
title={`${i.provider}:${i.entityType}:${i.externalId}`}
>
{PROVIDER_LABEL[i.provider] ?? i.provider} {i.externalId}
</span>
<Tone v={IDENTITY_STATUS[i.status]} />
{i.status !== "accepted" ? <Tone v={IDENTITY_STATUS[i.status]} /> : null}
{i.locked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
@@ -256,9 +261,12 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
</StatusBadge>
) : null}
</div>
<p className="mt-1 text-meta text-muted-foreground">
证据 {evidenceLabel(i.evidenceLevel)} · {VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus}
{i.verifiedAt ? `(${relTime(i.verifiedAt)})` : ""} · r{i.revision}
<p
className="mt-1 text-meta text-muted-foreground"
title={`证据 ${evidenceLabel(i.evidenceLevel)} · ${VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus} · r${i.revision}`}
>
{i.locked || i.evidenceLevel === "manual" ? "你确认的" : "自动匹配"}
{i.verifiedAt ? ` · ${relTime(i.verifiedAt)}` : ""}
</p>
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
</li>
@@ -365,29 +373,44 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
.filter((x) => x.final || !ENRICH_ONLY_FIELDS.has(x.field));
const candidatesOf = (f: string) => (entity?.metadataFields ?? []).filter((m) => m.field === f);
const current = editing ? finals.find((x) => x.field === editing)?.final?.value : undefined;
const prefs = useUiPrefs();
const [showSources, setShowSources] = useState(prefs.techInfoExpanded);
const [allAliases, setAllAliases] = useState(false);
// 别名:默认只列中英文与手动加的,其余折成 +N
const aliases = entity?.aliases ?? [];
const keyAliases = aliases.filter(
(a) => a.locked || !a.language || /^(zh|en|cn)/i.test(a.language) || /[\p{Script=Han}]/u.test(a.alias),
);
const shownAliases = allAliases ? aliases : keyAliases.slice(0, 12);
const moreAliases = aliases.length - shownAliases.length;
return (
<Panel
title="字段来源与锁"
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
title="元数据"
description={`更新于 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<Button
variant="outline"
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(
{ full: true },
{
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
},
)
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
刷新元数据
</Button>
<>
<Button variant="ghost" size="sm" aria-pressed={showSources} onClick={() => setShowSources((v) => !v)}>
{showSources ? "隐藏来源" : "显示来源"}
</Button>
<Button
variant="outline"
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(
{ full: true },
{
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
},
)
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
刷新元数据
</Button>
</>
}
>
<ul className="divide-y divide-border/60">
@@ -400,14 +423,14 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
<p className="line-clamp-4 text-sm break-words text-foreground">
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
</p>
{final ? (
{final && showSources ? (
<p className="text-meta text-muted-foreground">
来源 {METADATA_SOURCE[final.source] ?? final.source}
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
</p>
) : null}
{final ? <EvidenceLine field={cands.find((c) => c.source === final.source)} /> : null}
{cands.length ? (
{final && showSources ? <EvidenceLine field={cands.find((c) => c.source === final.source)} /> : null}
{cands.length && showSources ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground">
候选:
{cands
@@ -461,15 +484,20 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
</ul>
{entity?.aliases.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
<p className="mb-1.5 text-meta text-muted-foreground">别名</p>
<ChipList
items={entity.aliases.map((a) => (
items={shownAliases.map((a) => (
<span key={a.id}>
{a.alias}
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
</span>
))}
/>
{moreAliases > 0 || allAliases ? (
<Button variant="ghost" size="xs" className="mt-1" onClick={() => setAllAliases((v) => !v)}>
{allAliases ? "收起" : `还有 ${moreAliases} 个`}
</Button>
) : null}
</div>
) : null}
<FieldLockDialog entityId={id} field={editing} current={current} onOpenChange={(o) => !o && setEditing(null)} />
@@ -481,12 +509,12 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
if (detail.slots.length === 0) {
return (
<Panel title="版本目标">
<Panel title="收录版本">
<EmptyState
plain
icon={CalendarClock}
title="还没有版本目标"
description="新建订阅或版本目标后,按目标补齐和升级。"
title="还没有收录版本"
description="新建订阅或收录版本后,按目标补齐和升级。"
action={
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去订阅</Link>
@@ -499,7 +527,7 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
}
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
return (
<Panel title="版本目标" description="每个目标单独判断硬条件、软偏好和升级">
<Panel title="收录版本" description="每个目标单独判断硬条件、软偏好和升级">
<div className="grid gap-3 xl:grid-cols-2">
{detail.slots.map((s) => {
const cur = assetOf(s.currentAssetId);
@@ -547,7 +575,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
cell: (u) => (
<div className="flex flex-wrap gap-1">
{u.status === "provisional" ? (
<StatusBadge tone="warning">临时单元</StatusBadge>
<StatusBadge tone="warning">编号待确认</StatusBadge>
) : u.status === "retired" ? (
<StatusBadge>已退役</StatusBadge>
) : (
@@ -582,7 +610,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
a.segment.localeCompare(b.segment),
);
return (
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
<Panel title="季与集" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
@@ -592,15 +620,15 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
error={entity.error}
onRetry={() => entity.refetch()}
empty={{
title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有内容单元",
description: detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集后会建临时单元。",
title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有季和集",
description: detail.entity.kind === "movie" ? undefined : "识别到新的一集后会出现在这里。",
}}
/>
</Panel>
);
}
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按版本目标 ──
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按收录版本 ──
const CELL_TONE: Record<CellKind, "success" | "warning" | "destructive" | "neutral"> = {
have: "success",
@@ -717,7 +745,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
}
>
{rows.length > 1 ? (
<fieldset aria-label="按版本目标看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
<fieldset aria-label="按收录版本看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
{rows.map((r, i) => (
<button
key={r.slotId ?? "any"}
@@ -931,7 +959,7 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
{r.targetPath ?? r.sourcePath ?? r.id}
</Link>
<span className="text-meta text-muted-foreground tabular-nums">
{r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "Agent" : "自动"} · {r.fileSucceeded}/
{r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "智能体" : "自动"} · {r.fileSucceeded}/
{r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
</span>
</li>
@@ -1104,8 +1132,8 @@ export default function LibraryDetailPage() {
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
<span className="font-mono text-meta break-all text-muted-foreground sm:ml-auto">{e.id}</span>
</div>
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
<div className="space-y-5">
{credits.cast.length || credits.crew.length ? (
+6 -7
View File
@@ -16,7 +16,7 @@ import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx"
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
export const meta = () => [{ title: "媒体概览 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "媒体库 · UHD 媒体自动化控制台" }];
const FILTERS: { value: NonNullable<LibraryListQuery["filter"]>; label: string }[] = [
{ value: "gap", label: "有缺口" },
@@ -61,8 +61,7 @@ export default function LibraryPage() {
return (
<>
<PageHeader
title="媒体概览"
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
title="媒体库"
actions={
view === "unidentified" ? (
<Button asChild variant="outline" size="sm">
@@ -77,7 +76,7 @@ export default function LibraryPage() {
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}>
<TabsList className="mb-4">
<TabsTrigger value="works">作品</TabsTrigger>
<TabsTrigger value="unidentified">待识别</TabsTrigger>
<TabsTrigger value="unidentified">未识别</TabsTrigger>
<TabsTrigger value="identities">外部身份</TabsTrigger>
<TabsTrigger value="aliases">别名</TabsTrigger>
</TabsList>
@@ -155,10 +154,10 @@ function WorksView() {
</div>
),
},
{ key: "slots", header: "版本目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{ key: "slots", header: "收录版本", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{
key: "assets",
header: "资产",
header: "文件",
align: "right",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{r.assetCount}</span>,
@@ -214,7 +213,7 @@ function WorksView() {
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
: {
title: "还没有作品",
description: "扫描库根或新建订阅后,作品会出现在这里。",
description: "扫描媒体库目录或新建订阅后,作品会出现在这里。",
icon: LibraryIcon,
action: (
<Button asChild variant="outline" size="sm">
@@ -89,10 +89,10 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
{ provider: "tmdb", externalId: d.externalId, kind: d.entityType },
{
onSuccess: (r) => {
toast.success(r.created ? `已建档「${r.title}」` : `「${r.title}」已在媒体库`);
toast.success(r.created ? `已加入媒体库「${r.title}」` : `「${r.title}」已在媒体库`);
navigate(consolePath("library", r.mediaEntityId));
},
onError: (e) => toast.error(errorMessage(e, "建档失败")),
onError: (e) => toast.error(errorMessage(e, "加入媒体库失败")),
},
);
@@ -147,7 +147,7 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
) : (
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
<FolderPlus />
{entity.isPending ? "建档中…" : "建档不订阅"}
{entity.isPending ? "加入中…" : "加入媒体库"}
</Button>
)}
</div>
@@ -197,13 +197,13 @@ function InventoryPanel({ d }: { d: TmdbMediaDetail }) {
const inv = d.inventory;
const line = slotLine(inv);
return (
<Panel title="库内对比" description="本地账本按 TMDB 身份对应;Emby 查询失败记为未知,不等于缺失">
<Panel title="库中情况">
<dl className="grid gap-4 sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">本地账本</dt>
<dt className="text-meta text-muted-foreground">媒体库</dt>
<dd className="mt-1 space-y-1 text-caption text-foreground">
{inv.state === "not_collected" ? (
<p>未收录。订阅或建档后开始追踪。</p>
<p>不在媒体库。</p>
) : (
<>
<p>
@@ -283,7 +283,7 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
});
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
return (
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,不显示库内状态"}>
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "不在媒体库"}>
<div className="space-y-2">
{seasons.map((s) => (
<SeasonSection
+38 -34
View File
@@ -8,9 +8,11 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { actorTypeLabel } from "~/lib/labels.ts";
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
const ACTION_LABEL = {
@@ -23,7 +25,6 @@ const ACTION_LABEL = {
delete: "永久删除",
restore: "从回收站还原",
} as const;
const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "库根相对" } as const;
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队", tone: "neutral" },
running: { label: "执行中", tone: "neutral" },
@@ -42,22 +43,14 @@ const ROLE_LABEL = {
other: "其他",
} as const;
/** 稳定 code 与说明不同时才附上 code */
function reasonText(r: { code: string; message: string }) {
return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message;
}
/** failed:失败且没有目标路径时直接说"未生成目标路径",不再显示视角与"未知或未授权" */
/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
const noTarget = failed && p.path === null;
return (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">
{label}
{noTarget ? "" : ` · ${VIEW_LABEL[p.view]}`}
</p>
<p className="text-meta text-muted-foreground">{label}</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未知或未授权")}
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
</p>
</div>
);
@@ -107,13 +100,19 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
{duration(f.startedAt, f.finishedAt) ? ` · 用时 ${duration(f.startedAt, f.finishedAt)}` : ""}
</p>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(reason) },
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
{ label: "文件结果 ID", value: f.id },
]}
/>
</div>
) : null}
</li>
@@ -153,14 +152,15 @@ export default function OrganizeRecordPage() {
size="sm"
onClick={() =>
openAgentDrawer({
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
recordId: r.id,
entityId: r.media?.entityId,
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
})
}
>
<Bot />让 Agent 排查
<Bot />
让智能体排查
</Button>
) : null}
</>
@@ -175,15 +175,17 @@ export default function OrganizeRecordPage() {
) : (
<div className="space-y-4">
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
<StatusBadge>
{TRIGGER_LABEL[r.trigger]}
{r.trigger === "agent" ? "(计为自动)" : ""}
</StatusBadge>
<span className="text-meta text-muted-foreground tabular-nums">
{r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped}
{[
`${ACTION_LABEL[r.action]} · ${TRIGGER_LABEL[r.trigger]}`,
r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null,
r.counts.failed ? `${r.counts.failed} 个失败` : null,
r.counts.skipped ? `${r.counts.skipped} 个跳过` : null,
]
.filter(Boolean)
.join(" · ")}
</span>
</div>
{r.reason ? (
@@ -195,7 +197,7 @@ export default function OrganizeRecordPage() {
: "border-warning/40 bg-warning/10 text-warning",
)}
>
{reasonText(r.reason)}
{reasonLine(r.reason)}
</p>
) : null}
<div className="flex flex-col gap-2 md:flex-row md:items-center">
@@ -207,19 +209,21 @@ export default function OrganizeRecordPage() {
items={[
{ label: "开始", value: formatDateTime(r.startedAt) },
{ label: "完成", value: formatDateTime(r.finishedAt) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
]}
/>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(r.reason) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
{
label: "触发者",
value: `${actorTypeLabel(r.actor.type)}${r.actor.id && r.actor.type !== "system" ? ` · ${r.actor.id}` : ""}`,
},
{
label: "外部身份",
label: "外部编号",
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
mono: true,
},
{ label: "变更游标", value: r.changeSeq, mono: true },
{ label: "记录 ID", value: r.id, mono: true, wide: true },
{ label: "任务 ID", value: r.taskId, mono: true, wide: true },
{ label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null },
{ label: "变更序号", value: r.changeSeq },
{ label: "记录 ID", value: r.id, wide: true },
{ label: "任务 ID", value: r.taskId, wide: true },
]}
/>
{r.media ? (
+2 -2
View File
@@ -12,6 +12,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { reasonLine } from "~/lib/reasons.ts";
import { useSse } from "~/lib/sse.ts";
const ACTION_LABEL = {
@@ -63,7 +64,7 @@ const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
{r.reason ? (
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{r.reason.message}</span>
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
) : null}
</div>
),
@@ -120,7 +121,6 @@ export default function OrganizePage() {
<>
<PageHeader
title="整理记录"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
actions={
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
+4 -5
View File
@@ -72,12 +72,11 @@ export default function RulesPage() {
<>
<PageHeader
title="规则"
description="规则组决定哪些资源能下载、同一作品优先挑哪个。收录策略决定哪些新作品自动收录。"
actions={
tab === "policies" ? (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建策略草案
新建
</Button>
) : (
<Button onClick={create}>
@@ -104,9 +103,9 @@ export default function RulesPage() {
<TabsList aria-label="规则分节">
<TabsTrigger value="groups">规则组</TabsTrigger>
<TabsTrigger value="atoms">自定义条件</TabsTrigger>
<TabsTrigger value="defaults">默认值</TabsTrigger>
<TabsTrigger value="policies">收录策略</TabsTrigger>
<TabsTrigger value="suppressions">抑制记录</TabsTrigger>
<TabsTrigger value="defaults">默认规则</TabsTrigger>
<TabsTrigger value="policies">自动收新作品</TabsTrigger>
<TabsTrigger value="suppressions">已忽略</TabsTrigger>
</TabsList>
<TabsContent value="groups">
@@ -18,7 +18,6 @@ export default function SchedulesPage() {
<>
<PageHeader
title="计划"
description="定时任务的开关、间隔和下次运行。每次运行的结果在任务页。"
actions={
<Button
variant="outline"
+1 -1
View File
@@ -44,7 +44,7 @@ export default function SearchPage() {
return (
<>
<PageHeader title="搜索" description="先看作品和库内状态,需要时再到站点搜资源。" />
<PageHeader title="搜索" />
<form onSubmit={submit} className="mb-6 flex gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xl">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+1 -1
View File
@@ -59,7 +59,7 @@ export default function SettingsPage() {
return (
<>
<PageHeader title="设置" description="全局参数、实例与凭证。凭证保存后不回显。" />
<PageHeader title="设置" description="密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">
+19 -19
View File
@@ -44,7 +44,7 @@ import { errorMessage } from "~/lib/api.ts";
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "待识别" },
{ value: "unresolved", label: "未识别" },
{ value: "ambiguous", label: "有歧义" },
{ value: "unidentifiable", label: "无法识别" },
];
@@ -213,7 +213,7 @@ export default function SiteWorkbenchPage() {
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">抓取已过期</StatusBadge> : null}
</span>
) : (
"加载中…"
@@ -257,7 +257,7 @@ export default function SiteWorkbenchPage() {
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
>
<Bot />
<span className="sr-only sm:not-sr-only">问 Agent</span>
<span className="sr-only sm:not-sr-only">问智能体</span>
</Button>
</>
}
@@ -280,7 +280,7 @@ export default function SiteWorkbenchPage() {
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
<TabsTrigger value="snapshots">抓取与 RSS</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
</TabsList>
@@ -305,26 +305,26 @@ export default function SiteWorkbenchPage() {
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("快照已排队"),
onSuccess: () => toast.success("已开始抓取"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
快照
刷新
</Button>
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地快照,直接在站点搜索" : "先输入搜索词"}
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
实时补查
去站点搜
</Button>
</>
}
@@ -384,8 +384,8 @@ export default function SiteWorkbenchPage() {
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地快照里没有匹配的资源"
: "还没有快照数据"
? "本地资源里没有匹配的资源"
: "还没抓取过"
}
description={
all.length
@@ -393,10 +393,10 @@ export default function SiteWorkbenchPage() {
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
? "点「去站点搜」直接在站点搜索。"
: "点「刷新」抓取站点列表。"
}
/>
) : (
@@ -452,11 +452,11 @@ export default function SiteWorkbenchPage() {
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
<DetailSection
title="Feed"
title="RSS"
actions={
<Button size="sm" variant="outline" onClick={() => setCreatingFeed(true)}>
<Plus />
新建 Feed
新建 RSS
</Button>
}
>
@@ -501,12 +501,12 @@ function CoverageBanner({
notes.push({
tone: "warning",
text: coverage.lastSnapshotAt
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "没有快照,列表只来自 Feed 和实时补查",
? `抓取已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "还没抓取过,列表只来自 RSS 和站点搜索",
});
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError}` });
if (coverage.liveError) notes.push({ tone: "warning", text: `去站点搜失败:${coverage.liveError}` });
else if (coverage.live)
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "已实时补查" });
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "站点搜索完成" });
}
if (!notes.length) return null;
return (
-1
View File
@@ -100,7 +100,6 @@ export default function SitesPage() {
<>
<PageHeader
title="站点"
description="凭证、账号数据和健康状态;点站点名看资源、快照与 Feed"
actions={
<>
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
+8 -77
View File
@@ -1,24 +1,19 @@
import type { FeedHealth, Site } from "@umt/contracts";
import { Globe, Plus, RefreshCw } from "lucide-react";
import type { FeedHealth } from "@umt/contracts";
import { Plus, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { useFeeds } from "~/api/feeds.ts";
import { snapshotStale, useSiteAccounts, useSites } from "~/api/sites.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { useSites } from "~/api/sites.ts";
import { DetailSection } from "~/components/console/detail-sheet.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 { StatusBadge } from "~/components/console/status-badge.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { Button } from "~/components/ui/button.tsx";
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
import { legacyRedirectLoader } from "./redirects.ts";
// 来源健康:跨站点的 Feed 列表与站点健康总表;单个站点的快照、Feed、账号在站点详情。
// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。
export const meta = () => [{ title: "来源健康 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }];
// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed"
export const clientLoader = legacyRedirectLoader;
@@ -27,88 +22,24 @@ export default function SourcesPage() {
const f = useQueryState(["health", "siteId"] as const);
const [creating, setCreating] = useState(false);
const sites = useSites();
const accounts = useSiteAccounts();
const feeds = useFeeds({
health: (f.values.health || undefined) as FeedHealth | undefined,
siteId: f.values.siteId || undefined,
});
const siteColumns: DataColumn<Site>[] = [
{
key: "name",
header: "站点",
cell: (s) => (
<Link
to={`${consolePath("sites", s.id)}?tab=snapshots`}
className="font-medium text-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
{s.name}
</Link>
),
},
{
key: "health",
header: "健康",
cell: (s) => (
<div className="flex flex-wrap gap-1">
<StatusBadge tone={SITE_HEALTH[s.healthStatus].tone}>{SITE_HEALTH[s.healthStatus].label}</StatusBadge>
{circuitLabel(s) ? <StatusBadge tone="warning">{circuitLabel(s)}</StatusBadge> : null}
{!s.enabled && s.healthStatus !== "disabled" ? <StatusBadge>已停用</StatusBadge> : null}
</div>
),
},
{
key: "account",
header: "账号",
hideBelow: "md",
cell: (s) => {
const a = accountSummary((accounts.data?.items ?? []).filter((x) => x.siteId === s.id));
return <StatusBadge tone={a.tone}>{a.label}</StatusBadge>;
},
},
{
key: "snapshot",
header: "最近快照",
hideBelow: "sm",
cell: (s) => (
<span className="flex flex-wrap items-center gap-1.5 whitespace-nowrap text-muted-foreground">
{s.snapshotEnabled ? relTime(s.lastSnapshotAt) : "未开启"}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
</span>
),
},
];
return (
<>
<PageHeader
title="来源健康"
description="单个站点的快照、Feed 和账号在站点页管理。"
title="RSS"
actions={
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
新建 Feed
新建 RSS
</Button>
}
/>
<div className="space-y-8">
<DetailSection title="站点">
<DataTable
columns={siteColumns}
rows={sites.data?.items}
rowKey={(s) => s.id}
loading={sites.isPending}
error={sites.error}
onRetry={() => void sites.refetch()}
empty={{
icon: Globe,
title: "还没有登记站点",
description: "在站点页添加。",
}}
/>
</DetailSection>
<DetailSection title="Feed">
<DetailSection title="RSS 地址">
<FilterBar
onReset={f.active ? f.reset : undefined}
actions={
+20 -18
View File
@@ -21,6 +21,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { HistoryChart } from "~/components/status/history-chart.tsx";
import { StorageSection } from "~/components/status/storage-section.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -43,7 +44,7 @@ const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
const KIND_LABEL: Record<string, string> = {
api: "API",
worker: "Worker(io)",
"worker:agent": "Worker(Agent)",
"worker:agent": "Worker(智能体)",
all: "单进程(all)",
};
@@ -51,13 +52,13 @@ const KIND_LABEL: Record<string, string> = {
const KIND_NAME: Record<string, string> = {
api: "API",
worker: "io worker",
"worker:agent": "Agent worker",
"worker:agent": "智能体 worker",
all: "单进程",
};
const MOUNT_REASON: Record<string, string> = {
root_missing: "目录不存在",
sentinel_missing: "哨兵文件缺失",
sentinel_missing: "目录不可访问或未挂载",
permission_denied: "没有权限",
error: "检查出错",
timeout: "检查超时",
@@ -227,7 +228,7 @@ function Processes({ d }: { d: SystemHealth }) {
<span className="text-muted-foreground">—</span>
) : (
<span className="whitespace-nowrap tabular-nums">
io {p.concurrency.io ?? 0} · Agent {p.concurrency.agent ?? 0}
io {p.concurrency.io ?? 0} · 智能体 {p.concurrency.agent ?? 0}
</span>
),
},
@@ -371,7 +372,7 @@ function History() {
function Disks({ d }: { d: SystemHealth }) {
const t = d.diskThresholds;
const scope = { data_dir: "数据目录", library_root: "库根", download_dir: "下载目录" } as const;
const scope = { data_dir: "数据目录", library_root: "媒体库目录", download_dir: "下载目录" } as const;
return (
<Card title="磁盘" icon={HardDrive} level={worst(d.disks.map((x) => x.level))}>
<p className="mb-3 text-meta text-muted-foreground">
@@ -428,7 +429,7 @@ function Mounts({ d }: { d: SystemHealth }) {
return (
<Card title="挂载根" icon={HardDrive} level={d.mounts.length ? (bad ? "critical" : "ok") : undefined}>
{d.mounts.length === 0 ? (
<p className="text-caption text-muted-foreground">没有启用的库根或下载目录映射。</p>
<p className="text-caption text-muted-foreground">没有启用的媒体库目录或下载目录映射。</p>
) : (
<ul className="divide-y divide-border/60">
{d.mounts.map((m) => (
@@ -436,7 +437,7 @@ function Mounts({ d }: { d: SystemHealth }) {
<div className="min-w-0 flex-1">
<p className="truncate">
<span className="mr-1.5 text-meta text-muted-foreground">
{m.kind === "library_root" ? "库根" : "下载目录"}
{m.kind === "library_root" ? "媒体库目录" : "下载目录"}
</span>
{m.name}
</p>
@@ -465,7 +466,6 @@ export default function StatusPage() {
<>
<PageHeader
title="运行状态"
description="进程心跳、数据库、磁盘、挂载根与存储。15 秒刷新一次。"
actions={
<Button
variant="outline"
@@ -485,16 +485,6 @@ export default function StatusPage() {
) : d ? (
<div className="space-y-4">
<Banner d={d} />
<section className="min-w-0">
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
<Cpu className="h-4 w-4 text-muted-foreground" />
进程
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
</h2>
<Processes d={d} />
<RetiredProcesses rows={d.retiredProcesses} />
</section>
<History />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card title="数据库" icon={Database} level={d.db.level}>
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
@@ -578,6 +568,18 @@ export default function StatusPage() {
<Mounts d={d} />
</div>
<StorageSection health={d} />
<TechInfo title="进程与趋势" columns={1}>
<section className="min-w-0">
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
<Cpu className="h-4 w-4 text-muted-foreground" />
进程
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
</h2>
<Processes d={d} />
<RetiredProcesses rows={d.retiredProcesses} />
</section>
<History />
</TechInfo>
</div>
) : null}
</>
@@ -39,7 +39,7 @@ import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 版本目标矩阵;版本目标排序与自动补全;订阅信息。
// 订阅对比视图(FE-5,docs/design/subscriptions.md §2):季页签 → 集 × 收录版本矩阵;收录版本排序与自动补全;订阅信息。
export const meta = () => [{ title: "订阅详情 · UHD 媒体自动化控制台" }];
@@ -118,7 +118,7 @@ export default function SubscriptionDetailPage() {
{item?.group ? <StatusBadge>{GROUP_LABEL[item.group]}</StatusBadge> : null}
{item?.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
{!sub.mediaEntityId ? <StatusBadge tone="warning">尚未建档</StatusBadge> : null}
{!sub.mediaEntityId ? <StatusBadge tone="warning">不在媒体库</StatusBadge> : null}
</div>
{item ? (
<div className="grid max-w-xl gap-2">
@@ -163,7 +163,7 @@ export default function SubscriptionDetailPage() {
<div className="space-y-5">
<Panel
title="覆盖对比"
description={cov ? `按版本目标逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按版本目标逐集对比"}
description={cov ? `按收录版本逐集对比 · 计算于 ${relTime(cov.computedAt)}` : "按收录版本逐集对比"}
actions={
<Button variant="outline" size="sm" onClick={doRefresh} disabled={refresh.isPending || !sub.mediaEntityId}>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
@@ -172,7 +172,7 @@ export default function SubscriptionDetailPage() {
}
>
{!sub.mediaEntityId ? (
<p className="text-caption text-muted-foreground">作品建档后才能对比。建档后自动关联到这个订阅。</p>
<p className="text-caption text-muted-foreground">作品加入媒体库后才能对比,加入后自动关联到这个订阅。</p>
) : covQ.isPending ? (
<LoadingState rows={3} />
) : covQ.isError ? (
@@ -271,7 +271,7 @@ export default function SubscriptionDetailPage() {
/>
</Panel>
{sub.mediaEntityId ? (
<Panel title="版本目标规则" description="条件、阶梯与升级;新建的版本目标排在最后">
<Panel title="收录版本规则" description="条件、阶梯与升级;新建的收录版本排在最后">
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
</Panel>
) : null}
+63 -58
View File
@@ -275,63 +275,68 @@ export default function SubscriptionsPage() {
/>
) : (
<>
<div className="mb-3">
<Pills
label="分组"
value={v.group as SubscriptionGroup | ""}
onChange={(x) => f.set("group", x)}
items={[
{ value: "", label: "全部", count: allCount },
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
]}
/>
</div>
<FilterBar
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
onReset={
filtered
? () => {
setQ("");
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
}
: undefined
}
>
<FilterSelect
label="站点"
value={v.site}
onChange={(x) => f.set("site", x)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
<FilterSelect
label="规则组"
value={v.rg}
onChange={(x) => f.set("rg", x)}
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
/>
<FilterSelect
label="来源"
value={v.origin}
onChange={(x) => f.set("origin", x)}
options={opts(SUB_ORIGIN)}
/>
<FilterSelect
label="洗版"
value={v.up}
onChange={(x) => f.set("up", x)}
options={[
{ value: "true", label: "洗版中" },
{ value: "false", label: "未洗版" },
]}
/>
<FilterSelect
label="排序"
value={v.sort}
onChange={(x) => f.set("sort", x)}
allLabel="按分组"
options={opts(SORT_LABEL)}
/>
</FilterBar>
{/* 一条订阅都没有时只留空态和「新建订阅」,不铺筛选 */}
{allCount === 0 && !filtered && !v.group && !v.kind ? null : (
<>
<div className="mb-3">
<Pills
label="分组"
value={v.group as SubscriptionGroup | ""}
onChange={(x) => f.set("group", x)}
items={[
{ value: "", label: "全部", count: allCount },
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
]}
/>
</div>
<FilterBar
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
onReset={
filtered
? () => {
setQ("");
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
}
: undefined
}
>
<FilterSelect
label="站点"
value={v.site}
onChange={(x) => f.set("site", x)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
<FilterSelect
label="规则组"
value={v.rg}
onChange={(x) => f.set("rg", x)}
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
/>
<FilterSelect
label="来源"
value={v.origin}
onChange={(x) => f.set("origin", x)}
options={opts(SUB_ORIGIN)}
/>
<FilterSelect
label="洗版"
value={v.up}
onChange={(x) => f.set("up", x)}
options={[
{ value: "true", label: "洗版中" },
{ value: "false", label: "未洗版" },
]}
/>
<FilterSelect
label="排序"
value={v.sort}
onChange={(x) => f.set("sort", x)}
allLabel="按分组"
options={opts(SORT_LABEL)}
/>
</FilterBar>
</>
)}
{selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
@@ -393,7 +398,7 @@ export default function SubscriptionsPage() {
icon={CalendarClock}
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
description={
filtered || v.group || v.kind ? undefined : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。"
filtered || v.group || v.kind ? undefined : "在搜索或媒体库里点「订阅」,或点右上角「新建订阅」。"
}
/>
) : grouped ? (
+1 -1
View File
@@ -59,7 +59,7 @@ const OP_KIND: Record<AssetOperation["kind"], string> = {
delete_source: "删除源文件",
delete_target: "删除目标文件",
verify: "校验",
register: "登记资产",
register: "登记入库",
qb_pause: "暂停下载",
qb_resume: "恢复下载",
qb_remove: "移除下载",
+1 -2
View File
@@ -20,7 +20,7 @@ export const clientLoader = legacyRedirectLoader;
type Tab = StreamTab;
const TABS: Tab[] = ["all", "active", "failed"];
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"];
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
function Count({ n }: { n: number | undefined }) {
if (!n) return null;
@@ -99,7 +99,6 @@ export default function TasksPage() {
<>
<PageHeader
title="任务"
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
actions={
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
<RotateCw />