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
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftFromJson, draftToJson, type SlotRulesDraft, type SlotRulesJson } from "./slot-rules.tsx";
|
||||
|
||||
const existingRules = (): SlotRulesJson => ({
|
||||
hardConditions: {
|
||||
resolutions: ["1080p", "2160p"],
|
||||
sources: ["WEB-DL", "BluRay"],
|
||||
requireEncode: true,
|
||||
minSizeBytes: 1024 ** 3,
|
||||
maxSizeBytes: 10 * 1024 ** 3,
|
||||
requiredSubtitles: ["zh", "en"],
|
||||
cuts: ["default", "IMAX"],
|
||||
excludeGroups: ["LOW", "BAD"],
|
||||
contentTypes: ["main"],
|
||||
codecs: ["H.265"],
|
||||
hdr: { anyOf: ["HDR10"], noneOf: ["DV"] },
|
||||
},
|
||||
softPreferences: {
|
||||
hdr: ["DV", "HDR10"],
|
||||
groups: ["CHD", "ADE"],
|
||||
sources: ["BluRay", "WEB-DL"],
|
||||
codecs: ["H.265", "H.264"],
|
||||
preferredSizeBytes: { min: 2 * 1024 ** 3, max: 8 * 1024 ** 3 },
|
||||
tiers: [
|
||||
{ name: "优先蓝光", sources: ["BluRay"], codecs: ["H.265"] },
|
||||
{ name: "追更", sources: ["WEB-DL"], resolutions: ["1080p"] },
|
||||
],
|
||||
tiersRequired: true,
|
||||
},
|
||||
fallback: {
|
||||
unknownPolicy: "hold",
|
||||
retryLimit: 3,
|
||||
investigation: { enabled: true, fields: ["codec", "hdr"] },
|
||||
},
|
||||
upgradeThreshold: {
|
||||
enabled: true,
|
||||
ruleGroupIds: ["quality", "release-group"],
|
||||
consistency: "season_release",
|
||||
minTierSteps: 2,
|
||||
allowResolutionUpgrade: true,
|
||||
encodeGroupAllowlist: ["CHD", "ADE"],
|
||||
ceilingTier: 4,
|
||||
mode: "full_pack",
|
||||
basis: "priority",
|
||||
targetPri: 98,
|
||||
sourceTiers: { BluRay: 3, "WEB-DL": 2 },
|
||||
},
|
||||
completion: { kind: "movie", cuts: ["default", "IMAX"] },
|
||||
});
|
||||
|
||||
function save(draft: SlotRulesDraft): SlotRulesJson {
|
||||
const result = draftToJson(draft);
|
||||
if (!result.ok) throw new Error(JSON.stringify(result.errors));
|
||||
return result.value;
|
||||
}
|
||||
|
||||
describe("收录规则编辑回写", () => {
|
||||
it("逐版本修改洗版方式,关闭再开启保留模式,明确选默认才清除模式", () => {
|
||||
const original = existingRules();
|
||||
const disabled = save({ ...draftFromJson(original), upgradeEnabled: false });
|
||||
expect(disabled.upgradeThreshold).toEqual({ ...original.upgradeThreshold, enabled: false });
|
||||
const reopened = save({ ...draftFromJson(disabled), upgradeEnabled: true });
|
||||
expect(reopened).toEqual(original);
|
||||
const perUnit = save({ ...draftFromJson(original), upgradeMode: "per_unit" });
|
||||
expect(perUnit.upgradeThreshold).toEqual({ ...original.upgradeThreshold, mode: "per_unit" });
|
||||
const { mode: _mode, ...rest } = original.upgradeThreshold;
|
||||
expect(save({ ...draftFromJson(original), upgradeMode: "" }).upgradeThreshold).toEqual(rest);
|
||||
expect(save(draftFromJson({})).upgradeThreshold).toEqual({ enabled: false });
|
||||
});
|
||||
it("未修改保存时保留表单没有展示的各层 JSON 字段", () => {
|
||||
const original = existingRules();
|
||||
|
||||
expect(save(draftFromJson(original))).toEqual(original);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "分辨率",
|
||||
edit: { resolutions: ["2160p"] },
|
||||
expected: (original: SlotRulesJson) => ({
|
||||
...original,
|
||||
hardConditions: { ...original.hardConditions, resolutions: ["2160p"] },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "编码偏好",
|
||||
edit: { softCodecs: ["AV1", "H.265"] },
|
||||
expected: (original: SlotRulesJson) => ({
|
||||
...original,
|
||||
softPreferences: { ...original.softPreferences, codecs: ["AV1", "H.265"] },
|
||||
}),
|
||||
},
|
||||
])("编辑$name只改写对应字段,不改硬编码限制或其他规则", ({ edit, expected }) => {
|
||||
const original = existingRules();
|
||||
const snapshot = structuredClone(original);
|
||||
|
||||
expect(save({ ...draftFromJson(original), ...edit })).toEqual(expected(original));
|
||||
expect(original).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("同时更新分辨率和编码偏好仍保留阶梯、洗版口径、完成版本及回退扩展", () => {
|
||||
const original = existingRules();
|
||||
const value = save({
|
||||
...draftFromJson(original),
|
||||
resolutions: ["720p", "1080p"],
|
||||
softCodecs: ["AV1"],
|
||||
});
|
||||
|
||||
expect(value).toEqual({
|
||||
...original,
|
||||
hardConditions: { ...original.hardConditions, resolutions: ["720p", "1080p"] },
|
||||
softPreferences: { ...original.softPreferences, codecs: ["AV1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])("清空洗版规则和阈值不会从原 JSON 恢复,升级开关为 %s", (upgradeEnabled) => {
|
||||
const original = existingRules();
|
||||
const {
|
||||
ruleGroupIds: _groups,
|
||||
consistency: _consistency,
|
||||
minTierSteps: _steps,
|
||||
...remaining
|
||||
} = original.upgradeThreshold;
|
||||
const value = save({
|
||||
...draftFromJson(original),
|
||||
upgradeEnabled,
|
||||
upgradeGroupIds: [],
|
||||
seasonConsistency: false,
|
||||
minTierSteps: "",
|
||||
});
|
||||
|
||||
expect(value).toEqual({
|
||||
...original,
|
||||
upgradeThreshold: { ...remaining, enabled: upgradeEnabled },
|
||||
});
|
||||
expect(value.upgradeThreshold).not.toHaveProperty("ruleGroupIds");
|
||||
expect(value.upgradeThreshold).not.toHaveProperty("consistency");
|
||||
expect(value.upgradeThreshold).not.toHaveProperty("minTierSteps");
|
||||
});
|
||||
|
||||
it("关闭升级并清空配置后,再加载并打开升级也不会复活旧规则", () => {
|
||||
const disabled = save({
|
||||
...draftFromJson(existingRules()),
|
||||
upgradeEnabled: false,
|
||||
upgradeGroupIds: [],
|
||||
seasonConsistency: false,
|
||||
minTierSteps: "",
|
||||
});
|
||||
const reopened = draftFromJson(disabled);
|
||||
|
||||
expect(reopened.upgradeGroupIds).toEqual([]);
|
||||
expect(reopened.seasonConsistency).toBe(false);
|
||||
expect(reopened.minTierSteps).toBe("");
|
||||
const enabled = save({ ...reopened, upgradeEnabled: true });
|
||||
expect(enabled.upgradeThreshold).toEqual({ ...disabled.upgradeThreshold, enabled: true });
|
||||
expect(enabled.upgradeThreshold).not.toHaveProperty("ruleGroupIds");
|
||||
expect(enabled.upgradeThreshold).not.toHaveProperty("consistency");
|
||||
expect(enabled.upgradeThreshold).not.toHaveProperty("minTierSteps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("按字段配置的未知值回退", () => {
|
||||
const fieldFallback = () => ({
|
||||
...existingRules(),
|
||||
fallback: {
|
||||
unknownPolicy: { codec: "hold", resolution: "reject", hdr: "accept" },
|
||||
investigation: { enabled: true, fields: ["codec"] },
|
||||
},
|
||||
});
|
||||
|
||||
it("按字段对象回填为保留状态,未修改保存仍保留原配置", () => {
|
||||
const original = fieldFallback();
|
||||
const draft = draftFromJson(original);
|
||||
|
||||
expect(draft.unknownPolicy).toBe("fields");
|
||||
expect(save(draft)).toEqual(original);
|
||||
});
|
||||
|
||||
it("仅修改其他规则时也不会清除按字段回退对象及其扩展字段", () => {
|
||||
const original = fieldFallback();
|
||||
|
||||
expect(save({ ...draftFromJson(original), resolutions: ["2160p"] })).toEqual({
|
||||
...original,
|
||||
hardConditions: { ...original.hardConditions, resolutions: ["2160p"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("明确选择全局回退后替换原按字段对象,保留其他回退字段", () => {
|
||||
const original = fieldFallback();
|
||||
|
||||
expect(save({ ...draftFromJson(original), unknownPolicy: "reject" })).toEqual({
|
||||
...original,
|
||||
fallback: { ...original.fallback, unknownPolicy: "reject" },
|
||||
});
|
||||
});
|
||||
|
||||
it("原按字段配置先改为全局拒绝,再明确选系统默认时清空回退,不恢复原对象", () => {
|
||||
const original = fieldFallback();
|
||||
const draft = draftFromJson(original);
|
||||
const selectedGlobal = { ...draft, unknownPolicy: "reject" as const };
|
||||
const cleared = { ...selectedGlobal, unknownPolicy: "" as const };
|
||||
const { unknownPolicy: _policy, ...remaining } = original.fallback;
|
||||
const value = save(cleared);
|
||||
|
||||
expect(value).toEqual({ ...original, fallback: remaining });
|
||||
expect(value.fallback).not.toHaveProperty("unknownPolicy");
|
||||
});
|
||||
|
||||
it("已有全局回退可以清空,保存后不会恢复原选择", () => {
|
||||
const original = existingRules();
|
||||
const { unknownPolicy: _policy, ...remaining } = original.fallback;
|
||||
const value = save({ ...draftFromJson(original), unknownPolicy: "" });
|
||||
|
||||
expect(value).toEqual({ ...original, fallback: remaining });
|
||||
expect(value.fallback).not.toHaveProperty("unknownPolicy");
|
||||
});
|
||||
});
|
||||
@@ -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="最小来源等级提升"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AcquisitionPolicyRules,
|
||||
AcquisitionPolicyScope,
|
||||
type JsonObject,
|
||||
type PolicyRevision,
|
||||
type SlotTemplate,
|
||||
@@ -9,6 +10,7 @@ import { ChevronRight, Eye, FilePlus2, History, Pencil, Plus, ScrollText, Trash2
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useClassification } from "~/api/organize-sources.ts";
|
||||
import {
|
||||
PolicyPreview,
|
||||
useCreatePolicy,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
useRollbackPolicy,
|
||||
useUpdatePolicy,
|
||||
} from "~/api/policies.ts";
|
||||
import { useRuleGroups } from "~/api/rules.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -54,14 +57,16 @@ const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.opti
|
||||
}));
|
||||
|
||||
export const PUBLISH_NOTICE =
|
||||
"发布后,作用范围内的下载、追更和整理按这个策略自动执行,不再逐次审批;升级替换、媒体清理和下载缓存清理仍要单独审批。上一个已发布版本的授权随之失效。";
|
||||
"发布后,作用范围内的下载、追更和整理按这个策略自动执行;已开启的洗版在新文件核验后替换对应旧版本。媒体清理和下载缓存清理仍要单独审批。上一个已发布版本的授权随之失效。";
|
||||
|
||||
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
|
||||
type TemplateDraft = { key: string; name: string; ruleGroupIds: string[]; rules: SlotRulesDraft; open: boolean };
|
||||
type PolicyDraft = {
|
||||
policyKey: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
scopeKinds: string[];
|
||||
scopeCategories: string[];
|
||||
originalScope: JsonObject;
|
||||
defaultSlots: TemplateDraft[];
|
||||
newTitleEnabled: boolean;
|
||||
windowDays: string;
|
||||
@@ -75,6 +80,7 @@ let seq = 0;
|
||||
const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({
|
||||
key: `t${++seq}`,
|
||||
name: t?.name ?? "",
|
||||
ruleGroupIds: t?.ruleGroupIds ?? [],
|
||||
rules: t ? draftFromJson(t) : emptyDraft(),
|
||||
open,
|
||||
});
|
||||
@@ -88,6 +94,10 @@ function draftFromRevision(r: PolicyRevision | null): PolicyDraft {
|
||||
title: r?.title ?? "",
|
||||
summary: r?.summary ?? "",
|
||||
scopeKinds: kinds ?? [],
|
||||
scopeCategories: Object.entries(AcquisitionPolicyScope.safeParse(r?.scope ?? {}).data?.categories ?? {}).flatMap(
|
||||
([type, names]) => (names ?? []).map((name) => `${type}:${name}`),
|
||||
),
|
||||
originalScope: r?.scope ?? {},
|
||||
defaultSlots: (rules?.defaultSlots ?? []).map((t) => tpl(t)),
|
||||
newTitleEnabled: rules?.newTitle?.enabled ?? false,
|
||||
windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "",
|
||||
@@ -121,6 +131,14 @@ function buildPolicy(d: PolicyDraft):
|
||||
const errors: Record<string, string> = {};
|
||||
const meta = Meta.safeParse(d);
|
||||
if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message;
|
||||
for (const selected of d.scopeCategories) {
|
||||
const type = selected.split(":")[0];
|
||||
if (
|
||||
d.scopeKinds.length &&
|
||||
(type === "movie" ? !d.scopeKinds.includes("movie") : !d.scopeKinds.some((k) => k !== "movie"))
|
||||
)
|
||||
errors.scopeCategories = "适用分类与作品类型不一致,请调整其中一项";
|
||||
}
|
||||
const templates = (list: TemplateDraft[]) =>
|
||||
list.map((t) => {
|
||||
if (!t.name.trim()) errors[`${t.key}.name`] = "填写模板名称";
|
||||
@@ -131,6 +149,7 @@ function buildPolicy(d: PolicyDraft):
|
||||
}
|
||||
return {
|
||||
name: t.name.trim(),
|
||||
...(t.ruleGroupIds.length ? { ruleGroupIds: t.ruleGroupIds } : {}),
|
||||
hardConditions: stripEmpty(r.value.hardConditions),
|
||||
softPreferences: stripEmpty(r.value.softPreferences),
|
||||
fallback: stripEmpty(r.value.fallback),
|
||||
@@ -174,7 +193,18 @@ function buildPolicy(d: PolicyDraft):
|
||||
policyKey: meta.data.policyKey,
|
||||
title: meta.data.title,
|
||||
summary: meta.data.summary || null,
|
||||
scope: d.scopeKinds.length ? { mediaKinds: d.scopeKinds } : {},
|
||||
scope: {
|
||||
...d.originalScope,
|
||||
mediaKinds: d.scopeKinds.length ? d.scopeKinds : undefined,
|
||||
categories: d.scopeCategories.length
|
||||
? Object.fromEntries(
|
||||
["movie", "tv"].map((type) => [
|
||||
type,
|
||||
d.scopeCategories.filter((v) => v.startsWith(`${type}:`)).map((v) => v.slice(type.length + 1)),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
} as JsonObject,
|
||||
rules: rules as JsonObject,
|
||||
},
|
||||
};
|
||||
@@ -532,8 +562,12 @@ function PolicyDetailSheet({
|
||||
}
|
||||
|
||||
const scopeText = (scope: JsonObject) => {
|
||||
const k = z.array(z.string()).safeParse((scope as { mediaKinds?: unknown }).mediaKinds).data;
|
||||
return k?.length ? k.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? x).join(" / ") : "全部作品";
|
||||
const parsed = AcquisitionPolicyScope.safeParse(scope).data;
|
||||
const kinds = parsed?.mediaKinds?.map((x) => MEDIA_KIND[x]).join(" / ") || "全部作品";
|
||||
const categories = Object.values(parsed?.categories ?? {})
|
||||
.flat()
|
||||
.join(" / ");
|
||||
return categories ? `${kinds} · ${categories}` : kinds;
|
||||
};
|
||||
|
||||
function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
@@ -606,6 +640,7 @@ function PolicyEditorSheet({
|
||||
onSaved: (r: PolicyRevision) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const classification = useClassification();
|
||||
const create = useCreatePolicy();
|
||||
const update = useUpdatePolicy();
|
||||
const [d, setD] = useState<PolicyDraft>(() => {
|
||||
@@ -715,10 +750,37 @@ function PolicyEditorSheet({
|
||||
onChange={(v) => set("scopeKinds", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="适用分类"
|
||||
error={errors.scopeCategories}
|
||||
hint="沿用现有分类规则;不选时作为类型默认,订阅可以单独选择其他策略"
|
||||
>
|
||||
<ChipToggleGroup
|
||||
label="适用分类"
|
||||
options={[
|
||||
...Object.entries(classification.data?.categories ?? {}).flatMap(([type, names]) =>
|
||||
names.map((name) => ({
|
||||
value: `${type}:${name}`,
|
||||
label: `${type === "movie" ? "电影" : "剧集"} · ${name}`,
|
||||
})),
|
||||
),
|
||||
...d.scopeCategories
|
||||
.filter(
|
||||
(v) =>
|
||||
!Object.entries(classification.data?.categories ?? {}).some(([type, names]) =>
|
||||
names.some((name) => v === `${type}:${name}`),
|
||||
),
|
||||
)
|
||||
.map((v) => ({ value: v, label: `${v}(分类已删除)` })),
|
||||
]}
|
||||
value={d.scopeCategories}
|
||||
onChange={(v) => set("scopeCategories", v)}
|
||||
/>
|
||||
</FormField>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="默认收录版本模板"
|
||||
title="保留版本"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -731,6 +793,9 @@ function PolicyEditorSheet({
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有收录版本时,按这些模板创建。</p>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
一个目标保留单版本,可在允许分辨率内先低后高;多个目标分别保留、升级各自版本。
|
||||
</p>
|
||||
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
|
||||
</DetailSection>
|
||||
|
||||
@@ -825,6 +890,7 @@ function TemplateList({
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const groups = useRuleGroups({ enabled: "true" });
|
||||
if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>;
|
||||
const patch = (key: string, p: Partial<TemplateDraft>) =>
|
||||
onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t)));
|
||||
@@ -876,6 +942,23 @@ function TemplateList({
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border/60 px-3 py-3">
|
||||
<FormField
|
||||
label="收录组合规则"
|
||||
hint="在规则页维护组合档位;多个规则按选中顺序生效,最后一个带档位的规则决定优先级"
|
||||
>
|
||||
<ChipToggleGroup
|
||||
ordered
|
||||
label="收录组合规则"
|
||||
options={[
|
||||
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
|
||||
...t.ruleGroupIds
|
||||
.filter((id) => !(groups.data?.items ?? []).some((g) => g.id === id))
|
||||
.map((id) => ({ value: id, label: `${id}(规则不可用)` })),
|
||||
]}
|
||||
value={t.ruleGroupIds}
|
||||
onChange={(v) => patch(t.key, { ruleGroupIds: v })}
|
||||
/>
|
||||
</FormField>
|
||||
<SlotRulesEditor value={t.rules} onChange={(v) => patch(t.key, { rules: v })} errors={errs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pencil, Plus, Target } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useRuleGroups } from "~/api/rules.ts";
|
||||
import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -12,6 +13,7 @@ import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
import { ChipToggleGroup } from "./parts.tsx";
|
||||
import { SlotCard } from "./slot-card.tsx";
|
||||
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
|
||||
|
||||
@@ -46,7 +48,9 @@ export function SlotsPanel({
|
||||
}) {
|
||||
const q = useSlots({ mediaEntityId });
|
||||
const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null);
|
||||
const slots = q.data?.items ?? [];
|
||||
const slots = (q.data?.items ?? []).filter(
|
||||
(s) => !subscriptionId || s.subscriptionId === subscriptionId || s.subscriptionId === null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -89,9 +93,14 @@ export function SlotsPanel({
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{subscriptionId && s.subscriptionId === null ? (
|
||||
<span className="text-meta text-muted-foreground">作品共用</span>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -127,6 +136,8 @@ function SlotEditorSheet({
|
||||
const id = useId();
|
||||
const create = useCreateSlot();
|
||||
const update = useUpdateSlot();
|
||||
const groups = useRuleGroups({ enabled: "true" });
|
||||
const [ruleGroupIds, setRuleGroupIds] = useState<string[]>(slot?.ruleGroupIds ?? []);
|
||||
const [name, setName] = useState(slot?.name ?? "");
|
||||
const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? "");
|
||||
const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false);
|
||||
@@ -145,6 +156,7 @@ function SlotEditorSheet({
|
||||
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
|
||||
const common = {
|
||||
...rules.value,
|
||||
ruleGroupIds,
|
||||
distinctGroup: meta.data.distinctGroup || null,
|
||||
requireDistinctAsset: requireDistinct,
|
||||
};
|
||||
@@ -232,6 +244,20 @@ function SlotEditorSheet({
|
||||
</div>
|
||||
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" />
|
||||
</div>
|
||||
<FormField label="收录组合规则" hint="不选时沿用订阅兼容设置或默认规则;最后一个带档位的组决定优先级">
|
||||
<ChipToggleGroup
|
||||
label="收录组合规则"
|
||||
ordered
|
||||
options={[
|
||||
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
|
||||
...ruleGroupIds
|
||||
.filter((gid) => !(groups.data?.items ?? []).some((g) => g.id === gid))
|
||||
.map((gid) => ({ value: gid, label: `${gid}(规则不可用)` })),
|
||||
]}
|
||||
value={ruleGroupIds}
|
||||
onChange={setRuleGroupIds}
|
||||
/>
|
||||
</FormField>
|
||||
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
|
||||
</DetailSheet>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user