- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status; stream auto-follow only while the user stays near the bottom (abort stays reachable) - settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript) - download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of time-window engine run matching and path matching; new business-task tab with cancel/retry - workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons - preference tag revision history; subscription search by title/kind; identity/alias/subscription lists show entity titles; slot cards show current asset summary; policy preview sample titles - DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary / OrganizePlanArtifactContent taken from contracts - docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys Co-Authored-By: Claude Opus 5.5 <[email protected]>
892 lines
33 KiB
TypeScript
892 lines
33 KiB
TypeScript
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 : {};
|
||
};
|