Files
UHD_Media_Tool/apps/web/app/components/library/subscriptions-policy.tsx
T

890 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 flex-1 truncate font-mono text-micro text-muted-foreground"
title={s.reasons?.join(";") || undefined}
>
{s.title ?? 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 : {};
};