Files
UHD_Media_Tool/apps/web/app/components/library/subscriptions-policy.tsx
T
rootandClaude Opus 5.5 909cd2ad49 feat(web): regroup console nav into 7 groups, split downloads/plans/tasks, add legacy redirects
- Sidebar: 概览/资源/订阅/下载/整理/Agent/系统, 18 items (+ 定时任务); single
  active item via findNavItem; 待处理 badge; collapsed rail groups; mobile drawer
  shares NAV_GROUPS.
- New pages: /downloads (queue + cache watermark header), /downloads/plans,
  /downloads/:id, /tasks (business tasks only) + /tasks/:id detail, /rules.
- Legacy links: ?tab= on tasks/subscriptions/sources redirect via clientLoader;
  /tasks/:downloadId falls back to /downloads/:id.
- List filters in URL query; settingsPath() helper for settings deep links.
- Copy per glossary (待处理, 收录策略, 引擎面板, 文件管理, 版本目标).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 02:46:47 +08:00

882 lines
32 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 =
"发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。";
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">
{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" ? "移动" : 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)} · ${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">订阅的作品没有版本目标时,按这些模板创建。</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();
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 : {};
};