523 lines
21 KiB
TypeScript
523 lines
21 KiB
TypeScript
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="设置后,名单外的压制不触发自动升级">
|
||
<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>
|
||
);
|
||
}
|