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

@@ -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");
});
});
+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="最小来源等级提升"
@@ -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>
);