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

@@ -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>
)}