feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata

- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity
  mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons,
  content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions
- subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion),
  policy revision draft -> preview -> publish (grant warning) -> rollback
- preferences: tag CRUD, disable/enable, suppression records and lift, history timeline
- inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh
- identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management
- all responses validated against contracts zod schemas

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:39:02 +08:00
1 parent e5e42b1f0d
commit ef73abe3df
25 files changed
+6670 -19

No files matched your search

+274 -3
View File
@@ -1,6 +1,277 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import type {
ExternalIdentity,
IdentityCandidateReason,
IdentityCandidateStatus,
IdentityPoolItem,
IdentityStatus,
} from "@umt/contracts";
import { Fingerprint, Lock, SearchX } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
import { PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
import {
EVIDENCE_LEVEL,
IDENTITY_STATUS,
opts,
POOL_REASON,
POOL_STATUS,
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import { relTime, Tone } from "~/components/library/parts.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
// 占位:标题与说明取自 components/console/nav.ts
// 身份与元数据(REQ §7.2、§7.5):待识别池、外部身份映射与修订、别名。
function useDebounced(v: string, ms = 250) {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
function PoolTab() {
const [q, setQ] = useState("");
const [status, setStatus] = useState("pending");
const [reason, setReason] = useState("");
const dq = useDebounced(q.trim());
const pool = useIdentityPool({
q: dq || undefined,
status: (status || undefined) as IdentityCandidateStatus | undefined,
reason: (reason || undefined) as IdentityCandidateReason | undefined,
});
const [openId, setOpenId] = useState<string | null>(null);
const rows = pool.data?.pages.flatMap((p) => p.items);
const open = rows?.find((r) => r.id === openId) ?? null;
const columns: DataColumn<IdentityPoolItem>[] = [
{
key: "title",
header: "原始标题",
className: "max-w-0 w-full",
cell: (r) => (
<div className="min-w-0">
<p className="font-mono text-caption break-all text-foreground">{r.release.title}</p>
<p className="mt-0.5 truncate text-meta text-muted-foreground">
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
</p>
</div>
),
},
{ key: "reason", header: "原因", cell: (r) => <Tone v={POOL_REASON[r.reason]} /> },
{
key: "cands",
header: "候选",
hideBelow: "sm",
align: "right",
cell: (r) => <span className="tabular-nums">{r.candidates.length}</span>,
},
{
key: "attempts",
header: "尝试",
hideBelow: "md",
cell: (r) => (
<span className="text-meta whitespace-nowrap text-muted-foreground">
{r.attempts} 次 · {relTime(r.lastAttemptAt)}
</span>
),
},
{
key: "next",
header: "下次复查",
hideBelow: "lg",
cell: (r) => (
<span className="text-meta whitespace-nowrap text-muted-foreground">
{r.status === "pending" ? (
r.nextReviewAt ? (
relTime(r.nextReviewAt)
) : (
"不自动复查"
)
) : (
<Tone v={POOL_STATUS[r.status]} />
)}
</span>
),
},
];
const filtered = !!q || status !== "pending" || !!reason;
return (
<>
<FilterBar
search={{ value: q, onChange: setQ, placeholder: "按原始标题 / 解析标题检索" }}
onReset={
filtered
? () => {
setQ("");
setStatus("pending");
setReason("");
}
: undefined
}
>
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(POOL_STATUS)} />
<FilterSelect label="原因" value={reason} onChange={setReason} options={opts(POOL_REASON)} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={pool.isPending}
error={pool.error}
onRetry={() => pool.refetch()}
onRowClick={(r) => setOpenId(r.id)}
selectedKey={openId}
empty={{
icon: SearchX,
title: filtered ? "没有符合条件的待识别资源" : "待识别池是空的",
description: "识别证据不足或冲突的资源会留在这里,可按原始标题找回;新证据到来时会自动重试。",
}}
hasMore={pool.hasNextPage}
onLoadMore={() => pool.fetchNextPage()}
loadingMore={pool.isFetchingNextPage}
/>
<PoolSheet key={open?.id ?? "none"} item={open} onClose={() => setOpenId(null)} />
</>
);
}
function ExternalTab() {
const [provider, setProvider] = useState("");
const [status, setStatus] = useState("");
const list = useExternalIdentities({
provider: provider || undefined,
status: (status || undefined) as IdentityStatus | undefined,
});
const [openId, setOpenId] = useState<string | null>(null);
const rows = list.data?.pages.flatMap((p) => p.items);
const open = rows?.find((r) => r.id === openId) ?? null;
const columns: DataColumn<ExternalIdentity>[] = [
{
key: "ref",
header: "外部身份",
cell: (r) => (
<span className="inline-flex items-center gap-1.5 font-mono text-caption break-all text-foreground">
{r.locked ? <Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-label="人工锁" /> : null}
{identityRef(r)}
</span>
),
},
{ key: "status", header: "状态", cell: (r) => <Tone v={IDENTITY_STATUS[r.status]} /> },
{
key: "level",
header: "证据",
hideBelow: "sm",
cell: (r) => <span className="text-meta whitespace-nowrap">{EVIDENCE_LEVEL[r.evidenceLevel]}</span>,
},
{ key: "verify", header: "外部校验", hideBelow: "md", cell: (r) => <Tone v={VERIFY_STATUS[r.verifyStatus]} /> },
{
key: "rev",
header: "修订",
hideBelow: "lg",
align: "right",
cell: (r) => (
<span className="text-meta whitespace-nowrap text-muted-foreground tabular-nums">
r{r.revision} · {relTime(r.updatedAt)}
</span>
),
},
];
const filtered = !!provider || !!status;
return (
<>
<FilterBar
onReset={
filtered
? () => {
setProvider("");
setStatus("");
}
: undefined
}
>
<FilterSelect
label="来源"
value={provider}
onChange={setProvider}
options={[
{ value: "tmdb", label: "TMDB" },
{ value: "imdb", label: "IMDb" },
{ value: "tvdb", label: "TVDB" },
]}
/>
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(IDENTITY_STATUS)} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setOpenId(r.id)}
selectedKey={openId}
empty={{
icon: Fingerprint,
title: "没有外部身份",
description: "TMDB 等外部 ID 只是辅助映射;没有记录时作品仍可先建本地档案入库。",
}}
hasMore={list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
/>
<ExternalIdentitySheet item={open} onClose={() => setOpenId(null)} />
</>
);
}
const TABS = ["pool", "external", "aliases"] as const;
export default function IdentityPage() {
return <WorkspacePlaceholder />;
const [params, setParams] = useSearchParams();
const tab = (TABS as readonly string[]).includes(params.get("tab") ?? "") ? params.get("tab")! : "pool";
return (
<>
<PageHeader
title="身份与元数据"
description="本地 UUID 是主身份,外部 ID 是辅助映射。自动模糊匹配不会覆盖人工锁定的关系。"
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList>
<TabsTrigger value="pool">待识别池</TabsTrigger>
<TabsTrigger value="external">外部身份</TabsTrigger>
<TabsTrigger value="aliases">别名</TabsTrigger>
</TabsList>
<TabsContent value="pool">
<PoolTab />
</TabsContent>
<TabsContent value="external">
<ExternalTab />
</TabsContent>
<TabsContent value="aliases">
<AliasesPanel />
</TabsContent>
</Tabs>
</>
);
}
+395 -3
View File
@@ -1,6 +1,398 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import { useQueryClient } from "@tanstack/react-query";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
import { type ReactNode, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.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 { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatDateTime } from "~/lib/utils.ts";
// 占位:标题与说明取自 components/console/nav.ts
// 待处理中心(REQ §13.1):按作品与问题类型合并;每项给证据、建议、影响范围与预览;
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
const STATUS_FILTER = [
{ value: "resolved", label: "已处理" },
{ value: "dismissed", label: "已忽略" },
{ value: "expired", label: "已过期" },
];
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
function entityOf(d: Decision): string | null {
const m = d.evidence.mediaEntityId;
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
return d.subjectType === "media_entity" ? d.subjectId : null;
}
const IMPACT_KEY: Record<string, string> = {
slots: "收录目标",
units: "内容单元",
files: "文件",
releases: "来源资源",
tasks: "任务",
entities: "作品",
};
function describe(v: unknown): ReactNode {
if (v === null || v === undefined) return null;
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) return v.map((x) => (typeof x === "object" ? JSON.stringify(x) : String(x))).join("、");
return Object.entries(v as Record<string, unknown>)
.map(
([k, x]) =>
`${IMPACT_KEY[k] ?? k} ${Array.isArray(x) ? x.join("、") : typeof x === "object" ? JSON.stringify(x) : String(x)}`,
)
.join(";");
}
function Evidence({ d }: { d: Decision }) {
const e = d.evidence;
const suggestion = str(e.suggestion);
const impact = describe(e.impact);
const preview = describe(e.preview);
const checked = Array.isArray(e.checked) ? (e.checked as unknown[]).map(String) : [];
const rows: [string, ReactNode][] = [];
if (suggestion) rows.push(["建议", suggestion]);
if (impact) rows.push(["影响范围", impact]);
if (preview) rows.push(["预览", preview]);
return (
<div className="space-y-2">
{rows.length ? (
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted-foreground">{k}</dt>
<dd className="min-w-0 break-words text-foreground">{v}</dd>
</div>
))}
</dl>
) : null}
{checked.length ? (
<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" />
</div>
);
}
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
const resolve = useResolveDecision();
const [note, setNote] = useState("");
return (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
{option.label}
</Button>
}
title={option.label}
description={
<>
{option.description ? `${option.description}。` : ""}
{option.capabilityId
? `将以能力 ${option.capabilityId} 执行,仍经授权校验。`
: "处理结果会记录并通知相关任务。"}
</>
}
confirmLabel="确认"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: option.key, note: note.trim() || undefined }).then(
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
>
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} />
</ConfirmDialog>
);
}
function DismissButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
return (
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm">
<BellOff />
忽略
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,不改变失败本身;同根因的新证据出现时仍会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
() => toast.success("已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
throw err;
},
)
}
/>
);
}
function DecisionCard({ d }: { d: Decision }) {
const open = OPEN.includes(d.status);
const options = d.options.filter((o) => o.key !== "dismiss");
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]} />
<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}
<div className="mt-3">
<Evidence d={d} />
</div>
{open ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{options.map((o) => (
<OptionButton key={o.key} d={d} option={o} />
))}
<DismissButton d={d} />
{d.agentRunId ? (
<Link
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}
</div>
) : (
<p className="mt-3 text-meta text-muted-foreground">
{d.resolvedBy ? `${d.resolvedBy} ` : ""}于 {formatDateTime(d.resolvedAt)}
{resolution?.optionKey
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
: " 关闭"}
{resolution?.note ? `:${resolution.note}` : ""}
</p>
)}
</article>
);
}
type Group = { key: string; entityId: string | null; kind: DecisionKind; items: Decision[] };
export default function InboxPage() {
return <WorkspacePlaceholder />;
const [sp, setSp] = useSearchParams();
const status = (sp.get("status") ?? "") as DecisionStatus | "";
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
const set = (k: string, v: string) =>
setSp(
(prev) => {
const n = new URLSearchParams(prev);
if (v) n.set(k, v);
else n.delete(k);
return n;
},
{ replace: true },
);
const summary = useDecisionSummary(status || undefined);
const list = useDecisions({ status: status || undefined, kind: kind || undefined });
const rows = list.data?.pages.flatMap((p) => p.items);
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(["decision.created", "decision.updated"], () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
void qc.invalidateQueries({ queryKey: decisionKeys.all });
}, 500);
});
const live = useSseStatus();
const titles = useMemo(() => {
const m = new Map<string, string>();
for (const g of summary.data?.groups ?? [])
if (g.mediaEntityId && g.mediaEntityTitle) m.set(g.mediaEntityId, g.mediaEntityTitle);
return m;
}, [summary.data]);
const byKind = useMemo(() => {
const m = new Map<DecisionKind, { count: number; sev: keyof typeof SEV_RANK }>();
for (const g of summary.data?.groups ?? []) {
const cur = m.get(g.kind);
m.set(g.kind, {
count: (cur?.count ?? 0) + g.count,
sev: cur && SEV_RANK[cur.sev] > SEV_RANK[g.maxSeverity] ? cur.sev : g.maxSeverity,
});
}
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
}, [summary.data]);
// 按作品 + 问题类型合并;组内按严重度、时间
const groups = useMemo(() => {
const m = new Map<string, Group>();
for (const d of rows ?? []) {
const entityId = entityOf(d);
const key = `${entityId ?? `subject:${d.subjectType ?? "none"}`}|${d.kind}`;
const g = m.get(key) ?? { key, entityId, kind: d.kind, items: [] };
g.items.push(d);
m.set(key, g);
}
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
}, [rows]);
const isOpenView = !status;
return (
<>
<PageHeader
title="待处理中心"
description="Agent 先在授权内调查与有限重试,解决不了的才进这里;同一作品同类问题合并展示"
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>
<Button
variant="outline"
size="sm"
onClick={() => void qc.invalidateQueries({ queryKey: decisionKeys.all })}
>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
</>
}
/>
{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", "")}
/>
{byKind.map(([k, v]) => (
<StatTile
key={k}
label={DECISION_KIND[k]}
value={v.count}
hint={`最高 ${SEVERITY[v.sev].label}`}
tone={SEVERITY[v.sev].tone}
active={kind === k}
onClick={() => set("kind", kind === k ? "" : k)}
/>
))}
</div>
)}
<FilterBar onReset={status || kind ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
<FilterSelect
label="状态"
value={status}
onChange={(v) => set("status", v)}
options={STATUS_FILTER}
allLabel="未关闭"
/>
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
</FilterBar>
{list.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : list.isError && !rows ? (
<ErrorState error={list.error} onRetry={() => list.refetch()} />
) : groups.length === 0 ? (
<EmptyState
icon={CheckCheck}
title={isOpenView ? "没有需要你处理的事项" : "没有符合条件的事项"}
description={isOpenView ? "系统在正常推进目标;新的待决定事项会实时出现在这里。" : "换个状态或类型看看。"}
/>
) : (
<div className="space-y-4">
{groups.map((g) => {
const title = g.entityId ? titles.get(g.entityId) : null;
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
"info",
);
return (
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
{g.entityId ? (
<Link
to={consolePath("library", g.entityId)}
className="min-w-0 truncate text-body font-semibold text-foreground hover:underline"
>
{title ?? "关联作品"}
</Link>
) : (
<span className="text-body font-semibold text-muted-foreground">
{g.kind === "identity_ambiguous" ? "尚未归属作品" : "系统级"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
</header>
{g.items.map((d) => (
<DecisionCard key={d.id} d={d} />
))}
</section>
);
})}
{list.hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => list.fetchNextPage()}
disabled={list.isFetchingNextPage}
>
{list.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</div>
)}
</>
);
}
+666 -4
View File
@@ -1,6 +1,668 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import type {
ContentUnit,
EntityMetadataField,
ExternalIdentity,
LibraryAssetView,
LibraryDetail,
LibraryDownloadView,
MediaEntityDetail,
} from "@umt/contracts";
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
import {
ArrowLeft,
CalendarClock,
ChevronRight,
FolderTree,
GitMerge,
Lock,
LockOpen,
PencilLine,
RefreshCw,
SearchX,
} from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import {
useEntityDetail,
useLibraryDetail,
useLibraryRoots,
useRefreshMetadata,
useUnlockField,
} from "~/api/library.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
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 { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
import {
CONTENT_TYPE,
DOWNLOAD_STATUS,
ENTITY_FIELD_LABEL,
ENTITY_STATUS,
IDENTITY_STATUS,
LOCATION_KIND,
LOCATION_STATUS,
MEDIA_KIND,
METADATA_SOURCE,
ORG_STATUS,
SEGMENT,
UNIT_KIND,
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
export default function Page() {
return <WorkspacePlaceholder />;
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
// 内容单元、下载历史与校验结果,同一页同时展示。
const pad = (n: number) => String(n).padStart(2, "0");
function unitCode(u: ContentUnit): string {
const seg = SEGMENT[u.segment] ? `(${SEGMENT[u.segment]})` : "";
if (u.kind === "season") return u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "季";
if (u.kind === "episode") {
const s = u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "";
const e = u.episodeNumber !== null ? `E${pad(u.episodeNumber)}` : "";
return `${s}${e}${seg}` || (u.sourceIssue ?? "集");
}
return `${UNIT_KIND[u.kind] ?? u.kind}${u.episodeNumber !== null ? ` ${u.episodeNumber}` : ""}${seg}`;
}
function deliveryLine(a: LibraryAssetView["asset"]): string {
const d = a.delivery;
return [
d.resolution,
d.sourceType,
d.videoCodec,
d.hdr?.join("/"),
d.releaseGroup,
a.contentEdition ? `版本 ${a.contentEdition}` : null,
a.sizeBytes !== null ? formatBytes(a.sizeBytes, 1) : null,
]
.filter(Boolean)
.join(" · ");
}
// ── 实际路径与库根 ──
function PathsPanel({ detail }: { detail: LibraryDetail }) {
const roots = useLibraryRoots();
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="路径绑定一旦建立不随译名变化;执行进程可访问的绝对路径按库根拼接">
{bindings.length === 0 && detail.assets.length === 0 ? (
<EmptyState
plain
icon={FolderTree}
title="还没有入库文件"
description="收录目标满足后,文件位置会显示在这里。"
className="py-8"
/>
) : (
<div className="space-y-4">
{bindings.length ? (
<ul className="space-y-2">
{bindings.map((b) => {
const root = rootOf(b.libraryRootId);
return (
<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>
{b.status === "conflict" ? <StatusBadge tone="destructive">绑定冲突</StatusBadge> : null}
</div>
<p className="mt-1 font-mono text-caption break-all text-foreground">
{root ? `${root.localPath.replace(/\/+$/, "")}/` : ""}
{b.relativePath}
</p>
</li>
);
})}
</ul>
) : null}
{detail.assets.map((a) => (
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
<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>
)}
</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.libraryRootId ? (
<span className="text-meta text-muted-foreground">
{rootOf(l.libraryRootId)?.name ?? "库根"}
</span>
) : null}
</div>
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
{l.absolutePath ?? l.relativePath}
</p>
</li>
))}
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
</ul>
</div>
))}
</div>
)}
</Panel>
);
}
// ── 外部身份映射 ──
function IdentityPanel({ detail }: { detail: LibraryDetail }) {
const [open, setOpen] = useState(false);
const list = [...detail.identities].sort((a, b) => order(a) - order(b));
const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted");
return (
<Panel
title="外部身份映射"
description="本地 UUID 是主身份,TMDB 等外部 ID 是可修订的辅助映射"
actions={
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<PencilLine />
改映射
</Button>
}
>
{list.length === 0 ? (
<EmptyState plain icon={SearchX} title="还没有外部映射" description="可以手动指定 TMDB id。" className="py-8" />
) : (
<ul className="space-y-2">
{list.map((i) => (
<IdentityRow key={i.id} i={i} />
))}
</ul>
)}
<IdentityDialog
entityId={detail.entity.id}
entityKind={detail.entity.kind}
open={open}
onOpenChange={setOpen}
candidates={candidates}
/>
</Panel>
);
}
const order = (i: ExternalIdentity) => ["accepted", "conflicted", "proposed", "retired"].indexOf(i.status);
function IdentityRow({ i }: { i: ExternalIdentity }) {
const reason = typeof i.evidence.reason === "string" ? i.evidence.reason : null;
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>
<Tone v={IDENTITY_STATUS[i.status]} />
{i.locked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
人工锁
</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>
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
</li>
);
}
// ── 元数据字段来源与锁 ──
function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: MediaEntityDetail | undefined }) {
const id = detail.entity.id;
const refresh = useRefreshMetadata(id);
const unlock = useUnlockField(id);
const [editing, setEditing] = useState<EntityMetadataField | null>(null);
const locks = new Set(detail.metadataSources.fieldLocks);
const fields = detail.metadataSources.fields;
const finals = FieldEnum.options.map((f) => {
const parsed = FinalFieldValue.safeParse(fields[f]);
return { field: f, final: parsed.success ? parsed.data : null, locked: locks.has(f) };
});
const candidatesOf = (f: string) => (entity?.metadataFields ?? []).filter((m) => m.field === f);
const current = editing ? finals.find((x) => x.field === editing)?.final?.value : undefined;
return (
<Panel
title="元数据"
description={`字段级来源;锁定的字段刷新时不会被改写 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<Button
variant="outline"
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(undefined, {
onSuccess: () => toast.success("已提交元数据刷新(锁定字段保持不变)"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
})
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
刷新元数据
</Button>
}
>
<ul className="divide-y divide-border/60">
{finals.map(({ field, final, locked }) => {
const cands = candidatesOf(field);
return (
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
<div className="min-w-0 flex-1 basis-48">
<p className="text-sm break-words text-foreground">
{final ? String(final.value ?? "—") : <span className="text-muted-foreground">未提供</span>}
</p>
{final ? (
<p className="text-meta text-muted-foreground">
来源 {METADATA_SOURCE[final.source] ?? final.source}
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
</p>
) : null}
{cands.length ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground">
候选:
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{locked ? (
<>
<StatusBadge>
<Lock className="h-3 w-3" />
已锁定
</StatusBadge>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label={`解锁${ENTITY_FIELD_LABEL[field]}`}>
<LockOpen />
</Button>
}
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
description="取消人工覆盖与锁定,下次刷新会按来源优先级重新取值。"
confirmLabel="解锁"
onConfirm={() =>
unlock.mutateAsync(field).then(
() => toast.success("已解锁"),
(err) => {
toast.error(errorMessage(err, "解锁失败"));
throw err;
},
)
}
/>
</>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
onClick={() => setEditing(field)}
>
<PencilLine />
</Button>
</div>
</li>
);
})}
</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>
<ChipList
items={entity.aliases.map((a) => (
<span key={a.id}>
{a.alias}
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
</span>
))}
/>
</div>
) : null}
<FieldLockDialog entityId={id} field={editing} current={current} onOpenChange={(o) => !o && setEditing(null)} />
</Panel>
);
}
// ── Slot ──
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
if (detail.slots.length === 0) {
return (
<Panel title="收录目标(Slot)">
<EmptyState
plain
icon={CalendarClock}
title="这部作品还没有收录目标"
description="在订阅与收录规则里新建订阅或 Slot,系统会按目标持续补齐与升级。"
action={
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去订阅与收录规则</Link>
</Button>
}
className="py-8"
/>
</Panel>
);
}
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
return (
<Panel title="收录目标(Slot)" description="每个目标独立判断硬条件、软偏好与升级;状态均配文字">
<div className="grid gap-3 xl:grid-cols-2">
{detail.slots.map((s) => {
const cur = assetOf(s.currentAssetId);
const unit = units.find((u) => u.id === s.contentUnitId);
return (
<SlotCard
key={s.id}
slot={s}
unitLabel={unit ? unitCode(unit) : undefined}
currentAsset={cur ? deliveryLine(cur.asset) || "未知规格" : undefined}
candidates={detail.candidates.filter((c) => c.slotId === s.id).slice(0, 5)}
/>
);
})}
</div>
</Panel>
);
}
// ── 内容单元 ──
function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDetail>; detail: LibraryDetail }) {
const covered = new Set(detail.assets.flatMap((a) => a.contentUnitIds));
const columns: DataColumn<ContentUnit>[] = [
{
key: "code",
header: "编号",
cell: (u) => <span className="font-mono text-caption whitespace-nowrap">{unitCode(u)}</span>,
},
{
key: "title",
header: "标题 / 期号",
cell: (u) => (
<div className="min-w-0">
<p className="max-w-[12rem] truncate text-sm sm:max-w-xs">{u.title ?? u.sourceIssue ?? "—"}</p>
<p className="text-meta text-muted-foreground">
{UNIT_KIND[u.kind]} · {CONTENT_TYPE[u.contentType]}
{u.airDate ? ` · ${u.airDate}` : ""}
</p>
</div>
),
},
{
key: "status",
header: "状态",
cell: (u) => (
<div className="flex flex-wrap gap-1">
{u.status === "provisional" ? (
<StatusBadge tone="warning">临时单元</StatusBadge>
) : u.status === "retired" ? (
<StatusBadge>已退役</StatusBadge>
) : (
<StatusBadge tone="success">已确认</StatusBadge>
)}
{u.numberingLocked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
编号锁定
</StatusBadge>
) : null}
</div>
),
},
{
key: "asset",
header: "已有文件",
hideBelow: "sm",
cell: (u) =>
covered.has(u.id) ? (
<StatusBadge tone="success">已入库</StatusBadge>
) : (
<span className="text-muted-foreground">—</span>
),
},
];
const units = [...(entity.data?.contentUnits ?? [])].sort(
(a, b) =>
(a.seasonNumber ?? 999) - (b.seasonNumber ?? 999) ||
(a.kind === "season" ? -1 : 0) - (b.kind === "season" ? -1 : 0) ||
(a.episodeNumber ?? 0) - (b.episodeNumber ?? 0) ||
a.segment.localeCompare(b.segment),
);
return (
<Panel
title="内容单元"
description="季 / 集 / 期与分段;临时单元是按解析结果先建的,确认或改编号后锁定"
bodyClassName="p-0 sm:p-0"
>
<DataTable
plain
columns={columns}
rows={entity.data ? units : undefined}
rowKey={(u) => u.id}
loading={entity.isPending}
error={entity.error}
onRetry={() => entity.refetch()}
empty={{
title: detail.entity.kind === "movie" ? "电影没有分集单元" : "还没有内容单元",
description:
detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集或期后会自动建临时单元。",
}}
/>
</Panel>
);
}
// ── 下载历史与校验 ──
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
const columns: DataColumn<LibraryDownloadView>[] = [
{
key: "name",
header: "下载",
cell: (d) => (
<div className="min-w-0">
<Link
to={consolePath("tasks", d.downloadTask.id)}
className="block max-w-[14rem] truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={d.downloadTask.name}
>
{d.downloadTask.name}
</Link>
<p className="text-meta text-muted-foreground">
{d.plan
? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · `
: ""}
{formatDateTime(d.downloadTask.createdAt)}
</p>
</div>
),
},
{
key: "status",
header: "下载状态",
cell: (d) => (
<div className="min-w-24 space-y-1">
<Tone v={DOWNLOAD_STATUS[d.downloadTask.status]} />
{d.downloadTask.status !== "imported" ? (
<div className="flex items-center gap-2">
<ProgressLine value={d.downloadTask.progress} className="w-16" />
<span className="text-meta text-muted-foreground tabular-nums">
{Math.round(d.downloadTask.progress * 100)}%
</span>
</div>
) : null}
</div>
),
},
{
key: "size",
header: "大小",
align: "right",
hideBelow: "md",
cell: (d) => (
<span className="text-meta tabular-nums">
{d.downloadTask.sizeBytes !== null ? formatBytes(d.downloadTask.sizeBytes, 1) : "—"}
</span>
),
},
{
key: "verify",
header: "整理与校验",
cell: (d) =>
d.organizationRecordIds.length ? (
<div className="flex flex-wrap items-center gap-1.5">
{d.lastOrganizationStatus ? <Tone v={ORG_STATUS[d.lastOrganizationStatus]} /> : null}
{d.organizationRecordIds.map((rid, i) => (
<Link
key={rid}
to={consolePath("organize", rid)}
className="inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
>
记录 {i + 1}
<ChevronRight className="h-3 w-3" />
</Link>
))}
</div>
) : (
<span className="text-meta text-muted-foreground">尚未整理</span>
),
},
];
return (
<Panel
title="下载历史与校验结果"
description="下载与入库串成一个业务过程;整理记录含逐文件核验"
bodyClassName="p-0 sm:p-0"
>
<DataTable
plain
columns={columns}
rows={detail.downloads}
rowKey={(d) => d.downloadTask.id}
empty={{ title: "还没有下载记录" }}
/>
</Panel>
);
}
export default function LibraryDetailPage() {
const { id } = useParams();
const detail = useLibraryDetail(id);
const entity = useEntityDetail(id);
const [merging, setMerging] = useState(false);
const back = (
<Link
to={consolePath("library")}
className="mb-4 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
媒体库
</Link>
);
if (detail.isPending) {
return (
<>
{back}
<LoadingState rows={8} />
</>
);
}
if (detail.isError) {
const notFound = (detail.error as { code?: string }).code === "not_found";
return (
<>
{back}
<ErrorState
title={notFound ? "作品不存在" : "作品加载失败"}
error={notFound ? "这个 ID 没有对应的作品,可能已被删除。" : detail.error}
onRetry={notFound ? undefined : () => detail.refetch()}
/>
</>
);
}
const d = detail.data;
const e = d.entity;
const redirected = entity.data?.redirectedFrom;
return (
<>
{back}
{e.status === "merged" && e.mergedIntoId ? (
<div className="mb-4 rounded-xl border border-warning/30 bg-warning/10 px-4 py-3 text-caption text-warning">
这部作品已合并。
<Link to={consolePath("library", e.mergedIntoId)} className="ml-1 underline">
查看合并后的作品
</Link>
</div>
) : null}
{redirected ? (
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
) : null}
<header className="mb-6 flex flex-wrap items-start gap-4">
<CoverPlaceholder title={e.title} kind={e.kind} className="h-28 w-20 sm:h-36 sm:w-24" />
<div className="min-w-0 flex-1 basis-56">
<div className="flex flex-wrap items-center gap-2">
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
{e.title}
</h1>
<Tone v={ENTITY_STATUS[e.status]} />
</div>
<p className="mt-1 text-body text-muted-foreground">
{MEDIA_KIND[e.kind]}
{e.year ? ` · ${e.year}` : ""}
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
</p>
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
订阅与目标
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并到另一作品
</Button>
) : null}
</div>
</header>
<div className="space-y-5">
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
<div className="grid gap-5 xl:grid-cols-2">
<IdentityPanel detail={d} />
<MetadataPanel detail={d} entity={entity.data} />
</div>
<PathsPanel detail={d} />
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
<UnitsPanel entity={entity} detail={d} />
) : null}
<DownloadsPanel detail={d} />
</div>
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
</>
);
}
+166 -3
View File
@@ -1,6 +1,169 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts";
import { Download, Library as LibraryIcon, RotateCw } from "lucide-react";
import { useDeferredValue } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useLibraryList } from "~/api/library.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
// 占位:标题与说明取自 components/console/nav.ts
const FILTERS: { value: NonNullable<LibraryListQuery["filter"]>; label: string }[] = [
{ value: "gap", label: "有缺口" },
{ value: "attention", label: "需处理" },
{ value: "downloading", label: "下载中" },
{ value: "satisfied", label: "已满足" },
];
/** 各 Slot 状态计数:只列非零项,文字 + 语气 */
function SlotSummary({ counts }: { counts: LibraryItem["slotCounts"] }) {
const parts = SLOT_STATUS_ORDER.filter((s) => counts[s] > 0);
if (parts.length === 0) return <span className="text-meta text-muted-foreground">未设目标</span>;
return (
<div className="flex flex-wrap gap-1">
{parts.map((s: SlotStatus) => (
<StatusBadge key={s} tone={SLOT_STATUS[s].tone}>
{SLOT_STATUS[s].label} {counts[s]}
</StatusBadge>
))}
</div>
);
}
export default function LibraryPage() {
return <WorkspacePlaceholder />;
const [sp, setSp] = useSearchParams();
const navigate = useNavigate();
const kind = (sp.get("kind") ?? "") as MediaKind | "";
const filter = (sp.get("filter") ?? "") as LibraryListQuery["filter"] | "";
const q = sp.get("q") ?? "";
const deferredQ = useDeferredValue(q.trim());
const set = (k: string, v: string) =>
setSp(
(prev) => {
const next = new URLSearchParams(prev);
if (v) next.set(k, v);
else next.delete(k);
return next;
},
{ replace: true },
);
const list = useLibraryList({
kind: kind || undefined,
filter: filter || undefined,
q: deferredQ || undefined,
});
const rows = list.data?.pages.flatMap((p) => p.items);
const filtered = !!(kind || filter || q);
const columns: DataColumn<LibraryItem>[] = [
{
key: "title",
header: "作品",
cell: (r) => (
<div className="flex min-w-0 items-center gap-3">
<CoverPlaceholder title={r.entity.title} kind={r.entity.kind} small className="h-14 w-10" />
<div className="min-w-0">
<Link
to={consolePath("library", r.entity.id)}
onClick={(e) => e.stopPropagation()}
className="block max-w-[14rem] truncate font-medium text-foreground hover:underline sm:max-w-xs"
>
{r.entity.title}
</Link>
<p className="truncate text-meta text-muted-foreground">
{MEDIA_KIND[r.entity.kind]}
{r.entity.year ? ` · ${r.entity.year}` : ""}
{r.entity.originalTitle && r.entity.originalTitle !== r.entity.title
? ` · ${r.entity.originalTitle}`
: ""}
</p>
{r.entity.status !== "active" ? <Tone v={ENTITY_STATUS[r.entity.status]} className="mt-1" /> : null}
</div>
</div>
),
},
{ key: "slots", header: "收录目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{
key: "assets",
header: "资产",
align: "right",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{r.assetCount}</span>,
},
{
key: "dl",
header: "下载中",
align: "right",
hideBelow: "md",
cell: (r) =>
r.activeDownloads ? (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
{r.activeDownloads}
</span>
) : (
<span className="text-muted-foreground">—</span>
),
},
{
key: "org",
header: "最近入库",
hideBelow: "lg",
cell: (r) => <span className="text-meta text-muted-foreground">{relTime(r.lastOrganizedAt)}</span>,
},
];
return (
<>
<PageHeader
title="媒体库"
description="已登记资产或正在追踪的作品;每个作品按独立收录目标(Slot)显示满足情况"
actions={
<Button variant="outline" size="sm" onClick={() => list.refetch()} disabled={list.isFetching}>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
<FilterBar
search={{ value: q, onChange: (v) => set("q", v), placeholder: "标题 / 原名 / 别名" }}
onReset={filtered ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}
>
<FilterSelect label="类型" value={kind} onChange={(v) => set("kind", v)} options={opts(MEDIA_KIND)} />
<FilterSelect label="状态" value={filter ?? ""} onChange={(v) => set("filter", v)} options={FILTERS} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.entity.id}
onRowClick={(r) => navigate(consolePath("library", r.entity.id))}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
empty={
filtered
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选试试。", icon: LibraryIcon }
: {
title: "媒体库还是空的",
description: "登记库根并完成首次扫描,或新建订阅后,作品会出现在这里。",
icon: LibraryIcon,
action: (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去新建订阅</Link>
</Button>
),
}
}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
/>
</>
);
}
+361 -3
View File
@@ -1,6 +1,364 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts";
import { Ban, History, Plus, ShieldOff, Tags } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
useDeleteTag,
useLiftSuppression,
usePreferenceTags,
useSuppressions,
useUpdateTag,
} from "~/api/preferences.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { MEDIA_KIND, opts, PREF_KIND, PREF_ORIGIN, PREF_STATUS } from "~/components/library/labels.ts";
import { Chip, Panel, relTime, Tone } from "~/components/library/parts.tsx";
import { PreferenceFormDialog } from "~/components/library/preferences-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
// 占位:标题与说明取自 components/console/nav.ts
// 偏好标签(REQ §6.7):一个偏好一个标签;停用/删除/改语义留抑制记录,Agent 不据同一旧证据自动加回。
const SUP_REASON: Record<string, string> = {
deleted: "删除",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
};
const supReason = (r: string | null) => (r ? (SUP_REASON[r] ?? r) : "未注明");
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
/** scope 摘要:{} = 全局;mediaKinds 翻中文;其余键原样 */
function scopeSummary(scope: Record<string, unknown>): string {
const keys = Object.keys(scope);
if (keys.length === 0) return "全局";
const parts: string[] = [];
for (const k of keys) {
const v = scope[k];
if (k === "mediaKinds" && Array.isArray(v))
parts.push(v.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? String(x)).join("、"));
else parts.push(`${k}: ${Array.isArray(v) ? v.join("、") : typeof v === "object" ? "…" : String(v)}`);
}
return parts.join(" · ");
}
function TagCard({ tag, onOpen }: { tag: PreferenceTag; onOpen: () => void }) {
const inactive = tag.status !== "active";
return (
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 cursor-pointer rounded-xl border border-border/80 bg-foreground/[0.02] p-3 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
inactive && "opacity-70",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", inactive && "line-through")}>
{tag.label}
</span>
<Tone v={PREF_STATUS[tag.status]} className="shrink-0" />
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{PREF_KIND[tag.kind].label}</Chip>
<Chip>{scopeSummary(tag.scope)}</Chip>
<Chip>{PREF_ORIGIN[tag.origin]}</Chip>
</div>
{tag.description ? (
<p className="mt-2 line-clamp-2 text-meta break-words text-muted-foreground">{tag.description}</p>
) : null}
<p className="mt-2 text-micro text-muted-foreground tabular-nums">
r{tag.revision} · 更新于 {relTime(tag.updatedAt)}
</p>
</button>
);
}
export default function PreferencesPage() {
return <WorkspacePlaceholder />;
const [status, setStatus] = useState("");
const [kind, setKind] = useState("");
const tags = usePreferenceTags({
status: (status || undefined) as "active" | "disabled" | undefined,
kind: (kind || undefined) as PreferenceKind | undefined,
});
const deleted = usePreferenceTags({ status: "deleted" });
const allTags = usePreferenceTags({});
const sups = useSuppressions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [form, setForm] = useState<{ open: boolean; tag: PreferenceTag | null }>({ open: false, tag: null });
const update = useUpdateTag();
const del = useDeleteTag();
const lift = useLiftSuppression();
const tagById = useMemo(() => {
const m = new Map<string, PreferenceTag>();
for (const t of [...(allTags.data?.items ?? []), ...(deleted.data?.items ?? [])]) m.set(t.id, t);
return m;
}, [allTags.data, deleted.data]);
const selected = selectedId ? (tagById.get(selectedId) ?? tags.data?.items.find((t) => t.id === selectedId)) : null;
const toggle = async (t: PreferenceTag) => {
const next = t.status === "active" ? "disabled" : "active";
try {
await update.mutateAsync({ id: t.id, status: next });
toast.success(next === "active" ? "已重新启用,同语义抑制已解除" : "已停用,留下抑制记录");
} catch (err) {
toast.error(errorMessage(err));
}
};
const history = useMemo<TimelineItem[]>(() => {
const items: (TimelineItem & { ts: number })[] = [];
for (const s of sups.data?.items ?? []) {
const label = s.preferenceTagId ? (tagById.get(s.preferenceTagId)?.label ?? "未知标签") : "未关联标签";
items.push({
id: `sup-${s.id}`,
at: s.createdAt,
ts: Date.parse(s.createdAt),
tone: "warning",
title: `「${label}」被${supReason(s.reason)},写入抑制记录`,
meta: actorName(s.suppressedBy),
});
if (s.liftedAt)
items.push({
id: `lift-${s.id}`,
at: s.liftedAt,
ts: Date.parse(s.liftedAt),
tone: "success",
title: `「${label}」的抑制已解除`,
meta: actorName(s.liftedBy),
});
}
for (const t of deleted.data?.items ?? []) {
if ((sups.data?.items ?? []).some((s) => s.preferenceTagId === t.id && s.reason === "deleted")) continue;
items.push({
id: `del-${t.id}`,
at: t.updatedAt,
ts: Date.parse(t.updatedAt),
tone: "neutral",
title: `「${t.label}」已删除`,
});
}
return items.sort((a, b) => b.ts - a.ts).slice(0, 30);
}, [sups.data, deleted.data, tagById]);
const activeSups = (sups.data?.items ?? []).filter((s) => s.active);
const filtered = !!status || !!kind;
return (
<>
<PageHeader
title="偏好标签"
description="一个偏好一个标签。软偏好影响排序,硬规则直接排除;手动取消的偏好不会被 Agent 自动加回。"
actions={
<Button onClick={() => setForm({ open: true, tag: null })}>
<Plus />
添加偏好
</Button>
}
/>
<FilterBar
onReset={
filtered
? () => {
setStatus("");
setKind("");
}
: undefined
}
>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
allLabel="未删除"
options={[
{ value: "active", label: "生效中" },
{ value: "disabled", label: "已停用" },
]}
/>
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(PREF_KIND)} />
</FilterBar>
<div className="space-y-6">
{tags.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={3} />
</div>
) : tags.isError ? (
<ErrorState error={tags.error} onRetry={() => tags.refetch()} />
) : tags.data.items.length === 0 ? (
<EmptyState
icon={Tags}
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
description="偏好可以在这里添加,也可以在 Agent 会话里说「以后都这样」形成长期偏好。"
/>
) : (
<Panel title="偏好" description={`${tags.data.items.length} 个标签,点开看规则与证据`}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tags.data.items.map((t) => (
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
))}
</div>
</Panel>
)}
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p>
手动停用、删除或修改语义的偏好会留下抑制记录,Agent
不会根据同一旧证据自动加回;只有你重新启用、重新添加或在这里明确解除后才会恢复。
</p>
</div>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => (
<SuppressionRow
key={s.id}
s={s}
label={s.preferenceTagId ? tagById.get(s.preferenceTagId)?.label : undefined}
onLift={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
))}
</ul>
)}
</Panel>
<Panel title="历史" description="停用、删除、解除抑制的记录(新 → 旧)">
{sups.isPending || deleted.isPending ? (
<LoadingState rows={2} />
) : history.length === 0 ? (
<EmptyState plain icon={History} title="还没有变更历史" className="py-8" />
) : (
<Timeline items={history} />
)}
</Panel>
</div>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelectedId(null)}
title={selected?.label ?? ""}
description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined}
footer={
selected && selected.status !== "deleted" ? (
<>
<ConfirmDialog
trigger={<Button variant="destructive">删除</Button>}
title={`删除「${selected.label}」?`}
description="删除后会留下抑制记录,Agent 不会据同一旧证据自动加回;以后需要可重新添加。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(selected.id);
toast.success("已删除,留下抑制记录");
setSelectedId(null);
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
<Button variant="outline" onClick={() => toggle(selected)} disabled={update.isPending}>
{selected.status === "active" ? "停用" : "重新启用"}
</Button>
<Button onClick={() => setForm({ open: true, tag: selected })}>编辑</Button>
</>
) : undefined
}
>
{selected ? (
<>
<KvList
items={[
{ label: "状态", value: <Tone v={PREF_STATUS[selected.status]} /> },
{ label: "来源", value: PREF_ORIGIN[selected.origin] },
{ label: "作用范围", value: scopeSummary(selected.scope) },
{ label: "修订", value: `r${selected.revision}` },
{ label: "创建", value: formatDateTime(selected.createdAt) },
{ label: "更新", value: formatDateTime(selected.updatedAt) },
{ label: "说明", value: selected.description, wide: true },
{ label: "语义指纹", value: selected.fingerprint, mono: true, wide: true },
]}
/>
<DetailSection title="规则">
<JsonView value={selected.rule} />
</DetailSection>
<DetailSection title="作用范围">
<JsonView value={selected.scope} />
</DetailSection>
<DetailSection title="证据">
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
</DetailSection>
</>
) : null}
</DetailSheet>
<PreferenceFormDialog open={form.open} tag={form.tag} onOpenChange={(o) => setForm((f) => ({ ...f, open: o }))} />
</>
);
}
function SuppressionRow({
s,
label,
onLift,
}: {
s: PreferenceSuppression;
label: string | undefined;
onLift: () => Promise<void>;
}) {
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">{label ?? "未关联标签"}</span>
<Chip>{supReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后,Agent 可以再次根据证据提出同一偏好(仍需走正常授权)。标签本身不会自动恢复。"
confirmLabel="解除"
onConfirm={onLift}
/>
) : null}
</li>
);
}
+621 -3
View File
@@ -1,6 +1,624 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import {
AcquisitionPriority,
type MediaEntity,
SeasonScope,
type Subscription,
SubscriptionCreate,
SubscriptionMediaKind,
SubscriptionMode,
type SubscriptionStatus,
} from "@umt/contracts";
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useEntitySearch } from "~/api/library.ts";
import {
type SubscriptionFilters,
useCreateSubscription,
useEntityTitle,
useSubscriptions,
useUpdateSubscription,
} from "~/api/subscriptions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import {
MEDIA_KIND,
opts,
PRIORITY,
SEASON_SCOPE,
SUB_MODE,
SUB_ORIGIN,
SUB_STATUS,
} from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
// 订阅与收录规则:订阅(单次/持续、仅本季/接续)+ 各作品的收录目标 Slot + 收录策略版本(REQ §6.1、§6.4、§6.5)
const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { movie: "电影", series: "剧集", variety: "综艺" };
// 占位:标题与说明取自 components/console/nav.ts
export default function SubscriptionsPage() {
return <WorkspacePlaceholder />;
const [params, setParams] = useSearchParams();
const tab = params.get("tab") === "policies" ? "policies" : "subscriptions";
const [creatingSub, setCreatingSub] = useState(false);
const [creatingPolicy, setCreatingPolicy] = useState(false);
return (
<>
<PageHeader
title="订阅与收录规则"
description="订阅决定追什么,收录目标决定要哪种版本,策略决定默认怎么做;启用即授权范围内的持续操作。"
actions={
tab === "subscriptions" ? (
<Button onClick={() => setCreatingSub(true)}>
<Plus />
新建订阅
</Button>
) : (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建策略草案
</Button>
)
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "policies") p.set("tab", "policies");
else p.delete("tab");
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="订阅与收录规则">
<TabsTrigger value="subscriptions">订阅</TabsTrigger>
<TabsTrigger value="policies">收录策略</TabsTrigger>
</TabsList>
<TabsContent value="subscriptions">
<SubscriptionsTab />
</TabsContent>
<TabsContent value="policies">
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
</TabsContent>
</Tabs>
{creatingSub ? <CreateSubscriptionDialog onClose={() => setCreatingSub(false)} /> : null}
</>
);
}
// ── 订阅列表 ──
function EntityTitle({ sub }: { sub: Subscription }) {
const q = useEntityTitle(sub.mediaEntityId);
if (!sub.mediaEntityId)
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="break-all text-foreground">{sub.titleQuery ?? "—"}</span>
<Chip>尚未建档</Chip>
</span>
);
if (q.isPending) return <span className="text-muted-foreground">加载中…</span>;
if (q.isError)
return <span className="font-mono text-meta text-muted-foreground">{sub.mediaEntityId.slice(0, 8)}(无法读取)</span>;
return (
<span className="min-w-0 break-all text-foreground">
{q.data.title}
{q.data.year ? <span className="text-muted-foreground"> ({q.data.year})</span> : null}
</span>
);
}
function SubscriptionsTab() {
const [status, setStatus] = useState("");
const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"] };
const q = useSubscriptions(filters);
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
const [openId, setOpenId] = useState<string | null>(null);
const open = rows?.find((r) => r.id === openId) ?? null;
const columns: DataColumn<Subscription>[] = [
{ key: "title", header: "作品", cell: (r) => <EntityTitle sub={r} />, className: "min-w-40" },
{
key: "kind",
header: "类型",
cell: (r) => SUB_KIND_LABEL[r.mediaKind],
hideBelow: "sm",
className: "whitespace-nowrap",
},
{
key: "mode",
header: "追踪方式",
cell: (r) => (
<span className="whitespace-nowrap">
{SUB_MODE[r.mode]}
{r.mediaKind !== "movie" ? (
<span className="text-muted-foreground"> · {SEASON_SCOPE[r.seasonScope]}</span>
) : null}
</span>
),
hideBelow: "md",
},
{
key: "priority",
header: "优先级",
cell: (r) => PRIORITY[r.priority],
hideBelow: "lg",
className: "whitespace-nowrap",
},
{ key: "status", header: "状态", cell: (r) => <Tone v={SUB_STATUS[r.status]} /> },
{
key: "checked",
header: "最近检查",
cell: (r) => (
<span className="whitespace-nowrap text-muted-foreground tabular-nums">{relTime(r.lastCheckedAt)}</span>
),
hideBelow: "md",
align: "right",
},
];
return (
<>
<FilterBar onReset={status ? () => setStatus("") : undefined}>
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={q.isPending}
error={q.error}
onRetry={() => q.refetch()}
onRowClick={(r) => setOpenId(r.id)}
selectedKey={openId}
hasMore={q.hasNextPage}
onLoadMore={() => q.fetchNextPage()}
loadingMore={q.isFetchingNextPage}
empty={{
icon: CalendarClock,
title: status ? "没有符合条件的订阅" : "还没有订阅",
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
}}
/>
<SubscriptionSheet sub={open} onClose={() => setOpenId(null)} />
</>
);
}
// ── 订阅详情:修改 + 收录目标 ──
function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose: () => void }) {
const update = useUpdateSubscription();
const title = useEntityTitle(sub?.mediaEntityId);
const setStatus = (status: SubscriptionStatus, done: string) =>
sub
? update
.mutateAsync({ id: sub.id, status })
.then(() => toast.success(done))
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
: Promise.resolve();
return (
<DetailSheet
open={!!sub}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={sub ? (sub.mediaEntityId ? (title.data?.title ?? "订阅") : (sub.titleQuery ?? "订阅")) : ""}
description={sub ? `${SUB_KIND_LABEL[sub.mediaKind]} · 来源:${SUB_ORIGIN[sub.origin] ?? sub.origin}` : null}
footer={
sub && sub.status !== "cancelled" && sub.status !== "completed" ? (
<>
{sub.status === "active" ? (
<Button
variant="outline"
onClick={() => setStatus("paused", "已暂停,不再发起新下载")}
disabled={update.isPending}
>
<Pause />
暂停
</Button>
) : (
<Button variant="outline" onClick={() => setStatus("active", "已恢复追踪")} disabled={update.isPending}>
<Play />
恢复
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="outline" className="hover:border-destructive/40 hover:text-destructive">
<XCircle />
取消订阅
</Button>
}
title="取消这个订阅?"
description="不再追踪后续内容;已入库文件、已有收录目标与下载历史都保留。"
confirmLabel="取消订阅"
destructive
onConfirm={() => setStatus("cancelled", "订阅已取消")}
/>
</>
) : null
}
>
{sub ? (
<>
<DetailSection title="概况">
<KvList
items={[
{ label: "状态", value: <Tone v={SUB_STATUS[sub.status]} /> },
{
label: "作品",
value: sub.mediaEntityId ? (
<Link className="underline-offset-4 hover:underline" to={consolePath("library", sub.mediaEntityId)}>
打开媒体详情
</Link>
) : (
`按标题追踪:${sub.titleQuery ?? "—"}(尚未建档)`
),
},
{ label: "状态说明", value: sub.statusReason },
{ label: "最近检查", value: formatDateTime(sub.lastCheckedAt) },
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` },
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
]}
/>
</DetailSection>
<DetailSection title="追踪方式">
<SubscriptionEditForm key={`${sub.id}-${sub.updatedAt}`} sub={sub} />
</DetailSection>
<DetailSection title="收录目标">
{sub.mediaEntityId ? (
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
) : (
<p className="text-caption text-muted-foreground">作品建档并关联后才能设置收录目标。</p>
)}
</DetailSection>
</>
) : null}
</DetailSheet>
);
}
function SubscriptionEditForm({ sub }: { sub: Subscription }) {
const id = useId();
const update = useUpdateSubscription();
const [mode, setMode] = useState<SubscriptionMode>(sub.mode);
const [scope, setScope] = useState<SeasonScope>(sub.seasonScope);
const [priority, setPriority] = useState<AcquisitionPriority>(sub.priority);
const dirty = mode !== sub.mode || scope !== sub.seasonScope || priority !== sub.priority;
const disabled = sub.status === "cancelled" || sub.status === "completed";
const save = () =>
update.mutate(
{ id: sub.id, mode, seasonScope: scope, priority },
{ onSuccess: () => toast.success("追踪方式已更新"), onError: (e) => toast.error(errorMessage(e)) },
);
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="方式" htmlFor={`${id}-mode`}>
<EnumSelect
id={`${id}-mode`}
value={mode}
onChange={setMode}
options={SubscriptionMode.options}
labels={SUB_MODE}
disabled={disabled}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={sub.mediaKind === "movie" ? "电影不适用" : undefined}>
<EnumSelect
id={`${id}-scope`}
value={scope}
onChange={setScope}
options={SeasonScope.options}
labels={SEASON_SCOPE}
disabled={disabled || sub.mediaKind === "movie"}
/>
</FormField>
<FormField label="优先级" htmlFor={`${id}-prio`}>
<EnumSelect
id={`${id}-prio`}
value={priority}
onChange={setPriority}
options={AcquisitionPriority.options}
labels={PRIORITY}
disabled={disabled}
/>
</FormField>
</div>
{mode === "continuous" && scope === "following_seasons" && sub.mediaKind !== "movie" ? (
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不直接视作下一季,按证据识别。</p>
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={save} disabled={!dirty || update.isPending}>
<Check />
{update.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
);
}
function EnumSelect<T extends string>({
id,
value,
onChange,
options,
labels,
disabled,
}: {
id: string;
value: T;
onChange: (v: T) => void;
options: readonly T[];
labels: Record<T, string>;
disabled?: boolean;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as T)} disabled={disabled}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{options.map((o) => (
<SelectItem key={o} value={o}>
{labels[o]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ── 新建订阅 ──
const toSubKind = (k: MediaEntity["kind"]): SubscriptionMediaKind =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
const id = useId();
const create = useCreateSubscription();
const [byTitle, setByTitle] = useState(false);
const [term, setTerm] = useState("");
const [entity, setEntity] = useState<MediaEntity | null>(null);
const [titleQuery, setTitleQuery] = useState("");
const [mediaKind, setMediaKind] = useState<SubscriptionMediaKind>("series");
const [mode, setMode] = useState<SubscriptionMode>("continuous");
const [scope, setScope] = useState<SeasonScope>("following_seasons");
const [priority, setPriority] = useState<AcquisitionPriority>("tracking");
const [errors, setErrors] = useState<Record<string, string>>({});
const search = useEntitySearch(term, { enabled: !byTitle && !entity });
const pick = (e: MediaEntity) => {
setEntity(e);
const k = toSubKind(e.kind);
setMediaKind(k);
// REQ §6.5:用户主动添加的剧集/综艺默认持续追踪并接续后续季;电影默认单次
setMode(k === "movie" ? "once" : "continuous");
setScope("following_seasons");
};
const submit = () => {
const body = {
mediaEntityId: byTitle ? undefined : entity?.id,
titleQuery: byTitle ? titleQuery.trim() || undefined : undefined,
mediaKind,
mode,
seasonScope: mediaKind === "movie" ? "current_season" : scope,
priority,
origin: "user" as const,
};
const v = SubscriptionCreate.safeParse(body);
if (!v.success) {
const errs = zodFieldErrors(v.error.issues);
if (errs[""]) errs.target = byTitle ? "请填写要追踪的标题" : "请选择一部作品";
setErrors(errs);
return;
}
setErrors({});
create.mutate(v.data, {
onSuccess: () => {
toast.success("订阅已创建,将按收录目标开始检查");
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
});
};
return (
<Dialog open onOpenChange={(o) => !o && !create.isPending && onClose()}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>新建订阅</DialogTitle>
<DialogDescription>选择库里的作品,或先按标题追踪,作品建档后自动关联。</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
<fieldset className="flex gap-1" aria-label="订阅对象">
{[
[false, "选择作品"],
[true, "按标题追踪"],
].map(([v, l]) => (
<button
key={String(l)}
type="button"
aria-pressed={byTitle === v}
onClick={() => {
setByTitle(v as boolean);
setErrors({});
}}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-sm font-medium transition-colors duration-micro",
byTitle === v
? "pill-selected text-foreground"
: "border-transparent text-muted-foreground hover:bg-foreground/[0.06]",
)}
>
{l}
</button>
))}
</fieldset>
{byTitle ? (
<FormField
label="标题"
htmlFor={`${id}-tq`}
required
error={errors.target ?? errors.titleQuery}
hint="原名、译名或站点标题都可以"
>
<Input
id={`${id}-tq`}
value={titleQuery}
aria-invalid={!!errors.target}
onChange={(e) => setTitleQuery(e.target.value)}
/>
</FormField>
) : entity ? (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{entity.title}
{entity.year ? <span className="text-muted-foreground"> ({entity.year})</span> : null}
</p>
<p className="text-meta text-muted-foreground">{MEDIA_KIND[entity.kind]}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setEntity(null)}>
更换
</Button>
</div>
) : (
<FormField label="作品" htmlFor={`${id}-q`} required error={errors.target}>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${id}-q`}
className="pl-9"
value={term}
aria-invalid={!!errors.target}
placeholder="搜索标题 / 原名"
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<div className="max-h-52 overflow-y-auto rounded-lg border border-border/70">
{search.isPending ? (
<p className="px-3 py-2 text-caption text-muted-foreground">搜索中…</p>
) : search.isError ? (
<p className="px-3 py-2 text-caption text-destructive">{errorMessage(search.error)}</p>
) : (search.data?.items ?? []).filter((e) => e.status !== "merged").length === 0 ? (
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的作品,可以改用"按标题追踪"。</p>
) : (
<ul className="divide-y divide-border/60">
{(search.data?.items ?? [])
.filter((e) => e.status !== "merged")
.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => pick(e)}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left hover:bg-foreground/[0.04]"
>
<span className="min-w-0 truncate text-sm text-foreground">
{e.title}
{e.year ? <span className="text-muted-foreground"> ({e.year})</span> : null}
</span>
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
</button>
</li>
))}
</ul>
)}
</div>
</FormField>
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="类型" htmlFor={`${id}-kind`} error={errors.mediaKind}>
<EnumSelect
id={`${id}-kind`}
value={mediaKind}
onChange={(k) => {
setMediaKind(k);
if (k === "movie") setMode("once");
}}
options={SubscriptionMediaKind.options}
labels={SUB_KIND_LABEL}
disabled={!byTitle && !!entity}
/>
</FormField>
<FormField label="方式" htmlFor={`${id}-mode`}>
<EnumSelect
id={`${id}-mode`}
value={mode}
onChange={setMode}
options={SubscriptionMode.options}
labels={SUB_MODE}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={mediaKind === "movie" ? "电影不适用" : "随时可改"}>
<EnumSelect
id={`${id}-scope`}
value={mediaKind === "movie" ? "current_season" : scope}
onChange={setScope}
options={SeasonScope.options}
labels={SEASON_SCOPE}
disabled={mediaKind === "movie"}
/>
</FormField>
<FormField label="优先级" htmlFor={`${id}-prio`} hint="只影响资源分配,不越过磁盘阈值与权限">
<EnumSelect
id={`${id}-prio`}
value={priority}
onChange={setPriority}
options={AcquisitionPriority.options}
labels={PRIORITY}
/>
</FormField>
</div>
</div>
<DialogFooter>
<button
type="button"
className={buttonVariants({ variant: "outline" })}
onClick={onClose}
disabled={create.isPending}
>
取消
</button>
<Button onClick={submit} disabled={create.isPending}>
{create.isPending ? "创建中…" : "创建订阅"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}