From c3c44569134971c75c5810e2e787e9afa984f5cf Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Oct 2026 14:37:03 +0800 Subject: [PATCH] feat(web): simplify subscription and manual download flows and sync UHD styles --- apps/web/README.md | 12 +- apps/web/app/api/search.ts | 7 +- apps/web/app/api/slots.ts | 1 + apps/web/app/api/tasks.test.ts | 62 ++++ apps/web/app/api/tasks.ts | 33 +- apps/web/app/app.css | 5 + .../components/browser/resource-download.tsx | 10 +- .../web/app/components/console/inbox-bell.tsx | 7 +- .../app/components/console/record-sheet.tsx | 22 ++ .../app/components/discover/discover-card.tsx | 4 +- .../components/downloads/business-tasks.tsx | 25 +- .../downloads/manual-download-dialog.tsx | 227 +++++++++---- .../app/components/library/slot-rules.test.ts | 217 ++++++++++++ .../web/app/components/library/slot-rules.tsx | 113 +++++-- .../library/subscriptions-policy.tsx | 95 +++++- .../library/subscriptions-slots.tsx | 34 +- apps/web/app/components/liquid-surface.tsx | 3 + .../app/components/search/release-results.tsx | 38 +-- .../search/search-download-dialog.tsx | 17 +- .../search/search-results-ui.test.ts | 11 +- .../components/settings/organize-sources.tsx | 61 +++- .../components/subscriptions/model.test.ts | 114 ++++--- .../web/app/components/subscriptions/model.ts | 121 ++----- .../components/subscriptions/slot-panel.tsx | 4 +- .../subscriptions/subscription-form.tsx | 312 ++++++------------ .../components/workbench/download-dialog.tsx | 19 +- .../workbench/release-detail-sheet.tsx | 14 +- apps/web/app/lib/design-tokens.generated.ts | 2 +- apps/web/package.json | 61 ++-- apps/web/scripts/gen-design-tokens.mjs | 8 +- docs/design/README.md | 6 +- docs/design/interface-materials.md | 2 + docs/design/subscriptions.md | 49 ++- 33 files changed, 1083 insertions(+), 633 deletions(-) create mode 100644 apps/web/app/api/tasks.test.ts create mode 100644 apps/web/app/components/library/slot-rules.test.ts diff --git a/apps/web/README.md b/apps/web/README.md index 3f9f2863..ecc15cd3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,16 +1,16 @@ # @umt/web -React Router 7 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。 +React Router 8 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。 ## 命令 在仓库根目录执行: -- `deno task dev:web`:开发服务器(5180)。 -- `cd apps/web && deno task build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。 -- `cd apps/web && deno task test`:单测(`app/**/*.test.ts`)。 -- `deno task test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。 -- `deno task check:tokens`:设计 token 守卫。 +- `bun run dev:web`:开发服务器(5180)。 +- `cd apps/web && bun run build`:生产构建到 `build/client`,由 server 的 `WEB_ROOT` 同端口托管。 +- `cd apps/web && bun run test`:单测(`app/**/*.test.ts`)。 +- `bun run test:web:smoke`:Playwright 全页面冒烟,前提与环境变量见仓库 README。 +- `bun run check:tokens`:设计 token 守卫。 ## 目录 diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts index f45669d6..ee3223d2 100644 --- a/apps/web/app/api/search.ts +++ b/apps/web/app/api/search.ts @@ -100,9 +100,10 @@ export function usePlanFromSearch() { api .post(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body) .then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["plans"] }); - void qc.invalidateQueries({ queryKey: ["slots"] }); + onSuccess: (r) => { + if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] }); + void qc.invalidateQueries({ queryKey: ["inventory"] }); + void qc.invalidateQueries({ queryKey: ["library"] }); void qc.invalidateQueries({ queryKey: searchKeys.all }); }, }); diff --git a/apps/web/app/api/slots.ts b/apps/web/app/api/slots.ts index 1c32e321..3c43063b 100644 --- a/apps/web/app/api/slots.ts +++ b/apps/web/app/api/slots.ts @@ -37,6 +37,7 @@ function useInvalidate() { return () => { void qc.invalidateQueries({ queryKey: slotKeys.all }); void qc.invalidateQueries({ queryKey: ["library"] }); + void qc.invalidateQueries({ queryKey: ["subscriptions"] }); }; } diff --git a/apps/web/app/api/tasks.test.ts b/apps/web/app/api/tasks.test.ts new file mode 100644 index 00000000..0d838206 --- /dev/null +++ b/apps/web/app/api/tasks.test.ts @@ -0,0 +1,62 @@ +import type { Decision, DecisionStatus, DownloadTask } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { buildTimeline } from "./tasks.ts"; + +const task = { + id: "download", + name: "作品", + origin: "user", + status: "queued", + createdAt: "2026-10-13T00:00:00Z", +} as unknown as DownloadTask; +const decision = (status: DecisionStatus): Decision => + ({ + id: "decision", + status, + title: "等待补全资源规格", + summary: "规格缺少编码", + severity: "critical", + subjectType: "download_task", + subjectId: "download", + agentRunId: "agent", + createdAt: "2026-10-13T00:00:01Z", + updatedAt: "2026-10-13T00:00:02Z", + evidence: {}, + }) as Decision; + +describe("下载时间线的异常状态", () => { + it.each([ + ["open", "待处理", "destructive", false], + ["investigating", "智能体调查中", "neutral", true], + ["awaiting_user", "等你决定", "destructive", false], + ["resolved", "已处理", "success", false], + ["dismissed", "已忽略", "neutral", false], + ["expired", "已过期", "neutral", false], + ] as const)("%s按实际状态显示,不凭会话ID判断仍需人工", (status, label, tone, ongoing) => { + const events = buildTimeline({ task, decisions: [decision(status)] }); + expect(events.find((e) => e.key === "dec-decision")).toMatchObject({ + title: `${label}:等待补全资源规格`, + tone, + ongoing, + href: "/console/downloads?open=download", + }); + expect(events.find((e) => e.key === "agent-decision")).toMatchObject({ + title: "智能体调查记录", + tone: "neutral", + href: "/console/agent/agent", + }); + }); + + it("交还人工时展示已有恢复建议,计划事项定位到计划", () => { + const d = { + ...decision("awaiting_user"), + subjectType: "acquisition_plan", + subjectId: "plan", + evidence: { autoRecover: { suggestion: "请确认资源详情的编码" } }, + }; + expect(buildTimeline({ task, decisions: [d] }).find((e) => e.key === "dec-decision")).toMatchObject({ + detail: "请确认资源详情的编码", + href: "/console/downloads?plan=plan", + }); + }); +}); diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index 2aaa8a65..b5e7b198 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { type AcquisitionPlan, cursorPage, @@ -10,9 +10,12 @@ import { type TaskKind, type TaskStatus, } from "@umt/contracts"; -import { recordPath, taskPath } from "~/components/console/record-sheet.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { recordPath, subjectLink, taskPath } from "~/components/console/record-sheet.tsx"; +import { DECISION_STATUS } from "~/components/library/labels.ts"; import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts"; import { humanizeReason, reasonLine } from "~/lib/reasons.ts"; +import { useSse } from "~/lib/sse.ts"; import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts"; import { apiParsed } from "./parse.ts"; @@ -60,6 +63,14 @@ export function useSourceRelease(id: string | null | undefined) { /** 与该下载有关的待处理事项(subject = 下载任务或计划),含 Agent 调查 run */ export function useSubjectDecisions(subjectIds: string[]) { const ids = subjectIds.filter(Boolean); + const qc = useQueryClient(); + useSse( + ["decision.created", "decision.updated"], + () => { + void qc.invalidateQueries({ queryKey: ["decisions", "subjects", ids] }); + }, + ids.length > 0, + ); return useQuery({ queryKey: ["decisions", "subjects", ids], enabled: ids.length > 0, @@ -256,23 +267,29 @@ export function buildTimeline(input: { }); } for (const d of decisions) { + const state = DECISION_STATUS[d.status]; + const needsUser = d.status === "open" || d.status === "awaiting_user"; + const recovery = d.evidence.autoRecover as { suggestion?: unknown } | undefined; + const suggestion = + d.status === "awaiting_user" && typeof recovery?.suggestion === "string" ? recovery.suggestion : null; ev.push({ key: `dec-${d.id}`, stage: "agent", at: d.createdAt, - title: `需要处理:${d.title}`, - detail: d.summary, - tone: d.severity === "critical" ? "destructive" : "warning", - href: "/console/inbox", + title: `${state.label}:${d.title}`, + detail: suggestion ?? d.summary, + tone: needsUser && d.severity === "critical" ? "destructive" : state.tone, + href: subjectLink(d) ?? (d.agentRunId ? consolePath("agent", d.agentRunId) : consolePath("agent")), + ongoing: d.status === "investigating", }); if (d.agentRunId) ev.push({ key: `agent-${d.id}`, stage: "agent", at: d.updatedAt, - title: "智能体已介入调查", + title: "智能体调查记录", tone: "neutral", - href: `/console/agent/${d.agentRunId}`, + href: consolePath("agent", d.agentRunId), }); } for (const t of tasks) { diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 38277442..46c6b7f0 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -1427,6 +1427,11 @@ background: var(--mat-lens-fill-hover); } + /* 初始化时移除包自带的白光泽;折射玻璃用自身底色,磨砂玻璃的内层保持透明 */ + .liquid-surface[data-glass-strategy="pending"] > :first-child { + background: var(--glass-pending-fill) !important; + } + /* LiquidSurface 只模糊不折射时(触屏、测速页、frost 面板):包在玻璃层顶部画的 50% 白光泽太亮, 夜间会压住第一行字。普通档收到很淡,frost(字多的面板)去掉 */ .liquid-surface-blur > :first-child { diff --git a/apps/web/app/components/browser/resource-download.tsx b/apps/web/app/components/browser/resource-download.tsx index 08fbc6a0..f9cfabc4 100644 --- a/apps/web/app/components/browser/resource-download.tsx +++ b/apps/web/app/components/browser/resource-download.tsx @@ -56,13 +56,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) { {busy ? "提交中…" : "下载当前资源"} {planId ? ( @@ -70,13 +71,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) { ) : null} - {resource ? ( + {resource && open ? ( submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })} /> ) : null} diff --git a/apps/web/app/components/console/inbox-bell.tsx b/apps/web/app/components/console/inbox-bell.tsx index 7bf0cf62..af225f00 100644 --- a/apps/web/app/components/console/inbox-bell.tsx +++ b/apps/web/app/components/console/inbox-bell.tsx @@ -5,7 +5,7 @@ import { Link, useNavigate } from "react-router"; import { useAttention } from "~/api/attention.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { consolePath } from "~/components/console/nav.ts"; -import { recordPath } from "~/components/console/record-sheet.tsx"; +import { subjectLink } from "~/components/console/record-sheet.tsx"; import { SEVERITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; @@ -23,10 +23,7 @@ const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: " export function approvalHref(d: Pick): string { if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId); if (d.agentRunId) return consolePath("agent", d.agentRunId); - if (d.subjectType === "acquisition_plan" && d.subjectId) - return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`; - if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId); - return consolePath("agent"); + return subjectLink(d) ?? consolePath("agent"); } export const HEALTH_HREF = `${consolePath("status")}#attention`; diff --git a/apps/web/app/components/console/record-sheet.tsx b/apps/web/app/components/console/record-sheet.tsx index 5c4058c0..f59ffb5d 100644 --- a/apps/web/app/components/console/record-sheet.tsx +++ b/apps/web/app/components/console/record-sheet.tsx @@ -29,6 +29,28 @@ export function recordPath(page: RecordPage, id: string, extra?: Extra): string return `${consolePath(page)}?${q}`; } +/** 业务记录的对象 → 它的"家"页面 */ +export function subjectLink(t: { subjectType: string | null; subjectId: string | null }): string | null { + if (!t.subjectId) return null; + switch (t.subjectType) { + case "download_task": + return recordPath("downloads", t.subjectId); + case "acquisition_plan": + return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`; + case "organization_record": + return recordPath("organize", t.subjectId); + case "media_entity": + return consolePath("library", t.subjectId); + case "site": + return recordPath("sites", t.subjectId); + case "feed_source": + // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab + return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`; + default: + return null; + } +} + /** 任务(引擎运行)在日志页的抽屉里看:/console/logs?type=task&run= */ export function taskPath(runId: string): string { return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`; diff --git a/apps/web/app/components/discover/discover-card.tsx b/apps/web/app/components/discover/discover-card.tsx index 37649c15..e6e3b04a 100644 --- a/apps/web/app/components/discover/discover-card.tsx +++ b/apps/web/app/components/discover/discover-card.tsx @@ -1,4 +1,4 @@ -import { type DiscoverItem, tmdbImageUrl } from "@umt/contracts"; +import { type DiscoverItem, externalImageUrl, tmdbImageUrl } from "@umt/contracts"; import { CalendarPlus, LoaderCircle, PackageSearch, Star } from "lucide-react"; import { useState } from "react"; import { StatusBadge } from "~/components/console/status-badge.tsx"; @@ -11,7 +11,7 @@ import { itemMeta, localBadges } from "./discover-state.ts"; export type CardAction = "open" | "subscribe" | "search"; export function coverUrl(it: Pick): string | null { - return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : it.cover; + return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : externalImageUrl(it.cover); } export function DiscoverCard({ diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index eb0717ce..06fcff63 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -6,8 +6,7 @@ import { toast } from "sonner"; import { apiMutate } from "~/api/parse.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; -import { consolePath } from "~/components/console/nav.ts"; -import { recordPath, taskPath } from "~/components/console/record-sheet.tsx"; +import { subjectLink, taskPath } from "~/components/console/record-sheet.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -62,28 +61,6 @@ export const SUBJECT_LABEL: Record = { cache_volume: "下载盘", }; -/** 业务任务的对象 → 它的"家"页面 */ -export function subjectLink(t: Pick): string | null { - if (!t.subjectId) return null; - switch (t.subjectType) { - case "download_task": - return recordPath("downloads", t.subjectId); - case "acquisition_plan": - return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`; - case "organization_record": - return recordPath("organize", t.subjectId); - case "media_entity": - return consolePath("library", t.subjectId); - case "site": - return recordPath("sites", t.subjectId); - case "feed_source": - // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab - return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`; - default: - return null; - } -} - export function subjectText(t: Pick): string { if (!t.subjectType) return "—"; const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType; diff --git a/apps/web/app/components/downloads/manual-download-dialog.tsx b/apps/web/app/components/downloads/manual-download-dialog.tsx index 4ded6b26..6ae4514d 100644 --- a/apps/web/app/components/downloads/manual-download-dialog.tsx +++ b/apps/web/app/components/downloads/manual-download-dialog.tsx @@ -1,7 +1,7 @@ import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts"; -import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, Settings2, X } from "lucide-react"; import { useEffect, useId, useState } from "react"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { useDownloadSavePaths } from "~/api/downloads.ts"; import { useEntityDetail } from "~/api/library.ts"; @@ -67,6 +67,75 @@ export interface ManualDownloadResource { const AUTO = "__auto__"; +/** 搜索与工作台直接下载,只有高级选项或未识别确认才打开窗口。 */ +export function ManualDownloadActions({ + resource, + submit, + onDelegate, +}: { + resource: ManualDownloadResource; + submit: (options: ManualDownloadOptions) => Promise; + onDelegate?: () => void; +}) { + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(false); + const [unrecognized, setUnrecognized] = useState(false); + const go = async () => { + setBusy(true); + try { + const result = await submit({ importMode: "normal", replacementAssetIds: [] }); + if (result.requiresConfirmation) { + setUnrecognized(true); + setOpen(true); + } else if (result.submitted) { + const planId = result.plan?.id; + toast.success("已提交下载", { + ...(planId + ? { action: { label: "查看下载", onClick: () => navigate(`/console/downloads?plan=${planId}`) } } + : {}), + }); + } else toast.warning(result.rejected.map((r) => r.message).join(";") || "没有提交成功"); + } catch (e) { + toast.error(errorMessage(e)); + } finally { + setBusy(false); + } + }; + return ( + <> + + + {open ? ( + + ) : null} + + ); +} + /** 搜索、工作台和原站视图共用的一次性下载选项。 */ export function ManualDownloadDialog({ resource, @@ -74,6 +143,7 @@ export function ManualDownloadDialog({ onOpenChange, submit, initialUnrecognized = false, + initialAdvanced = false, onDelegate, }: { resource: ManualDownloadResource; @@ -81,6 +151,7 @@ export function ManualDownloadDialog({ onOpenChange: (open: boolean) => void; submit: (options: ManualDownloadOptions) => Promise; initialUnrecognized?: boolean; + initialAdvanced?: boolean; onDelegate?: () => void; }) { const formId = useId(); @@ -95,6 +166,7 @@ export function ManualDownloadDialog({ const [mode, setMode] = useState<"normal" | "replace">("normal"); const [selected, setSelected] = useState([]); const [unrecognized, setUnrecognized] = useState(initialUnrecognized); + const [advanced, setAdvanced] = useState(initialAdvanced); const [identityOpen, setIdentityOpen] = useState(!entityId); // null = 没动过,显示作品当前的值 const [tmdbType, setTmdbType] = useState(null); @@ -132,9 +204,9 @@ export function ManualDownloadDialog({ if (open) { setResult(null); setUnrecognized(initialUnrecognized); - if (initialUnrecognized) setIdentityOpen(true); + setAdvanced(initialAdvanced); } - }, [open, initialUnrecognized]); + }, [open, initialUnrecognized, initialAdvanced]); const go = async () => { setBusy(true); @@ -156,7 +228,6 @@ export function ManualDownloadDialog({ setResult(r); if (r.requiresConfirmation) { setUnrecognized(true); - setIdentityOpen(true); } if (r.submitted) toast.success("已提交下载"); } catch (e) { @@ -170,6 +241,7 @@ export function ManualDownloadDialog({ const siteName = resource.siteName ?? site.data?.name ?? null; const missingCustom = dir === CUSTOM_DIR && !custom.trim(); const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId); + const savePath = resolveSavePath(target, dir, custom); return ( @@ -203,65 +275,92 @@ export function ManualDownloadDialog({
- { - setInstanceId(v); - if (dir !== CUSTOM_DIR) setDir(""); - }} - target={target} - dir={dir} - onDirChange={setDir} - custom={custom} - onCustomChange={setCustom} - /> - 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 ? ( - +
+

+ {paths.error + ? errorMessage(paths.error) + : paths.isPending + ? "读取下载去向…" + : target + ? `${target.name} · ${savePath ? `保存到:${savePathText(savePath)}` : "填写保存目录"}` + : "没有可用的下载器,先在设置里添加下载器和保存目录"} +

+ {changed ? ( +

本次作品:{picked ? picked.title : `${shownType === "movie" ? "电影" : "剧集"} · TMDB ${shownId}`}

+ ) : null} + {effectiveMode === "replace" ?

本次洗版:替换 {selected.length} 个已有文件

: null} +
+ {unrecognized && !changed ? ( +

+ 没识别出作品。可以继续下载,整理时再识别;也可在高级选项里指定作品。 +

) : null} +
setAdvanced(e.currentTarget.open)}> + 高级选项 + {advanced ? ( +
+ { + setInstanceId(v); + if (dir !== CUSTOM_DIR) setDir(""); + }} + target={target} + dir={dir} + onDirChange={setDir} + custom={custom} + onCustomChange={setCustom} + /> + 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 ? ( + + ) : null} +
+ ) : null} +
{result && !result.submitted && !result.requiresConfirmation ? (

{result.rejected.map((r) => r.message).join(";")}

) : null} @@ -272,7 +371,7 @@ export function ManualDownloadDialog({ ) : null}
- {onDelegate ? ( + {onDelegate && (advanced || unrecognized) ? ( +
+ {subscriptionId && s.subscriptionId === null ? ( + 作品共用 + ) : null} + +
} /> ))} @@ -127,6 +136,8 @@ function SlotEditorSheet({ const id = useId(); const create = useCreateSlot(); const update = useUpdateSlot(); + const groups = useRuleGroups({ enabled: "true" }); + const [ruleGroupIds, setRuleGroupIds] = useState(slot?.ruleGroupIds ?? []); const [name, setName] = useState(slot?.name ?? ""); const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? ""); const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false); @@ -145,6 +156,7 @@ function SlotEditorSheet({ if (Object.keys(errs).length || !meta.success || !rules.ok) return; const common = { ...rules.value, + ruleGroupIds, distinctGroup: meta.data.distinctGroup || null, requireDistinctAsset: requireDistinct, }; @@ -232,6 +244,20 @@ function SlotEditorSheet({ + + ({ 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} + /> + ); diff --git a/apps/web/app/components/liquid-surface.tsx b/apps/web/app/components/liquid-surface.tsx index 74fb447e..c118d45e 100644 --- a/apps/web/app/components/liquid-surface.tsx +++ b/apps/web/app/components/liquid-surface.tsx @@ -65,6 +65,7 @@ export function LiquidSurface({ const t = TIERS[tier]; return ( (null); const [selected, setSelected] = useState(null); const filtered = selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]); @@ -267,7 +264,7 @@ export function ReleaseResults({ className="py-12" /> ) : ( - + )} {rows.length > 0 || res.hasNextPage ? : null} {first?.unmatchedTotal ? ( @@ -276,7 +273,6 @@ export function ReleaseResults({ rows={first.unmatched ?? []} total={first.unmatchedTotal} onOpen={setSelected} - onDownload={setDl} /> ) : null} @@ -293,7 +289,6 @@ export function ReleaseResults({ /> ) : null} - {dl ? !o && setDl(null)} /> : null} {selected?.sourceReleaseId ? ( void; - onDownload: (r: SearchRelease) => void; defaultOpen?: boolean; }) { const [open, setOpen] = useState(defaultOpen); @@ -338,7 +331,7 @@ export function UnmatchedReleases({ {open ? (
- +
) : null} @@ -517,20 +510,10 @@ function WorkHeader({ g, count }: { g: WorkGroup; count: number }) { } /** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */ -function ReleaseList({ - rows, - onOpen, - onDownload, -}: { - rows: SearchRelease[]; - onOpen: (r: SearchRelease) => void; - onDownload: (r: SearchRelease) => void; -}) { +function ReleaseList({ rows, onOpen }: { rows: SearchRelease[]; onOpen: (r: SearchRelease) => void }) { const { grouped, groups } = groupByWork(rows); const single = !grouped && groups[0]?.entity ? groups[0] : null; - const row = (r: SearchRelease) => ( - onOpen(r)} onDownload={() => onDownload(r)} /> - ); + const row = (r: SearchRelease) => onOpen(r)} />; if (!grouped) return ( <> @@ -618,7 +601,7 @@ function VerdictLine({ r }: { r: SearchRelease }) { * 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐, * 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。 */ -function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) { +function ReleaseRow({ r, onOpen }: { r: SearchRelease; onOpen: () => void }) { const hr = hrBadge(r); const seedless = r.seeders === 0 || r.seeders === null; return ( @@ -674,14 +657,7 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => ) : null} - - + diff --git a/apps/web/app/components/search/search-download-dialog.tsx b/apps/web/app/components/search/search-download-dialog.tsx index 9e1c43cb..441cbea8 100644 --- a/apps/web/app/components/search/search-download-dialog.tsx +++ b/apps/web/app/components/search/search-download-dialog.tsx @@ -1,7 +1,7 @@ import type { SearchRelease } from "@umt/contracts"; import { usePlanFromSearch } from "~/api/search.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; -import { ManualDownloadDialog, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx"; +import { ManualDownloadActions, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx"; import { kindHintOf } from "~/components/downloads/manual-download-model.ts"; import { promoLabel } from "./search-labels.ts"; @@ -36,21 +36,10 @@ export function searchResource(release: SearchRelease): ManualDownloadResource { }; } -export function SearchDownloadDialog({ - release, - open, - onOpenChange, -}: { - release: SearchRelease; - open: boolean; - onOpenChange: (open: boolean) => void; -}) { +export function SearchDownloadActions({ release }: { release: SearchRelease }) { const submit = usePlanFromSearch(); return ( - delegateToAgent(release)} submit={(options) => diff --git a/apps/web/app/components/search/search-results-ui.test.ts b/apps/web/app/components/search/search-results-ui.test.ts index dd1b620a..a7535e02 100644 --- a/apps/web/app/components/search/search-results-ui.test.ts +++ b/apps/web/app/components/search/search-results-ui.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { MediaServerInventory, SearchRelease, WorkInventory } from "@umt/contracts"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -173,9 +174,13 @@ describe("可能不是这部作品的资源", () => { const render = (defaultOpen: boolean, total = 1) => renderToStaticMarkup( createElement( - MemoryRouter, - null, - createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, onDownload: () => {}, defaultOpen }), + QueryClientProvider, + { client: new QueryClient() }, + createElement( + MemoryRouter, + null, + createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, defaultOpen }), + ), ), ); diff --git a/apps/web/app/components/settings/organize-sources.tsx b/apps/web/app/components/settings/organize-sources.tsx index 97892e11..8f5f6890 100644 --- a/apps/web/app/components/settings/organize-sources.tsx +++ b/apps/web/app/components/settings/organize-sources.tsx @@ -1,5 +1,12 @@ -import type { FileRef, OrganizeSourceInput, OrganizeSourceView } from "@umt/contracts"; +import { + AcquisitionPolicyScope, + type FileRef, + type OrganizeSourceInput, + type OrganizeSourceView, +} from "@umt/contracts"; +import { selectAcquisitionPolicy } from "@umt/domain"; import { cloneElement, type ReactElement, useId, useState } from "react"; +import { Link } from "react-router"; import { toast } from "sonner"; import { filesErrorText, useFileRoots } from "~/api/files.ts"; import { downloaders } from "~/api/instances.ts"; @@ -11,6 +18,7 @@ import { useSaveClassification, useSaveOrganizeSource, } from "~/api/organize-sources.ts"; +import { usePolicyRevisions } from "~/api/policies.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { DirectoryDialog } from "~/components/files/directory-dialog.tsx"; @@ -39,6 +47,20 @@ function Field({ label, children }: { label: string; children: ReactElement<{ id export function ClassificationSection() { const config = useClassification(); const save = useSaveClassification(); + const policies = usePolicyRevisions({ status: "published" }); + const available = (policies.data?.items ?? []) + .filter((p) => p.kind === "acquisition" && Array.isArray(p.rules.defaultSlots) && p.rules.defaultSlots.length) + .sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "") || a.policyKey.localeCompare(b.policyKey)) + .map((p) => ({ ...p, scope: AcquisitionPolicyScope.parse(p.scope) })); + const classificationPolicies = Object.entries(config.data?.categories ?? {}).flatMap(([type, names]) => + names.flatMap((category) => + (type === "movie" ? ["movie" as const] : ["series" as const, "variety" as const]).map((mediaKind) => ({ + category, + mediaKind, + policy: selectAcquisitionPolicy(available, { mediaKind, category }), + })), + ), + ); const [draft, setDraft] = useState(null); const yaml = draft ?? config.data?.yaml ?? ""; const dirty = draft !== null && draft !== config.data?.yaml; @@ -85,6 +107,43 @@ export function ClassificationSection() { ID)、production_countries(电影制作国家)、origin_country(剧集来源国家)、release_year(发行年份,支持 2020-2025 范围)。

+ {classificationPolicies.length ? ( +
+

+ 当前已保存分类的默认收录策略;在收录策略的适用分类中调整。 +

+ + + + + + + + + + {classificationPolicies.map(({ category, mediaKind, policy }) => ( + + + + + + ))} + +
分类作品类型默认策略
{category}{mediaKind === "movie" ? "电影" : mediaKind === "variety" ? "综艺" : "剧集"} + {policy ? ( + + {policy.title} ·{" "} + {Array.isArray(policy.rules.defaultSlots) ? policy.rules.defaultSlots.length : 0} 个保留版本 + + ) : ( + "默认版本" + )} +
+
+ ) : null} {dirty ? ( + + ) : null}