feat(web): simplify subscription and manual download flows and sync UHD styles

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 27c0f27434
commit c3c4456913
33 files changed
+1083 -633

No files matched your search

+89 -24
View File
@@ -1,6 +1,7 @@
import type { JsonObject } from "@umt/contracts";
import { useId } from "react";
import { z } from "zod";
import { useRuleGroups } from "~/api/rules.ts";
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";
@@ -52,7 +53,7 @@ const CT_LABEL: Record<string, string> = {
preview: "预告",
reaction: "reaction",
};
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const UNKNOWN_LABEL = { fields: "保留按字段配置", hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
@@ -81,10 +82,13 @@ export const SoftPreferences = z.object({
});
export const UpgradePolicy = z.object({
enabled: z.boolean(),
mode: z.enum(["movie", "per_unit", "full_pack"]).optional(),
minTierSteps: z.number().int().min(0).max(5).optional(),
allowResolutionUpgrade: z.boolean().optional(),
encodeGroupAllowlist: strArr,
ceilingTier: z.number().int().min(0).max(5).optional(),
ruleGroupIds: strArr,
consistency: z.literal("season_release").optional(),
});
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
@@ -95,6 +99,9 @@ export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
/** 编辑器状态(字符串形态,提交时转换) */
export type SlotRulesDraft = {
original: Partial<SlotRulesJson>;
upgradeGroupIds: string[];
seasonConsistency: boolean;
resolutions: string[];
sources: string[];
requireEncode: boolean;
@@ -104,12 +111,13 @@ export type SlotRulesDraft = {
cuts: string[];
excludeGroups: string;
contentTypes: string[];
unknownPolicy: "" | "hold" | "reject" | "accept";
unknownPolicy: "" | "fields" | "hold" | "reject" | "accept";
softHdr: string[];
softGroups: string;
softSources: string[];
softCodecs: string[];
upgradeEnabled: boolean;
upgradeMode: "" | "movie" | "per_unit" | "full_pack";
minTierSteps: string;
allowResolutionUpgrade: boolean;
encodeGroupAllowlist: string;
@@ -134,6 +142,9 @@ export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
const c = Completion.safeParse(j.completion ?? {}).data;
return {
original: j,
upgradeGroupIds: u?.ruleGroupIds ?? [],
seasonConsistency: u?.consistency === "season_release",
resolutions: h.resolutions ?? [],
sources: h.sources ?? [],
requireEncode: h.requireEncode ?? false,
@@ -143,12 +154,13 @@ export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
cuts: h.cuts ?? [],
excludeGroups: (h.excludeGroups ?? []).join(", "),
contentTypes: h.contentTypes ?? [],
unknownPolicy: up ?? "",
unknownPolicy: up ?? (typeof j.fallback?.unknownPolicy === "object" ? "fields" : ""),
softHdr: s.hdr ?? [],
softGroups: (s.groups ?? []).join(", "),
softSources: s.sources ?? [],
softCodecs: s.codecs ?? [],
upgradeEnabled: u?.enabled ?? false,
upgradeMode: u?.mode ?? "",
minTierSteps: numStr(u?.minTierSteps),
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
@@ -200,16 +212,16 @@ export function draftToJson(
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 upgrade: UpgradePolicy = {
enabled: d.upgradeEnabled,
mode: d.upgradeMode || undefined,
ruleGroupIds: arr(d.upgradeGroupIds),
consistency: d.seasonConsistency ? "season_release" : undefined,
minTierSteps: minTier,
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
ceilingTier: ceiling,
};
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
// 严格校验一遍(与 domain 类型对齐)
const hv = HardConditions.safeParse(clean(hard));
@@ -218,11 +230,14 @@ export function draftToJson(
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 } : {},
hardConditions: clean({ ...d.original.hardConditions, ...hard }),
softPreferences: clean({ ...d.original.softPreferences, ...soft }),
fallback: clean({
...d.original.fallback,
unknownPolicy: d.unknownPolicy === "fields" ? d.original.fallback?.unknownPolicy : d.unknownPolicy || undefined,
}),
upgradeThreshold: clean({ ...d.original.upgradeThreshold, ...upgrade }),
completion: clean({ ...d.original.completion, kind: d.completion || undefined }),
},
};
}
@@ -297,6 +312,7 @@ export function SlotRulesEditor({
errors?: Record<string, string>;
}) {
const id = useId();
const groups = useRuleGroups({ enabled: "true" });
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
return (
<div className="space-y-5">
@@ -304,7 +320,7 @@ export function SlotRulesEditor({
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
硬条件 · 已知不满足即拒绝
</legend>
<FormField label="分辨率">
<FormField label="允许收录的分辨率" hint="先收低档和后续洗版都限定在此范围内">
<ChipToggleGroup
label="分辨率"
options={o(RESOLUTIONS)}
@@ -396,11 +412,13 @@ export function SlotRulesEditor({
</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>
))}
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[])
.filter((k) => k !== "fields" || typeof value.original.fallback?.unknownPolicy === "object")
.map((k) => (
<SelectItem key={k} value={k}>
{UNKNOWN_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
@@ -454,6 +472,53 @@ export function SlotRulesEditor({
aria-label="允许自动升级"
/>
</div>
<FormField
label="洗版方式"
htmlFor={`${id}-up-mode`}
hint="全集洗版只接受覆盖目标全集的资源;整季同套也可以逐集完成"
>
<Select
value={value.upgradeMode || "__default__"}
onValueChange={(v) => set("upgradeMode", v === "__default__" ? "" : (v as SlotRulesDraft["upgradeMode"]))}
>
<SelectTrigger id={`${id}-up-mode`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">按作品类型(电影单片,剧集按集)</SelectItem>
<SelectItem value="per_unit">按集洗版</SelectItem>
<SelectItem value="full_pack">全集洗版</SelectItem>
<SelectItem value="movie">电影洗版</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="洗版组合规则" hint="不选时使用默认洗版规则;选中顺序中最后一个带档位的规则决定优先级">
<ChipToggleGroup
ordered
label="洗版组合规则"
options={[
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
...value.upgradeGroupIds
.filter((id) => !(groups.data?.items ?? []).some((g) => g.id === id))
.map((id) => ({ value: id, label: `${id}(规则不可用)` })),
]}
value={value.upgradeGroupIds}
onChange={(v) => set("upgradeGroupIds", v)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div>
<p className="text-caption font-medium">剧集洗版后整季同套</p>
<p className="text-meta text-muted-foreground">
追更允许混合,洗版按季向同组、同来源和编码对齐;可以逐集完成。
</p>
</div>
<Switch
checked={value.seasonConsistency}
onCheckedChange={(v) => set("seasonConsistency", v)}
aria-label="剧集洗版后整季同套"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="最小来源等级提升"