feat(web): simplify subscription and manual download flows and sync UHD styles
This commit is contained in:
1 parent
27c0f27434
commit
c3c4456913
33 files changed
+1083
-633
No files matched your search
+6
-6
@@ -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 守卫。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"] });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 一起更新,各仓库独立安装和构建。控制台的分页、轻输入边线、实底弹层、媒体头图和文件管理样式由本项目维护;流媒体主题和播放器专用机制留在上游。共用机制优先修当前实现,不增加跨仓库源码引用或主题框架。
|
||||||
|
|
||||||
## 控制台共享件
|
## 控制台共享件
|
||||||
|
|
||||||
|
|||||||
@@ -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 会把它当成背景根,里面的模糊采样不到页面。
|
||||||
|
|||||||
@@ -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. 实施拆分与估算
|
||||||
|
|
||||||
| 包 | 内容 | 估 | 依赖 |
|
| 包 | 内容 | 估 | 依赖 |
|
||||||
|
|||||||
Reference in new issue
Block a user