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:
1 parent
e5e42b1f0d
commit
ef73abe3df
25 files changed
+6670
-19
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user