feat(web): simplify subscription and manual download flows and sync UHD styles
This commit is contained in:
1 parent
27c0f27434
commit
c3c4456913
33 files changed
+1083
-633
No files matched your search
@@ -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="最小来源等级提升"
|
||||
|
||||
Reference in new issue
Block a user