import { AcquisitionPolicyRules, type JsonObject, type PolicyRevision, type SlotTemplate, SubscriptionMediaKind, } from "@umt/contracts"; import { ChevronRight, Eye, FilePlus2, History, Pencil, Plus, ScrollText, Trash2, Undo2, Upload } from "lucide-react"; import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { PolicyPreview, useCreatePolicy, usePolicyRevisions, usePreviewPolicy, usePublishPolicy, useRollbackPolicy, useUpdatePolicy, } from "~/api/policies.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 { FormField } from "~/components/console/form-field.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formErrorSummary, revealFirstError } from "~/lib/form-errors.ts"; import { actorText } from "~/lib/labels.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts"; import { ChipList, ChipToggleGroup, Panel, Tone } from "./parts.tsx"; import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor, summarizeHard, } from "./slot-rules.tsx"; // 收录策略(kind=acquisition 的 policy_revision):草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)。 // 规则只是结构化数据(AcquisitionPolicyRules),不是脚本;发布即对 scope 内正常下载/追更/整理生成持续授权。 const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.options.map((v) => ({ value: v, label: MEDIA_KIND[v], })); export const PUBLISH_NOTICE = "发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。"; type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean }; type PolicyDraft = { policyKey: string; title: string; summary: string; scopeKinds: string[]; defaultSlots: TemplateDraft[]; newTitleEnabled: boolean; windowDays: string; newTitleKinds: string[]; newTitleSlots: TemplateDraft[]; organizeMode: "" | "copy" | "move"; allowCopyWhenHnrPending: boolean; }; let seq = 0; const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({ key: `t${++seq}`, name: t?.name ?? "", rules: t ? draftFromJson(t) : emptyDraft(), open, }); function draftFromRevision(r: PolicyRevision | null): PolicyDraft { const rules = r ? AcquisitionPolicyRules.safeParse(r.rules).data : undefined; const kinds = r ? z.array(z.string()).safeParse((r.scope as { mediaKinds?: unknown }).mediaKinds).data : undefined; return { // 新草案预填 default:占位符看起来像已填的值,留空提交会报"填写策略键" policyKey: r?.policyKey ?? "default", title: r?.title ?? "", summary: r?.summary ?? "", scopeKinds: kinds ?? [], defaultSlots: (rules?.defaultSlots ?? []).map((t) => tpl(t)), newTitleEnabled: rules?.newTitle?.enabled ?? false, windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "", newTitleKinds: rules?.newTitle?.mediaKinds ?? [], newTitleSlots: (rules?.newTitle?.slots ?? []).map((t) => tpl(t)), organizeMode: rules?.organizeMode ?? "", allowCopyWhenHnrPending: rules?.allowCopyWhenHnrPending ?? false, }; } const Meta = z.object({ policyKey: z .string() .trim() .min(1, "填写策略键") .max(64, "最多 64 个字符") .regex(/^[a-z0-9][a-z0-9_.-]*$/, "只用小写字母、数字和 - _ ."), title: z.string().trim().min(1, "填写标题").max(120, "最多 120 个字符"), summary: z.string().trim().max(500, "最多 500 个字符"), }); const stripEmpty = (o: JsonObject) => (Object.keys(o).length ? o : undefined); /** 草稿 → rules/scope;错误键:meta 字段名、`${key}.name` / `${key}.${field}`、windowDays、rules */ function buildPolicy(d: PolicyDraft): | { ok: true; body: { policyKey: string; title: string; summary: string | null; scope: JsonObject; rules: JsonObject }; } | { ok: false; errors: Record } { const errors: Record = {}; const meta = Meta.safeParse(d); if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message; const templates = (list: TemplateDraft[]) => list.map((t) => { if (!t.name.trim()) errors[`${t.key}.name`] = "填写模板名称"; const r = draftToJson(t.rules); if (!r.ok) { for (const [k, v] of Object.entries(r.errors)) errors[`${t.key}.${k}`] = v; return { name: t.name.trim() }; } return { name: t.name.trim(), hardConditions: stripEmpty(r.value.hardConditions), softPreferences: stripEmpty(r.value.softPreferences), fallback: stripEmpty(r.value.fallback), upgradeThreshold: stripEmpty(r.value.upgradeThreshold), completion: stripEmpty(r.value.completion), }; }); const defaultSlots = templates(d.defaultSlots); const newTitleSlots = templates(d.newTitleSlots); let windowDays: number | undefined; if (d.windowDays.trim()) { windowDays = Number(d.windowDays); if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 3650) errors.windowDays = "1–3650 的整数"; } if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少需要一个模板"; const rules = { ...(defaultSlots.length ? { defaultSlots } : {}), ...(d.newTitleEnabled || newTitleSlots.length ? { newTitle: { enabled: d.newTitleEnabled, ...(windowDays ? { windowDays } : {}), ...(d.newTitleKinds.length ? { mediaKinds: d.newTitleKinds } : {}), slots: newTitleSlots, }, } : {}), ...(d.organizeMode ? { organizeMode: d.organizeMode } : {}), ...(d.allowCopyWhenHnrPending ? { allowCopyWhenHnrPending: true } : {}), }; // 与服务端同一份 schema 严格校验 const strict = AcquisitionPolicyRules.strict().safeParse(rules); if (!strict.success && !Object.keys(errors).length) { const i = strict.error.issues[0]; errors.rules = `规则结构不合法:${i?.path.join(".")} ${i?.message}`; } if (Object.keys(errors).length || !meta.success) return { ok: false, errors }; return { ok: true, body: { policyKey: meta.data.policyKey, title: meta.data.title, summary: meta.data.summary || null, scope: d.scopeKinds.length ? { mediaKinds: d.scopeKinds } : {}, rules: rules as JsonObject, }, }; } // ── 列表 ── export function PolicyWorkspace({ creating, onCreatingChange, }: { creating: boolean; onCreatingChange: (v: boolean) => void; }) { const q = usePolicyRevisions(); const [editor, setEditor] = useState<{ base: PolicyRevision | null; editId: string | null } | null>(null); const [viewId, setViewId] = useState(null); const groups = useMemo(() => { const m = new Map(); for (const r of q.data?.items ?? []) { if (r.kind !== "acquisition") continue; m.set(r.policyKey, [...(m.get(r.policyKey) ?? []), r]); } return [...m.entries()].map(([key, revs]) => ({ key, revs: revs.sort((a, b) => b.revision - a.revision) })); }, [q.data]); const all = q.data?.items ?? []; const viewing = all.find((r) => r.id === viewId) ?? null; const editorOpen = creating || !!editor; const closeEditor = () => { setEditor(null); onCreatingChange(false); }; return (
{q.isPending ? (
) : q.isError ? ( q.refetch()} /> ) : groups.length === 0 ? ( onCreatingChange(true)}> 新建策略草案 } /> ) : ( groups.map((g) => ( setEditor({ base: r, editId: r.id })} onDraftFrom={(r) => setEditor({ base: r, editId: null })} /> )) )} {editorOpen ? ( { closeEditor(); setViewId(r.id); }} /> ) : null} setViewId(null)} onEdit={(r) => { setViewId(null); setEditor({ base: r, editId: r.id }); }} onDraftFrom={(r) => { setViewId(null); setEditor({ base: r, editId: null }); }} />
); } function PolicyGroup({ policyKey, revs, onView, onEdit, onDraftFrom, }: { policyKey: string; revs: PolicyRevision[]; onView: (id: string) => void; onEdit: (r: PolicyRevision) => void; onDraftFrom: (r: PolicyRevision) => void; }) { const published = revs.find((r) => r.status === "published"); const draft = revs.find((r) => r.status === "draft"); const head = published ?? revs[0]!; const rules = AcquisitionPolicyRules.safeParse(head.rules).data; return ( {head.title} {policyKey} } description={ published ? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${actorText(published.publishedBy)} 发布` : "未发布,不会自动执行" } actions={ draft ? ( ) : ( ) } >

r{head.revision} 规则摘要

版本历史

    {revs.map((r) => (
  1. ))}
); } function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) { if (!valid || !rules) return

规则无法解析,不会执行

; const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`; return (
默认版本目标
新作品收录 {rules.newTitle?.enabled ? ( MEDIA_KIND[k]).join(" / ") : "全部类型", ...rules.newTitle.slots.map(tplLine), ]} /> ) : ( 关闭 )}
整理方式{" "} {rules.organizeMode === "move" ? "移动" : rules.organizeMode === "copy" ? "复制" : "按系统设置"} {rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用复制" : ""}
); } // ── 详情:预览 / 发布 / 回滚 ── function PolicyDetailSheet({ rev, onClose, onEdit, onDraftFrom, }: { rev: PolicyRevision | null; onClose: () => void; onEdit: (r: PolicyRevision) => void; onDraftFrom: (r: PolicyRevision) => void; }) { const preview = usePreviewPolicy(); const publish = usePublishPolicy(); const rollback = useRollbackPolicy(); const rules = rev ? AcquisitionPolicyRules.safeParse(rev.rules).data : undefined; const pv = rev?.preview ? PolicyPreview.safeParse(rev.preview) : null; const runPreview = (r: PolicyRevision) => preview.mutate(r.id, { onSuccess: () => toast.success(`r${r.revision} 预览已更新`), onError: (e) => toast.error(errorMessage(e)), }); return ( !o && onClose()} width="lg" title={rev ? `${rev.title} · r${rev.revision}` : ""} description={rev ? {rev.policyKey} : null} footer={ rev ? ( rev.status === "draft" ? ( <> 发布 } title={`发布 ${rev.policyKey} r${rev.revision}?`} description={PUBLISH_NOTICE} confirmLabel="发布" onConfirm={() => publish .mutateAsync({ id: rev.id, expectedRevision: rev.revision }) .then(() => toast.success(`r${rev.revision} 已发布`)) .catch((e) => { toast.error(errorMessage(e)); throw e; }) } > {!pv?.success ? (

