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 = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" }; const CUT_LABEL: Record = { default: "默认(无剪辑标记)" }; const CT_LABEL: Record = { main: "正片", extra: "花絮", behind_the_scenes: "幕后", performance_only: "纯享", preview: "预告", reaction: "reaction", }; const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const; const GB = 1024 ** 3; const o = (list: readonly T[], labels: Record = {}) => 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; export type SoftPreferences = z.infer; export type UpgradePolicy = z.infer; /** 编辑器状态(字符串形态,提交时转换) */ 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): 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 } { const errors: Record = {}; 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; }) { const id = useId(); const set = (k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v }); return (
硬条件 · 已知不满足即拒绝 set("resolutions", v)} /> set("sources", v)} />

只接受蓝光二次压制

不含原盘和 Remux

set("requireEncode", v)} aria-label="只接受蓝光二次压制" />
set("minSizeGb", e.target.value)} placeholder="不限" /> set("maxSizeGb", e.target.value)} placeholder="不限" />
set("requiredSubtitles", e.target.value)} placeholder="zh" /> set("excludeGroups", e.target.value)} />
set("cuts", v)} /> set("contentTypes", v)} />
软偏好 · 通过硬条件后排序,越靠前越优先 set("softHdr", v)} /> set("softSources", v)} /> set("softCodecs", v)} /> set("softGroups", e.target.value)} />
升级 · 生成替换计划,不直接覆盖旧文件

允许自动升级

set("upgradeEnabled", v)} aria-label="允许自动升级" />
set("minTierSteps", e.target.value)} placeholder="1" /> set("ceilingTier", e.target.value)} placeholder="不封顶" />
set("encodeGroupAllowlist", e.target.value)} />

允许仅因分辨率提升而升级

set("allowResolutionUpgrade", v)} aria-label="允许仅因分辨率提升而升级" />
完成条件 {errors.form ?

{errors.form}

: null}
); }