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

+6 -6
View File
@@ -1,16 +1,16 @@
# @umt/web # @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)。 - `bun run dev:web`:开发服务器(5180)。
- `cd apps/web && deno task build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。 - `cd apps/web && bun run build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。
- `cd apps/web && deno task test`:单测(`app/**/*.test.ts`)。 - `cd apps/web && bun run test`:单测(`app/**/*.test.ts`)。
- `deno task test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。 - `bun run test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。
- `deno task check:tokens`:设计 token 守卫。 - `bun run check:tokens`:设计 token 守卫。
## 目录 ## 目录
+4 -3
View File
@@ -100,9 +100,10 @@ export function usePlanFromSearch() {
api api
.post<unknown>(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body) .post<unknown>(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body)
.then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")), .then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")),
onSuccess: () => { onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: ["plans"] }); if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
void qc.invalidateQueries({ queryKey: ["slots"] }); void qc.invalidateQueries({ queryKey: ["inventory"] });
void qc.invalidateQueries({ queryKey: ["library"] });
void qc.invalidateQueries({ queryKey: searchKeys.all }); void qc.invalidateQueries({ queryKey: searchKeys.all });
}, },
}); });
+1
View File
@@ -37,6 +37,7 @@ function useInvalidate() {
return () => { return () => {
void qc.invalidateQueries({ queryKey: slotKeys.all }); void qc.invalidateQueries({ queryKey: slotKeys.all });
void qc.invalidateQueries({ queryKey: ["library"] }); void qc.invalidateQueries({ queryKey: ["library"] });
void qc.invalidateQueries({ queryKey: ["subscriptions"] });
}; };
} }
+62
View File
@@ -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",
});
});
});
+25 -8
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
type AcquisitionPlan, type AcquisitionPlan,
cursorPage, cursorPage,
@@ -10,9 +10,12 @@ import {
type TaskKind, type TaskKind,
type TaskStatus, type TaskStatus,
} from "@umt/contracts"; } 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 { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
import { humanizeReason, reasonLine } from "~/lib/reasons.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 { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts";
import { apiParsed } from "./parse.ts"; import { apiParsed } from "./parse.ts";
@@ -60,6 +63,14 @@ export function useSourceRelease(id: string | null | undefined) {
/** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */ /** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */
export function useSubjectDecisions(subjectIds: string[]) { export function useSubjectDecisions(subjectIds: string[]) {
const ids = subjectIds.filter(Boolean); 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({ return useQuery({
queryKey: ["decisions", "subjects", ids], queryKey: ["decisions", "subjects", ids],
enabled: ids.length > 0, enabled: ids.length > 0,
@@ -256,23 +267,29 @@ export function buildTimeline(input: {
}); });
} }
for (const d of decisions) { 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({ ev.push({
key: `dec-${d.id}`, key: `dec-${d.id}`,
stage: "agent", stage: "agent",
at: d.createdAt, at: d.createdAt,
title: `需要处理:${d.title}`, title: `${state.label}:${d.title}`,
detail: d.summary, detail: suggestion ?? d.summary,
tone: d.severity === "critical" ? "destructive" : "warning", tone: needsUser && d.severity === "critical" ? "destructive" : state.tone,
href: "/console/inbox", href: subjectLink(d) ?? (d.agentRunId ? consolePath("agent", d.agentRunId) : consolePath("agent")),
ongoing: d.status === "investigating",
}); });
if (d.agentRunId) if (d.agentRunId)
ev.push({ ev.push({
key: `agent-${d.id}`, key: `agent-${d.id}`,
stage: "agent", stage: "agent",
at: d.updatedAt, at: d.updatedAt,
title: "智能体已介入调查", title: "智能体调查记录",
tone: "neutral", tone: "neutral",
href: `/console/agent/${d.agentRunId}`, href: consolePath("agent", d.agentRunId),
}); });
} }
for (const t of tasks) { for (const t of tasks) {
+5
View File
@@ -1427,6 +1427,11 @@
background: var(--mat-lens-fill-hover); background: var(--mat-lens-fill-hover);
} }
/* 初始化时移除包自带的白光泽;折射玻璃用自身底色,磨砂玻璃的内层保持透明 */
.liquid-surface[data-glass-strategy="pending"] > :first-child {
background: var(--glass-pending-fill) !important;
}
/* LiquidSurface 只模糊不折射时(触屏、测速页、frost 面板):包在玻璃层顶部画的 50% 白光泽太亮, /* LiquidSurface 只模糊不折射时(触屏、测速页、frost 面板):包在玻璃层顶部画的 50% 白光泽太亮,
夜间会压住第一行字。普通档收到很淡,frost(字多的面板)去掉 */ 夜间会压住第一行字。普通档收到很淡,frost(字多的面板)去掉 */
.liquid-surface-blur > :first-child { .liquid-surface-blur > :first-child {
@@ -56,13 +56,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
{busy ? "提交中…" : "下载当前资源"} {busy ? "提交中…" : "下载当前资源"}
</Button> </Button>
<Button <Button
size="sm" size="icon-sm"
variant="outline" variant="ghost"
disabled={busy || session.controller === "closed"} disabled={busy || session.controller === "closed"}
aria-label="高级下载选项"
title="高级下载选项"
onClick={() => void prepare(true)} onClick={() => void prepare(true)}
> >
<Settings2 /> <Settings2 />
下载选项
</Button> </Button>
{planId ? ( {planId ? (
<Link className="text-caption underline" to={`/console/downloads?plan=${planId}`}> <Link className="text-caption underline" to={`/console/downloads?plan=${planId}`}>
@@ -70,13 +71,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
</Link> </Link>
) : null} ) : null}
</div> </div>
{resource ? ( {resource && open ? (
<ManualDownloadDialog <ManualDownloadDialog
key={resource.release.id} key={resource.release.id}
resource={workbenchResource(resource.release)} resource={workbenchResource(resource.release)}
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
initialUnrecognized={unrecognized} initialUnrecognized={unrecognized}
initialAdvanced={!unrecognized}
submit={(options) => submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })} submit={(options) => submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })}
/> />
) : null} ) : null}
@@ -5,7 +5,7 @@ import { Link, useNavigate } from "react-router";
import { useAttention } from "~/api/attention.ts"; import { useAttention } from "~/api/attention.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts"; 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 { SEVERITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.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<Decision, "subjectType" | "subjectId" | "agentRunId">): string { export function approvalHref(d: Pick<Decision, "subjectType" | "subjectId" | "agentRunId">): string {
if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId); if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId);
if (d.agentRunId) return consolePath("agent", d.agentRunId); if (d.agentRunId) return consolePath("agent", d.agentRunId);
if (d.subjectType === "acquisition_plan" && d.subjectId) return subjectLink(d) ?? consolePath("agent");
return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId);
return consolePath("agent");
} }
export const HEALTH_HREF = `${consolePath("status")}#attention`; export const HEALTH_HREF = `${consolePath("status")}#attention`;
@@ -29,6 +29,28 @@ export function recordPath(page: RecordPage, id: string, extra?: Extra): string
return `${consolePath(page)}?${q}`; 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=<id> */ /** 任务(引擎运行)在日志页的抽屉里看:/console/logs?type=task&run=<id> */
export function taskPath(runId: string): string { export function taskPath(runId: string): string {
return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`; return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`;
@@ -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 { CalendarPlus, LoaderCircle, PackageSearch, Star } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { StatusBadge } from "~/components/console/status-badge.tsx"; 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 type CardAction = "open" | "subscribe" | "search";
export function coverUrl(it: Pick<DiscoverItem, "cover" | "posterPath">): string | null { export function coverUrl(it: Pick<DiscoverItem, "cover" | "posterPath">): string | null {
return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : it.cover; return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : externalImageUrl(it.cover);
} }
export function DiscoverCard({ export function DiscoverCard({
@@ -6,8 +6,7 @@ import { toast } from "sonner";
import { apiMutate } from "~/api/parse.ts"; import { apiMutate } from "~/api/parse.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx"; import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts"; import { subjectLink, taskPath } from "~/components/console/record-sheet.tsx";
import { recordPath, taskPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
@@ -62,28 +61,6 @@ export const SUBJECT_LABEL: Record<string, string> = {
cache_volume: "下载盘", cache_volume: "下载盘",
}; };
/** 业务任务的对象 → 它的"家"页面 */
export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): 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<Task, "subjectType" | "subjectId">): string { export function subjectText(t: Pick<Task, "subjectType" | "subjectId">): string {
if (!t.subjectType) return "—"; if (!t.subjectType) return "—";
const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType; const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType;
@@ -1,7 +1,7 @@
import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts"; 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 { useEffect, useId, useState } from "react";
import { Link } from "react-router"; import { Link, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useDownloadSavePaths } from "~/api/downloads.ts"; import { useDownloadSavePaths } from "~/api/downloads.ts";
import { useEntityDetail } from "~/api/library.ts"; import { useEntityDetail } from "~/api/library.ts";
@@ -67,6 +67,75 @@ export interface ManualDownloadResource {
const AUTO = "__auto__"; const AUTO = "__auto__";
/** 搜索与工作台直接下载,只有高级选项或未识别确认才打开窗口。 */
export function ManualDownloadActions({
resource,
submit,
onDelegate,
}: {
resource: ManualDownloadResource;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
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 (
<>
<Button size="sm" disabled={busy} onClick={() => void go()}>
<Download />
{busy ? "提交中…" : "下载"}
</Button>
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
aria-label="高级下载选项"
title="高级下载选项"
onClick={() => {
setUnrecognized(false);
setOpen(true);
}}
>
<Settings2 />
</Button>
{open ? (
<ManualDownloadDialog
resource={resource}
open={open}
onOpenChange={setOpen}
submit={submit}
initialUnrecognized={unrecognized}
initialAdvanced={!unrecognized}
onDelegate={onDelegate}
/>
) : null}
</>
);
}
/** 搜索、工作台和原站视图共用的一次性下载选项。 */ /** 搜索、工作台和原站视图共用的一次性下载选项。 */
export function ManualDownloadDialog({ export function ManualDownloadDialog({
resource, resource,
@@ -74,6 +143,7 @@ export function ManualDownloadDialog({
onOpenChange, onOpenChange,
submit, submit,
initialUnrecognized = false, initialUnrecognized = false,
initialAdvanced = false,
onDelegate, onDelegate,
}: { }: {
resource: ManualDownloadResource; resource: ManualDownloadResource;
@@ -81,6 +151,7 @@ export function ManualDownloadDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>; submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
initialUnrecognized?: boolean; initialUnrecognized?: boolean;
initialAdvanced?: boolean;
onDelegate?: () => void; onDelegate?: () => void;
}) { }) {
const formId = useId(); const formId = useId();
@@ -95,6 +166,7 @@ export function ManualDownloadDialog({
const [mode, setMode] = useState<"normal" | "replace">("normal"); const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [unrecognized, setUnrecognized] = useState(initialUnrecognized); const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
const [advanced, setAdvanced] = useState(initialAdvanced);
const [identityOpen, setIdentityOpen] = useState(!entityId); const [identityOpen, setIdentityOpen] = useState(!entityId);
// null = 没动过,显示作品当前的值 // null = 没动过,显示作品当前的值
const [tmdbType, setTmdbType] = useState<TmdbType | null>(null); const [tmdbType, setTmdbType] = useState<TmdbType | null>(null);
@@ -132,9 +204,9 @@ export function ManualDownloadDialog({
if (open) { if (open) {
setResult(null); setResult(null);
setUnrecognized(initialUnrecognized); setUnrecognized(initialUnrecognized);
if (initialUnrecognized) setIdentityOpen(true); setAdvanced(initialAdvanced);
} }
}, [open, initialUnrecognized]); }, [open, initialUnrecognized, initialAdvanced]);
const go = async () => { const go = async () => {
setBusy(true); setBusy(true);
@@ -156,7 +228,6 @@ export function ManualDownloadDialog({
setResult(r); setResult(r);
if (r.requiresConfirmation) { if (r.requiresConfirmation) {
setUnrecognized(true); setUnrecognized(true);
setIdentityOpen(true);
} }
if (r.submitted) toast.success("已提交下载"); if (r.submitted) toast.success("已提交下载");
} catch (e) { } catch (e) {
@@ -170,6 +241,7 @@ export function ManualDownloadDialog({
const siteName = resource.siteName ?? site.data?.name ?? null; const siteName = resource.siteName ?? site.data?.name ?? null;
const missingCustom = dir === CUSTOM_DIR && !custom.trim(); const missingCustom = dir === CUSTOM_DIR && !custom.trim();
const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId); const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId);
const savePath = resolveSavePath(target, dir, custom);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -203,65 +275,92 @@ export function ManualDownloadDialog({
</DialogHeader> </DialogHeader>
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4">
<ResourceSummary resource={resource} /> <ResourceSummary resource={resource} />
<SaveTargetFields <div className="space-y-1 text-caption">
formId={formId} <p className={paths.error || (!paths.isPending && !target) ? "text-destructive" : "text-muted-foreground"}>
instances={paths.data?.instances ?? []} {paths.error
autoInstanceId={paths.data?.autoInstanceId ?? null} ? errorMessage(paths.error)
loading={paths.isPending} : paths.isPending
error={paths.error ? errorMessage(paths.error) : null} ? "读取下载去向…"
instanceId={instanceId} : target
onInstanceChange={(v) => { ? `${target.name} · ${savePath ? `保存到:${savePathText(savePath)}` : "填写保存目录"}`
setInstanceId(v); : "没有可用的下载器,先在设置里添加下载器和保存目录"}
if (dir !== CUSTOM_DIR) setDir(""); </p>
}} {changed ? (
target={target} <p>本次作品:{picked ? picked.title : `${shownType === "movie" ? "电影" : "剧集"} · TMDB ${shownId}`}</p>
dir={dir} ) : null}
onDirChange={setDir} {effectiveMode === "replace" ? <p>本次洗版:替换 {selected.length} 个已有文件</p> : null}
custom={custom} </div>
onCustomChange={setCustom} {unrecognized && !changed ? (
/> <p className="text-caption text-warning">
<IdentitySection 没识别出作品。可以继续下载,整理时再识别;也可在高级选项里指定作品。
formId={formId} </p>
open={identityOpen}
onToggle={() => setIdentityOpen((v) => !v)}
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
unrecognized={!entityId || unrecognized}
type={shownType}
onTypeChange={(v) => {
setTmdbType(v);
setPicked(null);
}}
tmdbId={shownId}
onTmdbIdChange={(v) => {
setTmdbId(v.trim());
setPicked(null);
}}
picked={picked}
onPick={(w) => {
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
else {
setPicked(null);
setTmdbType(w.tmdb.type);
setTmdbId(w.tmdb.id);
}
}}
onClearPick={() => setPicked(null)}
changed={changed}
subject={resource.title}
/>
{entityId && !changed ? (
<ExistingVersionsSection
formId={formId}
loading={inventory.isPending}
error={!!inventory.error}
summary={versionSummaryText(inventory.data?.summary)}
versions={existing}
mode={mode}
onModeChange={setMode}
selected={selected}
onSelectedChange={setSelected}
/>
) : null} ) : null}
<details open={advanced} onToggle={(e) => setAdvanced(e.currentTarget.open)}>
<summary className="cursor-pointer text-caption text-muted-foreground">高级选项</summary>
{advanced ? (
<div className="mt-3 space-y-4">
<SaveTargetFields
formId={formId}
instances={paths.data?.instances ?? []}
autoInstanceId={paths.data?.autoInstanceId ?? null}
loading={paths.isPending}
error={paths.error ? errorMessage(paths.error) : null}
instanceId={instanceId}
onInstanceChange={(v) => {
setInstanceId(v);
if (dir !== CUSTOM_DIR) setDir("");
}}
target={target}
dir={dir}
onDirChange={setDir}
custom={custom}
onCustomChange={setCustom}
/>
<IdentitySection
formId={formId}
open={identityOpen}
onToggle={() => setIdentityOpen((v) => !v)}
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
unrecognized={!entityId || unrecognized}
type={shownType}
onTypeChange={(v) => {
setTmdbType(v);
setPicked(null);
}}
tmdbId={shownId}
onTmdbIdChange={(v) => {
setTmdbId(v.trim());
setPicked(null);
}}
picked={picked}
onPick={(w) => {
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
else {
setPicked(null);
setTmdbType(w.tmdb.type);
setTmdbId(w.tmdb.id);
}
}}
onClearPick={() => setPicked(null)}
changed={changed}
subject={resource.title}
/>
{entityId && !changed ? (
<ExistingVersionsSection
formId={formId}
loading={inventory.isPending}
error={!!inventory.error}
summary={versionSummaryText(inventory.data?.summary)}
versions={existing}
mode={mode}
onModeChange={setMode}
selected={selected}
onSelectedChange={setSelected}
/>
) : null}
</div>
) : null}
</details>
{result && !result.submitted && !result.requiresConfirmation ? ( {result && !result.submitted && !result.requiresConfirmation ? (
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p> <p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
) : null} ) : null}
@@ -272,7 +371,7 @@ export function ManualDownloadDialog({
) : null} ) : null}
</div> </div>
<DialogFooter> <DialogFooter>
{onDelegate ? ( {onDelegate && (advanced || unrecognized) ? (
<Button <Button
variant="outline" variant="outline"
disabled={busy} disabled={busy}
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import { draftFromJson, draftToJson, type SlotRulesDraft, type SlotRulesJson } from "./slot-rules.tsx";
const existingRules = (): SlotRulesJson => ({
hardConditions: {
resolutions: ["1080p", "2160p"],
sources: ["WEB-DL", "BluRay"],
requireEncode: true,
minSizeBytes: 1024 ** 3,
maxSizeBytes: 10 * 1024 ** 3,
requiredSubtitles: ["zh", "en"],
cuts: ["default", "IMAX"],
excludeGroups: ["LOW", "BAD"],
contentTypes: ["main"],
codecs: ["H.265"],
hdr: { anyOf: ["HDR10"], noneOf: ["DV"] },
},
softPreferences: {
hdr: ["DV", "HDR10"],
groups: ["CHD", "ADE"],
sources: ["BluRay", "WEB-DL"],
codecs: ["H.265", "H.264"],
preferredSizeBytes: { min: 2 * 1024 ** 3, max: 8 * 1024 ** 3 },
tiers: [
{ name: "优先蓝光", sources: ["BluRay"], codecs: ["H.265"] },
{ name: "追更", sources: ["WEB-DL"], resolutions: ["1080p"] },
],
tiersRequired: true,
},
fallback: {
unknownPolicy: "hold",
retryLimit: 3,
investigation: { enabled: true, fields: ["codec", "hdr"] },
},
upgradeThreshold: {
enabled: true,
ruleGroupIds: ["quality", "release-group"],
consistency: "season_release",
minTierSteps: 2,
allowResolutionUpgrade: true,
encodeGroupAllowlist: ["CHD", "ADE"],
ceilingTier: 4,
mode: "full_pack",
basis: "priority",
targetPri: 98,
sourceTiers: { BluRay: 3, "WEB-DL": 2 },
},
completion: { kind: "movie", cuts: ["default", "IMAX"] },
});
function save(draft: SlotRulesDraft): SlotRulesJson {
const result = draftToJson(draft);
if (!result.ok) throw new Error(JSON.stringify(result.errors));
return result.value;
}
describe("收录规则编辑回写", () => {
it("逐版本修改洗版方式,关闭再开启保留模式,明确选默认才清除模式", () => {
const original = existingRules();
const disabled = save({ ...draftFromJson(original), upgradeEnabled: false });
expect(disabled.upgradeThreshold).toEqual({ ...original.upgradeThreshold, enabled: false });
const reopened = save({ ...draftFromJson(disabled), upgradeEnabled: true });
expect(reopened).toEqual(original);
const perUnit = save({ ...draftFromJson(original), upgradeMode: "per_unit" });
expect(perUnit.upgradeThreshold).toEqual({ ...original.upgradeThreshold, mode: "per_unit" });
const { mode: _mode, ...rest } = original.upgradeThreshold;
expect(save({ ...draftFromJson(original), upgradeMode: "" }).upgradeThreshold).toEqual(rest);
expect(save(draftFromJson({})).upgradeThreshold).toEqual({ enabled: false });
});
it("未修改保存时保留表单没有展示的各层 JSON 字段", () => {
const original = existingRules();
expect(save(draftFromJson(original))).toEqual(original);
});
it.each([
{
name: "分辨率",
edit: { resolutions: ["2160p"] },
expected: (original: SlotRulesJson) => ({
...original,
hardConditions: { ...original.hardConditions, resolutions: ["2160p"] },
}),
},
{
name: "编码偏好",
edit: { softCodecs: ["AV1", "H.265"] },
expected: (original: SlotRulesJson) => ({
...original,
softPreferences: { ...original.softPreferences, codecs: ["AV1", "H.265"] },
}),
},
])("编辑$name只改写对应字段,不改硬编码限制或其他规则", ({ edit, expected }) => {
const original = existingRules();
const snapshot = structuredClone(original);
expect(save({ ...draftFromJson(original), ...edit })).toEqual(expected(original));
expect(original).toEqual(snapshot);
});
it("同时更新分辨率和编码偏好仍保留阶梯、洗版口径、完成版本及回退扩展", () => {
const original = existingRules();
const value = save({
...draftFromJson(original),
resolutions: ["720p", "1080p"],
softCodecs: ["AV1"],
});
expect(value).toEqual({
...original,
hardConditions: { ...original.hardConditions, resolutions: ["720p", "1080p"] },
softPreferences: { ...original.softPreferences, codecs: ["AV1"] },
});
});
it.each([false, true])("清空洗版规则和阈值不会从原 JSON 恢复,升级开关为 %s", (upgradeEnabled) => {
const original = existingRules();
const {
ruleGroupIds: _groups,
consistency: _consistency,
minTierSteps: _steps,
...remaining
} = original.upgradeThreshold;
const value = save({
...draftFromJson(original),
upgradeEnabled,
upgradeGroupIds: [],
seasonConsistency: false,
minTierSteps: "",
});
expect(value).toEqual({
...original,
upgradeThreshold: { ...remaining, enabled: upgradeEnabled },
});
expect(value.upgradeThreshold).not.toHaveProperty("ruleGroupIds");
expect(value.upgradeThreshold).not.toHaveProperty("consistency");
expect(value.upgradeThreshold).not.toHaveProperty("minTierSteps");
});
it("关闭升级并清空配置后,再加载并打开升级也不会复活旧规则", () => {
const disabled = save({
...draftFromJson(existingRules()),
upgradeEnabled: false,
upgradeGroupIds: [],
seasonConsistency: false,
minTierSteps: "",
});
const reopened = draftFromJson(disabled);
expect(reopened.upgradeGroupIds).toEqual([]);
expect(reopened.seasonConsistency).toBe(false);
expect(reopened.minTierSteps).toBe("");
const enabled = save({ ...reopened, upgradeEnabled: true });
expect(enabled.upgradeThreshold).toEqual({ ...disabled.upgradeThreshold, enabled: true });
expect(enabled.upgradeThreshold).not.toHaveProperty("ruleGroupIds");
expect(enabled.upgradeThreshold).not.toHaveProperty("consistency");
expect(enabled.upgradeThreshold).not.toHaveProperty("minTierSteps");
});
});
describe("按字段配置的未知值回退", () => {
const fieldFallback = () => ({
...existingRules(),
fallback: {
unknownPolicy: { codec: "hold", resolution: "reject", hdr: "accept" },
investigation: { enabled: true, fields: ["codec"] },
},
});
it("按字段对象回填为保留状态,未修改保存仍保留原配置", () => {
const original = fieldFallback();
const draft = draftFromJson(original);
expect(draft.unknownPolicy).toBe("fields");
expect(save(draft)).toEqual(original);
});
it("仅修改其他规则时也不会清除按字段回退对象及其扩展字段", () => {
const original = fieldFallback();
expect(save({ ...draftFromJson(original), resolutions: ["2160p"] })).toEqual({
...original,
hardConditions: { ...original.hardConditions, resolutions: ["2160p"] },
});
});
it("明确选择全局回退后替换原按字段对象,保留其他回退字段", () => {
const original = fieldFallback();
expect(save({ ...draftFromJson(original), unknownPolicy: "reject" })).toEqual({
...original,
fallback: { ...original.fallback, unknownPolicy: "reject" },
});
});
it("原按字段配置先改为全局拒绝,再明确选系统默认时清空回退,不恢复原对象", () => {
const original = fieldFallback();
const draft = draftFromJson(original);
const selectedGlobal = { ...draft, unknownPolicy: "reject" as const };
const cleared = { ...selectedGlobal, unknownPolicy: "" as const };
const { unknownPolicy: _policy, ...remaining } = original.fallback;
const value = save(cleared);
expect(value).toEqual({ ...original, fallback: remaining });
expect(value.fallback).not.toHaveProperty("unknownPolicy");
});
it("已有全局回退可以清空,保存后不会恢复原选择", () => {
const original = existingRules();
const { unknownPolicy: _policy, ...remaining } = original.fallback;
const value = save({ ...draftFromJson(original), unknownPolicy: "" });
expect(value).toEqual({ ...original, fallback: remaining });
expect(value.fallback).not.toHaveProperty("unknownPolicy");
});
});
+89 -24
View File
@@ -1,6 +1,7 @@
import type { JsonObject } from "@umt/contracts"; import type { JsonObject } from "@umt/contracts";
import { useId } from "react"; import { useId } from "react";
import { z } from "zod"; import { z } from "zod";
import { useRuleGroups } from "~/api/rules.ts";
import { FormField } from "~/components/console/form-field.tsx"; import { FormField } from "~/components/console/form-field.tsx";
import { Input } from "~/components/ui/input.tsx"; import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
@@ -52,7 +53,7 @@ const CT_LABEL: Record<string, string> = {
preview: "预告", preview: "预告",
reaction: "reaction", reaction: "reaction",
}; };
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const; const UNKNOWN_LABEL = { fields: "保留按字段配置", hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3; const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) => const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
@@ -81,10 +82,13 @@ export const SoftPreferences = z.object({
}); });
export const UpgradePolicy = z.object({ export const UpgradePolicy = z.object({
enabled: z.boolean(), enabled: z.boolean(),
mode: z.enum(["movie", "per_unit", "full_pack"]).optional(),
minTierSteps: z.number().int().min(0).max(5).optional(), minTierSteps: z.number().int().min(0).max(5).optional(),
allowResolutionUpgrade: z.boolean().optional(), allowResolutionUpgrade: z.boolean().optional(),
encodeGroupAllowlist: strArr, encodeGroupAllowlist: strArr,
ceilingTier: z.number().int().min(0).max(5).optional(), ceilingTier: z.number().int().min(0).max(5).optional(),
ruleGroupIds: strArr,
consistency: z.literal("season_release").optional(),
}); });
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]); export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
export const Completion = z.object({ kind: z.enum(["movie", "units"]) }); export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
@@ -95,6 +99,9 @@ export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
/** 编辑器状态(字符串形态,提交时转换) */ /** 编辑器状态(字符串形态,提交时转换) */
export type SlotRulesDraft = { export type SlotRulesDraft = {
original: Partial<SlotRulesJson>;
upgradeGroupIds: string[];
seasonConsistency: boolean;
resolutions: string[]; resolutions: string[];
sources: string[]; sources: string[];
requireEncode: boolean; requireEncode: boolean;
@@ -104,12 +111,13 @@ export type SlotRulesDraft = {
cuts: string[]; cuts: string[];
excludeGroups: string; excludeGroups: string;
contentTypes: string[]; contentTypes: string[];
unknownPolicy: "" | "hold" | "reject" | "accept"; unknownPolicy: "" | "fields" | "hold" | "reject" | "accept";
softHdr: string[]; softHdr: string[];
softGroups: string; softGroups: string;
softSources: string[]; softSources: string[];
softCodecs: string[]; softCodecs: string[];
upgradeEnabled: boolean; upgradeEnabled: boolean;
upgradeMode: "" | "movie" | "per_unit" | "full_pack";
minTierSteps: string; minTierSteps: string;
allowResolutionUpgrade: boolean; allowResolutionUpgrade: boolean;
encodeGroupAllowlist: string; encodeGroupAllowlist: string;
@@ -134,6 +142,9 @@ export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data; const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
const c = Completion.safeParse(j.completion ?? {}).data; const c = Completion.safeParse(j.completion ?? {}).data;
return { return {
original: j,
upgradeGroupIds: u?.ruleGroupIds ?? [],
seasonConsistency: u?.consistency === "season_release",
resolutions: h.resolutions ?? [], resolutions: h.resolutions ?? [],
sources: h.sources ?? [], sources: h.sources ?? [],
requireEncode: h.requireEncode ?? false, requireEncode: h.requireEncode ?? false,
@@ -143,12 +154,13 @@ export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
cuts: h.cuts ?? [], cuts: h.cuts ?? [],
excludeGroups: (h.excludeGroups ?? []).join(", "), excludeGroups: (h.excludeGroups ?? []).join(", "),
contentTypes: h.contentTypes ?? [], contentTypes: h.contentTypes ?? [],
unknownPolicy: up ?? "", unknownPolicy: up ?? (typeof j.fallback?.unknownPolicy === "object" ? "fields" : ""),
softHdr: s.hdr ?? [], softHdr: s.hdr ?? [],
softGroups: (s.groups ?? []).join(", "), softGroups: (s.groups ?? []).join(", "),
softSources: s.sources ?? [], softSources: s.sources ?? [],
softCodecs: s.codecs ?? [], softCodecs: s.codecs ?? [],
upgradeEnabled: u?.enabled ?? false, upgradeEnabled: u?.enabled ?? false,
upgradeMode: u?.mode ?? "",
minTierSteps: numStr(u?.minTierSteps), minTierSteps: numStr(u?.minTierSteps),
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false, allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "), encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
@@ -200,16 +212,16 @@ export function draftToJson(
sources: arr(d.softSources), sources: arr(d.softSources),
codecs: arr(d.softCodecs), codecs: arr(d.softCodecs),
}; };
const upgrade: UpgradePolicy | undefined = const upgrade: UpgradePolicy = {
d.upgradeEnabled || minTier !== undefined || d.encodeGroupAllowlist.trim() enabled: d.upgradeEnabled,
? { mode: d.upgradeMode || undefined,
enabled: d.upgradeEnabled, ruleGroupIds: arr(d.upgradeGroupIds),
minTierSteps: minTier, consistency: d.seasonConsistency ? "season_release" : undefined,
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined, minTierSteps: minTier,
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)), allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
ceilingTier: ceiling, encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
} ceilingTier: ceiling,
: undefined; };
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject; const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
// 严格校验一遍(与 domain 类型对齐) // 严格校验一遍(与 domain 类型对齐)
const hv = HardConditions.safeParse(clean(hard)); const hv = HardConditions.safeParse(clean(hard));
@@ -218,11 +230,14 @@ export function draftToJson(
return { return {
ok: true, ok: true,
value: { value: {
hardConditions: clean(hard), hardConditions: clean({ ...d.original.hardConditions, ...hard }),
softPreferences: clean(soft), softPreferences: clean({ ...d.original.softPreferences, ...soft }),
fallback: d.unknownPolicy ? { unknownPolicy: d.unknownPolicy } : {}, fallback: clean({
upgradeThreshold: upgrade ? clean(upgrade) : {}, ...d.original.fallback,
completion: d.completion ? { kind: d.completion } : {}, unknownPolicy: d.unknownPolicy === "fields" ? d.original.fallback?.unknownPolicy : d.unknownPolicy || undefined,
}),
upgradeThreshold: clean({ ...d.original.upgradeThreshold, ...upgrade }),
completion: clean({ ...d.original.completion, kind: d.completion || undefined }),
}, },
}; };
} }
@@ -297,6 +312,7 @@ export function SlotRulesEditor({
errors?: Record<string, string>; errors?: Record<string, string>;
}) { }) {
const id = useId(); const id = useId();
const groups = useRuleGroups({ enabled: "true" });
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v }); const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -304,7 +320,7 @@ export function SlotRulesEditor({
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase"> <legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
硬条件 · 已知不满足即拒绝 硬条件 · 已知不满足即拒绝
</legend> </legend>
<FormField label="分辨率"> <FormField label="允许收录的分辨率" hint="先收低档和后续洗版都限定在此范围内">
<ChipToggleGroup <ChipToggleGroup
label="分辨率" label="分辨率"
options={o(RESOLUTIONS)} options={o(RESOLUTIONS)}
@@ -396,11 +412,13 @@ export function SlotRulesEditor({
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
<SelectItem value="__none">按系统默认</SelectItem> <SelectItem value="__none">按系统默认</SelectItem>
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[]).map((k) => ( {(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[])
<SelectItem key={k} value={k}> .filter((k) => k !== "fields" || typeof value.original.fallback?.unknownPolicy === "object")
{UNKNOWN_LABEL[k]} .map((k) => (
</SelectItem> <SelectItem key={k} value={k}>
))} {UNKNOWN_LABEL[k]}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormField> </FormField>
@@ -454,6 +472,53 @@ export function SlotRulesEditor({
aria-label="允许自动升级" aria-label="允许自动升级"
/> />
</div> </div>
<FormField
label="洗版方式"
htmlFor={`${id}-up-mode`}
hint="全集洗版只接受覆盖目标全集的资源;整季同套也可以逐集完成"
>
<Select
value={value.upgradeMode || "__default__"}
onValueChange={(v) => set("upgradeMode", v === "__default__" ? "" : (v as SlotRulesDraft["upgradeMode"]))}
>
<SelectTrigger id={`${id}-up-mode`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">按作品类型(电影单片,剧集按集)</SelectItem>
<SelectItem value="per_unit">按集洗版</SelectItem>
<SelectItem value="full_pack">全集洗版</SelectItem>
<SelectItem value="movie">电影洗版</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="洗版组合规则" hint="不选时使用默认洗版规则;选中顺序中最后一个带档位的规则决定优先级">
<ChipToggleGroup
ordered
label="洗版组合规则"
options={[
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
...value.upgradeGroupIds
.filter((id) => !(groups.data?.items ?? []).some((g) => g.id === id))
.map((id) => ({ value: id, label: `${id}(规则不可用)` })),
]}
value={value.upgradeGroupIds}
onChange={(v) => set("upgradeGroupIds", v)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div>
<p className="text-caption font-medium">剧集洗版后整季同套</p>
<p className="text-meta text-muted-foreground">
追更允许混合,洗版按季向同组、同来源和编码对齐;可以逐集完成。
</p>
</div>
<Switch
checked={value.seasonConsistency}
onCheckedChange={(v) => set("seasonConsistency", v)}
aria-label="剧集洗版后整季同套"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<FormField <FormField
label="最小来源等级提升" label="最小来源等级提升"
@@ -1,5 +1,6 @@
import { import {
AcquisitionPolicyRules, AcquisitionPolicyRules,
AcquisitionPolicyScope,
type JsonObject, type JsonObject,
type PolicyRevision, type PolicyRevision,
type SlotTemplate, type SlotTemplate,
@@ -9,6 +10,7 @@ import { ChevronRight, Eye, FilePlus2, History, Pencil, Plus, ScrollText, Trash2
import { useId, useMemo, useState } from "react"; import { useId, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { useClassification } from "~/api/organize-sources.ts";
import { import {
PolicyPreview, PolicyPreview,
useCreatePolicy, useCreatePolicy,
@@ -18,6 +20,7 @@ import {
useRollbackPolicy, useRollbackPolicy,
useUpdatePolicy, useUpdatePolicy,
} from "~/api/policies.ts"; } from "~/api/policies.ts";
import { useRuleGroups } from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 = 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 = { type PolicyDraft = {
policyKey: string; policyKey: string;
title: string; title: string;
summary: string; summary: string;
scopeKinds: string[]; scopeKinds: string[];
scopeCategories: string[];
originalScope: JsonObject;
defaultSlots: TemplateDraft[]; defaultSlots: TemplateDraft[];
newTitleEnabled: boolean; newTitleEnabled: boolean;
windowDays: string; windowDays: string;
@@ -75,6 +80,7 @@ let seq = 0;
const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({ const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({
key: `t${++seq}`, key: `t${++seq}`,
name: t?.name ?? "", name: t?.name ?? "",
ruleGroupIds: t?.ruleGroupIds ?? [],
rules: t ? draftFromJson(t) : emptyDraft(), rules: t ? draftFromJson(t) : emptyDraft(),
open, open,
}); });
@@ -88,6 +94,10 @@ function draftFromRevision(r: PolicyRevision | null): PolicyDraft {
title: r?.title ?? "", title: r?.title ?? "",
summary: r?.summary ?? "", summary: r?.summary ?? "",
scopeKinds: kinds ?? [], 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)), defaultSlots: (rules?.defaultSlots ?? []).map((t) => tpl(t)),
newTitleEnabled: rules?.newTitle?.enabled ?? false, newTitleEnabled: rules?.newTitle?.enabled ?? false,
windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "", windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "",
@@ -121,6 +131,14 @@ function buildPolicy(d: PolicyDraft):
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
const meta = Meta.safeParse(d); const meta = Meta.safeParse(d);
if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message; 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[]) => const templates = (list: TemplateDraft[]) =>
list.map((t) => { list.map((t) => {
if (!t.name.trim()) errors[`${t.key}.name`] = "填写模板名称"; if (!t.name.trim()) errors[`${t.key}.name`] = "填写模板名称";
@@ -131,6 +149,7 @@ function buildPolicy(d: PolicyDraft):
} }
return { return {
name: t.name.trim(), name: t.name.trim(),
...(t.ruleGroupIds.length ? { ruleGroupIds: t.ruleGroupIds } : {}),
hardConditions: stripEmpty(r.value.hardConditions), hardConditions: stripEmpty(r.value.hardConditions),
softPreferences: stripEmpty(r.value.softPreferences), softPreferences: stripEmpty(r.value.softPreferences),
fallback: stripEmpty(r.value.fallback), fallback: stripEmpty(r.value.fallback),
@@ -174,7 +193,18 @@ function buildPolicy(d: PolicyDraft):
policyKey: meta.data.policyKey, policyKey: meta.data.policyKey,
title: meta.data.title, title: meta.data.title,
summary: meta.data.summary || null, 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, rules: rules as JsonObject,
}, },
}; };
@@ -532,8 +562,12 @@ function PolicyDetailSheet({
} }
const scopeText = (scope: JsonObject) => { const scopeText = (scope: JsonObject) => {
const k = z.array(z.string()).safeParse((scope as { mediaKinds?: unknown }).mediaKinds).data; const parsed = AcquisitionPolicyScope.safeParse(scope).data;
return k?.length ? k.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? x).join(" / ") : "全部作品"; 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 }) { function PreviewBlock({ value }: { value: JsonObject | null }) {
@@ -606,6 +640,7 @@ function PolicyEditorSheet({
onSaved: (r: PolicyRevision) => void; onSaved: (r: PolicyRevision) => void;
}) { }) {
const id = useId(); const id = useId();
const classification = useClassification();
const create = useCreatePolicy(); const create = useCreatePolicy();
const update = useUpdatePolicy(); const update = useUpdatePolicy();
const [d, setD] = useState<PolicyDraft>(() => { const [d, setD] = useState<PolicyDraft>(() => {
@@ -715,10 +750,37 @@ function PolicyEditorSheet({
onChange={(v) => set("scopeKinds", v)} onChange={(v) => set("scopeKinds", v)}
/> />
</FormField> </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>
<DetailSection <DetailSection
title="默认收录版本模板" title="保留版本"
actions={ actions={
<Button <Button
variant="ghost" variant="ghost"
@@ -731,6 +793,9 @@ function PolicyEditorSheet({
} }
> >
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有收录版本时,按这些模板创建。</p> <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} /> <TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
</DetailSection> </DetailSection>
@@ -825,6 +890,7 @@ function TemplateList({
errors: Record<string, string>; errors: Record<string, string>;
}) { }) {
const id = useId(); const id = useId();
const groups = useRuleGroups({ enabled: "true" });
if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>; if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>;
const patch = (key: string, p: Partial<TemplateDraft>) => const patch = (key: string, p: Partial<TemplateDraft>) =>
onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t))); onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t)));
@@ -876,6 +942,23 @@ function TemplateList({
</div> </div>
) : ( ) : (
<div className="border-t border-border/60 px-3 py-3"> <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} /> <SlotRulesEditor value={t.rules} onChange={(v) => patch(t.key, { rules: v })} errors={errs} />
</div> </div>
)} )}
@@ -3,6 +3,7 @@ import { Pencil, Plus, Target } from "lucide-react";
import { useId, useState } from "react"; import { useId, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { useRuleGroups } from "~/api/rules.ts";
import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts"; import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts";
import { DetailSheet } from "~/components/console/detail-sheet.tsx"; import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
@@ -12,6 +13,7 @@ import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx"; import { Switch } from "~/components/ui/switch.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts"; import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts"; import { formatBytes } from "~/lib/utils.ts";
import { ChipToggleGroup } from "./parts.tsx";
import { SlotCard } from "./slot-card.tsx"; import { SlotCard } from "./slot-card.tsx";
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx"; import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
@@ -46,7 +48,9 @@ export function SlotsPanel({
}) { }) {
const q = useSlots({ mediaEntityId }); const q = useSlots({ mediaEntityId });
const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null); const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null);
const slots = q.data?.items ?? []; const slots = (q.data?.items ?? []).filter(
(s) => !subscriptionId || s.subscriptionId === subscriptionId || s.subscriptionId === null,
);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -89,9 +93,14 @@ export function SlotsPanel({
) : undefined ) : undefined
} }
actions={ actions={
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}> <div className="flex items-center gap-2">
<Pencil /> {subscriptionId && s.subscriptionId === null ? (
</Button> <span className="text-meta text-muted-foreground">作品共用</span>
) : null}
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
<Pencil />
</Button>
</div>
} }
/> />
))} ))}
@@ -127,6 +136,8 @@ function SlotEditorSheet({
const id = useId(); const id = useId();
const create = useCreateSlot(); const create = useCreateSlot();
const update = useUpdateSlot(); const update = useUpdateSlot();
const groups = useRuleGroups({ enabled: "true" });
const [ruleGroupIds, setRuleGroupIds] = useState<string[]>(slot?.ruleGroupIds ?? []);
const [name, setName] = useState(slot?.name ?? ""); const [name, setName] = useState(slot?.name ?? "");
const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? ""); const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? "");
const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false); const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false);
@@ -145,6 +156,7 @@ function SlotEditorSheet({
if (Object.keys(errs).length || !meta.success || !rules.ok) return; if (Object.keys(errs).length || !meta.success || !rules.ok) return;
const common = { const common = {
...rules.value, ...rules.value,
ruleGroupIds,
distinctGroup: meta.data.distinctGroup || null, distinctGroup: meta.data.distinctGroup || null,
requireDistinctAsset: requireDistinct, requireDistinctAsset: requireDistinct,
}; };
@@ -232,6 +244,20 @@ function SlotEditorSheet({
</div> </div>
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" /> <Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" />
</div> </div>
<FormField label="收录组合规则" hint="不选时沿用订阅兼容设置或默认规则;最后一个带档位的组决定优先级">
<ChipToggleGroup
label="收录组合规则"
ordered
options={[
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
...ruleGroupIds
.filter((gid) => !(groups.data?.items ?? []).some((g) => g.id === gid))
.map((gid) => ({ value: gid, label: `${gid}(规则不可用)` })),
]}
value={ruleGroupIds}
onChange={setRuleGroupIds}
/>
</FormField>
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} /> <SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
</DetailSheet> </DetailSheet>
); );
@@ -65,6 +65,7 @@ export function LiquidSurface({
const t = TIERS[tier]; const t = TIERS[tier];
return ( return (
<LiquidGlass <LiquidGlass
reveal="immediate"
mode="custom" mode="custom"
refraction="lens" refraction="lens"
lensProfile="material" lensProfile="material"
@@ -94,6 +95,8 @@ export function LiquidSurface({
: blurOnly : blurOnly
? { backgroundColor: dark ? t.fill.dark : t.fill.light } ? { backgroundColor: dark ? t.fill.dark : t.fill.light }
: null), : null),
// 初始化时使用本档底色;磨砂外层已经铺底,内层保持透明,避免叠底
["--glass-pending-fill" as string]: blurOnly ? "transparent" : dark ? t.fill.dark : t.fill.light,
// 1px 暗边线在亮棱外侧,投影只留贴边一层 // 1px 暗边线在亮棱外侧,投影只留贴边一层
boxShadow: dark boxShadow: dark
? "0 0 0 1px rgba(0,0,0,0.28), 0 8px 24px -14px rgba(0,0,0,0.5)" ? "0 0 0 1px rgba(0,0,0,0.28), 0 8px 24px -14px rgba(0,0,0,0.5)"
@@ -4,9 +4,7 @@ import {
ArrowDownWideNarrow, ArrowDownWideNarrow,
ArrowUp, ArrowUp,
ArrowUpNarrowWide, ArrowUpNarrowWide,
Bot,
ChevronDown, ChevronDown,
Download,
ExternalLink, ExternalLink,
PackageSearch, PackageSearch,
RefreshCw, RefreshCw,
@@ -38,7 +36,7 @@ import {
selectedCount, selectedCount,
useQuickFilters, useQuickFilters,
} from "./quick-filter-state.ts"; } from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; import { SearchDownloadActions } from "./search-download-dialog.tsx";
import { import {
gbToBytes, gbToBytes,
groupByWork, groupByWork,
@@ -147,7 +145,6 @@ export function ReleaseResults({
error: res.error, error: res.error,
resetKey: JSON.stringify(api), resetKey: JSON.stringify(api),
}); });
const [dl, setDl] = useState<SearchRelease | null>(null);
const [selected, setSelected] = useState<SearchRelease | null>(null); const [selected, setSelected] = useState<SearchRelease | null>(null);
const filtered = const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]); selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
@@ -267,7 +264,7 @@ export function ReleaseResults({
className="py-12" className="py-12"
/> />
) : ( ) : (
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} /> <ReleaseList rows={paged.items ?? []} onOpen={setSelected} />
)} )}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null} {rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
{first?.unmatchedTotal ? ( {first?.unmatchedTotal ? (
@@ -276,7 +273,6 @@ export function ReleaseResults({
rows={first.unmatched ?? []} rows={first.unmatched ?? []}
total={first.unmatchedTotal} total={first.unmatchedTotal}
onOpen={setSelected} onOpen={setSelected}
onDownload={setDl}
/> />
) : null} ) : null}
</div> </div>
@@ -293,7 +289,6 @@ export function ReleaseResults({
/> />
) : null} ) : null}
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
{selected?.sourceReleaseId ? ( {selected?.sourceReleaseId ? (
<ReleaseDetailSheet <ReleaseDetailSheet
key={selected.key} key={selected.key}
@@ -314,13 +309,11 @@ export function UnmatchedReleases({
rows, rows,
total, total,
onOpen, onOpen,
onDownload,
defaultOpen = false, defaultOpen = false,
}: { }: {
rows: SearchRelease[]; rows: SearchRelease[];
total: number; total: number;
onOpen: (r: SearchRelease) => void; onOpen: (r: SearchRelease) => void;
onDownload: (r: SearchRelease) => void;
defaultOpen?: boolean; defaultOpen?: boolean;
}) { }) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
@@ -338,7 +331,7 @@ export function UnmatchedReleases({
</button> </button>
{open ? ( {open ? (
<div className="border-t border-border/60"> <div className="border-t border-border/60">
<ReleaseList rows={rows} onOpen={onOpen} onDownload={onDownload} /> <ReleaseList rows={rows} onOpen={onOpen} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -517,20 +510,10 @@ function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
} }
/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */ /** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
function ReleaseList({ function ReleaseList({ rows, onOpen }: { rows: SearchRelease[]; onOpen: (r: SearchRelease) => void }) {
rows,
onOpen,
onDownload,
}: {
rows: SearchRelease[];
onOpen: (r: SearchRelease) => void;
onDownload: (r: SearchRelease) => void;
}) {
const { grouped, groups } = groupByWork(rows); const { grouped, groups } = groupByWork(rows);
const single = !grouped && groups[0]?.entity ? groups[0] : null; const single = !grouped && groups[0]?.entity ? groups[0] : null;
const row = (r: SearchRelease) => ( const row = (r: SearchRelease) => <ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} />;
<ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} />
);
if (!grouped) if (!grouped)
return ( return (
<> <>
@@ -618,7 +601,7 @@ function VerdictLine({ r }: { r: SearchRelease }) {
* 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐, * 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
* 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。 * 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
*/ */
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) { function ReleaseRow({ r, onOpen }: { r: SearchRelease; onOpen: () => void }) {
const hr = hrBadge(r); const hr = hrBadge(r);
const seedless = r.seeders === 0 || r.seeders === null; const seedless = r.seeders === 0 || r.seeders === null;
return ( return (
@@ -674,14 +657,7 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
</RecordLink> </RecordLink>
</Button> </Button>
) : null} ) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}> <SearchDownloadActions release={r} />
<Bot />
交给智能体
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
下载
</Button>
</span> </span>
</div> </div>
</li> </li>
@@ -1,7 +1,7 @@
import type { SearchRelease } from "@umt/contracts"; import type { SearchRelease } from "@umt/contracts";
import { usePlanFromSearch } from "~/api/search.ts"; import { usePlanFromSearch } from "~/api/search.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.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 { kindHintOf } from "~/components/downloads/manual-download-model.ts";
import { promoLabel } from "./search-labels.ts"; import { promoLabel } from "./search-labels.ts";
@@ -36,21 +36,10 @@ export function searchResource(release: SearchRelease): ManualDownloadResource {
}; };
} }
export function SearchDownloadDialog({ export function SearchDownloadActions({ release }: { release: SearchRelease }) {
release,
open,
onOpenChange,
}: {
release: SearchRelease;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const submit = usePlanFromSearch(); const submit = usePlanFromSearch();
return ( return (
<ManualDownloadDialog <ManualDownloadActions
key={`${release.siteId}:${release.siteReleaseId}`}
open={open}
onOpenChange={onOpenChange}
resource={searchResource(release)} resource={searchResource(release)}
onDelegate={() => delegateToAgent(release)} onDelegate={() => delegateToAgent(release)}
submit={(options) => submit={(options) =>
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { MediaServerInventory, SearchRelease, WorkInventory } from "@umt/contracts"; import type { MediaServerInventory, SearchRelease, WorkInventory } from "@umt/contracts";
import { createElement } from "react"; import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
@@ -173,9 +174,13 @@ describe("可能不是这部作品的资源", () => {
const render = (defaultOpen: boolean, total = 1) => const render = (defaultOpen: boolean, total = 1) =>
renderToStaticMarkup( renderToStaticMarkup(
createElement( createElement(
MemoryRouter, QueryClientProvider,
null, { client: new QueryClient() },
createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, onDownload: () => {}, defaultOpen }), createElement(
MemoryRouter,
null,
createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, defaultOpen }),
),
), ),
); );
@@ -1,5 +1,12 @@
import type { FileRef, OrganizeSourceInput, OrganizeSourceView } from "@umt/contracts"; import {
AcquisitionPolicyScope,
type FileRef,
type OrganizeSourceInput,
type OrganizeSourceView,
} from "@umt/contracts";
import { selectAcquisitionPolicy } from "@umt/domain";
import { cloneElement, type ReactElement, useId, useState } from "react"; import { cloneElement, type ReactElement, useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { filesErrorText, useFileRoots } from "~/api/files.ts"; import { filesErrorText, useFileRoots } from "~/api/files.ts";
import { downloaders } from "~/api/instances.ts"; import { downloaders } from "~/api/instances.ts";
@@ -11,6 +18,7 @@ import {
useSaveClassification, useSaveClassification,
useSaveOrganizeSource, useSaveOrganizeSource,
} from "~/api/organize-sources.ts"; } from "~/api/organize-sources.ts";
import { usePolicyRevisions } from "~/api/policies.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { DirectoryDialog } from "~/components/files/directory-dialog.tsx"; import { DirectoryDialog } from "~/components/files/directory-dialog.tsx";
@@ -39,6 +47,20 @@ function Field({ label, children }: { label: string; children: ReactElement<{ id
export function ClassificationSection() { export function ClassificationSection() {
const config = useClassification(); const config = useClassification();
const save = useSaveClassification(); const save = useSaveClassification();
const policies = usePolicyRevisions({ status: "published" });
const available = (policies.data?.items ?? [])
.filter((p) => p.kind === "acquisition" && Array.isArray(p.rules.defaultSlots) && p.rules.defaultSlots.length)
.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "") || a.policyKey.localeCompare(b.policyKey))
.map((p) => ({ ...p, scope: AcquisitionPolicyScope.parse(p.scope) }));
const classificationPolicies = Object.entries(config.data?.categories ?? {}).flatMap(([type, names]) =>
names.flatMap((category) =>
(type === "movie" ? ["movie" as const] : ["series" as const, "variety" as const]).map((mediaKind) => ({
category,
mediaKind,
policy: selectAcquisitionPolicy(available, { mediaKind, category }),
})),
),
);
const [draft, setDraft] = useState<string | null>(null); const [draft, setDraft] = useState<string | null>(null);
const yaml = draft ?? config.data?.yaml ?? ""; const yaml = draft ?? config.data?.yaml ?? "";
const dirty = draft !== null && draft !== config.data?.yaml; const dirty = draft !== null && draft !== config.data?.yaml;
@@ -85,6 +107,43 @@ export function ClassificationSection() {
ID)、production_countries(电影制作国家)、origin_country(剧集来源国家)、release_year(发行年份,支持 ID)、production_countries(电影制作国家)、origin_country(剧集来源国家)、release_year(发行年份,支持
2020-2025 范围)。 2020-2025 范围)。
</p> </p>
{classificationPolicies.length ? (
<div className="mt-4 overflow-x-auto">
<p className="mb-2 text-caption text-muted-foreground">
当前已保存分类的默认收录策略;在收录策略的适用分类中调整。
</p>
<table className="w-full text-left text-caption">
<thead>
<tr>
<th className="py-2">分类</th>
<th>作品类型</th>
<th>默认策略</th>
</tr>
</thead>
<tbody>
{classificationPolicies.map(({ category, mediaKind, policy }) => (
<tr key={`${mediaKind}:${category}`} className="border-t border-border/60">
<td className="py-2">{category}</td>
<td>{mediaKind === "movie" ? "电影" : mediaKind === "variety" ? "综艺" : "剧集"}</td>
<td>
{policy ? (
<Link
className="underline underline-offset-2"
to={`/console/rules?tab=policies&open=${policy.id}`}
>
{policy.title} ·{" "}
{Array.isArray(policy.rules.defaultSlots) ? policy.rules.defaultSlots.length : 0} 个保留版本
</Link>
) : (
"默认版本"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<SaveBar dirty={dirty}> <SaveBar dirty={dirty}>
{dirty ? ( {dirty ? (
<Button variant="ghost" size="sm" disabled={save.isPending} onClick={() => setDraft(null)}> <Button variant="ghost" size="sm" disabled={save.isPending} onClick={() => setDraft(null)}>
@@ -15,7 +15,6 @@ import {
seasonLabel, seasonLabel,
shiftMonth, shiftMonth,
slotBadge, slotBadge,
upgradeOf,
} from "./model.ts"; } from "./model.ts";
const slot = (x: Partial<SubscriptionSlotSummary>): SubscriptionSlotSummary => ({ 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", "c"], 2, 0)).toEqual(["c", "a", "b"]);
expect(moveItem(["a", "b"], 0, 5)).toEqual(["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", () => { 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 = { const d = {
...emptyDraft("series"), ...emptyDraft("series"),
siteIds: ["x"], siteIds: ["x"],
@@ -104,12 +132,11 @@ describe("表单 ↔ params", () => {
unitsPerDay: "2", unitsPerDay: "2",
gbPerDay: "1.5", gbPerDay: "1.5",
total: "12", total: "12",
upgrade: "full_pack" as const,
}; };
const { params, errors } = draftToParams( const { params, errors } = draftToParams(
d, d,
{ custom: 1, keywords: ["旧"] }, { custom: 1, keywords: ["旧"] },
{ upgradeChanged: true, totalChanged: true }, { totalChanged: true, autoFillChanged: true },
); );
expect(errors).toEqual({}); expect(errors).toEqual({});
expect(params).toEqual({ expect(params).toEqual({
@@ -121,10 +148,8 @@ describe("表单 ↔ params", () => {
manual_total_episode: false, manual_total_episode: false,
autoFill: "off", autoFill: "off",
fillBudget: { unitsPerDay: 2, bytesPerDay: Math.round(1.5 * 1024 ** 3) }, 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.best_version).toBeUndefined();
expect(same.total_episode).toBeUndefined(); expect(same.total_episode).toBeUndefined();
}); });
@@ -132,7 +157,7 @@ describe("表单 ↔ params", () => {
const { errors } = draftToParams( const { errors } = draftToParams(
{ ...emptyDraft("series"), episodeStart: "5", episodeEnd: "2" }, { ...emptyDraft("series"), episodeStart: "5", episodeEnd: "2" },
{}, {},
{ upgradeChanged: false, totalChanged: false }, { totalChanged: false, autoFillChanged: true },
); );
expect(errors.episodeEnd).toBeTruthy(); expect(errors.episodeEnd).toBeTruthy();
}); });
@@ -151,40 +176,45 @@ describe("表单 ↔ params", () => {
keywords: ["a", "b"], keywords: ["a", "b"],
}, },
} as unknown as Subscription; } as unknown as Subscription;
const d = draftFromSubscription(sub, "per_unit"); const d = draftFromSubscription(sub);
expect(d.episodeStart).toBe("2"); expect(d.episodeStart).toBe("2");
expect(d.autoFill).toBe("on"); expect(d.autoFill).toBe("on");
expect(d.gbPerDay).toBe("2"); expect(d.gbPerDay).toBe("2");
expect(d.keywords).toBe("a\nb"); expect(d.keywords).toBe("a\nb");
expect(d.upgrade).toBe("per_unit");
}); });
it("质量预设:草稿 ↔ params;未知值回填为空", () => { it("编辑跟踪与下载信息保留旧收录规则,不再生成统一洗版指令", () => {
const d = { ...emptyDraft("movie"), resolution: "4k" as const, source: "remux" as const, effect: "dv" as const }; const legacy = {
const { params } = draftToParams( resolution: "4k",
d, source: "uhd",
{ resolution: "720p", effect: "sdr" }, effect: "atmos",
{ upgradeChanged: false, totalChanged: false }, ruleGroupIds: ["quality"],
); future: { keep: true },
expect(params).toEqual({ resolution: "4k", source: "remux", effect: "dv" }); };
const cleared = draftToParams( const sub = {
emptyDraft("movie"), mediaKind: "movie",
{ resolution: "720p" }, seasonScope: "current_season",
{ upgradeChanged: false, totalChanged: false }, mode: "once",
); priority: "tracking",
expect(cleared.params.resolution).toBeUndefined(); seasonUnitId: null,
const back = draftFromSubscription( policyRevisionId: null,
{ params: legacy,
mediaKind: "movie", } as unknown as Subscription;
seasonScope: "current_season", const draft = draftFromSubscription(sub);
mode: "once", const { params, errors } = draftToParams({ ...draft, siteIds: ["site"], searchIntervalHours: "6" }, legacy, {
priority: "tracking", totalChanged: false,
seasonUnitId: null, autoFillChanged: true,
policyRevisionId: null, });
params: { resolution: "1080p", source: "bogus", effect: "hdr" }, expect(errors).toEqual({});
} as unknown as Subscription, expect(params).toEqual({ ...legacy, siteIds: ["site"], searchIntervalHours: 6 });
"off", expect(params.best_version).toBeUndefined();
); expect(params.best_version_full).toBeUndefined();
expect([back.resolution, back.source, back.effect]).toEqual(["1080p", "", "hdr"]); 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 { import type {
AcquisitionSlot,
AutoFillMode, AutoFillMode,
CalendarEntryState, CalendarEntryState,
CoverageCell, CoverageCell,
@@ -7,13 +6,10 @@ import type {
Subscription, Subscription,
SubscriptionCalendarEntry, SubscriptionCalendarEntry,
SubscriptionCoverage, SubscriptionCoverage,
SubscriptionEffect,
SubscriptionGroup, SubscriptionGroup,
SubscriptionMediaKind, SubscriptionMediaKind,
SubscriptionOverviewItem, SubscriptionOverviewItem,
SubscriptionResolution,
SubscriptionSlotSummary, SubscriptionSlotSummary,
SubscriptionSourcePreset,
} from "@umt/contracts"; } from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx"; 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; 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> = { export const AUTOFILL_SOURCE: Record<"subscription" | "rule_group" | "default", string> = {
subscription: "订阅设置", subscription: "订阅设置",
rule_group: "规则组", rule_group: "规则组",
@@ -155,27 +151,6 @@ export function seasonTitle(s: SubscriptionCoverage["seasons"][number], kind: Su
return s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`; 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(拖动排序) */ /** 把 from 移到 to(拖动排序) */
export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] { export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
const out = arr.slice(); 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 = { export type SubscriptionDraft = {
mediaKind: SubscriptionMediaKind; mediaKind: SubscriptionMediaKind;
seasonUnitId: string; seasonUnitId: string;
@@ -226,15 +177,9 @@ export type SubscriptionDraft = {
libraryRootId: string; libraryRootId: string;
downloadSavePath: string; downloadSavePath: string;
policyRevisionId: string; policyRevisionId: string;
upgrade: UpgradeStrategy;
ruleGroupIds: string[];
include: string; include: string;
exclude: string; exclude: string;
keywords: string; keywords: string;
/** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的收录版本) */
resolution: SubscriptionResolution | "";
source: SubscriptionSourcePreset | "";
effect: SubscriptionEffect | "";
searchIntervalHours: string; searchIntervalHours: string;
stopAfter: string; stopAfter: string;
autoFill: AutoFillMode; autoFill: AutoFillMode;
@@ -267,14 +212,9 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
libraryRootId: "", libraryRootId: "",
downloadSavePath: "", downloadSavePath: "",
policyRevisionId: "", policyRevisionId: "",
upgrade: "off",
ruleGroupIds: [],
include: "", include: "",
exclude: "", exclude: "",
keywords: "", keywords: "",
resolution: "",
source: "",
effect: "",
searchIntervalHours: "", searchIntervalHours: "",
stopAfter: "", stopAfter: "",
autoFill: "inherit", autoFill: "inherit",
@@ -284,6 +224,7 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
} }
const GB = 1024 ** 3; const GB = 1024 ** 3;
const gbStr = (v: unknown) => (typeof v === "number" ? String(Math.round((v / GB) * 10) / 10) : "");
/** datetime-local 值 ↔ ISO */ /** datetime-local 值 ↔ ISO */
export function toLocalInput(iso: string): string { 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())}`; 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 p = sub.params as Record<string, unknown>;
const range = (p.episodeRange ?? {}) as { start?: unknown; end?: unknown }; const range = (p.episodeRange ?? {}) as { start?: unknown; end?: unknown };
const budget = (p.fillBudget ?? {}) as { unitsPerDay?: unknown; bytesPerDay?: unknown }; const budget = (p.fillBudget ?? {}) as { unitsPerDay?: unknown; bytesPerDay?: unknown };
const bytes = typeof budget.bytesPerDay === "number" ? budget.bytesPerDay : null;
return { return {
...emptyDraft(sub.mediaKind), ...emptyDraft(sub.mediaKind),
mediaKind: sub.mediaKind, mediaKind: sub.mediaKind,
@@ -312,19 +252,14 @@ export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrateg
libraryRootId: str(p.libraryRootId), libraryRootId: str(p.libraryRootId),
downloadSavePath: str(p.downloadSavePath), downloadSavePath: str(p.downloadSavePath),
policyRevisionId: sub.policyRevisionId ?? "", policyRevisionId: sub.policyRevisionId ?? "",
upgrade,
ruleGroupIds: strArr(p.ruleGroupIds),
include: strArr(p.include).join("\n"), include: strArr(p.include).join("\n"),
exclude: strArr(p.exclude).join("\n"), exclude: strArr(p.exclude).join("\n"),
keywords: strArr(p.keywords).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), searchIntervalHours: numStr(p.searchIntervalHours),
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "", stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit", autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
unitsPerDay: numStr(budget.unitsPerDay), 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 为底保留未知键)。 * 草稿 → params(PATCH 整体替换,所以以现有 params 为底保留未知键)。
* upgradeChanged:只有升级策略改了才带 best_version(否则每次保存都会 bump 收录版本 revision)。 * 收录规则只在版本编辑器维护;旧导入的质量预设与规则组原样保留,不从普通表单重建。
*/ */
export function draftToParams( export function draftToParams(
d: SubscriptionDraft, d: SubscriptionDraft,
base: JsonObject = {}, base: JsonObject = {},
opts: { upgradeChanged: boolean; totalChanged: boolean }, opts: { totalChanged: boolean; autoFillChanged: boolean; clearQualityPreset?: boolean },
): { params: JsonObject; errors: DraftErrors } { ): { params: JsonObject; errors: DraftErrors } {
const errors: DraftErrors = {}; const errors: DraftErrors = {};
const out: Record<string, unknown> = { ...base }; const out: Record<string, unknown> = { ...base };
if (opts.clearQualityPreset) for (const k of ["resolution", "source", "effect"]) delete out[k];
const known = [ const known = [
"siteIds", "siteIds",
"episodeRange", "episodeRange",
"searchIntervalHours", "searchIntervalHours",
"stopAfter", "stopAfter",
"downloaderInstanceId", "downloaderInstanceId",
"ruleGroupIds",
"keywords", "keywords",
"include", "include",
"exclude", "exclude",
"libraryRootId", "libraryRootId",
"downloadSavePath", "downloadSavePath",
"autoFill",
"fillBudget",
"resolution",
"source",
"effect",
]; ];
for (const k of known) delete out[k]; for (const k of known) delete out[k];
const set = (k: string, v: unknown) => { const set = (k: string, v: unknown) => {
@@ -370,15 +300,11 @@ export function draftToParams(
set("downloaderInstanceId", d.downloaderInstanceId); set("downloaderInstanceId", d.downloaderInstanceId);
set("libraryRootId", d.libraryRootId); set("libraryRootId", d.libraryRootId);
set("downloadSavePath", d.downloadSavePath.trim()); set("downloadSavePath", d.downloadSavePath.trim());
set("ruleGroupIds", d.ruleGroupIds);
set("include", lines(d.include)); set("include", lines(d.include));
set("exclude", lines(d.exclude)); set("exclude", lines(d.exclude));
const kw = lines(d.keywords); const kw = lines(d.keywords);
if (kw.length > 10) errors.keywords = "最多 10 个"; if (kw.length > 10) errors.keywords = "最多 10 个";
set("keywords", kw); set("keywords", kw);
set("resolution", d.resolution);
set("source", d.source);
set("effect", d.effect);
if (d.mediaKind !== "movie") { if (d.mediaKind !== "movie") {
const s = d.episodeStart.trim(); const s = d.episodeStart.trim();
const e = d.episodeEnd.trim(); const e = d.episodeEnd.trim();
@@ -407,21 +333,24 @@ export function draftToParams(
if (Number.isNaN(t.getTime())) errors.stopAfter = "时间无效"; if (Number.isNaN(t.getTime())) errors.stopAfter = "时间无效";
else out.stopAfter = t.toISOString(); else out.stopAfter = t.toISOString();
} }
if (d.autoFill !== "inherit") out.autoFill = d.autoFill; if (opts.autoFillChanged) {
const budget: Record<string, number> = {}; delete out.autoFill;
if (d.unitsPerDay.trim()) { if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
if (!isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数"; const original = (base.fillBudget ?? {}) as Record<string, unknown>;
else budget.unitsPerDay = Number(d.unitsPerDay); const budget = { ...original };
} if (d.unitsPerDay.trim() && !isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
if (d.gbPerDay.trim()) { if (d.unitsPerDay !== numStr(original.unitsPerDay)) {
const g = Number(d.gbPerDay); delete budget.unitsPerDay;
if (!Number.isFinite(g) || g < 0) errors.gbPerDay = "填非负数"; if (d.unitsPerDay.trim() && !errors.unitsPerDay) budget.unitsPerDay = Number(d.unitsPerDay);
else budget.bytesPerDay = Math.round(g * GB); }
} if (d.gbPerDay.trim() && (!Number.isFinite(Number(d.gbPerDay)) || Number(d.gbPerDay) < 0))
if (Object.keys(budget).length) out.fillBudget = budget; errors.gbPerDay = "填非负数";
if (opts.upgradeChanged) { if (d.gbPerDay !== gbStr(original.bytesPerDay)) {
out.best_version = d.upgrade !== "off"; delete budget.bytesPerDay;
if (d.mediaKind !== "movie") out.best_version_full = d.upgrade === "full_pack"; 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 }; return { params: out as JsonObject, errors };
} }
@@ -43,7 +43,7 @@ export function SlotPanel({
useEffect(() => setRows(serverRows), [serverRows]); useEffect(() => setRows(serverRows), [serverRows]);
const dirty = rows.map((r) => r.id).join() !== serverRows.map((r) => r.id).join(); 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); const [af, setAf] = useState<Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">>(base);
useEffect(() => setAf(base), [base]); useEffect(() => setAf(base), [base]);
const [errs, setErrs] = useState<DraftErrors>({}); const [errs, setErrs] = useState<DraftErrors>({});
@@ -70,8 +70,8 @@ export function SlotPanel({
); );
const saveAutoFill = () => { const saveAutoFill = () => {
const { params, errors } = draftToParams({ ...base, ...af }, sub.params, { const { params, errors } = draftToParams({ ...base, ...af }, sub.params, {
upgradeChanged: false,
totalChanged: false, totalChanged: false,
autoFillChanged: true,
}); });
setErrs(errors); setErrs(errors);
if (Object.keys(errors).length) return; if (Object.keys(errors).length) return;
@@ -10,25 +10,16 @@ import {
type TmdbCandidate, type TmdbCandidate,
} from "@umt/contracts"; } from "@umt/contracts";
import { ExternalLink, Search, X } from "lucide-react"; import { ExternalLink, Search, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useState } from "react"; import { type ReactNode, useEffect, useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEpisodeGroups } from "~/api/episode-mapping.ts"; import { useEpisodeGroups } from "~/api/episode-mapping.ts";
import { useEntityFromCandidate } from "~/api/files.ts"; import { useEntityFromCandidate } from "~/api/files.ts";
import { downloaders, useSiteOptions } from "~/api/instances.ts"; import { downloaders, useSiteOptions } from "~/api/instances.ts";
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts"; import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
import { usePolicyRevisions } from "~/api/policies.ts"; import { usePolicyRevisions } from "~/api/policies.ts";
import { useRuleGroups } from "~/api/rules.ts";
import { useSearch } from "~/api/search.ts"; import { useSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts"; import { duplicateSubscriptionId, useCreateSubscription, useUpdateSubscription } from "~/api/subscriptions.ts";
import {
duplicateSubscriptionId,
useCreateSubscription,
useSetSlotOrder,
useUpdateSubscription,
} from "~/api/subscriptions.ts";
import { FormField } from "~/components/console/form-field.tsx"; import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { RecordLink } from "~/components/console/record-sheet.tsx"; import { RecordLink } from "~/components/console/record-sheet.tsx";
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx"; import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx"; import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx";
@@ -57,19 +48,12 @@ import {
type DraftErrors, type DraftErrors,
draftFromSubscription, draftFromSubscription,
draftToParams, draftToParams,
EFFECT_LABEL,
emptyDraft, emptyDraft,
RESOLUTION_LABEL,
SOURCE_PRESET_LABEL,
SUB_KIND_LABEL, SUB_KIND_LABEL,
type SubscriptionDraft, type SubscriptionDraft,
UPGRADE_LABEL,
type UpgradeStrategy,
upgradeOf,
} from "./model.ts"; } from "./model.ts";
import { SlotOrderList } from "./slot-order.tsx";
// 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。 // 订阅表单管理追踪与下载安排;收录规则统一在版本编辑器维护。新建策略只提供初始版本。
/** 订阅的作品:媒体库作品 / TMDB 条目(保存时先建档,SEARCH-2 详情页入口)/ 按标题追踪 */ /** 订阅的作品:媒体库作品 / TMDB 条目(保存时先建档,SEARCH-2 详情页入口)/ 按标题追踪 */
export type SubscriptionTarget = export type SubscriptionTarget =
@@ -295,21 +279,11 @@ export function SubscriptionFormDialog({
const editing = !!sub; const editing = !!sub;
const create = useCreateSubscription(); const create = useCreateSubscription();
const update = useUpdateSubscription(); const update = useUpdateSubscription();
const setOrder = useSetSlotOrder();
const [target, setTarget] = useState<Target | null>(initialTarget ?? null); const [target, setTarget] = useState<Target | null>(initialTarget ?? null);
const [tab, setTab] = useState("basic"); const [tab, setTab] = useState("basic");
const entityFromTmdb = useEntityFromCandidate(); 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>(() => { const [d, setD] = useState<SubscriptionDraft>(() => {
if (sub) return draftFromSubscription(sub, "off"); if (sub) return draftFromSubscription(sub);
const k = initialTarget?.mediaKind ?? "series"; const k = initialTarget?.mediaKind ?? "series";
const base = emptyDraft(k); const base = emptyDraft(k);
return initialTarget return initialTarget
@@ -321,16 +295,13 @@ export function SubscriptionFormDialog({
} }
: base; : base;
}); });
const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null);
const [upgradeTouched, setUpgradeTouched] = useState(false);
const [totalTouched, setTotalTouched] = 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 [errors, setErrors] = useState<DraftErrors & { target?: string }>({});
const [dup, setDup] = useState<string | null>(null); 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 patch = (p: Partial<SubscriptionDraft>) => setD((x) => ({ ...x, ...p }));
const entityId = sub?.mediaEntityId ?? (target?.kind === "entity" ? target.id : null); const entityId = sub?.mediaEntityId ?? (target?.kind === "entity" ? target.id : null);
const kind = d.mediaKind; const kind = d.mediaKind;
@@ -346,12 +317,10 @@ export function SubscriptionFormDialog({
const sites = useSiteOptions(); const sites = useSiteOptions();
const dls = downloaders.useList(); const dls = downloaders.useList();
const roots = useLibraryRoots(); const roots = useLibraryRoots();
const groups = useRuleGroups({ enabled: "true" });
const policies = usePolicyRevisions({ status: "published" }); const policies = usePolicyRevisions({ status: "published" });
const templates = (policies.data?.items ?? []).filter( const templates = (policies.data?.items ?? []).filter(
(p) => p.kind === "acquisition" && Array.isArray((p.rules as { defaultSlots?: unknown }).defaultSlots), (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) => const tplNames = (pid: string) =>
( (
((templates.find((p) => p.id === pid)?.rules as { defaultSlots?: { name?: string }[] })?.defaultSlots ?? []) as { ((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 submit = async () => {
const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, { const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, {
upgradeChanged: editing ? upgradeTouched && d.upgrade !== initialUpgrade : d.upgrade !== "off",
totalChanged: totalTouched, totalChanged: totalTouched,
autoFillChanged: !editing,
clearQualityPreset,
}); });
const all: typeof errors = { ...errs }; const all: typeof errors = { ...errs };
if (!editing && !target) all.target = "选择作品"; if (!editing && !target) all.target = "选择作品";
if (!editing && target?.kind === "title" && !target.titleQuery.trim()) all.target = "填写标题"; if (!editing && target?.kind === "title" && !target.titleQuery.trim()) all.target = "填写标题";
setErrors(all); setErrors(all);
if (Object.keys(all).length) { 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"); setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced");
revealFirstError(); revealFirstError();
return; return;
@@ -401,8 +371,6 @@ export function SubscriptionFormDialog({
priority: d.priority, priority: d.priority,
params, 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("订阅已保存"); toast.success("订阅已保存");
onClose(); onClose();
} else if (target) { } else if (target) {
@@ -531,7 +499,7 @@ export function SubscriptionFormDialog({
value={d.mediaKind} value={d.mediaKind}
onChange={(k) => { onChange={(k) => {
const mk = (k || "series") as SubscriptionMediaKind; 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) => ({ options={(["series", "movie", "variety"] as const).map((v) => ({
value: v, value: v,
@@ -651,6 +619,42 @@ export function SubscriptionFormDialog({
{isMovie ? null : <AbsoluteNumberingHint entityId={entityId} />} {isMovie ? null : <AbsoluteNumberingHint entityId={entityId} />}
</Section> </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="下载"> <Section title="下载">
<FormField label="站点范围" htmlFor={f("sites")} hint="不选 = 全部启用的站点"> <FormField label="站点范围" htmlFor={f("sites")} hint="不选 = 全部启用的站点">
{sites.isPending ? ( {sites.isPending ? (
@@ -695,50 +699,23 @@ export function SubscriptionFormDialog({
/> />
</FormField> </FormField>
</Section> </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>
<TabsContent value="advanced" className="mt-4 min-w-0 space-y-5"> <TabsContent value="advanced" className="mt-4 min-w-0 space-y-5">
<Section title="过滤"> <Section title="共同过滤与搜索词">
<FormField label="规则组" htmlFor={f("groups")} hint="不选时用默认规则组"> <p className="text-meta text-muted-foreground">包含和排除对这个订阅的全部收录版本同时生效。</p>
{groups.isPending ? ( {legacyQuality ? (
<p className="text-caption text-muted-foreground">读取规则组…</p> <div className="space-y-2 rounded-lg border border-border p-3">
) : (groups.data?.items ?? []).length === 0 ? (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
还没有规则组。 {clearQualityPreset
<Link className="underline underline-offset-4" to={consolePath("rules")}> ? "保存后取消旧预设限制,保留各版本单独调整过的规则。"
去规则页 : `旧质量预设仍生效:${legacyQuality}`}
</Link>
</p> </p>
) : ( <Button variant="outline" size="sm" onClick={() => setClearQualityPreset((v) => !v)}>
<ChipToggleGroup {clearQualityPreset ? "保留旧预设" : "清除旧质量预设"}
label="规则组" </Button>
ordered </div>
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))} ) : null}
value={d.ruleGroupIds}
onChange={(v) => patch({ ruleGroupIds: v })}
/>
)}
</FormField>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签"> <FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签">
<Textarea <Textarea
@@ -759,47 +736,6 @@ export function SubscriptionFormDialog({
/> />
</FormField> </FormField>
</div> </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 <FormField
label="搜索关键词" label="搜索关键词"
htmlFor={f("kw")} htmlFor={f("kw")}
@@ -843,62 +779,16 @@ export function SubscriptionFormDialog({
</div> </div>
</Section> </Section>
<details className="group min-w-0 border-t border-border/60 pt-4"> <Section title="下载优先级">
<summary className="cursor-pointer text-meta font-semibold tracking-wide text-muted-foreground"> <FormField label="优先级" htmlFor={f("prio")} hint="只影响下载排队先后">
多版本(一般不用改) <Pick
</summary> id={f("prio")}
<div className="mt-3 space-y-5"> value={d.priority}
<Section title="收录版本"> onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
{editing ? ( options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
slotsQ.isPending ? ( />
<p className="text-caption text-muted-foreground">读取收录版本…</p> </FormField>
) : subSlots.length === 0 ? ( </Section>
<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>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<DialogFooter> <DialogFooter>
@@ -935,9 +825,9 @@ export function AutoFillFields({
主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。 主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。
</p> </p>
)} )}
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2">
<FormField label="自动补全" htmlFor={`${idPrefix}-af`}> <FormField label="自动补全其他版本" htmlFor={`${idPrefix}-af`} className="sm:col-span-2">
<fieldset aria-label="自动补全" className="m-0 flex gap-1 border-0 p-0" id={`${idPrefix}-af`}> <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) => ( {(["inherit", "on", "off"] as AutoFillMode[]).map((m) => (
<button <button
key={m} key={m}
@@ -956,30 +846,38 @@ export function AutoFillFields({
))} ))}
</fieldset> </fieldset>
</FormField> </FormField>
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}> <details
<Input className="sm:col-span-2"
id={`${idPrefix}-units`} open={!!d.unitsPerDay || !!d.gbPerDay || !!errors.unitsPerDay || !!errors.gbPerDay}
inputMode="numeric" >
placeholder="不限" <summary className="cursor-pointer text-caption text-muted-foreground">补全预算(选填)</summary>
value={d.unitsPerDay} <div className="mt-3 grid gap-3 sm:grid-cols-2">
aria-invalid={!!errors.unitsPerDay} <FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
onChange={(e) => patch({ unitsPerDay: e.target.value })} <Input
/> id={`${idPrefix}-units`}
</FormField> inputMode="numeric"
<FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}> placeholder="不限"
<Input value={d.unitsPerDay}
id={`${idPrefix}-gb`} aria-invalid={!!errors.unitsPerDay}
inputMode="decimal" onChange={(e) => patch({ unitsPerDay: e.target.value })}
placeholder="不限" />
value={d.gbPerDay} </FormField>
aria-invalid={!!errors.gbPerDay} <FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}>
onChange={(e) => patch({ gbPerDay: e.target.value })} <Input
/> id={`${idPrefix}-gb`}
</FormField> 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> </div>
<p className="text-meta text-muted-foreground">
预算只算自动补全发起的下载,超出的延到第二天;也受每日总预算限制。
</p>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts"; import type { WorkbenchRelease } from "@umt/contracts";
import { useSubmitRelease } from "~/api/releases.ts"; import { useSubmitRelease } from "~/api/releases.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.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 { kindHintOf } from "~/components/downloads/manual-download-model.ts";
import { hrOf, promotionLabel } from "./release-fields.ts"; import { hrOf, promotionLabel } from "./release-fields.ts";
@@ -25,22 +25,11 @@ export function workbenchResource(r: WorkbenchRelease): ManualDownloadResource {
} }
/** 工作台使用独立手动下载,不要求先建立订阅或收录版本。 */ /** 工作台使用独立手动下载,不要求先建立订阅或收录版本。 */
export function DownloadDialog({ export function DownloadActions({ release }: { release: WorkbenchRelease }) {
release,
open,
onOpenChange,
}: {
release: WorkbenchRelease;
slots: AcquisitionSlot[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const submit = useSubmitRelease(); const submit = useSubmitRelease();
return ( return (
<ManualDownloadDialog <ManualDownloadActions
key={release.id} key={release.id}
open={open}
onOpenChange={onOpenChange}
resource={workbenchResource(release)} resource={workbenchResource(release)}
onDelegate={() => onDelegate={() =>
openAgentDrawer({ openAgentDrawer({
@@ -1,6 +1,6 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts"; import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
import { ArrowDown, ArrowUp, Bot, Check, Download, RefreshCw } from "lucide-react"; import { ArrowDown, ArrowUp, Bot, Check, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEntityDetail } from "~/api/inventory.ts"; import { useEntityDetail } from "~/api/inventory.ts";
@@ -15,7 +15,7 @@ import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { useUiPrefs } from "~/lib/ui-prefs.ts"; import { useUiPrefs } from "~/lib/ui-prefs.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.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 { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts";
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx"; import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx"; import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx";
@@ -136,10 +136,8 @@ export function ReleaseDetailSheet({
const rid = release?.id ?? releaseId; const rid = release?.id ?? releaseId;
const detail = useReleaseDetail(siteId, rid); const detail = useReleaseDetail(siteId, rid);
const refresh = useRefreshReleaseDetail(); const refresh = useRefreshReleaseDetail();
const [dl, setDl] = useState(false);
const techOpen = useUiPrefs().techInfoExpanded; const techOpen = useUiPrefs().techInfoExpanded;
const r = detail.data?.release ?? release; const r = detail.data?.release ?? release;
const entity = useEntityDetail(r?.entity?.id);
// 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次) // 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次)
const autoTried = useRef(new Set<string>()); const autoTried = useRef(new Set<string>());
@@ -191,10 +189,7 @@ export function ReleaseDetailSheet({
<Bot /> <Bot />
问智能体 问智能体
</Button> </Button>
<Button size="sm" onClick={() => setDl(true)}> <DownloadActions key={r.id} release={r} />
<Download />
下载
</Button>
</> </>
) : null ) : null
} }
@@ -321,7 +316,6 @@ export function ReleaseDetailSheet({
<JsonView label="解析结果" value={r.parsed} defaultOpen={techOpen} /> <JsonView label="解析结果" value={r.parsed} defaultOpen={techOpen} />
</> </>
)} )}
{r ? <DownloadDialog release={r} slots={entity.data?.slots ?? []} open={dl} onOpenChange={setDl} /> : null}
</DetailSheet> </DetailSheet>
); );
} }
+1 -1
View File
@@ -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 = { export const DESIGN_TOKENS = {
"fontSizes": [ "fontSizes": [
"micro", "micro",
+34 -27
View File
@@ -8,43 +8,50 @@
"build": "react-router build", "build": "react-router build",
"preview": "vite preview --host 0.0.0.0 --port 5180", "preview": "vite preview --host 0.0.0.0 --port 5180",
"typecheck": "react-router typegen && tsc -p tsconfig.json", "typecheck": "react-router typegen && tsc -p tsconfig.json",
"test": "deno run -A npm:vitest run", "test": "bun x --bun vitest run",
"gen:tokens": "deno run -A scripts/gen-design-tokens.mjs", "gen:tokens": "bun scripts/gen-design-tokens.mjs",
"check:tokens": "deno run -A scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh" "check:tokens": "bun scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0", "@fontsource-variable/inter-tight": "5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "5.3.0",
"@react-router/node": "8.4.0", "@react-router/node": "8.4.0",
"@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/browser": "^14.0.0",
"@tanstack/react-query": "^5.104.0", "@tanstack/react-query": "5.104.1",
"@umt/contracts": "workspace:*", "@umt/contracts": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "2.1.1",
"date-fns": "^4.4.0", "date-fns": "4.4.0",
"isbot": "^5.2.2", "isbot": "5.2.2",
"lucide-react": "^1.49.0", "lucide-react": "1.55.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"qr-code-styling": "^1.9.2", "qr-code-styling": "1.9.2",
"radix-ui": "^1.6.7", "radix-ui": "1.7.0",
"react": "^19.3.0", "react": "19.3.0",
"react-day-picker": "^10.0.2", "react-day-picker": "10.0.2",
"react-dom": "^19.3.0", "react-dom": "19.3.0",
"react-router": "8.4.0", "react-router": "8.4.0",
"recharts": "^3.10.1", "recharts": "3.10.1",
"simple-liquid-glass": "5.3.0", "simple-liquid-glass": "5.4.0",
"sonner": "^2.0.8", "sonner": "2.0.8",
"tailwind-merge": "^3.7.0", "tailwind-merge": "3.7.0",
"zod": "^4.6.5" "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": { "devDependencies": {
"@react-router/dev": "8.4.0", "@react-router/dev": "8.4.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "4.3.3",
"@types/react": "^19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "19.3.0",
"tailwindcss": "^4.3.3", "tailwindcss": "4.3.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "1.4.0",
"vite": "^8.3.1", "vite": "8.3.4",
"vite-tsconfig-paths": "^6.1.1" "vite-tsconfig-paths": "6.1.1"
} }
} }
+4 -4
View File
@@ -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, // 从 apps/web/app/app.css 抽出自定义工具类的 token 名,生成 app/lib/design-tokens.generated.ts,
// 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、 // 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、
// 和 `text-muted-foreground` 冲突后丢掉字号(2026-09-12 页面级复验抓到的)。 // 和 `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"; import { readFileSync, writeFileSync } from "node:fs";
const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8"); const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8");
@@ -15,7 +15,7 @@ const tokens = {
radii: pick("radius"), radii: pick("radius"),
durations: [...new Set([...css.matchAll(/^@utility duration-([a-z0-9-]+)/gm)].map((m) => m[1]))], 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; export const DESIGN_TOKENS = ${JSON.stringify(tokens, null, 2)} as const;
`; `;
const target = new URL("../app/lib/design-tokens.generated.ts", import.meta.url); 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"); current = readFileSync(target, "utf8");
} catch {} } catch {}
if (current !== out) { 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); process.exit(1);
} }
console.log( console.log(
+4 -2
View File
@@ -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`,并且配文字,不只靠颜色。 2. **禁止原色与十六进制。** 组件里不写 `#xxxxxx`、`text-[#..]`、`bg-red-500` 这类 Tailwind 调色板色、`white/10` 之类的黑白透明度,也不写 `text-[13px]`、`duration-300`、`ease-[cubic..]`、裸 `rounded`。状态只用 `success` / `warning` / `destructive`,并且配文字,不只靠颜色。
3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 `<EmptyState plain>`。 3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 `<EmptyState plain>`。
4. 确需例外(第三方库的颜色参数等),在该行或上一行写 `token-allow: 原因`。 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 一起更新,各仓库独立安装和构建。控制台的分页、轻输入边线、实底弹层、媒体头图和文件管理样式由本项目维护;流媒体主题和播放器专用机制留在上游。共用机制优先修当前实现,不增加跨仓库源码引用或主题框架。
## 控制台共享件 ## 控制台共享件
+2
View File
@@ -90,6 +90,8 @@
- `regular`:栏与胶囊。控制台顶栏右侧的胶囊(通知、智能体、日夜切换、账户)用它的只模糊档(`noRefraction`); - `regular`:栏与胶囊。控制台顶栏右侧的胶囊(通知、智能体、日夜切换、账户)用它的只模糊档(`noRefraction`);
- `frost`:字多的悬浮面板,只做真模糊(24px)加一层底色,不折射:以可读为先。 - `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` 不加边光。 全站其余玻璃不折射,但用同一套边光语言:左上亮、右下暗的 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 会把它当成背景根,里面的模糊采样不到页面。 折射只有 Chromium 桌面可见,其他浏览器与触屏设备由组件退回磨砂。背后没有画面的地方(内容卡、表单)不用液态玻璃,折射看不出来。玻璃之间至少留 8px,不叠放。含玻璃的弹层外壳不能挂一直生效的透明度动画(`animate-pg-pop` 用 `backwards`):Chromium 会把它当成背景根,里面的模糊采样不到页面。
+21 -28
View File
@@ -86,7 +86,7 @@
### 2.2 多版本与自动补全 ### 2.2 多版本与自动补全
- 每个版本目标一行,行尾:缺失数、在途数、"补全此版本"按钮、自动补全状态。 - 每个版本目标一行,行尾:缺失数、在途数、"补全此版本"按钮、自动补全状态。
- **自动补全其他版本**:订阅级开关 `params.autoFill: "inherit" | "on" | "off"`,缺省 inherit → 取引用规则组/订阅默认值的 `autoFill`(系统缺省 on)。 - **自动补全其他版本**:订阅级开关 `params.autoFill: "inherit" | "on" | "off"`,缺省 inherit → 取主版本收录链的 `autoFill`(后者优先,系统缺省关闭)。
- on:非主版本目标像主版本一样独立评估、搜索、下载缺失单元。 - on:非主版本目标像主版本一样独立评估、搜索、下载缺失单元。
- off:非主版本目标只登记缺失,不自动建计划;RSS 来的合格资源记为"延后(未开自动补全)";手动"补全此版本"或 Agent 调 `download.plan` 仍可下。 - off:非主版本目标只登记缺失,不自动建计划;RSS 来的合格资源记为"延后(未开自动补全)";手动"补全此版本"或 Agent 调 `download.plan` 仍可下。
- 主版本 = 版本目标排序第一个(可在版本目标区拖动)。 - 主版本 = 版本目标排序第一个(可在版本目标区拖动)。
@@ -94,33 +94,18 @@
## 3. 新建 / 编辑订阅表单 ## 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` | 空 = 全部启用站点 | 取消订阅表单的统一分辨率、质量、特效、规则组与洗版编辑,不再向所有版本批量写入。旧 `params.ruleGroupIds/resolution/source/effect` 通过 API 继续兼容,普通保存原样保留;UHD/Atmos 私有过滤不因修改站点或周期而丢失。概览逐版本解析有效规则后合并展示,隐藏订阅私有组。自动补全继承主版本收录链的最后一个显式设置,缺省关闭;订阅显式开/关优先。
| | 下载器 | 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 | | 放身份层(实体别名/集偏移),表单给链接 | |
保存:创建时同实体同季已有 active 订阅 → 返回 409 并给"打开已有订阅"(差距 #18)。 保存:创建时同实体同季已有 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),界面在该组上标"决定优先级"。 - 版本目标 `rule_group_ids`(有序)+ 订阅 `params.ruleGroupIds`;评估时编译为 SlotSpec:hard 取交集;各组按 scope 过滤后**全部须命中某层**,pri 取**最后一个**带阶梯的组(同 MP),界面在该组上标"决定优先级"。
- 版本目标自身的 hard/soft 字段保留,视为一个匿名组排在最前。 - 版本目标自身的 hard/soft 字段保留,视为一个匿名组排在最前。
- 默认值 Tab:按类型设 **订阅默认规则组**、**洗版默认规则组**(版本目标开升级时额外叠加)、**搜索默认规则组**(手动搜索与 Agent `release.search`)、订阅默认表单(§3 默认模式:站点、下载器、库根、版本目标模板、自动补全)。 - 默认值 Tab:按类型设 **订阅默认规则组**、**洗版默认规则组**(只在洗版阶段追加,版本目标可独立指定洗版规则组)、**搜索默认规则组**(手动搜索与 Agent `release.search`)、订阅默认表单(§3 默认模式:站点、下载器、库根、版本目标模板、自动补全)。
- 收录策略 Tab:现有策略工作区原样搬入(草案→预览→发布→回滚、newTitle、defaultSlots);`SlotTemplate` 增加 `ruleGroupIds`。 - 收录策略 Tab:现有策略工作区原样搬入(草案→预览→发布→回滚、newTitle、defaultSlots);`SlotTemplate` 增加 `ruleGroupIds`。
- Agent:能力 `rules.list` 读规则组;新增/修改走 `rules.propose`(origin=agent_inferred,生成待处理事项,批准后生效);命中有效抑制记录的指纹直接拒绝。对话中用户明确说的偏好 origin=conversation,直接生效并可撤销。 - 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(只剩音乐)。 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. 实施拆分与估算 ## 7. 实施拆分与估算
| 包 | 内容 | 估 | 依赖 | | 包 | 内容 | 估 | 依赖 |