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

976 lines
36 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,
AcquisitionPolicyScope,
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 { useClassification } from "~/api/organize-sources.ts";
import {
PolicyPreview,
useCreatePolicy,
usePolicyRevisions,
usePreviewPolicy,
usePublishPolicy,
useRollbackPolicy,
useUpdatePolicy,
} from "~/api/policies.ts";
import { useRuleGroups } from "~/api/rules.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 { useRecordSheet } from "~/components/console/record-sheet.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; ruleGroupIds: string[]; rules: SlotRulesDraft; open: boolean };
type PolicyDraft = {
policyKey: string;
title: string;
summary: string;
scopeKinds: string[];
scopeCategories: string[];
originalScope: JsonObject;
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 ?? "",
ruleGroupIds: t?.ruleGroupIds ?? [],
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 ?? [],
scopeCategories: Object.entries(AcquisitionPolicyScope.safeParse(r?.scope ?? {}).data?.categories ?? {}).flatMap(
([type, names]) => (names ?? []).map((name) => `${type}:${name}`),
),
originalScope: r?.scope ?? {},
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;
for (const selected of d.scopeCategories) {
const type = selected.split(":")[0];
if (
d.scopeKinds.length &&
(type === "movie" ? !d.scopeKinds.includes("movie") : !d.scopeKinds.some((k) => k !== "movie"))
)
errors.scopeCategories = "适用分类与作品类型不一致,请调整其中一项";
}
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(),
...(t.ruleGroupIds.length ? { ruleGroupIds: t.ruleGroupIds } : {}),
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.originalScope,
mediaKinds: d.scopeKinds.length ? d.scopeKinds : undefined,
categories: d.scopeCategories.length
? Object.fromEntries(
["movie", "tv"].map((type) => [
type,
d.scopeCategories.filter((v) => v.startsWith(`${type}:`)).map((v) => v.slice(type.length + 1)),
]),
)
: undefined,
} as JsonObject,
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);
// 记录抽屉(console-ia.md §9):查看的策略版本写在 ?open=<id>
const sheet = useRecordSheet();
const viewId = sheet.id;
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">
{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={(id) => sheet.open(id)}
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();
sheet.open(r.id);
}}
/>
) : null}
<PolicyDetailSheet
rev={viewing}
onClose={sheet.close}
onEdit={(r) => {
sheet.close();
setEditor({ base: r, editId: r.id });
}}
onDraftFrom={(r) => {
sheet.close();
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)} 由 ${actorText(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" ? "移动" : rules.organizeMode === "copy" ? "复制" : "按系统设置"}
</span>
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用复制" : ""}
</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)} · ${actorText(rev.createdBy)}` },
{
label: "发布",
value: rev.publishedAt
? `${formatDateTime(rev.publishedAt)} · ${actorText(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 parsed = AcquisitionPolicyScope.safeParse(scope).data;
const kinds = parsed?.mediaKinds?.map((x) => MEDIA_KIND[x]).join(" / ") || "全部作品";
const categories = Object.values(parsed?.categories ?? {})
.flat()
.join(" / ");
return categories ? `${kinds} · ${categories}` : kinds;
};
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 classification = useClassification();
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(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 (
<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>
<FormField
label="适用分类"
error={errors.scopeCategories}
hint="沿用现有分类规则;不选时作为类型默认,订阅可以单独选择其他策略"
>
<ChipToggleGroup
label="适用分类"
options={[
...Object.entries(classification.data?.categories ?? {}).flatMap(([type, names]) =>
names.map((name) => ({
value: `${type}:${name}`,
label: `${type === "movie" ? "电影" : "剧集"} · ${name}`,
})),
),
...d.scopeCategories
.filter(
(v) =>
!Object.entries(classification.data?.categories ?? {}).some(([type, names]) =>
names.some((name) => v === `${type}:${name}`),
),
)
.map((v) => ({ value: v, label: `${v}(分类已删除)` })),
]}
value={d.scopeCategories}
onChange={(v) => set("scopeCategories", 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">订阅的作品没有收录版本时,按这些模板创建。</p>
<p className="text-caption text-muted-foreground">
一个目标保留单版本,可在允许分辨率内先低后高;多个目标分别保留、升级各自版本。
</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">复制(保留源文件做种)</SelectItem>
<SelectItem value="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 未满足时经审批改用复制</p>
<Switch
checked={d.allowCopyWhenHnrPending}
onCheckedChange={(v) => set("allowCopyWhenHnrPending", v)}
aria-label="H&R 未满足时改用复制"
/>
</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();
const groups = useRuleGroups({ enabled: "true" });
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">
<FormField
label="收录组合规则"
hint="在规则页维护组合档位;多个规则按选中顺序生效,最后一个带档位的规则决定优先级"
>
<ChipToggleGroup
ordered
label="收录组合规则"
options={[
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
...t.ruleGroupIds
.filter((id) => !(groups.data?.items ?? []).some((g) => g.id === id))
.map((id) => ({ value: id, label: `${id}(规则不可用)` })),
]}
value={t.ruleGroupIds}
onChange={(v) => patch(t.key, { ruleGroupIds: v })}
/>
</FormField>
<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 : {};
};