docs(plan): v0.2 adds Pi subagents and scheduled-jobs board

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-28 02:09:39 +08:00
1 parent b839ed4056
commit 16ab2a6c84
97 files changed
+592 -648

No files matched your search

@@ -37,7 +37,7 @@ import { Tone } from "./parts.tsx";
// ── 改外部映射 ──
const IdentityForm = EntityIdentitySet.extend({
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
externalId: z.string().trim().regex(/^\d+$/, "TMDB ID 只能是数字"),
});
export function IdentityDialog({
@@ -90,7 +90,7 @@ export function IdentityDialog({
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "改映射失败")),
onError: (err) => toast.error(errorMessage(err, "修改失败")),
});
};
@@ -100,15 +100,15 @@ export function IdentityDialog({
<Dialog open={open} onOpenChange={(o) => !set.isPending && onOpenChange(o)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>改外部映射</DialogTitle>
<DialogTitle>修改外部身份</DialogTitle>
<DialogDescription>
旧的同类型 TMDB 映射会退役并留下修订记录;新映射直接确认。加人工锁后自动匹配与刷新不再改动它。
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
{usable.length ? (
<div className="grid gap-1.5">
<p className="text-caption font-medium text-foreground">从候选中选</p>
<p className="text-caption font-medium text-foreground">候选</p>
<ul className="grid gap-1.5">
{usable.map((c) => (
<li key={c.id}>
@@ -164,7 +164,7 @@ export function IdentityDialog({
<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>
<p className="text-meta text-muted-foreground">自动匹配不会覆盖</p>
</div>
<Switch checked={lock} onCheckedChange={setLock} aria-label="加人工锁" />
</div>
@@ -174,7 +174,7 @@ export function IdentityDialog({
</Button>
<Button type="submit" disabled={set.isPending}>
{set.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存映射
保存
</Button>
</DialogFooter>
</form>
@@ -186,7 +186,7 @@ export function IdentityDialog({
// ── 字段覆盖并锁定 ──
const FieldForm = z.object({
value: z.string().trim().min(1, "请填写值"),
value: z.string().trim().min(1, "填写值"),
reason: z.string().max(2000).optional(),
});
@@ -227,7 +227,7 @@ export function FieldLockDialog({
if (field === "year") {
const n = Number(r.data.value);
if (!Number.isInteger(n) || n < 1870 || n > 2200) {
setErrors({ value: "年份需为 1870–2200 的整数" });
setErrors({ value: "年份须是 1870–2200 的整数" });
return;
}
v = n;
@@ -250,7 +250,7 @@ export function FieldLockDialog({
<DialogContent>
<DialogHeader>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogDescription>保存为人工值并加锁,之后刷新元数据不会改写这个字段;不影响已绑定的路径。</DialogDescription>
<DialogDescription>锁定后刷新元数据不改这个字段。已绑定的路径不变。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
<FormField label="值" htmlFor={`${id}-v`} error={errors.value} required>
@@ -381,13 +381,13 @@ export function MergeDialog({
<Dialog open={open && !confirm} onOpenChange={(o) => !o && onOpenChange(false)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>合并到另一作品</DialogTitle>
<DialogTitle>合并作品</DialogTitle>
<DialogDescription>
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品;订阅、资产与历史引用一并转到目标。错误合并可以再修订。
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
</DialogDescription>
</DialogHeader>
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
<FormField label="原因" htmlFor="merge-reason" hint="写入审计与跳转记录">
<FormField label="原因" htmlFor="merge-reason" hint="写入审计日志">
<Input
id="merge-reason"
value={reason}
@@ -411,10 +411,10 @@ export function MergeDialog({
setConfirm(o);
if (!o && !merge.isSuccess) return;
}}
title="确认合并?"
title="合并作品?"
description={
target
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」。合并后本页将跳转到目标作品。`
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」,完成后跳转到目标作品。`
: ""
}
confirmLabel="合并"
@@ -31,7 +31,7 @@ const ALIAS_KIND: Record<z.infer<typeof AliasKind>, string> = {
};
const AliasForm = z.object({
alias: z.string().trim().min(1, "请填写别名").max(200, "最多 200 字"),
alias: z.string().trim().min(1, "填写别名").max(200, "最多 200 字"),
kind: AliasKind,
language: z
.string()
@@ -171,7 +171,7 @@ export function AliasesPanel() {
</Button>
}
title={`删除别名「${a.alias}」?`}
description="删除后该别名不再参与模糊召回;已确认的身份映射不受影响。"
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
confirmLabel="删除"
destructive
onConfirm={async () => {
@@ -190,7 +190,7 @@ export function AliasesPanel() {
return (
<div className="space-y-4">
<Panel title="按作品管理" description="选中作品后可新增别名;不选则按关键字检索全部别名" bodyClassName="space-y-3">
<Panel title="按作品管理" description="选中作品后可添加别名" bodyClassName="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<EntityPicker value={entity} onChange={setEntity} />
<FilterBar search={{ value: q, onChange: setQ, placeholder: "按别名关键字过滤" }} className="mb-0" />
@@ -206,7 +206,7 @@ export function AliasesPanel() {
onRetry={() => list.refetch()}
empty={{
title: entity ? `「${entity.title}」还没有别名` : "没有匹配的别名",
description: "别名用于从原始标题、译名、简称找回作品。",
description: "别名用于按原始标题、译名、简称匹配作品。",
}}
hasMore={list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
@@ -69,7 +69,7 @@ export function ExternalIdentitySheet({ item, onClose }: { item: ExternalIdentit
locked: !item.locked,
reason: item.locked ? "控制台解除人工锁" : "控制台加人工锁",
});
toast.success(item.locked ? "已解除人工锁,自动匹配可再次修订" : "已加人工锁,自动匹配不会改动");
toast.success(item.locked ? "已解除人工锁" : "已加人工锁");
onClose();
} catch (err) {
toast.error(errorMessage(err));
@@ -82,7 +82,6 @@ export function ExternalIdentitySheet({ item, onClose }: { item: ExternalIdentit
width="lg"
onOpenChange={(o) => !o && onClose()}
title={identityRef(item)}
description="外部身份是辅助映射;本地 UUID 才是主身份"
footer={
<Button variant="outline" onClick={toggleLock} disabled={update.isPending || item.status === "retired"}>
{item.locked ? <LockOpen /> : <Lock />}
@@ -25,7 +25,7 @@ import { Chip, Tone } from "./parts.tsx";
const TmdbForm = z.object({
entityType: z.enum(["movie", "tv"]),
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
externalId: z.string().trim().regex(/^\d+$/, "TMDB ID 只能是数字"),
});
/** query 摘要:标题 / 年份 / 季集 */
@@ -78,7 +78,7 @@ function CandidateRow({
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-meta text-muted-foreground">本地未建档,指定后按外部详情建档</span>
<span className="text-meta text-muted-foreground">本地未建档,指定后按 TMDB 信息建档</span>
)}
</div>
{c.evidence.length ? (
@@ -147,7 +147,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
</Button>
}
title="忽略这条待识别资源?"
description="忽略后不再自动复查;资源仍可在待识别入口按原始标题找回。"
description="忽略后不再自动复查,仍可按原始标题搜到。"
confirmLabel="忽略"
onConfirm={async () => {
try {
@@ -160,12 +160,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
}
}}
>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选):为什么忽略"
rows={2}
/>
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="忽略原因(可选)" rows={2} />
</ConfirmDialog>
<Button
variant="outline"
@@ -180,7 +175,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
}}
>
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
立即重试
重新识别
</Button>
</>
}
@@ -215,7 +210,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有候选。可以手动指定 TMDB id 或本地作品。</p>
<p className="text-caption text-muted-foreground">没有候选。手动指定 TMDB ID 或本地作品。</p>
)}
</DetailSection>
+2 -2
View File
@@ -137,7 +137,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
download_failed: "下载失败",
path_conflict: "路径冲突",
space_insufficient: "空间不足",
approval_required: "需要授权",
approval_required: "需要审批",
metadata_conflict: "元数据冲突",
source_unreachable: "来源不可达",
other: "其他",
@@ -176,7 +176,7 @@ export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }>
soft: { label: "软偏好", hint: "影响排序,不排除" },
hard: { label: "硬规则", hint: "命中即排除" },
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
target: { label: "独立目标", hint: "为作品建立独立的收录目标" },
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
};
export const PREF_ORIGIN: Record<PreferenceOrigin, string> = {
@@ -36,7 +36,7 @@ const jsonObject = (name: string) =>
});
const FormSchema = z.object({
label: z.string().trim().min(1, "请填写标签名").max(80, "最多 80 字"),
label: z.string().trim().min(1, "填写标签名").max(80, "最多 80 字"),
kind: PreferenceKindSchema,
description: z.string().trim().max(2000, "最多 2000 字"),
scope: jsonObject("作用范围"),
@@ -108,8 +108,8 @@ export function PreferenceFormDialog({
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
<DialogDescription>
{tag
? "修改作用范围或规则会改变语义:旧语义会留下抑制记录,Agent 不会据旧证据加回。"
: "手动添加的偏好与对话确认等价;若之前被取消过,重新添加即解除对应抑制。"}
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-2" noValidate>
@@ -144,7 +144,7 @@ export function PreferenceFormDialog({
rows={2}
value={draft.description}
onChange={(e) => set("description", e.target.value)}
placeholder="为什么有这个偏好、适用边界"
placeholder="原因和适用范围"
/>
</FormField>
<FormField label="作用范围(JSON)" htmlFor="pref-scope" error={errors.scope} hint="{} 表示全局">
@@ -76,7 +76,7 @@ export function SlotCard({
</dl>
{latest.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-2 text-meta text-muted-foreground">最近候选 · 为什么选 / 不选(每个资源显示最近一次评估)</p>
<p className="mb-2 text-meta text-muted-foreground">最近候选及选用理由(每个资源最近一次评估)</p>
<ul className="space-y-2">
{latest.map((c) => (
<li key={c.sourceReleaseId} className="min-w-0">
+9 -18
View File
@@ -168,7 +168,7 @@ export function draftToJson(
if (s.trim() === "") return undefined;
const n = Number(s);
if (!Number.isFinite(n) || (opts.int && !Number.isInteger(n))) {
errors[key] = opts.int ? "请输入整数" : "请输入数字";
errors[key] = opts.int ? "须是整数" : "须是数字";
return undefined;
}
if (opts.min !== undefined && n < opts.min) errors[key] = `不能小于 ${opts.min}`;
@@ -177,7 +177,7 @@ export function draftToJson(
};
const minGb = num("minSizeGb", d.minSizeGb, { min: 0 });
const maxGb = num("maxSizeGb", d.maxSizeGb, { min: 0.01 });
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限需大于下限";
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限须大于下限";
const minTier = num("minTierSteps", d.minTierSteps, { int: true, min: 0, max: 5 });
const ceiling = num("ceilingTier", d.ceilingTier, { int: true, min: 0, max: 5 });
if (Object.keys(errors).length) return { ok: false, errors };
@@ -323,7 +323,7 @@ export function SlotRulesEditor({
<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">不含原盘与 Remux;压制不等于"越大越好"</p>
<p className="text-meta text-muted-foreground">不含原盘和 Remux</p>
</div>
<Switch
checked={value.requireEncode}
@@ -370,7 +370,7 @@ export function SlotRulesEditor({
/>
</FormField>
</div>
<FormField label="内容版本(剪辑)" hint="与分辨率等交付特征分开;不选即不限">
<FormField label="内容版本(剪辑)" hint="不选即不限">
<ChipToggleGroup
label="内容版本"
options={o(CUTS, CUT_LABEL)}
@@ -386,7 +386,7 @@ export function SlotRulesEditor({
onChange={(v) => set("contentTypes", v)}
/>
</FormField>
<FormField label="字段未知时" hint="资源缺少判断所需字段(如字幕)时的处理">
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
<Select
value={value.unknownPolicy || "__none"}
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
@@ -408,7 +408,7 @@ export function SlotRulesEditor({
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
软偏好 · 硬条件通过后排序,按点选顺序越靠前越好
软偏好 · 通过硬条件后排序,越靠前越优先
</legend>
<FormField label="HDR">
<ChipToggleGroup
@@ -444,7 +444,7 @@ export function SlotRulesEditor({
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
升级阈值 · 创建替换计划,不直接覆盖旧文件
升级 · 生成替换计划,不直接覆盖旧文件
</legend>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许自动升级</p>
@@ -470,12 +470,7 @@ export function SlotRulesEditor({
placeholder="1"
/>
</FormField>
<FormField
label="封顶等级"
htmlFor={`${id}-ceil`}
error={errors.ceilingTier}
hint="当前资产达到后不再升级(0–5)"
>
<FormField label="封顶等级" htmlFor={`${id}-ceil`} error={errors.ceilingTier} hint="达到后不再升级(0–5)">
<Input
id={`${id}-ceil`}
inputMode="numeric"
@@ -486,11 +481,7 @@ export function SlotRulesEditor({
/>
</FormField>
</div>
<FormField
label="合格压制组名单"
htmlFor={`${id}-allow`}
hint="设置后名单外的压制不自动升级,低劣压制不会只因 BluRay 标签替换"
>
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
<Input
id={`${id}-allow`}
value={value.encodeGroupAllowlist}
@@ -51,7 +51,7 @@ const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.opti
}));
export const PUBLISH_NOTICE =
"发布即授权:scope 范围内的正常下载、追更与整理将按此策略持续自动执行,不再逐次审批;升级替换、媒体清理与下载缓存清理另行授权。旧的已发布版本会被替代,其授权随之吊销。";
"发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。";
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
type PolicyDraft = {
@@ -98,10 +98,10 @@ const Meta = z.object({
policyKey: z
.string()
.trim()
.min(1, "请填写策略键")
.min(1, "填写策略键")
.max(64, "最多 64 个字符")
.regex(/^[a-z0-9][a-z0-9_.-]*$/, "只用小写字母、数字和 - _ ."),
title: z.string().trim().min(1, "请填写标题").max(120, "最多 120 个字符"),
title: z.string().trim().min(1, "填写标题").max(120, "最多 120 个字符"),
summary: z.string().trim().max(500, "最多 500 个字符"),
});
@@ -119,7 +119,7 @@ function buildPolicy(d: PolicyDraft):
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`] = "请填写模板名称";
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;
@@ -141,7 +141,7 @@ function buildPolicy(d: PolicyDraft):
windowDays = Number(d.windowDays);
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 3650) errors.windowDays = "1–3650 的整数";
}
if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少要有一个目标模板";
if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少需要一个模板";
const rules = {
...(defaultSlots.length ? { defaultSlots } : {}),
...(d.newTitleEnabled || newTitleSlots.length
@@ -208,9 +208,7 @@ export function PolicyWorkspace({
return (
<div className="space-y-4">
<p className="text-caption text-muted-foreground">
策略只保存结构化条件。草案可以反复预览,发布才生效;回滚会以旧版本内容发布一个新版本,历史只增不改。
</p>
<p className="text-caption text-muted-foreground">草案发布后才生效。回滚会用旧版本内容发布一个新版本。</p>
{q.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
@@ -221,7 +219,7 @@ export function PolicyWorkspace({
<EmptyState
icon={ScrollText}
title="还没有收录策略"
description="新建一份草案,设置缺省目标模板与新作品收录条件,预览后再发布。"
description="新建草案,预览后发布。"
action={
<Button onClick={() => onCreatingChange(true)}>
<FilePlus2 />
@@ -297,7 +295,7 @@ function PolicyGroup({
description={
published
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布`
: "尚未发布,不产生任何自动操作"
: "未发布,不会自动执行"
}
actions={
draft ? (
@@ -348,13 +346,13 @@ function PolicyGroup({
}
function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) {
if (!valid || !rules) return <p className="text-caption text-destructive">规则结构无法解析(不会被执行)</p>;
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="无(订阅没有目标时不自动建)" />
<span className="text-muted-foreground">默认版本目标 </span>
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有版本目标时不自动创建)" />
</div>
<div>
<span className="text-muted-foreground">新作品收录 </span>
@@ -375,9 +373,9 @@ function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefi
<div className="text-muted-foreground">
整理方式{" "}
<span className="text-foreground">
{rules.organizeMode === "move" ? "Move" : rules.organizeMode === "copy" ? "Copy" : "按系统设置"}
{rules.organizeMode === "move" ? "移动" : rules.organizeMode === "copy" ? "复制" : "按系统设置"}
</span>
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用 Copy" : ""}
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用复制" : ""}
</div>
</div>
);
@@ -436,11 +434,11 @@ function PolicyDetailSheet({
}
title={`发布 ${rev.policyKey} r${rev.revision}?`}
description={PUBLISH_NOTICE}
confirmLabel="授权并发布"
confirmLabel="发布"
onConfirm={() =>
publish
.mutateAsync({ id: rev.id, expectedRevision: rev.revision })
.then(() => toast.success(`r${rev.revision} 已发布并生效`))
.then(() => toast.success(`r${rev.revision} 已发布`))
.catch((e) => {
toast.error(errorMessage(e));
throw e;
@@ -449,7 +447,7 @@ function PolicyDetailSheet({
>
{!pv?.success ? (
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
这个草案还没有预览结果,建议先预览确认影响范围。
这个草案还没有预览。先预览可以看到影响范围。
</p>
) : null}
</ConfirmDialog>
@@ -469,12 +467,12 @@ function PolicyDetailSheet({
</Button>
}
title={`回滚到 r${rev.revision}?`}
description={`将以 r${rev.revision} 的内容发布一个新版本,当前已发布版本标记为已回滚。${PUBLISH_NOTICE}`}
confirmLabel="回滚并发布"
description={`用 r${rev.revision} 的内容发布一个新版本,当前版本标记为已回滚。${PUBLISH_NOTICE}`}
confirmLabel="回滚"
onConfirm={() =>
rollback
.mutateAsync(rev.id)
.then((n) => toast.success(`已回滚,新版本 r${n.revision} 生效`))
.then((n) => toast.success(`已回滚,r${n.revision} 生效`))
.then(onClose)
.catch((e) => {
toast.error(errorMessage(e));
@@ -536,7 +534,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
if (!value)
return (
<p className="text-caption text-muted-foreground">
还没有预览。预览用最近已识别的发布评估模板,不产生任何副作用。
还没有预览。预览用最近已识别的资源评估模板,不会执行任何操作。
</p>
);
const p = PolicyPreview.safeParse(value);
@@ -546,12 +544,12 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
<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.sampleSize],
["模板数", v.templates],
["会接受", v.matched],
["会排除", v.excluded],
@@ -563,7 +561,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
))}
</div>
<p className="text-meta text-muted-foreground">
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"发布 × 模板"计数。
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"资源 × 模板"计数。
</p>
{v.samples.length ? (
<ul className="divide-y divide-border/60 rounded-lg border border-border/70">
@@ -584,7 +582,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有可用于回放的已识别发布。</p>
<p className="text-caption text-muted-foreground">没有可用于回放的已识别资源。</p>
)}
</div>
);
@@ -632,12 +630,12 @@ function PolicyEditorSheet({
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
})),
}));
toast.error("请先修正表单中的错误");
toast.error("表单有错误");
return;
}
setErrors({});
const done = (rev: PolicyRevision) => {
toast.success(`草案 r${rev.revision} 已保存,预览确认后再发布`);
toast.success(`草案 r${rev.revision} 已保存`);
onSaved(rev);
};
if (editId) {
@@ -657,7 +655,7 @@ function PolicyEditorSheet({
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
description="保存只生成草案,不会生效;发布时才授权自动执行。"
description="保存为草案,发布后才生效。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
@@ -676,7 +674,7 @@ function PolicyEditorSheet({
htmlFor={`${id}-key`}
required
error={errors.policyKey}
hint="同一键下版本递增,只有一个生效"
hint="同一键下只有一个版本生效"
>
<Input
id={`${id}-key`}
@@ -696,12 +694,7 @@ function PolicyEditorSheet({
/>
</FormField>
</div>
<FormField
label="本版说明"
htmlFor={`${id}-sum`}
error={errors.summary}
hint="写清这一版改了什么,出现在版本历史里"
>
<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="不选即全部作品类型">
@@ -715,7 +708,7 @@ function PolicyEditorSheet({
</DetailSection>
<DetailSection
title="缺省目标模板"
title="默认版本目标模板"
actions={
<Button
variant="ghost"
@@ -727,7 +720,7 @@ function PolicyEditorSheet({
</Button>
}
>
<p className="mb-2 text-meta text-muted-foreground">订阅对应的作品还没有目标时,按这些模板建 Slot。</p>
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有版本目标时,按这些模板创建。</p>
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
</DetailSection>
@@ -736,7 +729,7 @@ function PolicyEditorSheet({
<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>
<p className="text-meta text-muted-foreground">按发行/首播日期判断,新上传的旧片不算</p>
</div>
<Switch
checked={d.newTitleEnabled}
@@ -765,7 +758,7 @@ function PolicyEditorSheet({
</FormField>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">新作品的目标模板</p>
<p className="text-meta text-muted-foreground">新作品的版本目标模板</p>
<Button
variant="ghost"
size="xs"
@@ -792,17 +785,17 @@ function PolicyEditorSheet({
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__default">按系统设置</SelectItem>
<SelectItem value="copy">Copy(保留源文件做种)</SelectItem>
<SelectItem value="move">Move</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 未满足时经授权改用 Copy</p>
<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 未满足时改用 Copy"
aria-label="H&R 未满足时改用复制"
/>
</div>
{errors.rules ? <p className="text-meta text-destructive">{errors.rules}</p> : null}
@@ -21,7 +21,7 @@ const SlotMeta = z.object({
name: z
.string()
.trim()
.min(1, "请填写目标名称")
.min(1, "填写名称")
.max(64, "最多 64 个字符")
.regex(/^[\w\-.+ ]+$/u, "只用字母、数字、空格和 - _ . +"),
distinctGroup: z.string().trim().max(64, "最多 64 个字符"),
@@ -30,7 +30,7 @@ const SlotMeta = z.object({
/** 409:别人(或状态机)已改过这个 Slot */
export function conflictToast(err: unknown, onRefresh: () => void) {
if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) {
toast.error("规则已被修改(版本不一致),已刷新,请在最新版本上重新编辑", { duration: 4000 });
toast.error("规则已被修改,已刷新。在最新版本上重新编辑。", { duration: 4000 });
onRefresh();
return;
}
@@ -51,10 +51,10 @@ export function SlotsPanel({
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">每个目标独立判定与升级;状态只由状态机推进,这里只改规则。</p>
<p className="text-meta text-muted-foreground">每个版本目标单独判定和升级。这里只改规则。</p>
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
<Plus />
新建目标
新建版本目标
</Button>
</div>
{q.isPending ? (
@@ -65,8 +65,8 @@ export function SlotsPanel({
<EmptyState
plain
icon={Target}
title="还没有收录目标"
description="没有目标时按已发布收录策略的缺省模板建;也可以在这里手动建。"
title="还没有版本目标"
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
className="py-8"
/>
) : (
@@ -140,7 +140,7 @@ function SlotEditorSheet({
const errs: Record<string, string> = {};
if (!meta.success) for (const i of meta.error.issues) errs[String(i.path[0])] ??= i.message;
if (!rules.ok) Object.assign(errs, rules.errors);
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "需要不同物理资产时请填写分组名";
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "要求不同物理资产时须填写分组";
setErrors(errs);
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
const common = {
@@ -153,7 +153,7 @@ function SlotEditorSheet({
{ id: slot.id, expectedRevision: slot.revision, ...common },
{
onSuccess: (s) => {
toast.success(`已保存目标 ${s.name}(r${s.revision})`);
toast.success(`已保存 ${s.name}(r${s.revision})`);
onClose();
},
onError: (e) => conflictToast(e, onConflict),
@@ -164,7 +164,7 @@ function SlotEditorSheet({
{ mediaEntityId, subscriptionId, name: meta.data.name, ...common },
{
onSuccess: (s) => {
toast.success(`已新建目标 ${s.name}`);
toast.success(`已新建 ${s.name}`);
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
@@ -178,11 +178,9 @@ function SlotEditorSheet({
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={slot ? `编辑目标 ${slot.name}` : "新建收录目标"}
title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"}
description={
slot
? `当前版本 r${slot.revision};保存时校验版本,期间被修改会提示刷新。`
: "硬条件决定能不能收,软偏好决定先收哪个。"
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
}
footer={
<>
@@ -228,7 +226,7 @@ function SlotEditorSheet({
<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>
<p className="text-meta text-muted-foreground">同一个文件不能同时满足两个目标</p>
</div>
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
</div>