feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata

- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity
  mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons,
  content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions
- subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion),
  policy revision draft -> preview -> publish (grant warning) -> rollback
- preferences: tag CRUD, disable/enable, suppression records and lift, history timeline
- inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh
- identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management
- all responses validated against contracts zod schemas

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:39:02 +08:00
1 parent e5e42b1f0d
commit ef73abe3df
25 files changed
+6670 -19

No files matched your search

@@ -0,0 +1,531 @@
import type { JsonObject } from "@umt/contracts";
import { useId } from "react";
import { z } from "zod";
import { FormField } from "~/components/console/form-field.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 { formatBytes } from "~/lib/utils.ts";
import { ChipToggleGroup, splitList } from "./parts.tsx";
// Slot 规则:硬条件 / 软偏好 / 回退(字段未知时) / 升级阈值 / 完成条件。
// 结构对应 domain SlotSpec(packages/domain/src/slot/types.ts),落在 acquisition_slot 的 JSON 列与
// AcquisitionPolicyRules 的 SlotTemplate 上;这里只做结构化表单,不生成脚本。
export const RESOLUTIONS = ["2160p", "1080p", "1080i", "720p", "576p", "480p", "4320p", "1440p"] as const;
export const SOURCES = [
"REMUX",
"BluRay",
"BluRay-Disc",
"WEB-DL",
"WEBRip",
"HDTV",
"HDRip",
"DVD",
"DVDRip",
] as const;
export const HDRS = ["DV", "HDR10", "HDR10+", "HLG", "HDR Vivid"] as const;
export const CODECS = ["H.265", "H.264", "AV1", "VC-1", "MPEG-2"] as const;
export const CUTS = [
"default",
"Theatrical",
"Director's Cut",
"Extended",
"IMAX",
"Unrated",
"Uncut",
"Remastered",
"Final Cut",
"Criterion",
"Ultimate",
"Special Edition",
] as const;
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const CT_LABEL: Record<string, string> = {
main: "正片",
extra: "花絮",
behind_the_scenes: "幕后",
performance_only: "纯享",
preview: "预告",
reaction: "reaction",
};
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
list.map((v) => ({ value: v, label: labels[v] ?? v }));
// ── 规则结构(zod:读 JSON 列时宽松解析,写回前严格校验) ──
const strArr = z.array(z.string()).optional();
export const HardConditions = z.object({
resolutions: strArr,
sources: strArr,
requireEncode: z.boolean().optional(),
maxSizeBytes: z.number().positive().optional(),
minSizeBytes: z.number().nonnegative().optional(),
requiredSubtitles: strArr,
cuts: strArr,
excludeGroups: strArr,
contentTypes: strArr,
});
export const SoftPreferences = z.object({
hdr: strArr,
groups: strArr,
sources: strArr,
codecs: strArr,
preferredSizeBytes: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
});
export const UpgradePolicy = z.object({
enabled: z.boolean(),
minTierSteps: z.number().int().min(0).max(5).optional(),
allowResolutionUpgrade: z.boolean().optional(),
encodeGroupAllowlist: strArr,
ceilingTier: z.number().int().min(0).max(5).optional(),
});
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
export type HardConditions = z.infer<typeof HardConditions>;
export type SoftPreferences = z.infer<typeof SoftPreferences>;
export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
/** 编辑器状态(字符串形态,提交时转换) */
export type SlotRulesDraft = {
resolutions: string[];
sources: string[];
requireEncode: boolean;
minSizeGb: string;
maxSizeGb: string;
requiredSubtitles: string;
cuts: string[];
excludeGroups: string;
contentTypes: string[];
unknownPolicy: "" | "hold" | "reject" | "accept";
softHdr: string[];
softGroups: string;
softSources: string[];
softCodecs: string[];
upgradeEnabled: boolean;
minTierSteps: string;
allowResolutionUpgrade: boolean;
encodeGroupAllowlist: string;
ceilingTier: string;
completion: "" | "movie" | "units";
};
export type SlotRulesJson = {
hardConditions: JsonObject;
softPreferences: JsonObject;
fallback: JsonObject;
upgradeThreshold: JsonObject;
completion: JsonObject;
};
const numStr = (n: unknown, div = 1) => (typeof n === "number" ? String(Math.round((n / div) * 100) / 100) : "");
export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
const h = HardConditions.safeParse(j.hardConditions ?? {}).data ?? {};
const s = SoftPreferences.safeParse(j.softPreferences ?? {}).data ?? {};
const u = UpgradePolicy.safeParse(j.upgradeThreshold ?? {}).data;
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
const c = Completion.safeParse(j.completion ?? {}).data;
return {
resolutions: h.resolutions ?? [],
sources: h.sources ?? [],
requireEncode: h.requireEncode ?? false,
minSizeGb: numStr(h.minSizeBytes, GB),
maxSizeGb: numStr(h.maxSizeBytes, GB),
requiredSubtitles: (h.requiredSubtitles ?? []).join(", "),
cuts: h.cuts ?? [],
excludeGroups: (h.excludeGroups ?? []).join(", "),
contentTypes: h.contentTypes ?? [],
unknownPolicy: up ?? "",
softHdr: s.hdr ?? [],
softGroups: (s.groups ?? []).join(", "),
softSources: s.sources ?? [],
softCodecs: s.codecs ?? [],
upgradeEnabled: u?.enabled ?? false,
minTierSteps: numStr(u?.minTierSteps),
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
ceilingTier: numStr(u?.ceilingTier),
completion: c?.kind ?? "",
};
}
export const emptyDraft = (): SlotRulesDraft => draftFromJson({});
/** 草稿 → JSON 列;返回字段级错误(键同草稿字段名) */
export function draftToJson(
d: SlotRulesDraft,
): { ok: true; value: SlotRulesJson } | { ok: false; errors: Record<string, string> } {
const errors: Record<string, string> = {};
const num = (key: keyof SlotRulesDraft, s: string, opts: { int?: boolean; min?: number; max?: number } = {}) => {
if (s.trim() === "") return undefined;
const n = Number(s);
if (!Number.isFinite(n) || (opts.int && !Number.isInteger(n))) {
errors[key] = opts.int ? "请输入整数" : "请输入数字";
return undefined;
}
if (opts.min !== undefined && n < opts.min) errors[key] = `不能小于 ${opts.min}`;
if (opts.max !== undefined && n > opts.max) errors[key] = `不能大于 ${opts.max}`;
return n;
};
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 = "上限需大于下限";
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 };
const arr = (v: string[]) => (v.length ? v : undefined);
const hard: HardConditions = {
resolutions: arr(d.resolutions),
sources: arr(d.sources),
requireEncode: d.requireEncode || undefined,
minSizeBytes: minGb !== undefined ? Math.round(minGb * GB) : undefined,
maxSizeBytes: maxGb !== undefined ? Math.round(maxGb * GB) : undefined,
requiredSubtitles: arr(splitList(d.requiredSubtitles)),
cuts: arr(d.cuts),
excludeGroups: arr(splitList(d.excludeGroups)),
contentTypes: arr(d.contentTypes),
};
const soft: SoftPreferences = {
hdr: arr(d.softHdr),
groups: arr(splitList(d.softGroups)),
sources: arr(d.softSources),
codecs: arr(d.softCodecs),
};
const upgrade: UpgradePolicy | undefined =
d.upgradeEnabled || minTier !== undefined || d.encodeGroupAllowlist.trim()
? {
enabled: d.upgradeEnabled,
minTierSteps: minTier,
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
ceilingTier: ceiling,
}
: undefined;
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
// 严格校验一遍(与 domain 类型对齐)
const hv = HardConditions.safeParse(clean(hard));
const sv = SoftPreferences.safeParse(clean(soft));
if (!hv.success || !sv.success) return { ok: false, errors: { form: "规则结构不合法" } };
return {
ok: true,
value: {
hardConditions: clean(hard),
softPreferences: clean(soft),
fallback: d.unknownPolicy ? { unknownPolicy: d.unknownPolicy } : {},
upgradeThreshold: upgrade ? clean(upgrade) : {},
completion: d.completion ? { kind: d.completion } : {},
},
};
}
// ── 摘要(卡片/列表上的一行说明) ──
export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject): string[] {
const h = HardConditions.safeParse(j ?? {}).data;
const out: string[] = [];
if (!h) return ["规则无法解析"];
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
out.push(
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
h.maxSizeBytes !== undefined ? formatBytes(h.maxSizeBytes, 1) : "不限"
}`,
);
if (h.requiredSubtitles?.length) out.push(`需要字幕 ${h.requiredSubtitles.join(" / ")}`);
if (h.cuts?.length) out.push(`内容版本 ${h.cuts.map((c) => CUT_LABEL[c] ?? c).join(" / ")}`);
if (h.excludeGroups?.length) out.push(`排除组 ${h.excludeGroups.join(" / ")}`);
if (h.contentTypes?.length) out.push(`内容 ${h.contentTypes.map((c) => CT_LABEL[c] ?? c).join(" / ")}`);
const up = UnknownPolicy.safeParse((fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
if (up) out.push(`字段未知时${UNKNOWN_LABEL[up]}`);
return out;
}
export function summarizeSoft(j: JsonObject | undefined): string[] {
const s = SoftPreferences.safeParse(j ?? {}).data;
if (!s) return ["规则无法解析"];
const out: string[] = [];
if (s.hdr?.length) out.push(`HDR ${s.hdr.join(" > ")}`);
if (s.sources?.length) out.push(`来源 ${s.sources.join(" > ")}`);
if (s.codecs?.length) out.push(`编码 ${s.codecs.join(" > ")}`);
if (s.groups?.length) out.push(`发布组 ${s.groups.join(" > ")}`);
if (s.preferredSizeBytes)
out.push(
`体积 ${s.preferredSizeBytes.min ? formatBytes(s.preferredSizeBytes.min, 1) : "0"} – ${
s.preferredSizeBytes.max ? formatBytes(s.preferredSizeBytes.max, 1) : "不限"
}`,
);
return out;
}
export function summarizeUpgrade(j: JsonObject | undefined): string[] {
const u = UpgradePolicy.safeParse(j ?? {}).data;
if (!u) return Object.keys(j ?? {}).length ? ["规则无法解析"] : [];
if (!u.enabled) return ["不自动升级"];
const out = [`来源等级至少提升 ${u.minTierSteps ?? 1} 级`];
if (u.allowResolutionUpgrade) out.push("允许仅因分辨率升级");
if (u.encodeGroupAllowlist?.length) out.push(`合格压制组 ${u.encodeGroupAllowlist.join(" / ")}`);
if (u.ceilingTier !== undefined) out.push(`达到等级 ${u.ceilingTier} 后不再升级`);
return out;
}
export function summarizeCompletion(j: JsonObject | undefined): string {
const c = Completion.safeParse(j ?? {}).data;
if (!c) return "未设置";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
}
// ── 编辑器 ──
export function SlotRulesEditor({
value,
onChange,
errors = {},
}: {
value: SlotRulesDraft;
onChange: (v: SlotRulesDraft) => void;
errors?: Record<string, string>;
}) {
const id = useId();
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
return (
<div className="space-y-5">
<fieldset className="space-y-3">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
硬条件 · 已知不满足即拒绝
</legend>
<FormField label="分辨率">
<ChipToggleGroup
label="分辨率"
options={o(RESOLUTIONS)}
value={value.resolutions}
onChange={(v) => set("resolutions", v)}
/>
</FormField>
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<ChipToggleGroup
label="交付来源"
options={o(SOURCES, SOURCE_LABEL)}
value={value.sources}
onChange={(v) => set("sources", v)}
/>
</FormField>
<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>
</div>
<Switch
checked={value.requireEncode}
onCheckedChange={(v) => set("requireEncode", v)}
aria-label="只接受蓝光二次压制"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="体积下限(GB)" htmlFor={`${id}-min`} error={errors.minSizeGb}>
<Input
id={`${id}-min`}
inputMode="decimal"
value={value.minSizeGb}
aria-invalid={!!errors.minSizeGb}
onChange={(e) => set("minSizeGb", e.target.value)}
placeholder="不限"
/>
</FormField>
<FormField label="体积上限(GB)" htmlFor={`${id}-max`} error={errors.maxSizeGb}>
<Input
id={`${id}-max`}
inputMode="decimal"
value={value.maxSizeGb}
aria-invalid={!!errors.maxSizeGb}
onChange={(e) => set("maxSizeGb", e.target.value)}
placeholder="不限"
/>
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<Input
id={`${id}-subs`}
value={value.requiredSubtitles}
onChange={(e) => set("requiredSubtitles", e.target.value)}
placeholder="zh"
/>
</FormField>
<FormField label="排除发布组" htmlFor={`${id}-exg`} hint="逗号分隔">
<Input
id={`${id}-exg`}
value={value.excludeGroups}
onChange={(e) => set("excludeGroups", e.target.value)}
/>
</FormField>
</div>
<FormField label="内容版本(剪辑)" hint="与分辨率等交付特征分开;不选即不限">
<ChipToggleGroup
label="内容版本"
options={o(CUTS, CUT_LABEL)}
value={value.cuts}
onChange={(v) => set("cuts", v)}
/>
</FormField>
<FormField label="内容类型" hint="综艺正片 / 花絮等">
<ChipToggleGroup
label="内容类型"
options={o(CONTENT_TYPES, CT_LABEL)}
value={value.contentTypes}
onChange={(v) => set("contentTypes", v)}
/>
</FormField>
<FormField label="字段未知时" hint="资源缺少判断所需字段(如字幕)时的处理">
<Select
value={value.unknownPolicy || "__none"}
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
>
<SelectTrigger className="w-full sm:w-64" aria-label="字段未知时">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">按系统默认</SelectItem>
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[]).map((k) => (
<SelectItem key={k} value={k}>
{UNKNOWN_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</fieldset>
<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
ordered
label="HDR 偏好"
options={o(HDRS)}
value={value.softHdr}
onChange={(v) => set("softHdr", v)}
/>
</FormField>
<FormField label="来源">
<ChipToggleGroup
ordered
label="来源偏好"
options={o(SOURCES, SOURCE_LABEL)}
value={value.softSources}
onChange={(v) => set("softSources", v)}
/>
</FormField>
<FormField label="编码">
<ChipToggleGroup
ordered
label="编码偏好"
options={o(CODECS)}
value={value.softCodecs}
onChange={(v) => set("softCodecs", v)}
/>
</FormField>
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
</FormField>
</fieldset>
<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>
<Switch
checked={value.upgradeEnabled}
onCheckedChange={(v) => set("upgradeEnabled", v)}
aria-label="允许自动升级"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="最小来源等级提升"
htmlFor={`${id}-tier`}
error={errors.minTierSteps}
hint="默认 1;同等级不反复更换"
>
<Input
id={`${id}-tier`}
inputMode="numeric"
value={value.minTierSteps}
aria-invalid={!!errors.minTierSteps}
onChange={(e) => set("minTierSteps", e.target.value)}
placeholder="1"
/>
</FormField>
<FormField
label="封顶等级"
htmlFor={`${id}-ceil`}
error={errors.ceilingTier}
hint="当前资产达到后不再升级(0–5)"
>
<Input
id={`${id}-ceil`}
inputMode="numeric"
value={value.ceilingTier}
aria-invalid={!!errors.ceilingTier}
onChange={(e) => set("ceilingTier", e.target.value)}
placeholder="不封顶"
/>
</FormField>
</div>
<FormField
label="合格压制组名单"
htmlFor={`${id}-allow`}
hint="设置后名单外的压制不自动升级,低劣压制不会只因 BluRay 标签替换"
>
<Input
id={`${id}-allow`}
value={value.encodeGroupAllowlist}
onChange={(e) => set("encodeGroupAllowlist", e.target.value)}
/>
</FormField>
<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>
<Switch
checked={value.allowResolutionUpgrade}
onCheckedChange={(v) => set("allowResolutionUpgrade", v)}
aria-label="允许仅因分辨率提升而升级"
/>
</div>
</fieldset>
<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>
<Select
value={value.completion || "__none"}
onValueChange={(v) => set("completion", v === "__none" ? "" : (v as "movie" | "units"))}
>
<SelectTrigger className="w-full sm:w-72" aria-label="完成条件">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">未设置</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
</SelectContent>
</Select>
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}
</fieldset>
</div>
);
}