diff --git a/apps/web/README.md b/apps/web/README.md
index 3f9f2863..ecc15cd3 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -1,16 +1,16 @@
# @umt/web
-React Router 7 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。
+React Router 8 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。
## 命令
在仓库根目录执行:
-- `deno task dev:web`:开发服务器(5180)。
-- `cd apps/web && deno task build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。
-- `cd apps/web && deno task test`:单测(`app/**/*.test.ts`)。
-- `deno task test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。
-- `deno task check:tokens`:设计 token 守卫。
+- `bun run dev:web`:开发服务器(5180)。
+- `cd apps/web && bun run build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。
+- `cd apps/web && bun run test`:单测(`app/**/*.test.ts`)。
+- `bun run test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。
+- `bun run check:tokens`:设计 token 守卫。
## 目录
diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts
index f45669d6..ee3223d2 100644
--- a/apps/web/app/api/search.ts
+++ b/apps/web/app/api/search.ts
@@ -100,9 +100,10 @@ export function usePlanFromSearch() {
api
.post(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body)
.then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")),
- onSuccess: () => {
- void qc.invalidateQueries({ queryKey: ["plans"] });
- void qc.invalidateQueries({ queryKey: ["slots"] });
+ onSuccess: (r) => {
+ if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
+ void qc.invalidateQueries({ queryKey: ["inventory"] });
+ void qc.invalidateQueries({ queryKey: ["library"] });
void qc.invalidateQueries({ queryKey: searchKeys.all });
},
});
diff --git a/apps/web/app/api/slots.ts b/apps/web/app/api/slots.ts
index 1c32e321..3c43063b 100644
--- a/apps/web/app/api/slots.ts
+++ b/apps/web/app/api/slots.ts
@@ -37,6 +37,7 @@ function useInvalidate() {
return () => {
void qc.invalidateQueries({ queryKey: slotKeys.all });
void qc.invalidateQueries({ queryKey: ["library"] });
+ void qc.invalidateQueries({ queryKey: ["subscriptions"] });
};
}
diff --git a/apps/web/app/api/tasks.test.ts b/apps/web/app/api/tasks.test.ts
new file mode 100644
index 00000000..0d838206
--- /dev/null
+++ b/apps/web/app/api/tasks.test.ts
@@ -0,0 +1,62 @@
+import type { Decision, DecisionStatus, DownloadTask } from "@umt/contracts";
+import { describe, expect, it } from "vitest";
+import { buildTimeline } from "./tasks.ts";
+
+const task = {
+ id: "download",
+ name: "作品",
+ origin: "user",
+ status: "queued",
+ createdAt: "2026-10-13T00:00:00Z",
+} as unknown as DownloadTask;
+const decision = (status: DecisionStatus): Decision =>
+ ({
+ id: "decision",
+ status,
+ title: "等待补全资源规格",
+ summary: "规格缺少编码",
+ severity: "critical",
+ subjectType: "download_task",
+ subjectId: "download",
+ agentRunId: "agent",
+ createdAt: "2026-10-13T00:00:01Z",
+ updatedAt: "2026-10-13T00:00:02Z",
+ evidence: {},
+ }) as Decision;
+
+describe("下载时间线的异常状态", () => {
+ it.each([
+ ["open", "待处理", "destructive", false],
+ ["investigating", "智能体调查中", "neutral", true],
+ ["awaiting_user", "等你决定", "destructive", false],
+ ["resolved", "已处理", "success", false],
+ ["dismissed", "已忽略", "neutral", false],
+ ["expired", "已过期", "neutral", false],
+ ] as const)("%s按实际状态显示,不凭会话ID判断仍需人工", (status, label, tone, ongoing) => {
+ const events = buildTimeline({ task, decisions: [decision(status)] });
+ expect(events.find((e) => e.key === "dec-decision")).toMatchObject({
+ title: `${label}:等待补全资源规格`,
+ tone,
+ ongoing,
+ href: "/console/downloads?open=download",
+ });
+ expect(events.find((e) => e.key === "agent-decision")).toMatchObject({
+ title: "智能体调查记录",
+ tone: "neutral",
+ href: "/console/agent/agent",
+ });
+ });
+
+ it("交还人工时展示已有恢复建议,计划事项定位到计划", () => {
+ const d = {
+ ...decision("awaiting_user"),
+ subjectType: "acquisition_plan",
+ subjectId: "plan",
+ evidence: { autoRecover: { suggestion: "请确认资源详情的编码" } },
+ };
+ expect(buildTimeline({ task, decisions: [d] }).find((e) => e.key === "dec-decision")).toMatchObject({
+ detail: "请确认资源详情的编码",
+ href: "/console/downloads?plan=plan",
+ });
+ });
+});
diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts
index 2aaa8a65..b5e7b198 100644
--- a/apps/web/app/api/tasks.ts
+++ b/apps/web/app/api/tasks.ts
@@ -1,4 +1,4 @@
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
type AcquisitionPlan,
cursorPage,
@@ -10,9 +10,12 @@ import {
type TaskKind,
type TaskStatus,
} from "@umt/contracts";
-import { recordPath, taskPath } from "~/components/console/record-sheet.tsx";
+import { consolePath } from "~/components/console/nav.ts";
+import { recordPath, subjectLink, taskPath } from "~/components/console/record-sheet.tsx";
+import { DECISION_STATUS } from "~/components/library/labels.ts";
import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
+import { useSse } from "~/lib/sse.ts";
import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts";
import { apiParsed } from "./parse.ts";
@@ -60,6 +63,14 @@ export function useSourceRelease(id: string | null | undefined) {
/** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */
export function useSubjectDecisions(subjectIds: string[]) {
const ids = subjectIds.filter(Boolean);
+ const qc = useQueryClient();
+ useSse(
+ ["decision.created", "decision.updated"],
+ () => {
+ void qc.invalidateQueries({ queryKey: ["decisions", "subjects", ids] });
+ },
+ ids.length > 0,
+ );
return useQuery({
queryKey: ["decisions", "subjects", ids],
enabled: ids.length > 0,
@@ -256,23 +267,29 @@ export function buildTimeline(input: {
});
}
for (const d of decisions) {
+ const state = DECISION_STATUS[d.status];
+ const needsUser = d.status === "open" || d.status === "awaiting_user";
+ const recovery = d.evidence.autoRecover as { suggestion?: unknown } | undefined;
+ const suggestion =
+ d.status === "awaiting_user" && typeof recovery?.suggestion === "string" ? recovery.suggestion : null;
ev.push({
key: `dec-${d.id}`,
stage: "agent",
at: d.createdAt,
- title: `需要处理:${d.title}`,
- detail: d.summary,
- tone: d.severity === "critical" ? "destructive" : "warning",
- href: "/console/inbox",
+ title: `${state.label}:${d.title}`,
+ detail: suggestion ?? d.summary,
+ tone: needsUser && d.severity === "critical" ? "destructive" : state.tone,
+ href: subjectLink(d) ?? (d.agentRunId ? consolePath("agent", d.agentRunId) : consolePath("agent")),
+ ongoing: d.status === "investigating",
});
if (d.agentRunId)
ev.push({
key: `agent-${d.id}`,
stage: "agent",
at: d.updatedAt,
- title: "智能体已介入调查",
+ title: "智能体调查记录",
tone: "neutral",
- href: `/console/agent/${d.agentRunId}`,
+ href: consolePath("agent", d.agentRunId),
});
}
for (const t of tasks) {
diff --git a/apps/web/app/app.css b/apps/web/app/app.css
index 38277442..46c6b7f0 100644
--- a/apps/web/app/app.css
+++ b/apps/web/app/app.css
@@ -1427,6 +1427,11 @@
background: var(--mat-lens-fill-hover);
}
+ /* 初始化时移除包自带的白光泽;折射玻璃用自身底色,磨砂玻璃的内层保持透明 */
+ .liquid-surface[data-glass-strategy="pending"] > :first-child {
+ background: var(--glass-pending-fill) !important;
+ }
+
/* LiquidSurface 只模糊不折射时(触屏、测速页、frost 面板):包在玻璃层顶部画的 50% 白光泽太亮,
夜间会压住第一行字。普通档收到很淡,frost(字多的面板)去掉 */
.liquid-surface-blur > :first-child {
diff --git a/apps/web/app/components/browser/resource-download.tsx b/apps/web/app/components/browser/resource-download.tsx
index 08fbc6a0..f9cfabc4 100644
--- a/apps/web/app/components/browser/resource-download.tsx
+++ b/apps/web/app/components/browser/resource-download.tsx
@@ -56,13 +56,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
{busy ? "提交中…" : "下载当前资源"}
{planId ? (
@@ -70,13 +71,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
) : null}
- {resource ? (
+ {resource && open ? (
submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })}
/>
) : null}
diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx
index 7bf0cf62..af225f00 100644
--- a/apps/web/app/components/console/inbox-bell.tsx
+++ b/apps/web/app/components/console/inbox-bell.tsx
@@ -5,7 +5,7 @@ import { Link, useNavigate } from "react-router";
import { useAttention } from "~/api/attention.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
-import { recordPath } from "~/components/console/record-sheet.tsx";
+import { subjectLink } from "~/components/console/record-sheet.tsx";
import { SEVERITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
@@ -23,10 +23,7 @@ const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "
export function approvalHref(d: Pick): string {
if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId);
if (d.agentRunId) return consolePath("agent", d.agentRunId);
- if (d.subjectType === "acquisition_plan" && d.subjectId)
- return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
- if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId);
- return consolePath("agent");
+ return subjectLink(d) ?? consolePath("agent");
}
export const HEALTH_HREF = `${consolePath("status")}#attention`;
diff --git a/apps/web/app/components/console/record-sheet.tsx b/apps/web/app/components/console/record-sheet.tsx
index 5c4058c0..f59ffb5d 100644
--- a/apps/web/app/components/console/record-sheet.tsx
+++ b/apps/web/app/components/console/record-sheet.tsx
@@ -29,6 +29,28 @@ export function recordPath(page: RecordPage, id: string, extra?: Extra): string
return `${consolePath(page)}?${q}`;
}
+/** 业务记录的对象 → 它的"家"页面 */
+export function subjectLink(t: { subjectType: string | null; subjectId: string | null }): string | null {
+ if (!t.subjectId) return null;
+ switch (t.subjectType) {
+ case "download_task":
+ return recordPath("downloads", t.subjectId);
+ case "acquisition_plan":
+ return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
+ case "organization_record":
+ return recordPath("organize", t.subjectId);
+ case "media_entity":
+ return consolePath("library", t.subjectId);
+ case "site":
+ return recordPath("sites", t.subjectId);
+ case "feed_source":
+ // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab
+ return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`;
+ default:
+ return null;
+ }
+}
+
/** 任务(引擎运行)在日志页的抽屉里看:/console/logs?type=task&run= */
export function taskPath(runId: string): string {
return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`;
diff --git a/apps/web/app/components/discover/discover-card.tsx b/apps/web/app/components/discover/discover-card.tsx
index 37649c15..e6e3b04a 100644
--- a/apps/web/app/components/discover/discover-card.tsx
+++ b/apps/web/app/components/discover/discover-card.tsx
@@ -1,4 +1,4 @@
-import { type DiscoverItem, tmdbImageUrl } from "@umt/contracts";
+import { type DiscoverItem, externalImageUrl, tmdbImageUrl } from "@umt/contracts";
import { CalendarPlus, LoaderCircle, PackageSearch, Star } from "lucide-react";
import { useState } from "react";
import { StatusBadge } from "~/components/console/status-badge.tsx";
@@ -11,7 +11,7 @@ import { itemMeta, localBadges } from "./discover-state.ts";
export type CardAction = "open" | "subscribe" | "search";
export function coverUrl(it: Pick): string | null {
- return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : it.cover;
+ return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : externalImageUrl(it.cover);
}
export function DiscoverCard({
diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx
index eb0717ce..06fcff63 100644
--- a/apps/web/app/components/downloads/business-tasks.tsx
+++ b/apps/web/app/components/downloads/business-tasks.tsx
@@ -6,8 +6,7 @@ import { toast } from "sonner";
import { apiMutate } from "~/api/parse.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
-import { consolePath } from "~/components/console/nav.ts";
-import { recordPath, taskPath } from "~/components/console/record-sheet.tsx";
+import { subjectLink, taskPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -62,28 +61,6 @@ export const SUBJECT_LABEL: Record = {
cache_volume: "下载盘",
};
-/** 业务任务的对象 → 它的"家"页面 */
-export function subjectLink(t: Pick): string | null {
- if (!t.subjectId) return null;
- switch (t.subjectType) {
- case "download_task":
- return recordPath("downloads", t.subjectId);
- case "acquisition_plan":
- return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
- case "organization_record":
- return recordPath("organize", t.subjectId);
- case "media_entity":
- return consolePath("library", t.subjectId);
- case "site":
- return recordPath("sites", t.subjectId);
- case "feed_source":
- // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab
- return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`;
- default:
- return null;
- }
-}
-
export function subjectText(t: Pick): string {
if (!t.subjectType) return "—";
const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType;
diff --git a/apps/web/app/components/downloads/manual-download-dialog.tsx b/apps/web/app/components/downloads/manual-download-dialog.tsx
index 4ded6b26..6ae4514d 100644
--- a/apps/web/app/components/downloads/manual-download-dialog.tsx
+++ b/apps/web/app/components/downloads/manual-download-dialog.tsx
@@ -1,7 +1,7 @@
import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts";
-import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react";
+import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, Settings2, X } from "lucide-react";
import { useEffect, useId, useState } from "react";
-import { Link } from "react-router";
+import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useDownloadSavePaths } from "~/api/downloads.ts";
import { useEntityDetail } from "~/api/library.ts";
@@ -67,6 +67,75 @@ export interface ManualDownloadResource {
const AUTO = "__auto__";
+/** 搜索与工作台直接下载,只有高级选项或未识别确认才打开窗口。 */
+export function ManualDownloadActions({
+ resource,
+ submit,
+ onDelegate,
+}: {
+ resource: ManualDownloadResource;
+ submit: (options: ManualDownloadOptions) => Promise;
+ onDelegate?: () => void;
+}) {
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [unrecognized, setUnrecognized] = useState(false);
+ const go = async () => {
+ setBusy(true);
+ try {
+ const result = await submit({ importMode: "normal", replacementAssetIds: [] });
+ if (result.requiresConfirmation) {
+ setUnrecognized(true);
+ setOpen(true);
+ } else if (result.submitted) {
+ const planId = result.plan?.id;
+ toast.success("已提交下载", {
+ ...(planId
+ ? { action: { label: "查看下载", onClick: () => navigate(`/console/downloads?plan=${planId}`) } }
+ : {}),
+ });
+ } else toast.warning(result.rejected.map((r) => r.message).join(";") || "没有提交成功");
+ } catch (e) {
+ toast.error(errorMessage(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+ <>
+
+
+ {open ? (
+
+ ) : null}
+ >
+ );
+}
+
/** 搜索、工作台和原站视图共用的一次性下载选项。 */
export function ManualDownloadDialog({
resource,
@@ -74,6 +143,7 @@ export function ManualDownloadDialog({
onOpenChange,
submit,
initialUnrecognized = false,
+ initialAdvanced = false,
onDelegate,
}: {
resource: ManualDownloadResource;
@@ -81,6 +151,7 @@ export function ManualDownloadDialog({
onOpenChange: (open: boolean) => void;
submit: (options: ManualDownloadOptions) => Promise;
initialUnrecognized?: boolean;
+ initialAdvanced?: boolean;
onDelegate?: () => void;
}) {
const formId = useId();
@@ -95,6 +166,7 @@ export function ManualDownloadDialog({
const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState([]);
const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
+ const [advanced, setAdvanced] = useState(initialAdvanced);
const [identityOpen, setIdentityOpen] = useState(!entityId);
// null = 没动过,显示作品当前的值
const [tmdbType, setTmdbType] = useState(null);
@@ -132,9 +204,9 @@ export function ManualDownloadDialog({
if (open) {
setResult(null);
setUnrecognized(initialUnrecognized);
- if (initialUnrecognized) setIdentityOpen(true);
+ setAdvanced(initialAdvanced);
}
- }, [open, initialUnrecognized]);
+ }, [open, initialUnrecognized, initialAdvanced]);
const go = async () => {
setBusy(true);
@@ -156,7 +228,6 @@ export function ManualDownloadDialog({
setResult(r);
if (r.requiresConfirmation) {
setUnrecognized(true);
- setIdentityOpen(true);
}
if (r.submitted) toast.success("已提交下载");
} catch (e) {
@@ -170,6 +241,7 @@ export function ManualDownloadDialog({
const siteName = resource.siteName ?? site.data?.name ?? null;
const missingCustom = dir === CUSTOM_DIR && !custom.trim();
const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId);
+ const savePath = resolveSavePath(target, dir, custom);
return (
+ {classificationPolicies.length ? (
+
+
+ 当前已保存分类的默认收录策略;在收录策略的适用分类中调整。
+
+
+
+
+ | 分类 |
+ 作品类型 |
+ 默认策略 |
+
+
+
+ {classificationPolicies.map(({ category, mediaKind, policy }) => (
+
+ | {category} |
+ {mediaKind === "movie" ? "电影" : mediaKind === "variety" ? "综艺" : "剧集"} |
+
+ {policy ? (
+
+ {policy.title} ·{" "}
+ {Array.isArray(policy.rules.defaultSlots) ? policy.rules.defaultSlots.length : 0} 个保留版本
+
+ ) : (
+ "默认版本"
+ )}
+ |
+
+ ))}
+
+
+
+ ) : null}
{dirty ? (
setDraft(null)}>
diff --git a/apps/web/app/components/subscriptions/model.test.ts b/apps/web/app/components/subscriptions/model.test.ts
index 65ebb9c7..fa601359 100644
--- a/apps/web/app/components/subscriptions/model.test.ts
+++ b/apps/web/app/components/subscriptions/model.test.ts
@@ -15,7 +15,6 @@ import {
seasonLabel,
shiftMonth,
slotBadge,
- upgradeOf,
} from "./model.ts";
const slot = (x: Partial): 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({});
});
});
diff --git a/apps/web/app/components/subscriptions/model.ts b/apps/web/app/components/subscriptions/model.ts
index b76b7f20..9b0cfa55 100644
--- a/apps/web/app/components/subscriptions/model.ts
+++ b/apps/web/app/components/subscriptions/model.ts
@@ -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 = { inherit: "跟随规则组", on: "开", off: "关" };
+export const AUTOFILL_LABEL: Record = { 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 = {
- off: "不洗版",
- per_unit: "按集洗版",
- full_pack: "全集洗版",
- movie: "洗版",
-};
-
-/** 订阅收录版本当前的升级策略:任一开启取其 mode */
-export function upgradeOf(
- slots: Pick[],
- 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(arr: readonly T[], from: number, to: number): T[] {
const out = arr.slice();
@@ -187,30 +162,6 @@ export function moveItem(arr: readonly T[], from: number, to: number): T[] {
// ── 表单 ──
-export const RESOLUTION_LABEL: Record = {
- "4k": "4K",
- "1080p": "1080p",
- "720p": "720p",
-};
-export const SOURCE_PRESET_LABEL: Record = {
- bluray_disc: "蓝光原盘",
- remux: "Remux",
- bluray: "BluRay",
- uhd: "UHD",
- web: "WEB-DL",
- hdtv: "HDTV",
- h265: "H.265",
- h264: "H.264",
-};
-export const EFFECT_LABEL: Record = {
- dv: "杜比视界",
- hdr: "HDR",
- sdr: "SDR",
- atmos: "杜比全景声",
-};
-const oneOf = (v: unknown, labels: Record): 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;
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 = { ...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 = {};
- 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;
+ 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 };
}
diff --git a/apps/web/app/components/subscriptions/slot-panel.tsx b/apps/web/app/components/subscriptions/slot-panel.tsx
index 7ae8b474..78b88065 100644
--- a/apps/web/app/components/subscriptions/slot-panel.tsx
+++ b/apps/web/app/components/subscriptions/slot-panel.tsx
@@ -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>(base);
useEffect(() => setAf(base), [base]);
const [errs, setErrs] = useState({});
@@ -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;
diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx
index ba509a1c..06aff097 100644
--- a/apps/web/app/components/subscriptions/subscription-form.tsx
+++ b/apps/web/app/components/subscriptions/subscription-form.tsx
@@ -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(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(() => {
- 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({});
const [dup, setDup] = useState(null);
- // 编辑:收录版本到了再回填升级策略
- useEffect(() => {
- if (sub && slotsQ.isSuccess && !upgradeTouched) setD((x) => ({ ...x, upgrade: initialUpgrade }));
- }, [sub, slotsQ.isSuccess, initialUpgrade, upgradeTouched]);
-
const patch = (p: Partial) => 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 : }
+
+ {editing ? (
+
+ 分辨率、组合规则和洗版按每个收录版本独立设置。
+
+ 到订阅详情调整收录版本
+
+
+ ) : (
+ <>
+
+ patch({ policyRevisionId: v })}
+ placeholder="按作品分类默认,未匹配时按类型默认"
+ options={templates.map((p) => ({
+ value: p.id,
+ label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
+ }))}
+ />
+
+
+ >
+ )}
+
+
{sites.isPending ? (
@@ -695,50 +699,23 @@ export function SubscriptionFormDialog({
/>
-
-
-
- {
- 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] }),
- )}
- />
-
-
-
-
- {groups.isPending ? (
- 读取规则组…
- ) : (groups.data?.items ?? []).length === 0 ? (
+
+ 包含和排除对这个订阅的全部收录版本同时生效。
+ {legacyQuality ? (
+
- 还没有规则组。
-
- 去规则页
-
+ {clearQualityPreset
+ ? "保存后取消旧预设限制,保留各版本单独调整过的规则。"
+ : `旧质量预设仍生效:${legacyQuality}`}
- ) : (
-
({ value: g.id, label: g.name }))}
- value={d.ruleGroupIds}
- onChange={(v) => patch({ ruleGroupIds: v })}
- />
- )}
-
+ setClearQualityPreset((v) => !v)}>
+ {clearQualityPreset ? "保留旧预设" : "清除旧质量预设"}
+
+
+ ) : null}
-
-
- patch({ resolution: v })}
- placeholder="不限"
- options={Object.entries(RESOLUTION_LABEL).map(([value, label]) => ({
- value: value as keyof typeof RESOLUTION_LABEL,
- label,
- }))}
- />
-
-
- patch({ source: v })}
- placeholder="不限"
- options={Object.entries(SOURCE_PRESET_LABEL).map(([value, label]) => ({
- value: value as keyof typeof SOURCE_PRESET_LABEL,
- label,
- }))}
- />
-
-
- patch({ effect: v })}
- placeholder="不限"
- options={Object.entries(EFFECT_LABEL).map(([value, label]) => ({
- value: value as keyof typeof EFFECT_LABEL,
- label,
- }))}
- />
-
-
-
- 分辨率、资源质量、特效写进这个订阅自己的收录版本;UHD 和杜比全景声按标题匹配,与「包含」同时生效。
-
-
-
- 多版本(一般不用改)
-
-
-
- {editing ? (
- slotsQ.isPending ? (
- 读取收录版本…
- ) : subSlots.length === 0 ? (
- 还没有收录版本,保存时自动建一个默认版本。
- ) : (
-
- ({ id: s.id, name: s.name }))}
- onChange={setOrderState}
- disabled={pending}
- />
-
- )
- ) : (
-
- patch({ policyRevisionId: v })}
- placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"}
- options={templates.map((p) => ({
- value: p.id,
- label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
- }))}
- />
-
- )}
-
- patch({ priority: (v || "tracking") as AcquisitionPriority })}
- options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
- />
-
-
-
-
-
+
+
+ patch({ priority: (v || "tracking") as AcquisitionPriority })}
+ options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
+ />
+
+
@@ -935,9 +825,9 @@ export function AutoFillFields({
主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。
)}
-
-
-
);
}
diff --git a/apps/web/app/components/workbench/download-dialog.tsx b/apps/web/app/components/workbench/download-dialog.tsx
index 5cb9b4f3..1865f47f 100644
--- a/apps/web/app/components/workbench/download-dialog.tsx
+++ b/apps/web/app/components/workbench/download-dialog.tsx
@@ -1,7 +1,7 @@
-import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
+import type { WorkbenchRelease } from "@umt/contracts";
import { useSubmitRelease } from "~/api/releases.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
-import { ManualDownloadDialog, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx";
+import { ManualDownloadActions, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx";
import { kindHintOf } from "~/components/downloads/manual-download-model.ts";
import { hrOf, promotionLabel } from "./release-fields.ts";
@@ -25,22 +25,11 @@ export function workbenchResource(r: WorkbenchRelease): ManualDownloadResource {
}
/** 工作台使用独立手动下载,不要求先建立订阅或收录版本。 */
-export function DownloadDialog({
- release,
- open,
- onOpenChange,
-}: {
- release: WorkbenchRelease;
- slots: AcquisitionSlot[];
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}) {
+export function DownloadActions({ release }: { release: WorkbenchRelease }) {
const submit = useSubmitRelease();
return (
-
openAgentDrawer({
diff --git a/apps/web/app/components/workbench/release-detail-sheet.tsx b/apps/web/app/components/workbench/release-detail-sheet.tsx
index 401275cc..bce0006a 100644
--- a/apps/web/app/components/workbench/release-detail-sheet.tsx
+++ b/apps/web/app/components/workbench/release-detail-sheet.tsx
@@ -1,6 +1,6 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
-import { ArrowDown, ArrowUp, Bot, Check, Download, RefreshCw } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import { ArrowDown, ArrowUp, Bot, Check, RefreshCw } from "lucide-react";
+import { useEffect, useRef } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useEntityDetail } from "~/api/inventory.ts";
@@ -15,7 +15,7 @@ import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useUiPrefs } from "~/lib/ui-prefs.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
-import { DownloadDialog } from "./download-dialog.tsx";
+import { DownloadActions } from "./download-dialog.tsx";
import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts";
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx";
@@ -136,10 +136,8 @@ export function ReleaseDetailSheet({
const rid = release?.id ?? releaseId;
const detail = useReleaseDetail(siteId, rid);
const refresh = useRefreshReleaseDetail();
- const [dl, setDl] = useState(false);
const techOpen = useUiPrefs().techInfoExpanded;
const r = detail.data?.release ?? release;
- const entity = useEntityDetail(r?.entity?.id);
// 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次)
const autoTried = useRef(new Set());
@@ -191,10 +189,7 @@ export function ReleaseDetailSheet({
问智能体
- setDl(true)}>
-
- 下载
-
+
>
) : null
}
@@ -321,7 +316,6 @@ export function ReleaseDetailSheet({
>
)}
- {r ? : null}
);
}
diff --git a/apps/web/app/lib/design-tokens.generated.ts b/apps/web/app/lib/design-tokens.generated.ts
index d24746fd..4520f9d2 100644
--- a/apps/web/app/lib/design-tokens.generated.ts
+++ b/apps/web/app/lib/design-tokens.generated.ts
@@ -1,4 +1,4 @@
-// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 deno task gen:tokens
+// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 bun run gen:tokens
export const DESIGN_TOKENS = {
"fontSizes": [
"micro",
diff --git a/apps/web/package.json b/apps/web/package.json
index 1b44e4d8..a089f30a 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,43 +8,50 @@
"build": "react-router build",
"preview": "vite preview --host 0.0.0.0 --port 5180",
"typecheck": "react-router typegen && tsc -p tsconfig.json",
- "test": "deno run -A npm:vitest run",
- "gen:tokens": "deno run -A scripts/gen-design-tokens.mjs",
- "check:tokens": "deno run -A scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
+ "test": "bun x --bun vitest run",
+ "gen:tokens": "bun scripts/gen-design-tokens.mjs",
+ "check:tokens": "bun scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
},
"dependencies": {
- "@fontsource-variable/inter-tight": "^5.3.0",
- "@fontsource-variable/space-grotesk": "^5.3.0",
+ "@fontsource-variable/inter-tight": "5.3.0",
+ "@fontsource-variable/space-grotesk": "5.3.0",
"@react-router/node": "8.4.0",
"@simplewebauthn/browser": "^14.0.0",
- "@tanstack/react-query": "^5.104.0",
+ "@tanstack/react-query": "5.104.1",
"@umt/contracts": "workspace:*",
"class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "date-fns": "^4.4.0",
- "isbot": "^5.2.2",
- "lucide-react": "^1.49.0",
+ "clsx": "2.1.1",
+ "date-fns": "4.4.0",
+ "isbot": "5.2.2",
+ "lucide-react": "1.55.0",
"next-themes": "^0.4.6",
- "qr-code-styling": "^1.9.2",
- "radix-ui": "^1.6.7",
- "react": "^19.3.0",
- "react-day-picker": "^10.0.2",
- "react-dom": "^19.3.0",
+ "qr-code-styling": "1.9.2",
+ "radix-ui": "1.7.0",
+ "react": "19.3.0",
+ "react-day-picker": "10.0.2",
+ "react-dom": "19.3.0",
"react-router": "8.4.0",
- "recharts": "^3.10.1",
- "simple-liquid-glass": "5.3.0",
- "sonner": "^2.0.8",
- "tailwind-merge": "^3.7.0",
- "zod": "^4.6.5"
+ "recharts": "3.10.1",
+ "simple-liquid-glass": "5.4.0",
+ "sonner": "2.0.8",
+ "tailwind-merge": "3.7.0",
+ "zod": "4.6.5",
+ "@umt/agent": "workspace:*",
+ "@umt/domain": "workspace:*",
+ "@umt/adapters": "workspace:*",
+ "@umt/tasks": "workspace:*",
+ "@umt/db": "workspace:*",
+ "@umt/testkit": "workspace:*",
+ "@umt/mapping": "workspace:*"
},
"devDependencies": {
"@react-router/dev": "8.4.0",
- "@tailwindcss/vite": "^4.3.3",
- "@types/react": "^19.3.0",
- "@types/react-dom": "^19.3.0",
- "tailwindcss": "^4.3.3",
- "tw-animate-css": "^1.4.0",
- "vite": "^8.3.1",
- "vite-tsconfig-paths": "^6.1.1"
+ "@tailwindcss/vite": "4.3.3",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "tailwindcss": "4.3.3",
+ "tw-animate-css": "1.4.0",
+ "vite": "8.3.4",
+ "vite-tsconfig-paths": "6.1.1"
}
}
diff --git a/apps/web/scripts/gen-design-tokens.mjs b/apps/web/scripts/gen-design-tokens.mjs
index 0b764840..070a12c1 100644
--- a/apps/web/scripts/gen-design-tokens.mjs
+++ b/apps/web/scripts/gen-design-tokens.mjs
@@ -1,8 +1,8 @@
-#!/usr/bin/env -S deno run -A
+#!/usr/bin/env -S bun
// 从 apps/web/app/app.css 抽出自定义工具类的 token 名,生成 app/lib/design-tokens.generated.ts,
// 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、
// 和 `text-muted-foreground` 冲突后丢掉字号(2026-09-12 页面级复验抓到的)。
-// 手动维护登记表会漂移,所以改成生成:改了 app.css 跑 `cd apps/web && deno task gen:tokens`,CI 校验生成物是最新的。
+// 手动维护登记表会漂移,所以改成生成:改了 app.css 跑 `cd apps/web && bun run gen:tokens`,CI 校验生成物是最新的。
import { readFileSync, writeFileSync } from "node:fs";
const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8");
@@ -15,7 +15,7 @@ const tokens = {
radii: pick("radius"),
durations: [...new Set([...css.matchAll(/^@utility duration-([a-z0-9-]+)/gm)].map((m) => m[1]))],
};
-const out = `// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 deno task gen:tokens
+const out = `// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 bun run gen:tokens
export const DESIGN_TOKENS = ${JSON.stringify(tokens, null, 2)} as const;
`;
const target = new URL("../app/lib/design-tokens.generated.ts", import.meta.url);
@@ -25,7 +25,7 @@ if (process.argv.includes("--check")) {
current = readFileSync(target, "utf8");
} catch {}
if (current !== out) {
- console.error("app/lib/design-tokens.generated.ts 已过期,运行 cd apps/web && deno task gen:tokens");
+ console.error("app/lib/design-tokens.generated.ts 已过期,运行 cd apps/web && bun run gen:tokens");
process.exit(1);
}
console.log(
diff --git a/docs/design/README.md b/docs/design/README.md
index 2fbc564f..ed34cab6 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -10,12 +10,14 @@
## 硬规则
-1. **新增语义先加 token 再用。** 需要新的颜色、字号、圆角、时长或缓动时,先在 `app.css` 定义语义 token(日夜两套值都给),跑 `deno task gen:tokens`,再在组件里引用。
+1. **新增语义先加 token 再用。** 需要新的颜色、字号、圆角、时长或缓动时,先在 `app.css` 定义语义 token(日夜两套值都给),跑 `bun run gen:tokens`,再在组件里引用。
2. **禁止原色与十六进制。** 组件里不写 `#xxxxxx`、`text-[#..]`、`bg-red-500` 这类 Tailwind 调色板色、`white/10` 之类的黑白透明度,也不写 `text-[13px]`、`duration-300`、`ease-[cubic..]`、裸 `rounded`。状态只用 `success` / `warning` / `destructive`,并且配文字,不只靠颜色。
3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 ``。
4. 确需例外(第三方库的颜色参数等),在该行或上一行写 `token-allow: 原因`。
-以上规则由 `deno task check:tokens`(`apps/web/scripts/check-design-tokens.sh` + 生成物校验)在 `deno task ci` 中守住。
+以上规则由 `bun run check:tokens`(`apps/web/scripts/check-design-tokens.sh` + 生成物校验)在 `bun run ci` 中守住。
+
+同步 uhd_web 时,以共用依赖和已使用的材质机制为范围。依赖、组件参数与配套 CSS 一起更新,各仓库独立安装和构建。控制台的分页、轻输入边线、实底弹层、媒体头图和文件管理样式由本项目维护;流媒体主题和播放器专用机制留在上游。共用机制优先修当前实现,不增加跨仓库源码引用或主题框架。
## 控制台共享件
diff --git a/docs/design/interface-materials.md b/docs/design/interface-materials.md
index 6b77e570..15307160 100644
--- a/docs/design/interface-materials.md
+++ b/docs/design/interface-materials.md
@@ -90,6 +90,8 @@
- `regular`:栏与胶囊。控制台顶栏右侧的胶囊(通知、智能体、日夜切换、账户)用它的只模糊档(`noRefraction`);
- `frost`:字多的悬浮面板,只做真模糊(24px)加一层底色,不折射:以可读为先。
+`LiquidSurface` 使用 `reveal="immediate"`,初始化阶段的内层底色由 `--glass-pending-fill` 和配套 CSS 控制:折射档使用本档底色,外层已有底色的磨砂档保持内层透明,避免白色光泽闪现或重复叠底。更新 `simple-liquid-glass` 时同时核对组件参数和 CSS。
+
全站其余玻璃不折射,但用同一套边光语言:左上亮、右下暗的 1px 内阴影边光,加外侧 1px 暗线。包括内容卡 `pg-slab`、悬浮层 `pg-float`(提示条也走它:状态色只在图标上,不开 `richColors`;展开排列不叠放)、选中态 `pill-selected`、分段滑块 `seg-thumb`、药丸 `pg-pill`。激活态(菜单展开)在玻璃里多一层淡填充,不改描边。压在图片上的小控件(海报角标、状态圆片)统一用烟色小玻璃 `glass-chip-media`,白字,状态色只留在圆点上(`StatusBadge onMedia`);页面底上的同类圆片用 `glass-chip`,参数是 `--mat-chip-glass`。贴边的 `pg-surface` / `pg-chrome` 不加边光。
折射只有 Chromium 桌面可见,其他浏览器与触屏设备由组件退回磨砂。背后没有画面的地方(内容卡、表单)不用液态玻璃,折射看不出来。玻璃之间至少留 8px,不叠放。含玻璃的弹层外壳不能挂一直生效的透明度动画(`animate-pg-pop` 用 `backwards`):Chromium 会把它当成背景根,里面的模糊采样不到页面。
diff --git a/docs/design/subscriptions.md b/docs/design/subscriptions.md
index aa521183..3edc8dd6 100644
--- a/docs/design/subscriptions.md
+++ b/docs/design/subscriptions.md
@@ -86,7 +86,7 @@
### 2.2 多版本与自动补全
- 每个版本目标一行,行尾:缺失数、在途数、"补全此版本"按钮、自动补全状态。
-- **自动补全其他版本**:订阅级开关 `params.autoFill: "inherit" | "on" | "off"`,缺省 inherit → 取引用规则组/订阅默认值的 `autoFill`(系统缺省 on)。
+- **自动补全其他版本**:订阅级开关 `params.autoFill: "inherit" | "on" | "off"`,缺省 inherit → 取主版本收录链的 `autoFill`(后者优先,系统缺省关闭)。
- on:非主版本目标像主版本一样独立评估、搜索、下载缺失单元。
- off:非主版本目标只登记缺失,不自动建计划;RSS 来的合格资源记为"延后(未开自动补全)";手动"补全此版本"或 Agent 调 `download.plan` 仍可下。
- 主版本 = 版本目标排序第一个(可在版本目标区拖动)。
@@ -94,33 +94,18 @@
## 3. 新建 / 编辑订阅表单
-对话框两 Tab,同 MP 分"基础 / 进阶"。"订阅默认值"(按类型)用同一表单的默认模式,存规则页 §6.4。
+对话框分「基础 / 高级」。订阅维护追踪范围、站点和下载安排,实际收录条件集中在详情的版本编辑器。
-| 区 | 字段(界面) | MP 字段 | 我们存放 | 默认 / 显示条件 |
-| --- | --- | --- | --- | --- |
-| 基础 | 作品(搜索本地作品或 TMDB) | media_id / name | `mediaEntityId` / `titleQuery` | 必填 |
-| | 类型 | type | `mediaKind` | 由作品带出 |
-| | 季 / 季范围 | season | `seasonUnitId` + `seasonScope` | 剧集;缺省最新季 + 接续后续季(Agent 来源缺省仅本季) |
-| | 开始集 / 结束集 | start_episode | `params.episodeRange {start,end}` | 剧集 |
-| | 总集数(可锁定) | total_episode / manual_total_episode | `POST /episode-total` | 剧集;显示来源 |
-| | 方式 | — | `mode` once / continuous | 电影缺省 once |
-| | 优先级 | — | `priority` | tracking |
-| | 站点范围 | sites | `params.siteIds` | 空 = 全部启用站点 |
-| | 下载器 | downloader | `params.downloaderInstanceId` | 空 = 默认实例 |
-| | 库根 | save_path(整理去向部分) | `params.libraryRootId`(新) | 空 = 按类型默认库根 |
-| | 版本目标 | best_version / best_version_full | 子表:名称 + 规则组 + 升级(关 / 按集 / 整季 / 电影)+ 目标 pri | 缺省取订阅默认值 |
-| 进阶 | 规则组 | filter_groups | `params.ruleGroupIds`(作用于全部版本目标)+ 版本目标 `rule_group_ids` | 空 = 订阅默认组 |
-| | 包含 / 排除 | include / exclude | 订阅私有规则组(`scope.subscriptionId`,不在列表页显示) | 正则白名单校验 |
-| | 分辨率 / 来源 / 特效 | resolution / quality / effect | 同上,写进私有规则组 hard | |
-| | 搜索关键词 | keyword | `params.keywords[]`(新,追加到标题/别名检索词) | |
-| | 主动搜索间隔 | —(全局) | `params.searchIntervalHours` | 24;0 = 关 |
-| | 自动补全 / 补全预算 | — | `params.autoFill`、`params.fillBudget` | inherit |
-| | 截止时间 | — | `params.stopAfter` | |
-| | 下载保存路径 | save_path | `params.downloadSavePath`(新,P2) | 空 = 实例默认 |
-| 不做 | 剧集组 episode_group | | P2(需 TMDB episode group 适配) | |
-| | ImdbID 搜索 search_imdbid | | P2 `params.searchByImdb` | |
-| | 自定义类别 media_category | | 用命名模板 `category` | |
-| | 识别词 custom_words | | 放身份层(实体别名/集偏移),表单给链接 | |
+| 入口 | 配置 | 生效范围 |
+| --- | --- | --- |
+| 新建订阅 · 基础 | 作品、方式、季范围与集范围、总集数、站点、下载器、媒体库目录、保存路径 | 当前订阅 |
+| 新建订阅 · 收录版本 | 收录策略(缺省按分类、类型、全局匹配) | 只在创建时生成版本;模板更新不覆盖已有版本 |
+| 新建订阅 · 收录版本 | 其他版本自动补全与预算 | 当前订阅;已有订阅只在详情调整 |
+| 订阅 · 高级 | 包含/排除、搜索词、主动搜索周期、截止时间、排队优先级 | 当前订阅;共同过滤适用于全部版本 |
+| 订阅详情 · 收录版本 | 主版本顺序、自动补全与预算 | 当前订阅及作品共用版本;排除其他订阅的版本 |
+| 订阅详情 · 收录版本规则 | 分辨率等硬条件、软偏好、收录组合规则、洗版组合规则与方式、整季同套、完成条件 | 每个版本独立 |
+
+取消订阅表单的统一分辨率、质量、特效、规则组与洗版编辑,不再向所有版本批量写入。旧 `params.ruleGroupIds/resolution/source/effect` 通过 API 继续兼容,普通保存原样保留;UHD/Atmos 私有过滤不因修改站点或周期而丢失。概览逐版本解析有效规则后合并展示,隐藏订阅私有组。自动补全继承主版本收录链的最后一个显式设置,缺省关闭;订阅显式开/关优先。
保存:创建时同实体同季已有 active 订阅 → 返回 409 并给"打开已有订阅"(差距 #18)。
@@ -199,7 +184,7 @@ DB:`subscription.last_progress_at`;`acquisition_slot.rule_group_ids uuid[]`、`a
- 版本目标 `rule_group_ids`(有序)+ 订阅 `params.ruleGroupIds`;评估时编译为 SlotSpec:hard 取交集;各组按 scope 过滤后**全部须命中某层**,pri 取**最后一个**带阶梯的组(同 MP),界面在该组上标"决定优先级"。
- 版本目标自身的 hard/soft 字段保留,视为一个匿名组排在最前。
-- 默认值 Tab:按类型设 **订阅默认规则组**、**洗版默认规则组**(版本目标开升级时额外叠加)、**搜索默认规则组**(手动搜索与 Agent `release.search`)、订阅默认表单(§3 默认模式:站点、下载器、库根、版本目标模板、自动补全)。
+- 默认值 Tab:按类型设 **订阅默认规则组**、**洗版默认规则组**(只在洗版阶段追加,版本目标可独立指定洗版规则组)、**搜索默认规则组**(手动搜索与 Agent `release.search`)、订阅默认表单(§3 默认模式:站点、下载器、库根、版本目标模板、自动补全)。
- 收录策略 Tab:现有策略工作区原样搬入(草案→预览→发布→回滚、newTitle、defaultSlots);`SlotTemplate` 增加 `ruleGroupIds`。
- Agent:能力 `rules.list` 读规则组;新增/修改走 `rules.propose`(origin=agent_inferred,生成待处理事项,批准后生效);命中有效抑制记录的指纹直接拒绝。对话中用户明确说的偏好 origin=conversation,直接生效并可撤销。
@@ -220,6 +205,14 @@ DB:`subscription.last_progress_at`;`acquisition_slot.rule_group_ids uuid[]`、`a
domain:`slot/rule-expr.ts`(词法/解析/求值/规范化输出)、`slot/atoms.ts`(内置原子表)、`SlotTierRule.expr?`;`mapping/slot.ts` 编译规则组 → SlotSpec。差分:`filter-cases.json#rules` 43 条全部重跑,目标 ✗ 从 15 降到 1(只剩音乐)。
+## 6.8 分类策略与两阶段收录
+
+分类仍由已有 TMDB 分类规则计算,收录策略通过 `scope.categories.movie / tv` 指定分类名称,`scope.mediaKinds` 区分电影、剧集、综艺。默认选择为显式订阅策略、分类策略、类型默认、全局默认;同层使用发布时间顺序。一个策略模板创建一个独立保留版本,分辨率范围是该版本的硬边界。单版本可允许过渡分辨率,多版本按模板分开;手动下载不持久化这些选择。
+
+追更与补齐允许混合版本,洗版可配置 `upgradeThreshold.ruleGroupIds` 与 `consistency: season_release`。成套身份包含分辨率、来源、编码、规范化发布组、HDR、剪辑版,覆盖由实际季集账本决定,单集同样可以组成整季。同档对齐仅用于当前季目标且不得降来源或分辨率;洗版完成要求该目标规格覆盖范围内全部单元。规格与优先级随计划冻结,在途期间不切换套装。
+
+Agent 补充事实后重新进入原规则。新增 `release_facts` 事项沿用现有决策、调查、持续授权和恢复验证,不增加另一套自动化流程。身份和其他来源到 TMDB 的季集映射继续使用已有长效规则及证据。
+
## 7. 实施拆分与估算
| 包 | 内容 | 估 | 依赖 |