这个草案还没有预览。先预览可以看到影响范围。

) : null}
) : ( <> {rev.status === "superseded" || rev.status === "rolled_back" ? ( 回滚到此版本 } title={`回滚到 r${rev.revision}?`} description={`用 r${rev.revision} 的内容发布一个新版本,当前版本标记为已回滚。${PUBLISH_NOTICE}`} confirmLabel="回滚" onConfirm={() => rollback .mutateAsync(rev.id) .then((n) => toast.success(`已回滚,r${n.revision} 生效`)) .then(onClose) .catch((e) => { toast.error(errorMessage(e)); throw e; }) } /> ) : null} ) ) : null } > {rev ? ( <> }, { label: "作用范围", value: scopeText(rev.scope) }, { label: "创建", value: `${formatDateTime(rev.createdAt)} · ${actorText(rev.createdBy)}` }, { label: "发布", value: rev.publishedAt ? `${formatDateTime(rev.publishedAt)} · ${actorText(rev.publishedBy)}` : "未发布", }, { label: "说明", value: rev.summary, wide: true }, ]} /> runPreview(rev)} disabled={preview.isPending}> 重新预览 ) : null } > ) : null}
); } const scopeText = (scope: JsonObject) => { const k = z.array(z.string()).safeParse((scope as { mediaKinds?: unknown }).mediaKinds).data; return k?.length ? k.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? x).join(" / ") : "全部作品"; }; function PreviewBlock({ value }: { value: JsonObject | null }) { if (!value) return (

还没有预览。预览用最近已识别的资源评估模板,不会执行任何操作。

); const p = PolicyPreview.safeParse(value); if (!p.success) return ; const v = p.data; return (
{!v.valid ? (

规则未通过校验,预览按空模板计算,无法发布。

) : null}
{[ ["样本资源", v.sampleSize], ["模板数", v.templates], ["会接受", v.matched], ["会排除", v.excluded], ].map(([l, n]) => (

{l}

{n}

))}

计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"资源 × 模板"计数。

{v.samples.length ? (
    {v.samples.map((s, i) => (
  • {s.template} {s.title ?? s.sourceReleaseId}
  • ))}
) : (

没有可用于回放的已识别资源。

)}
); } // ── 草案编辑 ── function PolicyEditorSheet({ base, editId, onClose, onSaved, }: { base: PolicyRevision | null; editId: string | null; onClose: () => void; onSaved: (r: PolicyRevision) => void; }) { const id = useId(); const create = useCreatePolicy(); const update = useUpdatePolicy(); const [d, setD] = useState(() => { const x = draftFromRevision(base); if (!base) x.defaultSlots = [tpl(undefined, true)]; if (base && !editId) x.summary = ""; return x; }); const [errors, setErrors] = useState>({}); const pending = create.isPending || update.isPending; const set = (k: K, v: PolicyDraft[K]) => setD((p) => ({ ...p, [k]: v })); const submit = () => { const r = buildPolicy(d); if (!r.ok) { setErrors(r.errors); // 展开有错的模板 setD((p) => ({ ...p, defaultSlots: p.defaultSlots.map((t) => ({ ...t, open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)), })), newTitleSlots: p.newTitleSlots.map((t) => ({ ...t, open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)), })), })); toast.error(formErrorSummary(r.errors)); revealFirstError(); return; } setErrors({}); const done = (rev: PolicyRevision) => { toast.success(`草案 r${rev.revision} 已保存`); onSaved(rev); }; if (editId) { const { policyKey: _k, ...body } = r.body; update.mutate({ id: editId, ...body }, { onSuccess: done, onError: (e) => toast.error(errorMessage(e)) }); } else { create.mutate( { ...r.body, kind: "acquisition", basedOnRevisionId: base?.id ?? null }, { onSuccess: done, onError: (e) => toast.error(errorMessage(e)) }, ); } }; return ( !o && !pending && onClose()} width="lg" title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"} description="保存为草案,发布后才生效。" footer={ <> } >
set("policyKey", e.target.value)} placeholder="default" /> set("title", e.target.value)} />