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

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

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

No files matched your search

@@ -0,0 +1,886 @@
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 { 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 =
"发布即授权:scope 范围内的正常下载、追更与整理将按此策略持续自动执行,不再逐次审批;升级替换、媒体清理与下载缓存清理另行授权。旧的已发布版本会被替代,其授权随之吊销。";
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 {
policyKey: r?.policyKey ?? "",
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<string, string> } {
const errors: Record<string, string> = {};
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<string | null>(null);
const groups = useMemo(() => {
const m = new Map<string, PolicyRevision[]>();
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 (
<div className="space-y-4">
<p className="text-caption text-muted-foreground">
策略只保存结构化条件。草案可以反复预览,发布才生效;回滚会以旧版本内容发布一个新版本,历史只增不改。
</p>
{q.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : q.isError ? (
<ErrorState error={q.error} onRetry={() => q.refetch()} />
) : groups.length === 0 ? (
<EmptyState
icon={ScrollText}
title="还没有收录策略"
description="新建一份草案,设置缺省目标模板与新作品收录条件,预览后再发布。"
action={
<Button onClick={() => onCreatingChange(true)}>
<FilePlus2 />
新建策略草案
</Button>
}
/>
) : (
groups.map((g) => (
<PolicyGroup
key={g.key}
policyKey={g.key}
revs={g.revs}
onView={setViewId}
onEdit={(r) => setEditor({ base: r, editId: r.id })}
onDraftFrom={(r) => setEditor({ base: r, editId: null })}
/>
))
)}
{editorOpen ? (
<PolicyEditorSheet
key={editor ? `${editor.base?.id}-${editor.editId}` : "new"}
base={editor?.base ?? null}
editId={editor?.editId ?? null}
onClose={closeEditor}
onSaved={(r) => {
closeEditor();
setViewId(r.id);
}}
/>
) : null}
<PolicyDetailSheet
rev={viewing}
onClose={() => setViewId(null)}
onEdit={(r) => {
setViewId(null);
setEditor({ base: r, editId: r.id });
}}
onDraftFrom={(r) => {
setViewId(null);
setEditor({ base: r, editId: null });
}}
/>
</div>
);
}
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 (
<Panel
title={
<span className="flex flex-wrap items-center gap-2">
<span className="min-w-0 break-all">{head.title}</span>
<span className="font-mono text-meta font-normal text-muted-foreground">{policyKey}</span>
</span>
}
description={
published
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布`
: "尚未发布,不产生任何自动操作"
}
actions={
draft ? (
<Button variant="outline" size="sm" onClick={() => onEdit(draft)}>
<Pencil />
继续编辑草案 r{draft.revision}
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => onDraftFrom(head)}>
<FilePlus2 />
基于 r{head.revision} 新建草案
</Button>
)
}
>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="min-w-0 space-y-2">
<p className="text-meta text-muted-foreground">r{head.revision} 规则摘要</p>
<RulesSummary rules={rules} valid={!!rules} />
</div>
<div className="min-w-0">
<p className="mb-2 text-meta text-muted-foreground">版本历史</p>
<ol className="divide-y divide-border/60 rounded-lg border border-border/70">
{revs.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => onView(r.id)}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<span className="w-8 shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
r{r.revision}
</span>
<Tone v={POLICY_STATUS[r.status]} />
<span className="min-w-0 flex-1 truncate text-caption text-foreground">{r.summary ?? r.title}</span>
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
{formatDateTime(r.publishedAt ?? r.createdAt)}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</li>
))}
</ol>
</div>
</div>
</Panel>
);
}
function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) {
if (!valid || !rules) return <p className="text-caption text-destructive">规则结构无法解析(不会被执行)</p>;
const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`;
return (
<div className="space-y-2 text-caption">
<div>
<span className="text-muted-foreground">缺省目标 </span>
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有目标时不自动建)" />
</div>
<div>
<span className="text-muted-foreground">新作品收录 </span>
{rules.newTitle?.enabled ? (
<ChipList
items={[
`窗口 ${rules.newTitle.windowDays ?? "默认"} 天`,
rules.newTitle.mediaKinds?.length
? rules.newTitle.mediaKinds.map((k) => MEDIA_KIND[k]).join(" / ")
: "全部类型",
...rules.newTitle.slots.map(tplLine),
]}
/>
) : (
<span className="text-foreground">关闭</span>
)}
</div>
<div className="text-muted-foreground">
整理方式{" "}
<span className="text-foreground">
{rules.organizeMode === "move" ? "Move" : rules.organizeMode === "copy" ? "Copy" : "按系统设置"}
</span>
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用 Copy" : ""}
</div>
</div>
);
}
// ── 详情:预览 / 发布 / 回滚 ──
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 (
<DetailSheet
open={!!rev}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={rev ? `${rev.title} · r${rev.revision}` : ""}
description={rev ? <span className="font-mono">{rev.policyKey}</span> : null}
footer={
rev ? (
<>
{rev.status === "draft" ? (
<>
<Button variant="outline" onClick={() => onEdit(rev)}>
<Pencil />
编辑
</Button>
<Button variant="outline" onClick={() => runPreview(rev)} disabled={preview.isPending}>
<Eye />
{preview.isPending ? "预览中…" : "预览"}
</Button>
<ConfirmDialog
trigger={
<Button disabled={!rules}>
<Upload />
发布
</Button>
}
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 ? (
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
这个草案还没有预览结果,建议先预览确认影响范围。
</p>
) : null}
</ConfirmDialog>
</>
) : (
<>
<Button variant="outline" onClick={() => onDraftFrom(rev)}>
<FilePlus2 />
基于此版本新建草案
</Button>
{rev.status === "superseded" || rev.status === "rolled_back" ? (
<ConfirmDialog
trigger={
<Button variant="outline">
<Undo2 />
回滚到此版本
</Button>
}
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 ? (
<>
<DetailSection title="版本">
<KvList
items={[
{ label: "状态", value: <Tone v={POLICY_STATUS[rev.status]} /> },
{ label: "作用范围", value: scopeText(rev.scope) },
{ label: "创建", value: `${formatDateTime(rev.createdAt)} · ${rev.createdBy ?? "—"}` },
{
label: "发布",
value: rev.publishedAt ? `${formatDateTime(rev.publishedAt)} · ${rev.publishedBy ?? "—"}` : "未发布",
},
{ label: "说明", value: rev.summary, wide: true },
]}
/>
</DetailSection>
<DetailSection title="规则">
<RulesSummary rules={rules} valid={!!rules} />
<JsonView className="mt-3" label="原始规则" value={rev.rules} defaultOpen={false} />
</DetailSection>
<DetailSection
title="历史回放预览"
actions={
rev.status !== "draft" ? (
<Button variant="ghost" size="xs" onClick={() => runPreview(rev)} disabled={preview.isPending}>
<History />
重新预览
</Button>
) : null
}
>
<PreviewBlock value={rev.preview} />
</DetailSection>
</>
) : null}
</DetailSheet>
);
}
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 (
<p className="text-caption text-muted-foreground">
还没有预览。预览用最近已识别的发布评估模板,不产生任何副作用。
</p>
);
const p = PolicyPreview.safeParse(value);
if (!p.success) return <JsonView value={value} />;
const v = p.data;
return (
<div className="space-y-3">
{!v.valid ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-caption text-destructive">
规则结构未通过校验,预览按空模板计算;发布会被拒绝。
</p>
) : null}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
["样本发布", v.sampleSize],
["模板数", v.templates],
["会接受", v.matched],
["会排除", v.excluded],
].map(([l, n]) => (
<div key={String(l)} className="rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">{l}</p>
<p className="text-title-xs font-semibold text-foreground tabular-nums">{n}</p>
</div>
))}
</div>
<p className="text-meta text-muted-foreground">
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"发布 × 模板"计数。
</p>
{v.samples.length ? (
<ul className="divide-y divide-border/60 rounded-lg border border-border/70">
{v.samples.map((s, i) => (
<li
key={`${s.sourceReleaseId}-${s.template}-${i}`}
className="flex flex-wrap items-center gap-2 px-3 py-1.5"
>
<Tone v={CANDIDATE_DECISION[s.decision] ?? { label: s.decision, tone: "neutral" }} />
<span className="font-mono text-meta text-foreground">{s.template}</span>
<span className="min-w-0 truncate font-mono text-micro text-muted-foreground">{s.sourceReleaseId}</span>
</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有可用于回放的已识别发布。</p>
)}
</div>
);
}
// ── 草案编辑 ──
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<PolicyDraft>(() => {
const x = draftFromRevision(base);
if (!base) x.defaultSlots = [tpl(undefined, true)];
if (base && !editId) x.summary = "";
return x;
});
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = create.isPending || update.isPending;
const set = <K extends keyof PolicyDraft>(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("请先修正表单中的错误");
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 (
<DetailSheet
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
description="保存只生成草案,不会生效;发布时才授权自动执行。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? "保存中…" : "保存草案"}
</Button>
</>
}
>
<DetailSection title="基本">
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="策略键"
htmlFor={`${id}-key`}
required
error={errors.policyKey}
hint="同一键下版本递增,只有一个生效"
>
<Input
id={`${id}-key`}
value={d.policyKey}
disabled={!!base}
aria-invalid={!!errors.policyKey}
onChange={(e) => set("policyKey", e.target.value)}
placeholder="default"
/>
</FormField>
<FormField label="标题" htmlFor={`${id}-title`} required error={errors.title}>
<Input
id={`${id}-title`}
value={d.title}
aria-invalid={!!errors.title}
onChange={(e) => set("title", e.target.value)}
/>
</FormField>
</div>
<FormField
label="本版说明"
htmlFor={`${id}-sum`}
error={errors.summary}
hint="写清这一版改了什么,出现在版本历史里"
>
<Textarea id={`${id}-sum`} rows={2} value={d.summary} onChange={(e) => set("summary", e.target.value)} />
</FormField>
<FormField label="作用范围" hint="不选即全部作品类型">
<ChipToggleGroup
label="作用范围"
options={KIND_OPTS}
value={d.scopeKinds}
onChange={(v) => set("scopeKinds", v)}
/>
</FormField>
</DetailSection>
<DetailSection
title="缺省目标模板"
actions={
<Button
variant="ghost"
size="xs"
onClick={() => set("defaultSlots", [...d.defaultSlots, tpl(undefined, true)])}
>
<Plus />
添加模板
</Button>
}
>
<p className="mb-2 text-meta text-muted-foreground">订阅对应的作品还没有目标时,按这些模板建 Slot。</p>
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
</DetailSection>
<DetailSection title="新作品收录">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">开启新作品收录</p>
<p className="text-meta text-muted-foreground">按发行/首播日期判断,站点今天上传的旧片不算新作品</p>
</div>
<Switch
checked={d.newTitleEnabled}
onCheckedChange={(v) => set("newTitleEnabled", v)}
aria-label="开启新作品收录"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="时间窗口(天)" htmlFor={`${id}-win`} error={errors.windowDays} hint="发行/首播在窗口内">
<Input
id={`${id}-win`}
inputMode="numeric"
value={d.windowDays}
aria-invalid={!!errors.windowDays}
onChange={(e) => set("windowDays", e.target.value)}
placeholder="默认"
/>
</FormField>
<FormField label="作品类型" hint="不选即全部">
<ChipToggleGroup
label="新作品类型"
options={KIND_OPTS}
value={d.newTitleKinds}
onChange={(v) => set("newTitleKinds", v)}
/>
</FormField>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">新作品的目标模板</p>
<Button
variant="ghost"
size="xs"
onClick={() => set("newTitleSlots", [...d.newTitleSlots, tpl(undefined, true)])}
>
<Plus />
添加模板
</Button>
</div>
{errors.newTitleSlots ? <p className="text-meta text-destructive">{errors.newTitleSlots}</p> : null}
<TemplateList list={d.newTitleSlots} onChange={(v) => set("newTitleSlots", v)} errors={errors} />
</div>
</DetailSection>
<DetailSection title="整理">
<div className="space-y-3">
<FormField label="整理方式">
<Select
value={d.organizeMode || "__default"}
onValueChange={(v) => set("organizeMode", v === "__default" ? "" : (v as "copy" | "move"))}
>
<SelectTrigger className="w-full sm:w-64" aria-label="整理方式">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__default">按系统设置</SelectItem>
<SelectItem value="copy">Copy(保留源文件做种)</SelectItem>
<SelectItem value="move">Move</SelectItem>
</SelectContent>
</Select>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="min-w-0 text-caption font-medium text-foreground">H&R 未满足时经授权改用 Copy</p>
<Switch
checked={d.allowCopyWhenHnrPending}
onCheckedChange={(v) => set("allowCopyWhenHnrPending", v)}
aria-label="H&R 未满足时改用 Copy"
/>
</div>
{errors.rules ? <p className="text-meta text-destructive">{errors.rules}</p> : null}
</div>
</DetailSection>
</DetailSheet>
);
}
function TemplateList({
list,
onChange,
errors,
}: {
list: TemplateDraft[];
onChange: (v: TemplateDraft[]) => void;
errors: Record<string, string>;
}) {
const id = useId();
if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>;
const patch = (key: string, p: Partial<TemplateDraft>) =>
onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t)));
return (
<div className="space-y-2">
{list.map((t) => {
const prefix = `${t.key}.`;
const errs = Object.fromEntries(
Object.entries(errors)
.filter(([k]) => k.startsWith(prefix))
.map(([k, v]) => [k.slice(prefix.length), v]),
);
const hasErr = Object.keys(errs).length > 0;
return (
<div key={t.key} className={cn("rounded-lg border", hasErr ? "border-destructive/40" : "border-border/80")}>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
aria-expanded={t.open}
aria-label={t.open ? "收起模板" : "展开模板"}
onClick={() => patch(t.key, { open: !t.open })}
className="grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-base", t.open && "rotate-90")} />
</button>
<Input
id={`${id}-${t.key}`}
aria-label="模板名称"
aria-invalid={!!errs.name}
value={t.name}
placeholder="模板名称,如 archival"
onChange={(e) => patch(t.key, { name: e.target.value })}
className="h-8 min-w-0 flex-1 font-mono"
/>
<Button
variant="ghost"
size="icon-sm"
aria-label="删除模板"
onClick={() => onChange(list.filter((x) => x.key !== t.key))}
className="hover:text-destructive"
>
<Trash2 />
</Button>
</div>
{errs.name ? <p className="px-3 pb-2 text-meta text-destructive">{errs.name}</p> : null}
{!t.open ? (
<div className="px-3 pb-2">
<ChipList items={summarizeHard(draftToJsonSafe(t.rules))} empty="硬条件不限" />
</div>
) : (
<div className="border-t border-border/60 px-3 py-3">
<SlotRulesEditor value={t.rules} onChange={(v) => patch(t.key, { rules: v })} errors={errs} />
</div>
)}
</div>
);
})}
</div>
);
}
const draftToJsonSafe = (d: SlotRulesDraft): JsonObject => {
const r = draftToJson(d);
return r.ok ? r.value.hardConditions : {};
};