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

@@ -15,7 +15,6 @@ import {
seasonLabel,
shiftMonth,
slotBadge,
upgradeOf,
} from "./model.ts";
const slot = (x: Partial<SubscriptionSlotSummary>): SubscriptionSlotSummary => ({
@@ -86,15 +85,44 @@ describe("覆盖矩阵格", () => {
expect(moveItem(["a", "b", "c"], 2, 0)).toEqual(["c", "a", "b"]);
expect(moveItem(["a", "b"], 0, 5)).toEqual(["a", "b"]);
});
it("升级策略取自收录版本", () => {
expect(upgradeOf([{ upgradeThreshold: {} }], "series")).toBe("off");
expect(upgradeOf([{ upgradeThreshold: { enabled: true, mode: "full_pack" } }], "series")).toBe("full_pack");
expect(upgradeOf([{ upgradeThreshold: { enabled: true } }], "movie")).toBe("movie");
});
});
describe("表单 ↔ params", () => {
it("保留未知键,空值不写,只在改动时带 best_version / total_episode", () => {
it("普通编辑不重建补全配置;只改补全开关也保留预算精度,明确修改才更新", () => {
const params = {
autoFill: "on",
fillBudget: { unitsPerDay: 2, bytesPerDay: Math.round(1.25 * 1024 ** 3), future: true },
};
const sub = {
mediaKind: "movie",
seasonScope: "current_season",
mode: "once",
priority: "tracking",
seasonUnitId: null,
policyRevisionId: null,
params,
} as unknown as Subscription;
const d = draftFromSubscription(sub);
const ordinary = draftToParams({ ...d, searchIntervalHours: "6" }, params, {
totalChanged: false,
autoFillChanged: false,
});
expect(ordinary.params).toEqual({ ...params, searchIntervalHours: 6 });
const modeOnly = draftToParams({ ...d, autoFill: "off" }, params, { totalChanged: false, autoFillChanged: true });
expect(modeOnly.params).toEqual({ ...params, autoFill: "off" });
const changed = draftToParams({ ...d, gbPerDay: "2", unitsPerDay: "" }, params, {
totalChanged: false,
autoFillChanged: true,
});
expect(changed.params.fillBudget).toEqual({ bytesPerDay: 2 * 1024 ** 3, future: true });
const cleared = draftToParams(
{ ...d, gbPerDay: "", unitsPerDay: "" },
{ ...params, fillBudget: { unitsPerDay: 2, bytesPerDay: params.fillBudget.bytesPerDay } },
{ totalChanged: false, autoFillChanged: true },
);
expect(cleared.params.fillBudget).toBeUndefined();
});
it("保留未知键,空值不写,只在改动时带 total_episode", () => {
const d = {
...emptyDraft("series"),
siteIds: ["x"],
@@ -104,12 +132,11 @@ describe("表单 ↔ params", () => {
unitsPerDay: "2",
gbPerDay: "1.5",
total: "12",
upgrade: "full_pack" as const,
};
const { params, errors } = draftToParams(
d,
{ custom: 1, keywords: ["旧"] },
{ upgradeChanged: true, totalChanged: true },
{ totalChanged: true, autoFillChanged: true },
);
expect(errors).toEqual({});
expect(params).toEqual({
@@ -121,10 +148,8 @@ describe("表单 ↔ params", () => {
manual_total_episode: false,
autoFill: "off",
fillBudget: { unitsPerDay: 2, bytesPerDay: Math.round(1.5 * 1024 ** 3) },
best_version: true,
best_version_full: true,
});
const same = draftToParams(d, {}, { upgradeChanged: false, totalChanged: false }).params;
const same = draftToParams(d, {}, { totalChanged: false, autoFillChanged: true }).params;
expect(same.best_version).toBeUndefined();
expect(same.total_episode).toBeUndefined();
});
@@ -132,7 +157,7 @@ describe("表单 ↔ params", () => {
const { errors } = draftToParams(
{ ...emptyDraft("series"), episodeStart: "5", episodeEnd: "2" },
{},
{ upgradeChanged: false, totalChanged: false },
{ totalChanged: false, autoFillChanged: true },
);
expect(errors.episodeEnd).toBeTruthy();
});
@@ -151,40 +176,45 @@ describe("表单 ↔ params", () => {
keywords: ["a", "b"],
},
} as unknown as Subscription;
const d = draftFromSubscription(sub, "per_unit");
const d = draftFromSubscription(sub);
expect(d.episodeStart).toBe("2");
expect(d.autoFill).toBe("on");
expect(d.gbPerDay).toBe("2");
expect(d.keywords).toBe("a\nb");
expect(d.upgrade).toBe("per_unit");
});
it("质量预设:草稿 ↔ params;未知值回填为空", () => {
const d = { ...emptyDraft("movie"), resolution: "4k" as const, source: "remux" as const, effect: "dv" as const };
const { params } = draftToParams(
d,
{ resolution: "720p", effect: "sdr" },
{ upgradeChanged: false, totalChanged: false },
);
expect(params).toEqual({ resolution: "4k", source: "remux", effect: "dv" });
const cleared = draftToParams(
emptyDraft("movie"),
{ resolution: "720p" },
{ upgradeChanged: false, totalChanged: false },
);
expect(cleared.params.resolution).toBeUndefined();
const back = draftFromSubscription(
{
mediaKind: "movie",
seasonScope: "current_season",
mode: "once",
priority: "tracking",
seasonUnitId: null,
policyRevisionId: null,
params: { resolution: "1080p", source: "bogus", effect: "hdr" },
} as unknown as Subscription,
"off",
);
expect([back.resolution, back.source, back.effect]).toEqual(["1080p", "", "hdr"]);
it("编辑跟踪与下载信息保留旧收录规则,不再生成统一洗版指令", () => {
const legacy = {
resolution: "4k",
source: "uhd",
effect: "atmos",
ruleGroupIds: ["quality"],
future: { keep: true },
};
const sub = {
mediaKind: "movie",
seasonScope: "current_season",
mode: "once",
priority: "tracking",
seasonUnitId: null,
policyRevisionId: null,
params: legacy,
} as unknown as Subscription;
const draft = draftFromSubscription(sub);
const { params, errors } = draftToParams({ ...draft, siteIds: ["site"], searchIntervalHours: "6" }, legacy, {
totalChanged: false,
autoFillChanged: true,
});
expect(errors).toEqual({});
expect(params).toEqual({ ...legacy, siteIds: ["site"], searchIntervalHours: 6 });
expect(params.best_version).toBeUndefined();
expect(params.best_version_full).toBeUndefined();
const cleared = draftToParams(draft, legacy, {
totalChanged: false,
autoFillChanged: false,
clearQualityPreset: true,
});
expect(cleared.params).toEqual({ ruleGroupIds: ["quality"], future: { keep: true } });
expect(draftToParams(emptyDraft("movie"), {}, { totalChanged: false, autoFillChanged: true }).params).toEqual({});
});
});
+25 -96
View File
@@ -1,5 +1,4 @@
import type {
AcquisitionSlot,
AutoFillMode,
CalendarEntryState,
CoverageCell,
@@ -7,13 +6,10 @@ import type {
Subscription,
SubscriptionCalendarEntry,
SubscriptionCoverage,
SubscriptionEffect,
SubscriptionGroup,
SubscriptionMediaKind,
SubscriptionOverviewItem,
SubscriptionResolution,
SubscriptionSlotSummary,
SubscriptionSourcePreset,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
@@ -75,7 +71,7 @@ export function foldNames(names: string[], max = 2, emptyLabel = "全部站点")
return names.length > max ? `${head} +${names.length - max}` : head;
}
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随规则组", on: "开", off: "关" };
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随主版本规则", on: "开", off: "关" };
export const AUTOFILL_SOURCE: Record<"subscription" | "rule_group" | "default", string> = {
subscription: "订阅设置",
rule_group: "规则组",
@@ -155,27 +151,6 @@ export function seasonTitle(s: SubscriptionCoverage["seasons"][number], kind: Su
return s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`;
}
// ── 收录版本 ──
export type UpgradeStrategy = "off" | "per_unit" | "full_pack" | "movie";
export const UPGRADE_LABEL: Record<UpgradeStrategy, string> = {
off: "不洗版",
per_unit: "按集洗版",
full_pack: "全集洗版",
movie: "洗版",
};
/** 订阅收录版本当前的升级策略:任一开启取其 mode */
export function upgradeOf(
slots: Pick<AcquisitionSlot, "upgradeThreshold">[],
kind: SubscriptionMediaKind,
): UpgradeStrategy {
const on = slots.find((s) => (s.upgradeThreshold as { enabled?: unknown }).enabled === true);
if (!on) return "off";
if (kind === "movie") return "movie";
return (on.upgradeThreshold as { mode?: unknown }).mode === "full_pack" ? "full_pack" : "per_unit";
}
/** 把 from 移到 to(拖动排序) */
export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
const out = arr.slice();
@@ -187,30 +162,6 @@ export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
// ── 表单 ──
export const RESOLUTION_LABEL: Record<SubscriptionResolution, string> = {
"4k": "4K",
"1080p": "1080p",
"720p": "720p",
};
export const SOURCE_PRESET_LABEL: Record<SubscriptionSourcePreset, string> = {
bluray_disc: "蓝光原盘",
remux: "Remux",
bluray: "BluRay",
uhd: "UHD",
web: "WEB-DL",
hdtv: "HDTV",
h265: "H.265",
h264: "H.264",
};
export const EFFECT_LABEL: Record<SubscriptionEffect, string> = {
dv: "杜比视界",
hdr: "HDR",
sdr: "SDR",
atmos: "杜比全景声",
};
const oneOf = <K extends string>(v: unknown, labels: Record<K, string>): K | "" =>
typeof v === "string" && v in labels ? (v as K) : "";
export type SubscriptionDraft = {
mediaKind: SubscriptionMediaKind;
seasonUnitId: string;
@@ -226,15 +177,9 @@ export type SubscriptionDraft = {
libraryRootId: string;
downloadSavePath: string;
policyRevisionId: string;
upgrade: UpgradeStrategy;
ruleGroupIds: string[];
include: string;
exclude: string;
keywords: string;
/** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的收录版本) */
resolution: SubscriptionResolution | "";
source: SubscriptionSourcePreset | "";
effect: SubscriptionEffect | "";
searchIntervalHours: string;
stopAfter: string;
autoFill: AutoFillMode;
@@ -267,14 +212,9 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
libraryRootId: "",
downloadSavePath: "",
policyRevisionId: "",
upgrade: "off",
ruleGroupIds: [],
include: "",
exclude: "",
keywords: "",
resolution: "",
source: "",
effect: "",
searchIntervalHours: "",
stopAfter: "",
autoFill: "inherit",
@@ -284,6 +224,7 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
}
const GB = 1024 ** 3;
const gbStr = (v: unknown) => (typeof v === "number" ? String(Math.round((v / GB) * 10) / 10) : "");
/** datetime-local 值 ↔ ISO */
export function toLocalInput(iso: string): string {
@@ -293,11 +234,10 @@ export function toLocalInput(iso: string): string {
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`;
}
export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrategy): SubscriptionDraft {
export function draftFromSubscription(sub: Subscription): SubscriptionDraft {
const p = sub.params as Record<string, unknown>;
const range = (p.episodeRange ?? {}) as { start?: unknown; end?: unknown };
const budget = (p.fillBudget ?? {}) as { unitsPerDay?: unknown; bytesPerDay?: unknown };
const bytes = typeof budget.bytesPerDay === "number" ? budget.bytesPerDay : null;
return {
...emptyDraft(sub.mediaKind),
mediaKind: sub.mediaKind,
@@ -312,19 +252,14 @@ export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrateg
libraryRootId: str(p.libraryRootId),
downloadSavePath: str(p.downloadSavePath),
policyRevisionId: sub.policyRevisionId ?? "",
upgrade,
ruleGroupIds: strArr(p.ruleGroupIds),
include: strArr(p.include).join("\n"),
exclude: strArr(p.exclude).join("\n"),
keywords: strArr(p.keywords).join("\n"),
resolution: oneOf(p.resolution, RESOLUTION_LABEL),
source: oneOf(p.source, SOURCE_PRESET_LABEL),
effect: oneOf(p.effect, EFFECT_LABEL),
searchIntervalHours: numStr(p.searchIntervalHours),
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
unitsPerDay: numStr(budget.unitsPerDay),
gbPerDay: bytes !== null ? String(Math.round((bytes / GB) * 10) / 10) : "",
gbPerDay: gbStr(budget.bytesPerDay),
};
}
@@ -334,32 +269,27 @@ const isInt = (s: string) => /^\d+$/.test(s.trim());
/**
* 草稿 → params(PATCH 整体替换,所以以现有 params 为底保留未知键)。
* upgradeChanged:只有升级策略改了才带 best_version(否则每次保存都会 bump 收录版本 revision)。
* 收录规则只在版本编辑器维护;旧导入的质量预设与规则组原样保留,不从普通表单重建。
*/
export function draftToParams(
d: SubscriptionDraft,
base: JsonObject = {},
opts: { upgradeChanged: boolean; totalChanged: boolean },
opts: { totalChanged: boolean; autoFillChanged: boolean; clearQualityPreset?: boolean },
): { params: JsonObject; errors: DraftErrors } {
const errors: DraftErrors = {};
const out: Record<string, unknown> = { ...base };
if (opts.clearQualityPreset) for (const k of ["resolution", "source", "effect"]) delete out[k];
const known = [
"siteIds",
"episodeRange",
"searchIntervalHours",
"stopAfter",
"downloaderInstanceId",
"ruleGroupIds",
"keywords",
"include",
"exclude",
"libraryRootId",
"downloadSavePath",
"autoFill",
"fillBudget",
"resolution",
"source",
"effect",
];
for (const k of known) delete out[k];
const set = (k: string, v: unknown) => {
@@ -370,15 +300,11 @@ export function draftToParams(
set("downloaderInstanceId", d.downloaderInstanceId);
set("libraryRootId", d.libraryRootId);
set("downloadSavePath", d.downloadSavePath.trim());
set("ruleGroupIds", d.ruleGroupIds);
set("include", lines(d.include));
set("exclude", lines(d.exclude));
const kw = lines(d.keywords);
if (kw.length > 10) errors.keywords = "最多 10 个";
set("keywords", kw);
set("resolution", d.resolution);
set("source", d.source);
set("effect", d.effect);
if (d.mediaKind !== "movie") {
const s = d.episodeStart.trim();
const e = d.episodeEnd.trim();
@@ -407,21 +333,24 @@ export function draftToParams(
if (Number.isNaN(t.getTime())) errors.stopAfter = "时间无效";
else out.stopAfter = t.toISOString();
}
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
const budget: Record<string, number> = {};
if (d.unitsPerDay.trim()) {
if (!isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
else budget.unitsPerDay = Number(d.unitsPerDay);
}
if (d.gbPerDay.trim()) {
const g = Number(d.gbPerDay);
if (!Number.isFinite(g) || g < 0) errors.gbPerDay = "填非负数";
else budget.bytesPerDay = Math.round(g * GB);
}
if (Object.keys(budget).length) out.fillBudget = budget;
if (opts.upgradeChanged) {
out.best_version = d.upgrade !== "off";
if (d.mediaKind !== "movie") out.best_version_full = d.upgrade === "full_pack";
if (opts.autoFillChanged) {
delete out.autoFill;
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
const original = (base.fillBudget ?? {}) as Record<string, unknown>;
const budget = { ...original };
if (d.unitsPerDay.trim() && !isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
if (d.unitsPerDay !== numStr(original.unitsPerDay)) {
delete budget.unitsPerDay;
if (d.unitsPerDay.trim() && !errors.unitsPerDay) budget.unitsPerDay = Number(d.unitsPerDay);
}
if (d.gbPerDay.trim() && (!Number.isFinite(Number(d.gbPerDay)) || Number(d.gbPerDay) < 0))
errors.gbPerDay = "填非负数";
if (d.gbPerDay !== gbStr(original.bytesPerDay)) {
delete budget.bytesPerDay;
if (d.gbPerDay.trim() && !errors.gbPerDay) budget.bytesPerDay = Math.round(Number(d.gbPerDay) * GB);
}
delete out.fillBudget;
if (Object.keys(budget).length) out.fillBudget = budget;
}
return { params: out as JsonObject, errors };
}
@@ -43,7 +43,7 @@ export function SlotPanel({
useEffect(() => setRows(serverRows), [serverRows]);
const dirty = rows.map((r) => r.id).join() !== serverRows.map((r) => r.id).join();
const base = useMemo(() => draftFromSubscription(sub, "off"), [sub]);
const base = useMemo(() => draftFromSubscription(sub), [sub]);
const [af, setAf] = useState<Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">>(base);
useEffect(() => setAf(base), [base]);
const [errs, setErrs] = useState<DraftErrors>({});
@@ -70,8 +70,8 @@ export function SlotPanel({
);
const saveAutoFill = () => {
const { params, errors } = draftToParams({ ...base, ...af }, sub.params, {
upgradeChanged: false,
totalChanged: false,
autoFillChanged: true,
});
setErrs(errors);
if (Object.keys(errors).length) return;
@@ -10,25 +10,16 @@ import {
type TmdbCandidate,
} from "@umt/contracts";
import { ExternalLink, Search, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
import { Link } from "react-router";
import { type ReactNode, useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { useEpisodeGroups } from "~/api/episode-mapping.ts";
import { useEntityFromCandidate } from "~/api/files.ts";
import { downloaders, useSiteOptions } from "~/api/instances.ts";
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
import { usePolicyRevisions } from "~/api/policies.ts";
import { useRuleGroups } from "~/api/rules.ts";
import { useSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts";
import {
duplicateSubscriptionId,
useCreateSubscription,
useSetSlotOrder,
useUpdateSubscription,
} from "~/api/subscriptions.ts";
import { duplicateSubscriptionId, useCreateSubscription, useUpdateSubscription } from "~/api/subscriptions.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { RecordLink } from "~/components/console/record-sheet.tsx";
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx";
@@ -57,19 +48,12 @@ import {
type DraftErrors,
draftFromSubscription,
draftToParams,
EFFECT_LABEL,
emptyDraft,
RESOLUTION_LABEL,
SOURCE_PRESET_LABEL,
SUB_KIND_LABEL,
type SubscriptionDraft,
UPGRADE_LABEL,
type UpgradeStrategy,
upgradeOf,
} from "./model.ts";
import { SlotOrderList } from "./slot-order.tsx";
// 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。
// 订阅表单管理追踪与下载安排;收录规则统一在版本编辑器维护。新建策略只提供初始版本。
/** 订阅的作品:媒体库作品 / TMDB 条目(保存时先建档,SEARCH-2 详情页入口)/ 按标题追踪 */
export type SubscriptionTarget =
@@ -295,21 +279,11 @@ export function SubscriptionFormDialog({
const editing = !!sub;
const create = useCreateSubscription();
const update = useUpdateSubscription();
const setOrder = useSetSlotOrder();
const [target, setTarget] = useState<Target | null>(initialTarget ?? null);
const [tab, setTab] = useState("basic");
const entityFromTmdb = useEntityFromCandidate();
const slotsQ = useSlots({ subscriptionId: sub?.id }, editing);
const subSlots = useMemo(
() =>
(slotsQ.data?.items ?? [])
.filter((s) => s.subscriptionId === sub?.id)
.sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt)),
[slotsQ.data, sub?.id],
);
const initialUpgrade: UpgradeStrategy = sub ? upgradeOf(subSlots, sub.mediaKind) : "off";
const [d, setD] = useState<SubscriptionDraft>(() => {
if (sub) return draftFromSubscription(sub, "off");
if (sub) return draftFromSubscription(sub);
const k = initialTarget?.mediaKind ?? "series";
const base = emptyDraft(k);
return initialTarget
@@ -321,16 +295,13 @@ export function SubscriptionFormDialog({
}
: base;
});
const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null);
const [upgradeTouched, setUpgradeTouched] = useState(false);
const [totalTouched, setTotalTouched] = useState(false);
const [clearQualityPreset, setClearQualityPreset] = useState(false);
const legacyQuality = sub
? [sub.params.resolution, sub.params.source, sub.params.effect].filter((v) => typeof v === "string").join(" · ")
: "";
const [errors, setErrors] = useState<DraftErrors & { target?: string }>({});
const [dup, setDup] = useState<string | null>(null);
// 编辑:收录版本到了再回填升级策略
useEffect(() => {
if (sub && slotsQ.isSuccess && !upgradeTouched) setD((x) => ({ ...x, upgrade: initialUpgrade }));
}, [sub, slotsQ.isSuccess, initialUpgrade, upgradeTouched]);
const patch = (p: Partial<SubscriptionDraft>) => setD((x) => ({ ...x, ...p }));
const entityId = sub?.mediaEntityId ?? (target?.kind === "entity" ? target.id : null);
const kind = d.mediaKind;
@@ -346,12 +317,10 @@ export function SubscriptionFormDialog({
const sites = useSiteOptions();
const dls = downloaders.useList();
const roots = useLibraryRoots();
const groups = useRuleGroups({ enabled: "true" });
const policies = usePolicyRevisions({ status: "published" });
const templates = (policies.data?.items ?? []).filter(
(p) => p.kind === "acquisition" && Array.isArray((p.rules as { defaultSlots?: unknown }).defaultSlots),
);
const defaultTpl = templates.find((p) => ((p.rules as { defaultSlots?: unknown[] }).defaultSlots ?? []).length);
const tplNames = (pid: string) =>
(
((templates.find((p) => p.id === pid)?.rules as { defaultSlots?: { name?: string }[] })?.defaultSlots ?? []) as {
@@ -374,19 +343,20 @@ export function SubscriptionFormDialog({
});
};
const pending = create.isPending || update.isPending || setOrder.isPending || entityFromTmdb.isPending;
const pending = create.isPending || update.isPending || entityFromTmdb.isPending;
const submit = async () => {
const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, {
upgradeChanged: editing ? upgradeTouched && d.upgrade !== initialUpgrade : d.upgrade !== "off",
totalChanged: totalTouched,
autoFillChanged: !editing,
clearQualityPreset,
});
const all: typeof errors = { ...errs };
if (!editing && !target) all.target = "选择作品";
if (!editing && target?.kind === "title" && !target.titleQuery.trim()) all.target = "填写标题";
setErrors(all);
if (Object.keys(all).length) {
const basicKeys = ["target", "episodeStart", "episodeEnd", "total"];
const basicKeys = ["target", "episodeStart", "episodeEnd", "total", "unitsPerDay", "gbPerDay"];
setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced");
revealFirstError();
return;
@@ -401,8 +371,6 @@ export function SubscriptionFormDialog({
priority: d.priority,
params,
});
if (order && order.map((o) => o.id).join() !== subSlots.map((s) => s.id).join())
await setOrder.mutateAsync({ id: sub.id, slotIds: order.map((o) => o.id) });
toast.success("订阅已保存");
onClose();
} else if (target) {
@@ -531,7 +499,7 @@ export function SubscriptionFormDialog({
value={d.mediaKind}
onChange={(k) => {
const mk = (k || "series") as SubscriptionMediaKind;
patch({ mediaKind: mk, ...(mk === "movie" ? { mode: "once", upgrade: "off" } : {}) });
patch({ mediaKind: mk, ...(mk === "movie" ? { mode: "once" } : {}) });
}}
options={(["series", "movie", "variety"] as const).map((v) => ({
value: v,
@@ -651,6 +619,42 @@ export function SubscriptionFormDialog({
{isMovie ? null : <AbsoluteNumberingHint entityId={entityId} />}
</Section>
<Section title="收录版本">
{editing ? (
<p className="text-caption text-muted-foreground">
分辨率、组合规则和洗版按每个收录版本独立设置。
<RecordLink
page="subscriptions"
id={sub.id}
onClick={onClose}
className="underline underline-offset-4"
>
到订阅详情调整收录版本
</RecordLink>
</p>
) : (
<>
<FormField
label="收录策略"
htmlFor={f("tpl")}
hint="新建时生成收录版本,后续在订阅详情独立调整;修改策略不会覆盖已建版本"
>
<Pick
id={f("tpl")}
value={d.policyRevisionId}
onChange={(v) => patch({ policyRevisionId: v })}
placeholder="按作品分类默认,未匹配时按类型默认"
options={templates.map((p) => ({
value: p.id,
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
}))}
/>
</FormField>
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
</>
)}
</Section>
<Section title="下载">
<FormField label="站点范围" htmlFor={f("sites")} hint="不选 = 全部启用的站点">
{sites.isPending ? (
@@ -695,50 +699,23 @@ export function SubscriptionFormDialog({
/>
</FormField>
</Section>
<Section title="洗版">
<FormField
label="洗版"
htmlFor={f("upgrade")}
hint="已整理的低于规则组最高档时继续找更好的,拿到最高档即完成"
>
<Pick
id={f("upgrade")}
value={d.upgrade}
onChange={(v) => {
setUpgradeTouched(true);
patch({ upgrade: (v || "off") as UpgradeStrategy });
}}
options={(isMovie ? (["off", "movie"] as const) : (["off", "per_unit", "full_pack"] as const)).map(
(v) => ({ value: v, label: UPGRADE_LABEL[v] }),
)}
/>
</FormField>
</Section>
</TabsContent>
<TabsContent value="advanced" className="mt-4 min-w-0 space-y-5">
<Section title="过滤">
<FormField label="规则组" htmlFor={f("groups")} hint="不选时用默认规则组">
{groups.isPending ? (
<p className="text-caption text-muted-foreground">读取规则组…</p>
) : (groups.data?.items ?? []).length === 0 ? (
<Section title="共同过滤与搜索词">
<p className="text-meta text-muted-foreground">包含和排除对这个订阅的全部收录版本同时生效。</p>
{legacyQuality ? (
<div className="space-y-2 rounded-lg border border-border p-3">
<p className="text-caption text-muted-foreground">
还没有规则组。
<Link className="underline underline-offset-4" to={consolePath("rules")}>
去规则页
</Link>
{clearQualityPreset
? "保存后取消旧预设限制,保留各版本单独调整过的规则。"
: `旧质量预设仍生效:${legacyQuality}`}
</p>
) : (
<ChipToggleGroup
label="规则组"
ordered
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
value={d.ruleGroupIds}
onChange={(v) => patch({ ruleGroupIds: v })}
/>
)}
</FormField>
<Button variant="outline" size="sm" onClick={() => setClearQualityPreset((v) => !v)}>
{clearQualityPreset ? "保留旧预设" : "清除旧质量预设"}
</Button>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签">
<Textarea
@@ -759,47 +736,6 @@ export function SubscriptionFormDialog({
/>
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="分辨率" htmlFor={f("res")}>
<Pick
id={f("res")}
value={d.resolution}
onChange={(v) => patch({ resolution: v })}
placeholder="不限"
options={Object.entries(RESOLUTION_LABEL).map(([value, label]) => ({
value: value as keyof typeof RESOLUTION_LABEL,
label,
}))}
/>
</FormField>
<FormField label="资源质量" htmlFor={f("src")}>
<Pick
id={f("src")}
value={d.source}
onChange={(v) => patch({ source: v })}
placeholder="不限"
options={Object.entries(SOURCE_PRESET_LABEL).map(([value, label]) => ({
value: value as keyof typeof SOURCE_PRESET_LABEL,
label,
}))}
/>
</FormField>
<FormField label="特效" htmlFor={f("fx")}>
<Pick
id={f("fx")}
value={d.effect}
onChange={(v) => patch({ effect: v })}
placeholder="不限"
options={Object.entries(EFFECT_LABEL).map(([value, label]) => ({
value: value as keyof typeof EFFECT_LABEL,
label,
}))}
/>
</FormField>
</div>
<p className="text-meta text-muted-foreground">
分辨率、资源质量、特效写进这个订阅自己的收录版本;UHD 和杜比全景声按标题匹配,与「包含」同时生效。
</p>
<FormField
label="搜索关键词"
htmlFor={f("kw")}
@@ -843,62 +779,16 @@ export function SubscriptionFormDialog({
</div>
</Section>
<details className="group min-w-0 border-t border-border/60 pt-4">
<summary className="cursor-pointer text-meta font-semibold tracking-wide text-muted-foreground">
多版本(一般不用改)
</summary>
<div className="mt-3 space-y-5">
<Section title="收录版本">
{editing ? (
slotsQ.isPending ? (
<p className="text-caption text-muted-foreground">读取收录版本…</p>
) : subSlots.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有收录版本,保存时自动建一个默认版本。</p>
) : (
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
<SlotOrderList
items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))}
onChange={setOrderState}
disabled={pending}
/>
</FormField>
)
) : (
<FormField
label="模板"
htmlFor={f("tpl")}
hint={
target?.kind === "entity"
? "按模板顺序给本订阅创建收录版本,第一个是主版本;没有模板时建一个默认版本"
: "作品加入媒体库后按模板创建收录版本"
}
>
<Pick
id={f("tpl")}
value={d.policyRevisionId}
onChange={(v) => patch({ policyRevisionId: v })}
placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"}
options={templates.map((p) => ({
value: p.id,
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
}))}
/>
</FormField>
)}
<FormField label="优先级" htmlFor={f("prio")} hint="只影响下载排队先后">
<Pick
id={f("prio")}
value={d.priority}
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
/>
</FormField>
</Section>
<Section title="自动补全其他版本">
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
</Section>
</div>
</details>
<Section title="下载优先级">
<FormField label="优先级" htmlFor={f("prio")} hint="只影响下载排队先后">
<Pick
id={f("prio")}
value={d.priority}
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
/>
</FormField>
</Section>
</TabsContent>
</Tabs>
<DialogFooter>
@@ -935,9 +825,9 @@ export function AutoFillFields({
主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。
</p>
)}
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="自动补全" htmlFor={`${idPrefix}-af`}>
<fieldset aria-label="自动补全" className="m-0 flex gap-1 border-0 p-0" id={`${idPrefix}-af`}>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="自动补全其他版本" htmlFor={`${idPrefix}-af`} className="sm:col-span-2">
<fieldset aria-label="自动补全" className="m-0 flex flex-wrap gap-1 border-0 p-0" id={`${idPrefix}-af`}>
{(["inherit", "on", "off"] as AutoFillMode[]).map((m) => (
<button
key={m}
@@ -956,30 +846,38 @@ export function AutoFillFields({
))}
</fieldset>
</FormField>
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
<Input
id={`${idPrefix}-units`}
inputMode="numeric"
placeholder="不限"
value={d.unitsPerDay}
aria-invalid={!!errors.unitsPerDay}
onChange={(e) => patch({ unitsPerDay: e.target.value })}
/>
</FormField>
<FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}>
<Input
id={`${idPrefix}-gb`}
inputMode="decimal"
placeholder="不限"
value={d.gbPerDay}
aria-invalid={!!errors.gbPerDay}
onChange={(e) => patch({ gbPerDay: e.target.value })}
/>
</FormField>
<details
className="sm:col-span-2"
open={!!d.unitsPerDay || !!d.gbPerDay || !!errors.unitsPerDay || !!errors.gbPerDay}
>
<summary className="cursor-pointer text-caption text-muted-foreground">补全预算(选填)</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
<Input
id={`${idPrefix}-units`}
inputMode="numeric"
placeholder="不限"
value={d.unitsPerDay}
aria-invalid={!!errors.unitsPerDay}
onChange={(e) => patch({ unitsPerDay: e.target.value })}
/>
</FormField>
<FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}>
<Input
id={`${idPrefix}-gb`}
inputMode="decimal"
placeholder="不限"
value={d.gbPerDay}
aria-invalid={!!errors.gbPerDay}
onChange={(e) => patch({ gbPerDay: e.target.value })}
/>
</FormField>
</div>
<p className="mt-2 text-meta text-muted-foreground">
预算只算自动补全发起的下载,超出的延到第二天;也受每日总预算限制。
</p>
</details>
</div>
<p className="text-meta text-muted-foreground">
预算只算自动补全发起的下载,超出的延到第二天;也受每日总预算限制。
</p>
</div>
);
}