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