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
@@ -56,13 +56,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
|
||||
{busy ? "提交中…" : "下载当前资源"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={busy || session.controller === "closed"}
|
||||
aria-label="高级下载选项"
|
||||
title="高级下载选项"
|
||||
onClick={() => void prepare(true)}
|
||||
>
|
||||
<Settings2 />
|
||||
下载选项
|
||||
</Button>
|
||||
{planId ? (
|
||||
<Link className="text-caption underline" to={`/console/downloads?plan=${planId}`}>
|
||||
@@ -70,13 +71,14 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{resource ? (
|
||||
{resource && open ? (
|
||||
<ManualDownloadDialog
|
||||
key={resource.release.id}
|
||||
resource={workbenchResource(resource.release)}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
initialUnrecognized={unrecognized}
|
||||
initialAdvanced={!unrecognized}
|
||||
submit={(options) => submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Link, useNavigate } from "react-router";
|
||||
import { useAttention } from "~/api/attention.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { recordPath } from "~/components/console/record-sheet.tsx";
|
||||
import { subjectLink } from "~/components/console/record-sheet.tsx";
|
||||
import { SEVERITY } from "~/components/library/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||
@@ -23,10 +23,7 @@ const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "
|
||||
export function approvalHref(d: Pick<Decision, "subjectType" | "subjectId" | "agentRunId">): string {
|
||||
if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId);
|
||||
if (d.agentRunId) return consolePath("agent", d.agentRunId);
|
||||
if (d.subjectType === "acquisition_plan" && d.subjectId)
|
||||
return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
|
||||
if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId);
|
||||
return consolePath("agent");
|
||||
return subjectLink(d) ?? consolePath("agent");
|
||||
}
|
||||
|
||||
export const HEALTH_HREF = `${consolePath("status")}#attention`;
|
||||
|
||||
@@ -29,6 +29,28 @@ export function recordPath(page: RecordPage, id: string, extra?: Extra): string
|
||||
return `${consolePath(page)}?${q}`;
|
||||
}
|
||||
|
||||
/** 业务记录的对象 → 它的"家"页面 */
|
||||
export function subjectLink(t: { subjectType: string | null; subjectId: string | null }): string | null {
|
||||
if (!t.subjectId) return null;
|
||||
switch (t.subjectType) {
|
||||
case "download_task":
|
||||
return recordPath("downloads", t.subjectId);
|
||||
case "acquisition_plan":
|
||||
return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
|
||||
case "organization_record":
|
||||
return recordPath("organize", t.subjectId);
|
||||
case "media_entity":
|
||||
return consolePath("library", t.subjectId);
|
||||
case "site":
|
||||
return recordPath("sites", t.subjectId);
|
||||
case "feed_source":
|
||||
// RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
||||
return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务(引擎运行)在日志页的抽屉里看:/console/logs?type=task&run=<id> */
|
||||
export function taskPath(runId: string): string {
|
||||
return `${consolePath("logs")}?${new URLSearchParams({ type: "task", run: runId })}`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DiscoverItem, tmdbImageUrl } from "@umt/contracts";
|
||||
import { type DiscoverItem, externalImageUrl, tmdbImageUrl } from "@umt/contracts";
|
||||
import { CalendarPlus, LoaderCircle, PackageSearch, Star } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
@@ -11,7 +11,7 @@ import { itemMeta, localBadges } from "./discover-state.ts";
|
||||
export type CardAction = "open" | "subscribe" | "search";
|
||||
|
||||
export function coverUrl(it: Pick<DiscoverItem, "cover" | "posterPath">): string | null {
|
||||
return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : it.cover;
|
||||
return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : externalImageUrl(it.cover);
|
||||
}
|
||||
|
||||
export function DiscoverCard({
|
||||
|
||||
@@ -6,8 +6,7 @@ import { toast } from "sonner";
|
||||
import { apiMutate } from "~/api/parse.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { recordPath, taskPath } from "~/components/console/record-sheet.tsx";
|
||||
import { subjectLink, taskPath } from "~/components/console/record-sheet.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -62,28 +61,6 @@ export const SUBJECT_LABEL: Record<string, string> = {
|
||||
cache_volume: "下载盘",
|
||||
};
|
||||
|
||||
/** 业务任务的对象 → 它的"家"页面 */
|
||||
export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): string | null {
|
||||
if (!t.subjectId) return null;
|
||||
switch (t.subjectType) {
|
||||
case "download_task":
|
||||
return recordPath("downloads", t.subjectId);
|
||||
case "acquisition_plan":
|
||||
return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
|
||||
case "organization_record":
|
||||
return recordPath("organize", t.subjectId);
|
||||
case "media_entity":
|
||||
return consolePath("library", t.subjectId);
|
||||
case "site":
|
||||
return recordPath("sites", t.subjectId);
|
||||
case "feed_source":
|
||||
// RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
||||
return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function subjectText(t: Pick<Task, "subjectType" | "subjectId">): string {
|
||||
if (!t.subjectType) return "—";
|
||||
const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, Settings2, X } from "lucide-react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDownloadSavePaths } from "~/api/downloads.ts";
|
||||
import { useEntityDetail } from "~/api/library.ts";
|
||||
@@ -67,6 +67,75 @@ export interface ManualDownloadResource {
|
||||
|
||||
const AUTO = "__auto__";
|
||||
|
||||
/** 搜索与工作台直接下载,只有高级选项或未识别确认才打开窗口。 */
|
||||
export function ManualDownloadActions({
|
||||
resource,
|
||||
submit,
|
||||
onDelegate,
|
||||
}: {
|
||||
resource: ManualDownloadResource;
|
||||
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
|
||||
onDelegate?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [unrecognized, setUnrecognized] = useState(false);
|
||||
const go = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await submit({ importMode: "normal", replacementAssetIds: [] });
|
||||
if (result.requiresConfirmation) {
|
||||
setUnrecognized(true);
|
||||
setOpen(true);
|
||||
} else if (result.submitted) {
|
||||
const planId = result.plan?.id;
|
||||
toast.success("已提交下载", {
|
||||
...(planId
|
||||
? { action: { label: "查看下载", onClick: () => navigate(`/console/downloads?plan=${planId}`) } }
|
||||
: {}),
|
||||
});
|
||||
} else toast.warning(result.rejected.map((r) => r.message).join(";") || "没有提交成功");
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" disabled={busy} onClick={() => void go()}>
|
||||
<Download />
|
||||
{busy ? "提交中…" : "下载"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={busy}
|
||||
aria-label="高级下载选项"
|
||||
title="高级下载选项"
|
||||
onClick={() => {
|
||||
setUnrecognized(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings2 />
|
||||
</Button>
|
||||
{open ? (
|
||||
<ManualDownloadDialog
|
||||
resource={resource}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
submit={submit}
|
||||
initialUnrecognized={unrecognized}
|
||||
initialAdvanced={!unrecognized}
|
||||
onDelegate={onDelegate}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 搜索、工作台和原站视图共用的一次性下载选项。 */
|
||||
export function ManualDownloadDialog({
|
||||
resource,
|
||||
@@ -74,6 +143,7 @@ export function ManualDownloadDialog({
|
||||
onOpenChange,
|
||||
submit,
|
||||
initialUnrecognized = false,
|
||||
initialAdvanced = false,
|
||||
onDelegate,
|
||||
}: {
|
||||
resource: ManualDownloadResource;
|
||||
@@ -81,6 +151,7 @@ export function ManualDownloadDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
|
||||
initialUnrecognized?: boolean;
|
||||
initialAdvanced?: boolean;
|
||||
onDelegate?: () => void;
|
||||
}) {
|
||||
const formId = useId();
|
||||
@@ -95,6 +166,7 @@ export function ManualDownloadDialog({
|
||||
const [mode, setMode] = useState<"normal" | "replace">("normal");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
|
||||
const [advanced, setAdvanced] = useState(initialAdvanced);
|
||||
const [identityOpen, setIdentityOpen] = useState(!entityId);
|
||||
// null = 没动过,显示作品当前的值
|
||||
const [tmdbType, setTmdbType] = useState<TmdbType | null>(null);
|
||||
@@ -132,9 +204,9 @@ export function ManualDownloadDialog({
|
||||
if (open) {
|
||||
setResult(null);
|
||||
setUnrecognized(initialUnrecognized);
|
||||
if (initialUnrecognized) setIdentityOpen(true);
|
||||
setAdvanced(initialAdvanced);
|
||||
}
|
||||
}, [open, initialUnrecognized]);
|
||||
}, [open, initialUnrecognized, initialAdvanced]);
|
||||
|
||||
const go = async () => {
|
||||
setBusy(true);
|
||||
@@ -156,7 +228,6 @@ export function ManualDownloadDialog({
|
||||
setResult(r);
|
||||
if (r.requiresConfirmation) {
|
||||
setUnrecognized(true);
|
||||
setIdentityOpen(true);
|
||||
}
|
||||
if (r.submitted) toast.success("已提交下载");
|
||||
} catch (e) {
|
||||
@@ -170,6 +241,7 @@ export function ManualDownloadDialog({
|
||||
const siteName = resource.siteName ?? site.data?.name ?? null;
|
||||
const missingCustom = dir === CUSTOM_DIR && !custom.trim();
|
||||
const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId);
|
||||
const savePath = resolveSavePath(target, dir, custom);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -203,65 +275,92 @@ export function ManualDownloadDialog({
|
||||
</DialogHeader>
|
||||
<div className="min-w-0 space-y-4">
|
||||
<ResourceSummary resource={resource} />
|
||||
<SaveTargetFields
|
||||
formId={formId}
|
||||
instances={paths.data?.instances ?? []}
|
||||
autoInstanceId={paths.data?.autoInstanceId ?? null}
|
||||
loading={paths.isPending}
|
||||
error={paths.error ? errorMessage(paths.error) : null}
|
||||
instanceId={instanceId}
|
||||
onInstanceChange={(v) => {
|
||||
setInstanceId(v);
|
||||
if (dir !== CUSTOM_DIR) setDir("");
|
||||
}}
|
||||
target={target}
|
||||
dir={dir}
|
||||
onDirChange={setDir}
|
||||
custom={custom}
|
||||
onCustomChange={setCustom}
|
||||
/>
|
||||
<IdentitySection
|
||||
formId={formId}
|
||||
open={identityOpen}
|
||||
onToggle={() => setIdentityOpen((v) => !v)}
|
||||
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
|
||||
unrecognized={!entityId || unrecognized}
|
||||
type={shownType}
|
||||
onTypeChange={(v) => {
|
||||
setTmdbType(v);
|
||||
setPicked(null);
|
||||
}}
|
||||
tmdbId={shownId}
|
||||
onTmdbIdChange={(v) => {
|
||||
setTmdbId(v.trim());
|
||||
setPicked(null);
|
||||
}}
|
||||
picked={picked}
|
||||
onPick={(w) => {
|
||||
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
|
||||
else {
|
||||
setPicked(null);
|
||||
setTmdbType(w.tmdb.type);
|
||||
setTmdbId(w.tmdb.id);
|
||||
}
|
||||
}}
|
||||
onClearPick={() => setPicked(null)}
|
||||
changed={changed}
|
||||
subject={resource.title}
|
||||
/>
|
||||
{entityId && !changed ? (
|
||||
<ExistingVersionsSection
|
||||
formId={formId}
|
||||
loading={inventory.isPending}
|
||||
error={!!inventory.error}
|
||||
summary={versionSummaryText(inventory.data?.summary)}
|
||||
versions={existing}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
selected={selected}
|
||||
onSelectedChange={setSelected}
|
||||
/>
|
||||
<div className="space-y-1 text-caption">
|
||||
<p className={paths.error || (!paths.isPending && !target) ? "text-destructive" : "text-muted-foreground"}>
|
||||
{paths.error
|
||||
? errorMessage(paths.error)
|
||||
: paths.isPending
|
||||
? "读取下载去向…"
|
||||
: target
|
||||
? `${target.name} · ${savePath ? `保存到:${savePathText(savePath)}` : "填写保存目录"}`
|
||||
: "没有可用的下载器,先在设置里添加下载器和保存目录"}
|
||||
</p>
|
||||
{changed ? (
|
||||
<p>本次作品:{picked ? picked.title : `${shownType === "movie" ? "电影" : "剧集"} · TMDB ${shownId}`}</p>
|
||||
) : null}
|
||||
{effectiveMode === "replace" ? <p>本次洗版:替换 {selected.length} 个已有文件</p> : null}
|
||||
</div>
|
||||
{unrecognized && !changed ? (
|
||||
<p className="text-caption text-warning">
|
||||
没识别出作品。可以继续下载,整理时再识别;也可在高级选项里指定作品。
|
||||
</p>
|
||||
) : null}
|
||||
<details open={advanced} onToggle={(e) => setAdvanced(e.currentTarget.open)}>
|
||||
<summary className="cursor-pointer text-caption text-muted-foreground">高级选项</summary>
|
||||
{advanced ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
<SaveTargetFields
|
||||
formId={formId}
|
||||
instances={paths.data?.instances ?? []}
|
||||
autoInstanceId={paths.data?.autoInstanceId ?? null}
|
||||
loading={paths.isPending}
|
||||
error={paths.error ? errorMessage(paths.error) : null}
|
||||
instanceId={instanceId}
|
||||
onInstanceChange={(v) => {
|
||||
setInstanceId(v);
|
||||
if (dir !== CUSTOM_DIR) setDir("");
|
||||
}}
|
||||
target={target}
|
||||
dir={dir}
|
||||
onDirChange={setDir}
|
||||
custom={custom}
|
||||
onCustomChange={setCustom}
|
||||
/>
|
||||
<IdentitySection
|
||||
formId={formId}
|
||||
open={identityOpen}
|
||||
onToggle={() => setIdentityOpen((v) => !v)}
|
||||
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
|
||||
unrecognized={!entityId || unrecognized}
|
||||
type={shownType}
|
||||
onTypeChange={(v) => {
|
||||
setTmdbType(v);
|
||||
setPicked(null);
|
||||
}}
|
||||
tmdbId={shownId}
|
||||
onTmdbIdChange={(v) => {
|
||||
setTmdbId(v.trim());
|
||||
setPicked(null);
|
||||
}}
|
||||
picked={picked}
|
||||
onPick={(w) => {
|
||||
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
|
||||
else {
|
||||
setPicked(null);
|
||||
setTmdbType(w.tmdb.type);
|
||||
setTmdbId(w.tmdb.id);
|
||||
}
|
||||
}}
|
||||
onClearPick={() => setPicked(null)}
|
||||
changed={changed}
|
||||
subject={resource.title}
|
||||
/>
|
||||
{entityId && !changed ? (
|
||||
<ExistingVersionsSection
|
||||
formId={formId}
|
||||
loading={inventory.isPending}
|
||||
error={!!inventory.error}
|
||||
summary={versionSummaryText(inventory.data?.summary)}
|
||||
versions={existing}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
selected={selected}
|
||||
onSelectedChange={setSelected}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
{result && !result.submitted && !result.requiresConfirmation ? (
|
||||
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
|
||||
) : null}
|
||||
@@ -272,7 +371,7 @@ export function ManualDownloadDialog({
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{onDelegate ? (
|
||||
{onDelegate && (advanced || unrecognized) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftFromJson, draftToJson, type SlotRulesDraft, type SlotRulesJson } from "./slot-rules.tsx";
|
||||
|
||||
const existingRules = (): SlotRulesJson => ({
|
||||
hardConditions: {
|
||||
resolutions: ["1080p", "2160p"],
|
||||
sources: ["WEB-DL", "BluRay"],
|
||||
requireEncode: true,
|
||||
minSizeBytes: 1024 ** 3,
|
||||
maxSizeBytes: 10 * 1024 ** 3,
|
||||
requiredSubtitles: ["zh", "en"],
|
||||
cuts: ["default", "IMAX"],
|
||||
excludeGroups: ["LOW", "BAD"],
|
||||
contentTypes: ["main"],
|
||||
codecs: ["H.265"],
|
||||
hdr: { anyOf: ["HDR10"], noneOf: ["DV"] },
|
||||
},
|
||||
softPreferences: {
|
||||
hdr: ["DV", "HDR10"],
|
||||
groups: ["CHD", "ADE"],
|
||||
sources: ["BluRay", "WEB-DL"],
|
||||
codecs: ["H.265", "H.264"],
|
||||
preferredSizeBytes: { min: 2 * 1024 ** 3, max: 8 * 1024 ** 3 },
|
||||
tiers: [
|
||||
{ name: "优先蓝光", sources: ["BluRay"], codecs: ["H.265"] },
|
||||
{ name: "追更", sources: ["WEB-DL"], resolutions: ["1080p"] },
|
||||
],
|
||||
tiersRequired: true,
|
||||
},
|
||||
fallback: {
|
||||
unknownPolicy: "hold",
|
||||
retryLimit: 3,
|
||||
investigation: { enabled: true, fields: ["codec", "hdr"] },
|
||||
},
|
||||
upgradeThreshold: {
|
||||
enabled: true,
|
||||
ruleGroupIds: ["quality", "release-group"],
|
||||
consistency: "season_release",
|
||||
minTierSteps: 2,
|
||||
allowResolutionUpgrade: true,
|
||||
encodeGroupAllowlist: ["CHD", "ADE"],
|
||||
ceilingTier: 4,
|
||||
mode: "full_pack",
|
||||
basis: "priority",
|
||||
targetPri: 98,
|
||||
sourceTiers: { BluRay: 3, "WEB-DL": 2 },
|
||||
},
|
||||
completion: { kind: "movie", cuts: ["default", "IMAX"] },
|
||||
});
|
||||
|
||||
function save(draft: SlotRulesDraft): SlotRulesJson {
|
||||
const result = draftToJson(draft);
|
||||
if (!result.ok) throw new Error(JSON.stringify(result.errors));
|
||||
return result.value;
|
||||
}
|
||||
|
||||
describe("收录规则编辑回写", () => {
|
||||
it("逐版本修改洗版方式,关闭再开启保留模式,明确选默认才清除模式", () => {
|
||||
const original = existingRules();
|
||||
const disabled = save({ ...draftFromJson(original), upgradeEnabled: false });
|
||||
expect(disabled.upgradeThreshold).toEqual({ ...original.upgradeThreshold, enabled: false });
|
||||
const reopened = save({ ...draftFromJson(disabled), upgradeEnabled: true });
|
||||
expect(reopened).toEqual(original);
|
||||
const perUnit = save({ ...draftFromJson(original), upgradeMode: "per_unit" });
|
||||
expect(perUnit.upgradeThreshold).toEqual({ ...original.upgradeThreshold, mode: "per_unit" });
|
||||
const { mode: _mode, ...rest } = original.upgradeThreshold;
|
||||
expect(save({ ...draftFromJson(original), upgradeMode: "" }).upgradeThreshold).toEqual(rest);
|
||||
expect(save(draftFromJson({})).upgradeThreshold).toEqual({ enabled: false });
|
||||
});
|
||||
it("未修改保存时保留表单没有展示的各层 JSON 字段", () => {
|
||||
const original = existingRules();
|
||||
|
||||
expect(save(draftFromJson(original))).toEqual(original);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "分辨率",
|
||||
edit: { resolutions: ["2160p"] },
|
||||
expected: (original: SlotRulesJson) => ({
|
||||
...original,
|
||||
hardConditions: { ...original.hardConditions, resolutions: ["2160p"] },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "编码偏好",
|
||||
edit: { softCodecs: ["AV1", "H.265"] },
|
||||
expected: (original: SlotRulesJson) => ({
|
||||
...original,
|
||||
softPreferences: { ...original.softPreferences, codecs: ["AV1", "H.265"] },
|
||||
}),
|
||||
},
|
||||
])("编辑$name只改写对应字段,不改硬编码限制或其他规则", ({ edit, expected }) => {
|
||||
const original = existingRules();
|
||||
const snapshot = structuredClone(original);
|
||||
|
||||
expect(save({ ...draftFromJson(original), ...edit })).toEqual(expected(original));
|
||||
expect(original).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("同时更新分辨率和编码偏好仍保留阶梯、洗版口径、完成版本及回退扩展", () => {
|
||||
const original = existingRules();
|
||||
const value = save({
|
||||
...draftFromJson(original),
|
||||
resolutions: ["720p", "1080p"],
|
||||
softCodecs: ["AV1"],
|
||||
});
|
||||
|
||||
expect(value).toEqual({
|
||||
...original,
|
||||
hardConditions: { ...original.hardConditions, resolutions: ["720p", "1080p"] },
|
||||
softPreferences: { ...original.softPreferences, codecs: ["AV1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])("清空洗版规则和阈值不会从原 JSON 恢复,升级开关为 %s", (upgradeEnabled) => {
|
||||
const original = existingRules();
|
||||
const {
|
||||
ruleGroupIds: _groups,
|
||||
consistency: _consistency,
|
||||
minTierSteps: _steps,
|
||||
...remaining
|
||||
} = original.upgradeThreshold;
|
||||
const value = save({
|
||||
...draftFromJson(original),
|
||||
upgradeEnabled,
|
||||
upgradeGroupIds: [],
|
||||
seasonConsistency: false,
|
||||
minTierSteps: "",
|
||||
});
|
||||
|
||||
expect(value).toEqual({
|
||||
...original,
|
||||
upgradeThreshold: { ...remaining, enabled: upgradeEnabled },
|
||||
});
|
||||
expect(value.upgradeThreshold).not.toHaveProperty("ruleGroupIds");
|
||||
expect(value.upgradeThreshold).not.toHaveProperty("consistency");
|
||||
expect(value.upgradeThreshold).not.toHaveProperty("minTierSteps");
|
||||
});
|
||||
|
||||
it("关闭升级并清空配置后,再加载并打开升级也不会复活旧规则", () => {
|
||||
const disabled = save({
|
||||
...draftFromJson(existingRules()),
|
||||
upgradeEnabled: false,
|
||||
upgradeGroupIds: [],
|
||||
seasonConsistency: false,
|
||||
minTierSteps: "",
|
||||
});
|
||||
const reopened = draftFromJson(disabled);
|
||||
|
||||
expect(reopened.upgradeGroupIds).toEqual([]);
|
||||
expect(reopened.seasonConsistency).toBe(false);
|
||||
expect(reopened.minTierSteps).toBe("");
|
||||
const enabled = save({ ...reopened, upgradeEnabled: true });
|
||||
expect(enabled.upgradeThreshold).toEqual({ ...disabled.upgradeThreshold, enabled: true });
|
||||
expect(enabled.upgradeThreshold).not.toHaveProperty("ruleGroupIds");
|
||||
expect(enabled.upgradeThreshold).not.toHaveProperty("consistency");
|
||||
expect(enabled.upgradeThreshold).not.toHaveProperty("minTierSteps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("按字段配置的未知值回退", () => {
|
||||
const fieldFallback = () => ({
|
||||
...existingRules(),
|
||||
fallback: {
|
||||
unknownPolicy: { codec: "hold", resolution: "reject", hdr: "accept" },
|
||||
investigation: { enabled: true, fields: ["codec"] },
|
||||
},
|
||||
});
|
||||
|
||||
it("按字段对象回填为保留状态,未修改保存仍保留原配置", () => {
|
||||
const original = fieldFallback();
|
||||
const draft = draftFromJson(original);
|
||||
|
||||
expect(draft.unknownPolicy).toBe("fields");
|
||||
expect(save(draft)).toEqual(original);
|
||||
});
|
||||
|
||||
it("仅修改其他规则时也不会清除按字段回退对象及其扩展字段", () => {
|
||||
const original = fieldFallback();
|
||||
|
||||
expect(save({ ...draftFromJson(original), resolutions: ["2160p"] })).toEqual({
|
||||
...original,
|
||||
hardConditions: { ...original.hardConditions, resolutions: ["2160p"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("明确选择全局回退后替换原按字段对象,保留其他回退字段", () => {
|
||||
const original = fieldFallback();
|
||||
|
||||
expect(save({ ...draftFromJson(original), unknownPolicy: "reject" })).toEqual({
|
||||
...original,
|
||||
fallback: { ...original.fallback, unknownPolicy: "reject" },
|
||||
});
|
||||
});
|
||||
|
||||
it("原按字段配置先改为全局拒绝,再明确选系统默认时清空回退,不恢复原对象", () => {
|
||||
const original = fieldFallback();
|
||||
const draft = draftFromJson(original);
|
||||
const selectedGlobal = { ...draft, unknownPolicy: "reject" as const };
|
||||
const cleared = { ...selectedGlobal, unknownPolicy: "" as const };
|
||||
const { unknownPolicy: _policy, ...remaining } = original.fallback;
|
||||
const value = save(cleared);
|
||||
|
||||
expect(value).toEqual({ ...original, fallback: remaining });
|
||||
expect(value.fallback).not.toHaveProperty("unknownPolicy");
|
||||
});
|
||||
|
||||
it("已有全局回退可以清空,保存后不会恢复原选择", () => {
|
||||
const original = existingRules();
|
||||
const { unknownPolicy: _policy, ...remaining } = original.fallback;
|
||||
const value = save({ ...draftFromJson(original), unknownPolicy: "" });
|
||||
|
||||
expect(value).toEqual({ ...original, fallback: remaining });
|
||||
expect(value.fallback).not.toHaveProperty("unknownPolicy");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JsonObject } from "@umt/contracts";
|
||||
import { useId } from "react";
|
||||
import { z } from "zod";
|
||||
import { useRuleGroups } from "~/api/rules.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
@@ -52,7 +53,7 @@ const CT_LABEL: Record<string, string> = {
|
||||
preview: "预告",
|
||||
reaction: "reaction",
|
||||
};
|
||||
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
|
||||
const UNKNOWN_LABEL = { fields: "保留按字段配置", hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
|
||||
@@ -81,10 +82,13 @@ export const SoftPreferences = z.object({
|
||||
});
|
||||
export const UpgradePolicy = z.object({
|
||||
enabled: z.boolean(),
|
||||
mode: z.enum(["movie", "per_unit", "full_pack"]).optional(),
|
||||
minTierSteps: z.number().int().min(0).max(5).optional(),
|
||||
allowResolutionUpgrade: z.boolean().optional(),
|
||||
encodeGroupAllowlist: strArr,
|
||||
ceilingTier: z.number().int().min(0).max(5).optional(),
|
||||
ruleGroupIds: strArr,
|
||||
consistency: z.literal("season_release").optional(),
|
||||
});
|
||||
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
|
||||
export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
|
||||
@@ -95,6 +99,9 @@ export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
|
||||
|
||||
/** 编辑器状态(字符串形态,提交时转换) */
|
||||
export type SlotRulesDraft = {
|
||||
original: Partial<SlotRulesJson>;
|
||||
upgradeGroupIds: string[];
|
||||
seasonConsistency: boolean;
|
||||
resolutions: string[];
|
||||
sources: string[];
|
||||
requireEncode: boolean;
|
||||
@@ -104,12 +111,13 @@ export type SlotRulesDraft = {
|
||||
cuts: string[];
|
||||
excludeGroups: string;
|
||||
contentTypes: string[];
|
||||
unknownPolicy: "" | "hold" | "reject" | "accept";
|
||||
unknownPolicy: "" | "fields" | "hold" | "reject" | "accept";
|
||||
softHdr: string[];
|
||||
softGroups: string;
|
||||
softSources: string[];
|
||||
softCodecs: string[];
|
||||
upgradeEnabled: boolean;
|
||||
upgradeMode: "" | "movie" | "per_unit" | "full_pack";
|
||||
minTierSteps: string;
|
||||
allowResolutionUpgrade: boolean;
|
||||
encodeGroupAllowlist: string;
|
||||
@@ -134,6 +142,9 @@ export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
|
||||
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
|
||||
const c = Completion.safeParse(j.completion ?? {}).data;
|
||||
return {
|
||||
original: j,
|
||||
upgradeGroupIds: u?.ruleGroupIds ?? [],
|
||||
seasonConsistency: u?.consistency === "season_release",
|
||||
resolutions: h.resolutions ?? [],
|
||||
sources: h.sources ?? [],
|
||||
requireEncode: h.requireEncode ?? false,
|
||||
@@ -143,12 +154,13 @@ export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
|
||||
cuts: h.cuts ?? [],
|
||||
excludeGroups: (h.excludeGroups ?? []).join(", "),
|
||||
contentTypes: h.contentTypes ?? [],
|
||||
unknownPolicy: up ?? "",
|
||||
unknownPolicy: up ?? (typeof j.fallback?.unknownPolicy === "object" ? "fields" : ""),
|
||||
softHdr: s.hdr ?? [],
|
||||
softGroups: (s.groups ?? []).join(", "),
|
||||
softSources: s.sources ?? [],
|
||||
softCodecs: s.codecs ?? [],
|
||||
upgradeEnabled: u?.enabled ?? false,
|
||||
upgradeMode: u?.mode ?? "",
|
||||
minTierSteps: numStr(u?.minTierSteps),
|
||||
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
|
||||
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
|
||||
@@ -200,16 +212,16 @@ export function draftToJson(
|
||||
sources: arr(d.softSources),
|
||||
codecs: arr(d.softCodecs),
|
||||
};
|
||||
const upgrade: UpgradePolicy | undefined =
|
||||
d.upgradeEnabled || minTier !== undefined || d.encodeGroupAllowlist.trim()
|
||||
? {
|
||||
enabled: d.upgradeEnabled,
|
||||
minTierSteps: minTier,
|
||||
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
|
||||
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
|
||||
ceilingTier: ceiling,
|
||||
}
|
||||
: undefined;
|
||||
const upgrade: UpgradePolicy = {
|
||||
enabled: d.upgradeEnabled,
|
||||
mode: d.upgradeMode || undefined,
|
||||
ruleGroupIds: arr(d.upgradeGroupIds),
|
||||
consistency: d.seasonConsistency ? "season_release" : undefined,
|
||||
minTierSteps: minTier,
|
||||
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
|
||||
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
|
||||
ceilingTier: ceiling,
|
||||
};
|
||||
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
|
||||
// 严格校验一遍(与 domain 类型对齐)
|
||||
const hv = HardConditions.safeParse(clean(hard));
|
||||
@@ -218,11 +230,14 @@ export function draftToJson(
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
hardConditions: clean(hard),
|
||||
softPreferences: clean(soft),
|
||||
fallback: d.unknownPolicy ? { unknownPolicy: d.unknownPolicy } : {},
|
||||
upgradeThreshold: upgrade ? clean(upgrade) : {},
|
||||
completion: d.completion ? { kind: d.completion } : {},
|
||||
hardConditions: clean({ ...d.original.hardConditions, ...hard }),
|
||||
softPreferences: clean({ ...d.original.softPreferences, ...soft }),
|
||||
fallback: clean({
|
||||
...d.original.fallback,
|
||||
unknownPolicy: d.unknownPolicy === "fields" ? d.original.fallback?.unknownPolicy : d.unknownPolicy || undefined,
|
||||
}),
|
||||
upgradeThreshold: clean({ ...d.original.upgradeThreshold, ...upgrade }),
|
||||
completion: clean({ ...d.original.completion, kind: d.completion || undefined }),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -297,6 +312,7 @@ export function SlotRulesEditor({
|
||||
errors?: Record<string, string>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const groups = useRuleGroups({ enabled: "true" });
|
||||
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -304,7 +320,7 @@ export function SlotRulesEditor({
|
||||
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
硬条件 · 已知不满足即拒绝
|
||||
</legend>
|
||||
<FormField label="分辨率">
|
||||
<FormField label="允许收录的分辨率" hint="先收低档和后续洗版都限定在此范围内">
|
||||
<ChipToggleGroup
|
||||
label="分辨率"
|
||||
options={o(RESOLUTIONS)}
|
||||
@@ -396,11 +412,13 @@ export function SlotRulesEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="__none">按系统默认</SelectItem>
|
||||
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{UNKNOWN_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[])
|
||||
.filter((k) => k !== "fields" || typeof value.original.fallback?.unknownPolicy === "object")
|
||||
.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{UNKNOWN_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
@@ -454,6 +472,53 @@ export function SlotRulesEditor({
|
||||
aria-label="允许自动升级"
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="洗版方式"
|
||||
htmlFor={`${id}-up-mode`}
|
||||
hint="全集洗版只接受覆盖目标全集的资源;整季同套也可以逐集完成"
|
||||
>
|
||||
<Select
|
||||
value={value.upgradeMode || "__default__"}
|
||||
onValueChange={(v) => set("upgradeMode", v === "__default__" ? "" : (v as SlotRulesDraft["upgradeMode"]))}
|
||||
>
|
||||
<SelectTrigger id={`${id}-up-mode`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">按作品类型(电影单片,剧集按集)</SelectItem>
|
||||
<SelectItem value="per_unit">按集洗版</SelectItem>
|
||||
<SelectItem value="full_pack">全集洗版</SelectItem>
|
||||
<SelectItem value="movie">电影洗版</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="洗版组合规则" hint="不选时使用默认洗版规则;选中顺序中最后一个带档位的规则决定优先级">
|
||||
<ChipToggleGroup
|
||||
ordered
|
||||
label="洗版组合规则"
|
||||
options={[
|
||||
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
|
||||
...value.upgradeGroupIds
|
||||
.filter((id) => !(groups.data?.items ?? []).some((g) => g.id === id))
|
||||
.map((id) => ({ value: id, label: `${id}(规则不可用)` })),
|
||||
]}
|
||||
value={value.upgradeGroupIds}
|
||||
onChange={(v) => set("upgradeGroupIds", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-caption font-medium">剧集洗版后整季同套</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
追更允许混合,洗版按季向同组、同来源和编码对齐;可以逐集完成。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.seasonConsistency}
|
||||
onCheckedChange={(v) => set("seasonConsistency", v)}
|
||||
aria-label="剧集洗版后整季同套"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="最小来源等级提升"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AcquisitionPolicyRules,
|
||||
AcquisitionPolicyScope,
|
||||
type JsonObject,
|
||||
type PolicyRevision,
|
||||
type SlotTemplate,
|
||||
@@ -9,6 +10,7 @@ import { ChevronRight, Eye, FilePlus2, History, Pencil, Plus, ScrollText, Trash2
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useClassification } from "~/api/organize-sources.ts";
|
||||
import {
|
||||
PolicyPreview,
|
||||
useCreatePolicy,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
useRollbackPolicy,
|
||||
useUpdatePolicy,
|
||||
} from "~/api/policies.ts";
|
||||
import { useRuleGroups } from "~/api/rules.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -54,14 +57,16 @@ const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.opti
|
||||
}));
|
||||
|
||||
export const PUBLISH_NOTICE =
|
||||
"发布后,作用范围内的下载、追更和整理按这个策略自动执行,不再逐次审批;升级替换、媒体清理和下载缓存清理仍要单独审批。上一个已发布版本的授权随之失效。";
|
||||
"发布后,作用范围内的下载、追更和整理按这个策略自动执行;已开启的洗版在新文件核验后替换对应旧版本。媒体清理和下载缓存清理仍要单独审批。上一个已发布版本的授权随之失效。";
|
||||
|
||||
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
|
||||
type TemplateDraft = { key: string; name: string; ruleGroupIds: string[]; rules: SlotRulesDraft; open: boolean };
|
||||
type PolicyDraft = {
|
||||
policyKey: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
scopeKinds: string[];
|
||||
scopeCategories: string[];
|
||||
originalScope: JsonObject;
|
||||
defaultSlots: TemplateDraft[];
|
||||
newTitleEnabled: boolean;
|
||||
windowDays: string;
|
||||
@@ -75,6 +80,7 @@ let seq = 0;
|
||||
const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({
|
||||
key: `t${++seq}`,
|
||||
name: t?.name ?? "",
|
||||
ruleGroupIds: t?.ruleGroupIds ?? [],
|
||||
rules: t ? draftFromJson(t) : emptyDraft(),
|
||||
open,
|
||||
});
|
||||
@@ -88,6 +94,10 @@ function draftFromRevision(r: PolicyRevision | null): PolicyDraft {
|
||||
title: r?.title ?? "",
|
||||
summary: r?.summary ?? "",
|
||||
scopeKinds: kinds ?? [],
|
||||
scopeCategories: Object.entries(AcquisitionPolicyScope.safeParse(r?.scope ?? {}).data?.categories ?? {}).flatMap(
|
||||
([type, names]) => (names ?? []).map((name) => `${type}:${name}`),
|
||||
),
|
||||
originalScope: r?.scope ?? {},
|
||||
defaultSlots: (rules?.defaultSlots ?? []).map((t) => tpl(t)),
|
||||
newTitleEnabled: rules?.newTitle?.enabled ?? false,
|
||||
windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "",
|
||||
@@ -121,6 +131,14 @@ function buildPolicy(d: PolicyDraft):
|
||||
const errors: Record<string, string> = {};
|
||||
const meta = Meta.safeParse(d);
|
||||
if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message;
|
||||
for (const selected of d.scopeCategories) {
|
||||
const type = selected.split(":")[0];
|
||||
if (
|
||||
d.scopeKinds.length &&
|
||||
(type === "movie" ? !d.scopeKinds.includes("movie") : !d.scopeKinds.some((k) => k !== "movie"))
|
||||
)
|
||||
errors.scopeCategories = "适用分类与作品类型不一致,请调整其中一项";
|
||||
}
|
||||
const templates = (list: TemplateDraft[]) =>
|
||||
list.map((t) => {
|
||||
if (!t.name.trim()) errors[`${t.key}.name`] = "填写模板名称";
|
||||
@@ -131,6 +149,7 @@ function buildPolicy(d: PolicyDraft):
|
||||
}
|
||||
return {
|
||||
name: t.name.trim(),
|
||||
...(t.ruleGroupIds.length ? { ruleGroupIds: t.ruleGroupIds } : {}),
|
||||
hardConditions: stripEmpty(r.value.hardConditions),
|
||||
softPreferences: stripEmpty(r.value.softPreferences),
|
||||
fallback: stripEmpty(r.value.fallback),
|
||||
@@ -174,7 +193,18 @@ function buildPolicy(d: PolicyDraft):
|
||||
policyKey: meta.data.policyKey,
|
||||
title: meta.data.title,
|
||||
summary: meta.data.summary || null,
|
||||
scope: d.scopeKinds.length ? { mediaKinds: d.scopeKinds } : {},
|
||||
scope: {
|
||||
...d.originalScope,
|
||||
mediaKinds: d.scopeKinds.length ? d.scopeKinds : undefined,
|
||||
categories: d.scopeCategories.length
|
||||
? Object.fromEntries(
|
||||
["movie", "tv"].map((type) => [
|
||||
type,
|
||||
d.scopeCategories.filter((v) => v.startsWith(`${type}:`)).map((v) => v.slice(type.length + 1)),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
} as JsonObject,
|
||||
rules: rules as JsonObject,
|
||||
},
|
||||
};
|
||||
@@ -532,8 +562,12 @@ function PolicyDetailSheet({
|
||||
}
|
||||
|
||||
const scopeText = (scope: JsonObject) => {
|
||||
const k = z.array(z.string()).safeParse((scope as { mediaKinds?: unknown }).mediaKinds).data;
|
||||
return k?.length ? k.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? x).join(" / ") : "全部作品";
|
||||
const parsed = AcquisitionPolicyScope.safeParse(scope).data;
|
||||
const kinds = parsed?.mediaKinds?.map((x) => MEDIA_KIND[x]).join(" / ") || "全部作品";
|
||||
const categories = Object.values(parsed?.categories ?? {})
|
||||
.flat()
|
||||
.join(" / ");
|
||||
return categories ? `${kinds} · ${categories}` : kinds;
|
||||
};
|
||||
|
||||
function PreviewBlock({ value }: { value: JsonObject | null }) {
|
||||
@@ -606,6 +640,7 @@ function PolicyEditorSheet({
|
||||
onSaved: (r: PolicyRevision) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const classification = useClassification();
|
||||
const create = useCreatePolicy();
|
||||
const update = useUpdatePolicy();
|
||||
const [d, setD] = useState<PolicyDraft>(() => {
|
||||
@@ -715,10 +750,37 @@ function PolicyEditorSheet({
|
||||
onChange={(v) => set("scopeKinds", v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="适用分类"
|
||||
error={errors.scopeCategories}
|
||||
hint="沿用现有分类规则;不选时作为类型默认,订阅可以单独选择其他策略"
|
||||
>
|
||||
<ChipToggleGroup
|
||||
label="适用分类"
|
||||
options={[
|
||||
...Object.entries(classification.data?.categories ?? {}).flatMap(([type, names]) =>
|
||||
names.map((name) => ({
|
||||
value: `${type}:${name}`,
|
||||
label: `${type === "movie" ? "电影" : "剧集"} · ${name}`,
|
||||
})),
|
||||
),
|
||||
...d.scopeCategories
|
||||
.filter(
|
||||
(v) =>
|
||||
!Object.entries(classification.data?.categories ?? {}).some(([type, names]) =>
|
||||
names.some((name) => v === `${type}:${name}`),
|
||||
),
|
||||
)
|
||||
.map((v) => ({ value: v, label: `${v}(分类已删除)` })),
|
||||
]}
|
||||
value={d.scopeCategories}
|
||||
onChange={(v) => set("scopeCategories", v)}
|
||||
/>
|
||||
</FormField>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="默认收录版本模板"
|
||||
title="保留版本"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -731,6 +793,9 @@ function PolicyEditorSheet({
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有收录版本时,按这些模板创建。</p>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
一个目标保留单版本,可在允许分辨率内先低后高;多个目标分别保留、升级各自版本。
|
||||
</p>
|
||||
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
|
||||
</DetailSection>
|
||||
|
||||
@@ -825,6 +890,7 @@ function TemplateList({
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const groups = useRuleGroups({ enabled: "true" });
|
||||
if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>;
|
||||
const patch = (key: string, p: Partial<TemplateDraft>) =>
|
||||
onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t)));
|
||||
@@ -876,6 +942,23 @@ function TemplateList({
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border/60 px-3 py-3">
|
||||
<FormField
|
||||
label="收录组合规则"
|
||||
hint="在规则页维护组合档位;多个规则按选中顺序生效,最后一个带档位的规则决定优先级"
|
||||
>
|
||||
<ChipToggleGroup
|
||||
ordered
|
||||
label="收录组合规则"
|
||||
options={[
|
||||
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
|
||||
...t.ruleGroupIds
|
||||
.filter((id) => !(groups.data?.items ?? []).some((g) => g.id === id))
|
||||
.map((id) => ({ value: id, label: `${id}(规则不可用)` })),
|
||||
]}
|
||||
value={t.ruleGroupIds}
|
||||
onChange={(v) => patch(t.key, { ruleGroupIds: v })}
|
||||
/>
|
||||
</FormField>
|
||||
<SlotRulesEditor value={t.rules} onChange={(v) => patch(t.key, { rules: v })} errors={errs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pencil, Plus, Target } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useRuleGroups } from "~/api/rules.ts";
|
||||
import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -12,6 +13,7 @@ import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
import { ChipToggleGroup } from "./parts.tsx";
|
||||
import { SlotCard } from "./slot-card.tsx";
|
||||
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
|
||||
|
||||
@@ -46,7 +48,9 @@ export function SlotsPanel({
|
||||
}) {
|
||||
const q = useSlots({ mediaEntityId });
|
||||
const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null);
|
||||
const slots = q.data?.items ?? [];
|
||||
const slots = (q.data?.items ?? []).filter(
|
||||
(s) => !subscriptionId || s.subscriptionId === subscriptionId || s.subscriptionId === null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -89,9 +93,14 @@ export function SlotsPanel({
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{subscriptionId && s.subscriptionId === null ? (
|
||||
<span className="text-meta text-muted-foreground">作品共用</span>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -127,6 +136,8 @@ function SlotEditorSheet({
|
||||
const id = useId();
|
||||
const create = useCreateSlot();
|
||||
const update = useUpdateSlot();
|
||||
const groups = useRuleGroups({ enabled: "true" });
|
||||
const [ruleGroupIds, setRuleGroupIds] = useState<string[]>(slot?.ruleGroupIds ?? []);
|
||||
const [name, setName] = useState(slot?.name ?? "");
|
||||
const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? "");
|
||||
const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false);
|
||||
@@ -145,6 +156,7 @@ function SlotEditorSheet({
|
||||
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
|
||||
const common = {
|
||||
...rules.value,
|
||||
ruleGroupIds,
|
||||
distinctGroup: meta.data.distinctGroup || null,
|
||||
requireDistinctAsset: requireDistinct,
|
||||
};
|
||||
@@ -232,6 +244,20 @@ function SlotEditorSheet({
|
||||
</div>
|
||||
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" />
|
||||
</div>
|
||||
<FormField label="收录组合规则" hint="不选时沿用订阅兼容设置或默认规则;最后一个带档位的组决定优先级">
|
||||
<ChipToggleGroup
|
||||
label="收录组合规则"
|
||||
ordered
|
||||
options={[
|
||||
...(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name })),
|
||||
...ruleGroupIds
|
||||
.filter((gid) => !(groups.data?.items ?? []).some((g) => g.id === gid))
|
||||
.map((gid) => ({ value: gid, label: `${gid}(规则不可用)` })),
|
||||
]}
|
||||
value={ruleGroupIds}
|
||||
onChange={setRuleGroupIds}
|
||||
/>
|
||||
</FormField>
|
||||
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
|
||||
</DetailSheet>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ export function LiquidSurface({
|
||||
const t = TIERS[tier];
|
||||
return (
|
||||
<LiquidGlass
|
||||
reveal="immediate"
|
||||
mode="custom"
|
||||
refraction="lens"
|
||||
lensProfile="material"
|
||||
@@ -94,6 +95,8 @@ export function LiquidSurface({
|
||||
: blurOnly
|
||||
? { backgroundColor: dark ? t.fill.dark : t.fill.light }
|
||||
: null),
|
||||
// 初始化时使用本档底色;磨砂外层已经铺底,内层保持透明,避免叠底
|
||||
["--glass-pending-fill" as string]: blurOnly ? "transparent" : dark ? t.fill.dark : t.fill.light,
|
||||
// 1px 暗边线在亮棱外侧,投影只留贴边一层
|
||||
boxShadow: dark
|
||||
? "0 0 0 1px rgba(0,0,0,0.28), 0 8px 24px -14px rgba(0,0,0,0.5)"
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
ArrowDownWideNarrow,
|
||||
ArrowUp,
|
||||
ArrowUpNarrowWide,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Download,
|
||||
ExternalLink,
|
||||
PackageSearch,
|
||||
RefreshCw,
|
||||
@@ -38,7 +36,7 @@ import {
|
||||
selectedCount,
|
||||
useQuickFilters,
|
||||
} from "./quick-filter-state.ts";
|
||||
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
|
||||
import { SearchDownloadActions } from "./search-download-dialog.tsx";
|
||||
import {
|
||||
gbToBytes,
|
||||
groupByWork,
|
||||
@@ -147,7 +145,6 @@ export function ReleaseResults({
|
||||
error: res.error,
|
||||
resetKey: JSON.stringify(api),
|
||||
});
|
||||
const [dl, setDl] = useState<SearchRelease | null>(null);
|
||||
const [selected, setSelected] = useState<SearchRelease | null>(null);
|
||||
const filtered =
|
||||
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
|
||||
@@ -267,7 +264,7 @@ export function ReleaseResults({
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
|
||||
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} />
|
||||
)}
|
||||
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
{first?.unmatchedTotal ? (
|
||||
@@ -276,7 +273,6 @@ export function ReleaseResults({
|
||||
rows={first.unmatched ?? []}
|
||||
total={first.unmatchedTotal}
|
||||
onOpen={setSelected}
|
||||
onDownload={setDl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -293,7 +289,6 @@ export function ReleaseResults({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
|
||||
{selected?.sourceReleaseId ? (
|
||||
<ReleaseDetailSheet
|
||||
key={selected.key}
|
||||
@@ -314,13 +309,11 @@ export function UnmatchedReleases({
|
||||
rows,
|
||||
total,
|
||||
onOpen,
|
||||
onDownload,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
rows: SearchRelease[];
|
||||
total: number;
|
||||
onOpen: (r: SearchRelease) => void;
|
||||
onDownload: (r: SearchRelease) => void;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -338,7 +331,7 @@ export function UnmatchedReleases({
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="border-t border-border/60">
|
||||
<ReleaseList rows={rows} onOpen={onOpen} onDownload={onDownload} />
|
||||
<ReleaseList rows={rows} onOpen={onOpen} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -517,20 +510,10 @@ function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
|
||||
}
|
||||
|
||||
/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
|
||||
function ReleaseList({
|
||||
rows,
|
||||
onOpen,
|
||||
onDownload,
|
||||
}: {
|
||||
rows: SearchRelease[];
|
||||
onOpen: (r: SearchRelease) => void;
|
||||
onDownload: (r: SearchRelease) => void;
|
||||
}) {
|
||||
function ReleaseList({ rows, onOpen }: { rows: SearchRelease[]; onOpen: (r: SearchRelease) => void }) {
|
||||
const { grouped, groups } = groupByWork(rows);
|
||||
const single = !grouped && groups[0]?.entity ? groups[0] : null;
|
||||
const row = (r: SearchRelease) => (
|
||||
<ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} />
|
||||
);
|
||||
const row = (r: SearchRelease) => <ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} />;
|
||||
if (!grouped)
|
||||
return (
|
||||
<>
|
||||
@@ -618,7 +601,7 @@ function VerdictLine({ r }: { r: SearchRelease }) {
|
||||
* 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
|
||||
* 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
|
||||
*/
|
||||
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
|
||||
function ReleaseRow({ r, onOpen }: { r: SearchRelease; onOpen: () => void }) {
|
||||
const hr = hrBadge(r);
|
||||
const seedless = r.seeders === 0 || r.seeders === null;
|
||||
return (
|
||||
@@ -674,14 +657,7 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
|
||||
</RecordLink>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
|
||||
<Bot />
|
||||
交给智能体
|
||||
</Button>
|
||||
<Button size="sm" onClick={onDownload}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
<SearchDownloadActions release={r} />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SearchRelease } from "@umt/contracts";
|
||||
import { usePlanFromSearch } from "~/api/search.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { ManualDownloadDialog, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx";
|
||||
import { ManualDownloadActions, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx";
|
||||
import { kindHintOf } from "~/components/downloads/manual-download-model.ts";
|
||||
import { promoLabel } from "./search-labels.ts";
|
||||
|
||||
@@ -36,21 +36,10 @@ export function searchResource(release: SearchRelease): ManualDownloadResource {
|
||||
};
|
||||
}
|
||||
|
||||
export function SearchDownloadDialog({
|
||||
release,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: SearchRelease;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
export function SearchDownloadActions({ release }: { release: SearchRelease }) {
|
||||
const submit = usePlanFromSearch();
|
||||
return (
|
||||
<ManualDownloadDialog
|
||||
key={`${release.siteId}:${release.siteReleaseId}`}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
<ManualDownloadActions
|
||||
resource={searchResource(release)}
|
||||
onDelegate={() => delegateToAgent(release)}
|
||||
submit={(options) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { MediaServerInventory, SearchRelease, WorkInventory } from "@umt/contracts";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -173,9 +174,13 @@ describe("可能不是这部作品的资源", () => {
|
||||
const render = (defaultOpen: boolean, total = 1) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, onDownload: () => {}, defaultOpen }),
|
||||
QueryClientProvider,
|
||||
{ client: new QueryClient() },
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, defaultOpen }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { FileRef, OrganizeSourceInput, OrganizeSourceView } from "@umt/contracts";
|
||||
import {
|
||||
AcquisitionPolicyScope,
|
||||
type FileRef,
|
||||
type OrganizeSourceInput,
|
||||
type OrganizeSourceView,
|
||||
} from "@umt/contracts";
|
||||
import { selectAcquisitionPolicy } from "@umt/domain";
|
||||
import { cloneElement, type ReactElement, useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { filesErrorText, useFileRoots } from "~/api/files.ts";
|
||||
import { downloaders } from "~/api/instances.ts";
|
||||
@@ -11,6 +18,7 @@ import {
|
||||
useSaveClassification,
|
||||
useSaveOrganizeSource,
|
||||
} from "~/api/organize-sources.ts";
|
||||
import { usePolicyRevisions } from "~/api/policies.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { DirectoryDialog } from "~/components/files/directory-dialog.tsx";
|
||||
@@ -39,6 +47,20 @@ function Field({ label, children }: { label: string; children: ReactElement<{ id
|
||||
export function ClassificationSection() {
|
||||
const config = useClassification();
|
||||
const save = useSaveClassification();
|
||||
const policies = usePolicyRevisions({ status: "published" });
|
||||
const available = (policies.data?.items ?? [])
|
||||
.filter((p) => p.kind === "acquisition" && Array.isArray(p.rules.defaultSlots) && p.rules.defaultSlots.length)
|
||||
.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "") || a.policyKey.localeCompare(b.policyKey))
|
||||
.map((p) => ({ ...p, scope: AcquisitionPolicyScope.parse(p.scope) }));
|
||||
const classificationPolicies = Object.entries(config.data?.categories ?? {}).flatMap(([type, names]) =>
|
||||
names.flatMap((category) =>
|
||||
(type === "movie" ? ["movie" as const] : ["series" as const, "variety" as const]).map((mediaKind) => ({
|
||||
category,
|
||||
mediaKind,
|
||||
policy: selectAcquisitionPolicy(available, { mediaKind, category }),
|
||||
})),
|
||||
),
|
||||
);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const yaml = draft ?? config.data?.yaml ?? "";
|
||||
const dirty = draft !== null && draft !== config.data?.yaml;
|
||||
@@ -85,6 +107,43 @@ export function ClassificationSection() {
|
||||
ID)、production_countries(电影制作国家)、origin_country(剧集来源国家)、release_year(发行年份,支持
|
||||
2020-2025 范围)。
|
||||
</p>
|
||||
{classificationPolicies.length ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<p className="mb-2 text-caption text-muted-foreground">
|
||||
当前已保存分类的默认收录策略;在收录策略的适用分类中调整。
|
||||
</p>
|
||||
<table className="w-full text-left text-caption">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">分类</th>
|
||||
<th>作品类型</th>
|
||||
<th>默认策略</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classificationPolicies.map(({ category, mediaKind, policy }) => (
|
||||
<tr key={`${mediaKind}:${category}`} className="border-t border-border/60">
|
||||
<td className="py-2">{category}</td>
|
||||
<td>{mediaKind === "movie" ? "电影" : mediaKind === "variety" ? "综艺" : "剧集"}</td>
|
||||
<td>
|
||||
{policy ? (
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
to={`/console/rules?tab=policies&open=${policy.id}`}
|
||||
>
|
||||
{policy.title} ·{" "}
|
||||
{Array.isArray(policy.rules.defaultSlots) ? policy.rules.defaultSlots.length : 0} 个保留版本
|
||||
</Link>
|
||||
) : (
|
||||
"默认版本"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
<SaveBar dirty={dirty}>
|
||||
{dirty ? (
|
||||
<Button variant="ghost" size="sm" disabled={save.isPending} onClick={() => setDraft(null)}>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
seasonLabel,
|
||||
shiftMonth,
|
||||
slotBadge,
|
||||
upgradeOf,
|
||||
} from "./model.ts";
|
||||
|
||||
const slot = (x: Partial<SubscriptionSlotSummary>): SubscriptionSlotSummary => ({
|
||||
@@ -86,15 +85,44 @@ describe("覆盖矩阵格", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, 0)).toEqual(["c", "a", "b"]);
|
||||
expect(moveItem(["a", "b"], 0, 5)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("升级策略取自收录版本", () => {
|
||||
expect(upgradeOf([{ upgradeThreshold: {} }], "series")).toBe("off");
|
||||
expect(upgradeOf([{ upgradeThreshold: { enabled: true, mode: "full_pack" } }], "series")).toBe("full_pack");
|
||||
expect(upgradeOf([{ upgradeThreshold: { enabled: true } }], "movie")).toBe("movie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("表单 ↔ params", () => {
|
||||
it("保留未知键,空值不写,只在改动时带 best_version / total_episode", () => {
|
||||
it("普通编辑不重建补全配置;只改补全开关也保留预算精度,明确修改才更新", () => {
|
||||
const params = {
|
||||
autoFill: "on",
|
||||
fillBudget: { unitsPerDay: 2, bytesPerDay: Math.round(1.25 * 1024 ** 3), future: true },
|
||||
};
|
||||
const sub = {
|
||||
mediaKind: "movie",
|
||||
seasonScope: "current_season",
|
||||
mode: "once",
|
||||
priority: "tracking",
|
||||
seasonUnitId: null,
|
||||
policyRevisionId: null,
|
||||
params,
|
||||
} as unknown as Subscription;
|
||||
const d = draftFromSubscription(sub);
|
||||
const ordinary = draftToParams({ ...d, searchIntervalHours: "6" }, params, {
|
||||
totalChanged: false,
|
||||
autoFillChanged: false,
|
||||
});
|
||||
expect(ordinary.params).toEqual({ ...params, searchIntervalHours: 6 });
|
||||
const modeOnly = draftToParams({ ...d, autoFill: "off" }, params, { totalChanged: false, autoFillChanged: true });
|
||||
expect(modeOnly.params).toEqual({ ...params, autoFill: "off" });
|
||||
const changed = draftToParams({ ...d, gbPerDay: "2", unitsPerDay: "" }, params, {
|
||||
totalChanged: false,
|
||||
autoFillChanged: true,
|
||||
});
|
||||
expect(changed.params.fillBudget).toEqual({ bytesPerDay: 2 * 1024 ** 3, future: true });
|
||||
const cleared = draftToParams(
|
||||
{ ...d, gbPerDay: "", unitsPerDay: "" },
|
||||
{ ...params, fillBudget: { unitsPerDay: 2, bytesPerDay: params.fillBudget.bytesPerDay } },
|
||||
{ totalChanged: false, autoFillChanged: true },
|
||||
);
|
||||
expect(cleared.params.fillBudget).toBeUndefined();
|
||||
});
|
||||
it("保留未知键,空值不写,只在改动时带 total_episode", () => {
|
||||
const d = {
|
||||
...emptyDraft("series"),
|
||||
siteIds: ["x"],
|
||||
@@ -104,12 +132,11 @@ describe("表单 ↔ params", () => {
|
||||
unitsPerDay: "2",
|
||||
gbPerDay: "1.5",
|
||||
total: "12",
|
||||
upgrade: "full_pack" as const,
|
||||
};
|
||||
const { params, errors } = draftToParams(
|
||||
d,
|
||||
{ custom: 1, keywords: ["旧"] },
|
||||
{ upgradeChanged: true, totalChanged: true },
|
||||
{ totalChanged: true, autoFillChanged: true },
|
||||
);
|
||||
expect(errors).toEqual({});
|
||||
expect(params).toEqual({
|
||||
@@ -121,10 +148,8 @@ describe("表单 ↔ params", () => {
|
||||
manual_total_episode: false,
|
||||
autoFill: "off",
|
||||
fillBudget: { unitsPerDay: 2, bytesPerDay: Math.round(1.5 * 1024 ** 3) },
|
||||
best_version: true,
|
||||
best_version_full: true,
|
||||
});
|
||||
const same = draftToParams(d, {}, { upgradeChanged: false, totalChanged: false }).params;
|
||||
const same = draftToParams(d, {}, { totalChanged: false, autoFillChanged: true }).params;
|
||||
expect(same.best_version).toBeUndefined();
|
||||
expect(same.total_episode).toBeUndefined();
|
||||
});
|
||||
@@ -132,7 +157,7 @@ describe("表单 ↔ params", () => {
|
||||
const { errors } = draftToParams(
|
||||
{ ...emptyDraft("series"), episodeStart: "5", episodeEnd: "2" },
|
||||
{},
|
||||
{ upgradeChanged: false, totalChanged: false },
|
||||
{ totalChanged: false, autoFillChanged: true },
|
||||
);
|
||||
expect(errors.episodeEnd).toBeTruthy();
|
||||
});
|
||||
@@ -151,40 +176,45 @@ describe("表单 ↔ params", () => {
|
||||
keywords: ["a", "b"],
|
||||
},
|
||||
} as unknown as Subscription;
|
||||
const d = draftFromSubscription(sub, "per_unit");
|
||||
const d = draftFromSubscription(sub);
|
||||
expect(d.episodeStart).toBe("2");
|
||||
expect(d.autoFill).toBe("on");
|
||||
expect(d.gbPerDay).toBe("2");
|
||||
expect(d.keywords).toBe("a\nb");
|
||||
expect(d.upgrade).toBe("per_unit");
|
||||
});
|
||||
it("质量预设:草稿 ↔ params;未知值回填为空", () => {
|
||||
const d = { ...emptyDraft("movie"), resolution: "4k" as const, source: "remux" as const, effect: "dv" as const };
|
||||
const { params } = draftToParams(
|
||||
d,
|
||||
{ resolution: "720p", effect: "sdr" },
|
||||
{ upgradeChanged: false, totalChanged: false },
|
||||
);
|
||||
expect(params).toEqual({ resolution: "4k", source: "remux", effect: "dv" });
|
||||
const cleared = draftToParams(
|
||||
emptyDraft("movie"),
|
||||
{ resolution: "720p" },
|
||||
{ upgradeChanged: false, totalChanged: false },
|
||||
);
|
||||
expect(cleared.params.resolution).toBeUndefined();
|
||||
const back = draftFromSubscription(
|
||||
{
|
||||
mediaKind: "movie",
|
||||
seasonScope: "current_season",
|
||||
mode: "once",
|
||||
priority: "tracking",
|
||||
seasonUnitId: null,
|
||||
policyRevisionId: null,
|
||||
params: { resolution: "1080p", source: "bogus", effect: "hdr" },
|
||||
} as unknown as Subscription,
|
||||
"off",
|
||||
);
|
||||
expect([back.resolution, back.source, back.effect]).toEqual(["1080p", "", "hdr"]);
|
||||
it("编辑跟踪与下载信息保留旧收录规则,不再生成统一洗版指令", () => {
|
||||
const legacy = {
|
||||
resolution: "4k",
|
||||
source: "uhd",
|
||||
effect: "atmos",
|
||||
ruleGroupIds: ["quality"],
|
||||
future: { keep: true },
|
||||
};
|
||||
const sub = {
|
||||
mediaKind: "movie",
|
||||
seasonScope: "current_season",
|
||||
mode: "once",
|
||||
priority: "tracking",
|
||||
seasonUnitId: null,
|
||||
policyRevisionId: null,
|
||||
params: legacy,
|
||||
} as unknown as Subscription;
|
||||
const draft = draftFromSubscription(sub);
|
||||
const { params, errors } = draftToParams({ ...draft, siteIds: ["site"], searchIntervalHours: "6" }, legacy, {
|
||||
totalChanged: false,
|
||||
autoFillChanged: true,
|
||||
});
|
||||
expect(errors).toEqual({});
|
||||
expect(params).toEqual({ ...legacy, siteIds: ["site"], searchIntervalHours: 6 });
|
||||
expect(params.best_version).toBeUndefined();
|
||||
expect(params.best_version_full).toBeUndefined();
|
||||
const cleared = draftToParams(draft, legacy, {
|
||||
totalChanged: false,
|
||||
autoFillChanged: false,
|
||||
clearQualityPreset: true,
|
||||
});
|
||||
expect(cleared.params).toEqual({ ruleGroupIds: ["quality"], future: { keep: true } });
|
||||
expect(draftToParams(emptyDraft("movie"), {}, { totalChanged: false, autoFillChanged: true }).params).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AcquisitionSlot,
|
||||
AutoFillMode,
|
||||
CalendarEntryState,
|
||||
CoverageCell,
|
||||
@@ -7,13 +6,10 @@ import type {
|
||||
Subscription,
|
||||
SubscriptionCalendarEntry,
|
||||
SubscriptionCoverage,
|
||||
SubscriptionEffect,
|
||||
SubscriptionGroup,
|
||||
SubscriptionMediaKind,
|
||||
SubscriptionOverviewItem,
|
||||
SubscriptionResolution,
|
||||
SubscriptionSlotSummary,
|
||||
SubscriptionSourcePreset,
|
||||
} from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
@@ -75,7 +71,7 @@ export function foldNames(names: string[], max = 2, emptyLabel = "全部站点")
|
||||
return names.length > max ? `${head} +${names.length - max}` : head;
|
||||
}
|
||||
|
||||
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随规则组", on: "开", off: "关" };
|
||||
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随主版本规则", on: "开", off: "关" };
|
||||
export const AUTOFILL_SOURCE: Record<"subscription" | "rule_group" | "default", string> = {
|
||||
subscription: "订阅设置",
|
||||
rule_group: "规则组",
|
||||
@@ -155,27 +151,6 @@ export function seasonTitle(s: SubscriptionCoverage["seasons"][number], kind: Su
|
||||
return s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`;
|
||||
}
|
||||
|
||||
// ── 收录版本 ──
|
||||
|
||||
export type UpgradeStrategy = "off" | "per_unit" | "full_pack" | "movie";
|
||||
export const UPGRADE_LABEL: Record<UpgradeStrategy, string> = {
|
||||
off: "不洗版",
|
||||
per_unit: "按集洗版",
|
||||
full_pack: "全集洗版",
|
||||
movie: "洗版",
|
||||
};
|
||||
|
||||
/** 订阅收录版本当前的升级策略:任一开启取其 mode */
|
||||
export function upgradeOf(
|
||||
slots: Pick<AcquisitionSlot, "upgradeThreshold">[],
|
||||
kind: SubscriptionMediaKind,
|
||||
): UpgradeStrategy {
|
||||
const on = slots.find((s) => (s.upgradeThreshold as { enabled?: unknown }).enabled === true);
|
||||
if (!on) return "off";
|
||||
if (kind === "movie") return "movie";
|
||||
return (on.upgradeThreshold as { mode?: unknown }).mode === "full_pack" ? "full_pack" : "per_unit";
|
||||
}
|
||||
|
||||
/** 把 from 移到 to(拖动排序) */
|
||||
export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
|
||||
const out = arr.slice();
|
||||
@@ -187,30 +162,6 @@ export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
|
||||
|
||||
// ── 表单 ──
|
||||
|
||||
export const RESOLUTION_LABEL: Record<SubscriptionResolution, string> = {
|
||||
"4k": "4K",
|
||||
"1080p": "1080p",
|
||||
"720p": "720p",
|
||||
};
|
||||
export const SOURCE_PRESET_LABEL: Record<SubscriptionSourcePreset, string> = {
|
||||
bluray_disc: "蓝光原盘",
|
||||
remux: "Remux",
|
||||
bluray: "BluRay",
|
||||
uhd: "UHD",
|
||||
web: "WEB-DL",
|
||||
hdtv: "HDTV",
|
||||
h265: "H.265",
|
||||
h264: "H.264",
|
||||
};
|
||||
export const EFFECT_LABEL: Record<SubscriptionEffect, string> = {
|
||||
dv: "杜比视界",
|
||||
hdr: "HDR",
|
||||
sdr: "SDR",
|
||||
atmos: "杜比全景声",
|
||||
};
|
||||
const oneOf = <K extends string>(v: unknown, labels: Record<K, string>): K | "" =>
|
||||
typeof v === "string" && v in labels ? (v as K) : "";
|
||||
|
||||
export type SubscriptionDraft = {
|
||||
mediaKind: SubscriptionMediaKind;
|
||||
seasonUnitId: string;
|
||||
@@ -226,15 +177,9 @@ export type SubscriptionDraft = {
|
||||
libraryRootId: string;
|
||||
downloadSavePath: string;
|
||||
policyRevisionId: string;
|
||||
upgrade: UpgradeStrategy;
|
||||
ruleGroupIds: string[];
|
||||
include: string;
|
||||
exclude: string;
|
||||
keywords: string;
|
||||
/** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的收录版本) */
|
||||
resolution: SubscriptionResolution | "";
|
||||
source: SubscriptionSourcePreset | "";
|
||||
effect: SubscriptionEffect | "";
|
||||
searchIntervalHours: string;
|
||||
stopAfter: string;
|
||||
autoFill: AutoFillMode;
|
||||
@@ -267,14 +212,9 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
|
||||
libraryRootId: "",
|
||||
downloadSavePath: "",
|
||||
policyRevisionId: "",
|
||||
upgrade: "off",
|
||||
ruleGroupIds: [],
|
||||
include: "",
|
||||
exclude: "",
|
||||
keywords: "",
|
||||
resolution: "",
|
||||
source: "",
|
||||
effect: "",
|
||||
searchIntervalHours: "",
|
||||
stopAfter: "",
|
||||
autoFill: "inherit",
|
||||
@@ -284,6 +224,7 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
|
||||
}
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const gbStr = (v: unknown) => (typeof v === "number" ? String(Math.round((v / GB) * 10) / 10) : "");
|
||||
|
||||
/** datetime-local 值 ↔ ISO */
|
||||
export function toLocalInput(iso: string): string {
|
||||
@@ -293,11 +234,10 @@ export function toLocalInput(iso: string): string {
|
||||
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrategy): SubscriptionDraft {
|
||||
export function draftFromSubscription(sub: Subscription): SubscriptionDraft {
|
||||
const p = sub.params as Record<string, unknown>;
|
||||
const range = (p.episodeRange ?? {}) as { start?: unknown; end?: unknown };
|
||||
const budget = (p.fillBudget ?? {}) as { unitsPerDay?: unknown; bytesPerDay?: unknown };
|
||||
const bytes = typeof budget.bytesPerDay === "number" ? budget.bytesPerDay : null;
|
||||
return {
|
||||
...emptyDraft(sub.mediaKind),
|
||||
mediaKind: sub.mediaKind,
|
||||
@@ -312,19 +252,14 @@ export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrateg
|
||||
libraryRootId: str(p.libraryRootId),
|
||||
downloadSavePath: str(p.downloadSavePath),
|
||||
policyRevisionId: sub.policyRevisionId ?? "",
|
||||
upgrade,
|
||||
ruleGroupIds: strArr(p.ruleGroupIds),
|
||||
include: strArr(p.include).join("\n"),
|
||||
exclude: strArr(p.exclude).join("\n"),
|
||||
keywords: strArr(p.keywords).join("\n"),
|
||||
resolution: oneOf(p.resolution, RESOLUTION_LABEL),
|
||||
source: oneOf(p.source, SOURCE_PRESET_LABEL),
|
||||
effect: oneOf(p.effect, EFFECT_LABEL),
|
||||
searchIntervalHours: numStr(p.searchIntervalHours),
|
||||
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
|
||||
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
|
||||
unitsPerDay: numStr(budget.unitsPerDay),
|
||||
gbPerDay: bytes !== null ? String(Math.round((bytes / GB) * 10) / 10) : "",
|
||||
gbPerDay: gbStr(budget.bytesPerDay),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,32 +269,27 @@ const isInt = (s: string) => /^\d+$/.test(s.trim());
|
||||
|
||||
/**
|
||||
* 草稿 → params(PATCH 整体替换,所以以现有 params 为底保留未知键)。
|
||||
* upgradeChanged:只有升级策略改了才带 best_version(否则每次保存都会 bump 收录版本 revision)。
|
||||
* 收录规则只在版本编辑器维护;旧导入的质量预设与规则组原样保留,不从普通表单重建。
|
||||
*/
|
||||
export function draftToParams(
|
||||
d: SubscriptionDraft,
|
||||
base: JsonObject = {},
|
||||
opts: { upgradeChanged: boolean; totalChanged: boolean },
|
||||
opts: { totalChanged: boolean; autoFillChanged: boolean; clearQualityPreset?: boolean },
|
||||
): { params: JsonObject; errors: DraftErrors } {
|
||||
const errors: DraftErrors = {};
|
||||
const out: Record<string, unknown> = { ...base };
|
||||
if (opts.clearQualityPreset) for (const k of ["resolution", "source", "effect"]) delete out[k];
|
||||
const known = [
|
||||
"siteIds",
|
||||
"episodeRange",
|
||||
"searchIntervalHours",
|
||||
"stopAfter",
|
||||
"downloaderInstanceId",
|
||||
"ruleGroupIds",
|
||||
"keywords",
|
||||
"include",
|
||||
"exclude",
|
||||
"libraryRootId",
|
||||
"downloadSavePath",
|
||||
"autoFill",
|
||||
"fillBudget",
|
||||
"resolution",
|
||||
"source",
|
||||
"effect",
|
||||
];
|
||||
for (const k of known) delete out[k];
|
||||
const set = (k: string, v: unknown) => {
|
||||
@@ -370,15 +300,11 @@ export function draftToParams(
|
||||
set("downloaderInstanceId", d.downloaderInstanceId);
|
||||
set("libraryRootId", d.libraryRootId);
|
||||
set("downloadSavePath", d.downloadSavePath.trim());
|
||||
set("ruleGroupIds", d.ruleGroupIds);
|
||||
set("include", lines(d.include));
|
||||
set("exclude", lines(d.exclude));
|
||||
const kw = lines(d.keywords);
|
||||
if (kw.length > 10) errors.keywords = "最多 10 个";
|
||||
set("keywords", kw);
|
||||
set("resolution", d.resolution);
|
||||
set("source", d.source);
|
||||
set("effect", d.effect);
|
||||
if (d.mediaKind !== "movie") {
|
||||
const s = d.episodeStart.trim();
|
||||
const e = d.episodeEnd.trim();
|
||||
@@ -407,21 +333,24 @@ export function draftToParams(
|
||||
if (Number.isNaN(t.getTime())) errors.stopAfter = "时间无效";
|
||||
else out.stopAfter = t.toISOString();
|
||||
}
|
||||
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
|
||||
const budget: Record<string, number> = {};
|
||||
if (d.unitsPerDay.trim()) {
|
||||
if (!isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
|
||||
else budget.unitsPerDay = Number(d.unitsPerDay);
|
||||
}
|
||||
if (d.gbPerDay.trim()) {
|
||||
const g = Number(d.gbPerDay);
|
||||
if (!Number.isFinite(g) || g < 0) errors.gbPerDay = "填非负数";
|
||||
else budget.bytesPerDay = Math.round(g * GB);
|
||||
}
|
||||
if (Object.keys(budget).length) out.fillBudget = budget;
|
||||
if (opts.upgradeChanged) {
|
||||
out.best_version = d.upgrade !== "off";
|
||||
if (d.mediaKind !== "movie") out.best_version_full = d.upgrade === "full_pack";
|
||||
if (opts.autoFillChanged) {
|
||||
delete out.autoFill;
|
||||
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
|
||||
const original = (base.fillBudget ?? {}) as Record<string, unknown>;
|
||||
const budget = { ...original };
|
||||
if (d.unitsPerDay.trim() && !isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
|
||||
if (d.unitsPerDay !== numStr(original.unitsPerDay)) {
|
||||
delete budget.unitsPerDay;
|
||||
if (d.unitsPerDay.trim() && !errors.unitsPerDay) budget.unitsPerDay = Number(d.unitsPerDay);
|
||||
}
|
||||
if (d.gbPerDay.trim() && (!Number.isFinite(Number(d.gbPerDay)) || Number(d.gbPerDay) < 0))
|
||||
errors.gbPerDay = "填非负数";
|
||||
if (d.gbPerDay !== gbStr(original.bytesPerDay)) {
|
||||
delete budget.bytesPerDay;
|
||||
if (d.gbPerDay.trim() && !errors.gbPerDay) budget.bytesPerDay = Math.round(Number(d.gbPerDay) * GB);
|
||||
}
|
||||
delete out.fillBudget;
|
||||
if (Object.keys(budget).length) out.fillBudget = budget;
|
||||
}
|
||||
return { params: out as JsonObject, errors };
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function SlotPanel({
|
||||
useEffect(() => setRows(serverRows), [serverRows]);
|
||||
const dirty = rows.map((r) => r.id).join() !== serverRows.map((r) => r.id).join();
|
||||
|
||||
const base = useMemo(() => draftFromSubscription(sub, "off"), [sub]);
|
||||
const base = useMemo(() => draftFromSubscription(sub), [sub]);
|
||||
const [af, setAf] = useState<Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">>(base);
|
||||
useEffect(() => setAf(base), [base]);
|
||||
const [errs, setErrs] = useState<DraftErrors>({});
|
||||
@@ -70,8 +70,8 @@ export function SlotPanel({
|
||||
);
|
||||
const saveAutoFill = () => {
|
||||
const { params, errors } = draftToParams({ ...base, ...af }, sub.params, {
|
||||
upgradeChanged: false,
|
||||
totalChanged: false,
|
||||
autoFillChanged: true,
|
||||
});
|
||||
setErrs(errors);
|
||||
if (Object.keys(errors).length) return;
|
||||
|
||||
@@ -10,25 +10,16 @@ import {
|
||||
type TmdbCandidate,
|
||||
} from "@umt/contracts";
|
||||
import { ExternalLink, Search, X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { type ReactNode, useEffect, useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useEpisodeGroups } from "~/api/episode-mapping.ts";
|
||||
import { useEntityFromCandidate } from "~/api/files.ts";
|
||||
import { downloaders, useSiteOptions } from "~/api/instances.ts";
|
||||
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
|
||||
import { usePolicyRevisions } from "~/api/policies.ts";
|
||||
import { useRuleGroups } from "~/api/rules.ts";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
import { useSlots } from "~/api/slots.ts";
|
||||
import {
|
||||
duplicateSubscriptionId,
|
||||
useCreateSubscription,
|
||||
useSetSlotOrder,
|
||||
useUpdateSubscription,
|
||||
} from "~/api/subscriptions.ts";
|
||||
import { duplicateSubscriptionId, useCreateSubscription, useUpdateSubscription } from "~/api/subscriptions.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { RecordLink } from "~/components/console/record-sheet.tsx";
|
||||
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
|
||||
import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx";
|
||||
@@ -57,19 +48,12 @@ import {
|
||||
type DraftErrors,
|
||||
draftFromSubscription,
|
||||
draftToParams,
|
||||
EFFECT_LABEL,
|
||||
emptyDraft,
|
||||
RESOLUTION_LABEL,
|
||||
SOURCE_PRESET_LABEL,
|
||||
SUB_KIND_LABEL,
|
||||
type SubscriptionDraft,
|
||||
UPGRADE_LABEL,
|
||||
type UpgradeStrategy,
|
||||
upgradeOf,
|
||||
} from "./model.ts";
|
||||
import { SlotOrderList } from "./slot-order.tsx";
|
||||
|
||||
// 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。
|
||||
// 订阅表单管理追踪与下载安排;收录规则统一在版本编辑器维护。新建策略只提供初始版本。
|
||||
|
||||
/** 订阅的作品:媒体库作品 / TMDB 条目(保存时先建档,SEARCH-2 详情页入口)/ 按标题追踪 */
|
||||
export type SubscriptionTarget =
|
||||
@@ -295,21 +279,11 @@ export function SubscriptionFormDialog({
|
||||
const editing = !!sub;
|
||||
const create = useCreateSubscription();
|
||||
const update = useUpdateSubscription();
|
||||
const setOrder = useSetSlotOrder();
|
||||
const [target, setTarget] = useState<Target | null>(initialTarget ?? null);
|
||||
const [tab, setTab] = useState("basic");
|
||||
const entityFromTmdb = useEntityFromCandidate();
|
||||
const slotsQ = useSlots({ subscriptionId: sub?.id }, editing);
|
||||
const subSlots = useMemo(
|
||||
() =>
|
||||
(slotsQ.data?.items ?? [])
|
||||
.filter((s) => s.subscriptionId === sub?.id)
|
||||
.sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt)),
|
||||
[slotsQ.data, sub?.id],
|
||||
);
|
||||
const initialUpgrade: UpgradeStrategy = sub ? upgradeOf(subSlots, sub.mediaKind) : "off";
|
||||
const [d, setD] = useState<SubscriptionDraft>(() => {
|
||||
if (sub) return draftFromSubscription(sub, "off");
|
||||
if (sub) return draftFromSubscription(sub);
|
||||
const k = initialTarget?.mediaKind ?? "series";
|
||||
const base = emptyDraft(k);
|
||||
return initialTarget
|
||||
@@ -321,16 +295,13 @@ export function SubscriptionFormDialog({
|
||||
}
|
||||
: base;
|
||||
});
|
||||
const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null);
|
||||
const [upgradeTouched, setUpgradeTouched] = useState(false);
|
||||
const [totalTouched, setTotalTouched] = useState(false);
|
||||
const [clearQualityPreset, setClearQualityPreset] = useState(false);
|
||||
const legacyQuality = sub
|
||||
? [sub.params.resolution, sub.params.source, sub.params.effect].filter((v) => typeof v === "string").join(" · ")
|
||||
: "";
|
||||
const [errors, setErrors] = useState<DraftErrors & { target?: string }>({});
|
||||
const [dup, setDup] = useState<string | null>(null);
|
||||
// 编辑:收录版本到了再回填升级策略
|
||||
useEffect(() => {
|
||||
if (sub && slotsQ.isSuccess && !upgradeTouched) setD((x) => ({ ...x, upgrade: initialUpgrade }));
|
||||
}, [sub, slotsQ.isSuccess, initialUpgrade, upgradeTouched]);
|
||||
|
||||
const patch = (p: Partial<SubscriptionDraft>) => setD((x) => ({ ...x, ...p }));
|
||||
const entityId = sub?.mediaEntityId ?? (target?.kind === "entity" ? target.id : null);
|
||||
const kind = d.mediaKind;
|
||||
@@ -346,12 +317,10 @@ export function SubscriptionFormDialog({
|
||||
const sites = useSiteOptions();
|
||||
const dls = downloaders.useList();
|
||||
const roots = useLibraryRoots();
|
||||
const groups = useRuleGroups({ enabled: "true" });
|
||||
const policies = usePolicyRevisions({ status: "published" });
|
||||
const templates = (policies.data?.items ?? []).filter(
|
||||
(p) => p.kind === "acquisition" && Array.isArray((p.rules as { defaultSlots?: unknown }).defaultSlots),
|
||||
);
|
||||
const defaultTpl = templates.find((p) => ((p.rules as { defaultSlots?: unknown[] }).defaultSlots ?? []).length);
|
||||
const tplNames = (pid: string) =>
|
||||
(
|
||||
((templates.find((p) => p.id === pid)?.rules as { defaultSlots?: { name?: string }[] })?.defaultSlots ?? []) as {
|
||||
@@ -374,19 +343,20 @@ export function SubscriptionFormDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const pending = create.isPending || update.isPending || setOrder.isPending || entityFromTmdb.isPending;
|
||||
const pending = create.isPending || update.isPending || entityFromTmdb.isPending;
|
||||
|
||||
const submit = async () => {
|
||||
const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, {
|
||||
upgradeChanged: editing ? upgradeTouched && d.upgrade !== initialUpgrade : d.upgrade !== "off",
|
||||
totalChanged: totalTouched,
|
||||
autoFillChanged: !editing,
|
||||
clearQualityPreset,
|
||||
});
|
||||
const all: typeof errors = { ...errs };
|
||||
if (!editing && !target) all.target = "选择作品";
|
||||
if (!editing && target?.kind === "title" && !target.titleQuery.trim()) all.target = "填写标题";
|
||||
setErrors(all);
|
||||
if (Object.keys(all).length) {
|
||||
const basicKeys = ["target", "episodeStart", "episodeEnd", "total"];
|
||||
const basicKeys = ["target", "episodeStart", "episodeEnd", "total", "unitsPerDay", "gbPerDay"];
|
||||
setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced");
|
||||
revealFirstError();
|
||||
return;
|
||||
@@ -401,8 +371,6 @@ export function SubscriptionFormDialog({
|
||||
priority: d.priority,
|
||||
params,
|
||||
});
|
||||
if (order && order.map((o) => o.id).join() !== subSlots.map((s) => s.id).join())
|
||||
await setOrder.mutateAsync({ id: sub.id, slotIds: order.map((o) => o.id) });
|
||||
toast.success("订阅已保存");
|
||||
onClose();
|
||||
} else if (target) {
|
||||
@@ -531,7 +499,7 @@ export function SubscriptionFormDialog({
|
||||
value={d.mediaKind}
|
||||
onChange={(k) => {
|
||||
const mk = (k || "series") as SubscriptionMediaKind;
|
||||
patch({ mediaKind: mk, ...(mk === "movie" ? { mode: "once", upgrade: "off" } : {}) });
|
||||
patch({ mediaKind: mk, ...(mk === "movie" ? { mode: "once" } : {}) });
|
||||
}}
|
||||
options={(["series", "movie", "variety"] as const).map((v) => ({
|
||||
value: v,
|
||||
@@ -651,6 +619,42 @@ export function SubscriptionFormDialog({
|
||||
{isMovie ? null : <AbsoluteNumberingHint entityId={entityId} />}
|
||||
</Section>
|
||||
|
||||
<Section title="收录版本">
|
||||
{editing ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
分辨率、组合规则和洗版按每个收录版本独立设置。
|
||||
<RecordLink
|
||||
page="subscriptions"
|
||||
id={sub.id}
|
||||
onClick={onClose}
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
到订阅详情调整收录版本
|
||||
</RecordLink>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
label="收录策略"
|
||||
htmlFor={f("tpl")}
|
||||
hint="新建时生成收录版本,后续在订阅详情独立调整;修改策略不会覆盖已建版本"
|
||||
>
|
||||
<Pick
|
||||
id={f("tpl")}
|
||||
value={d.policyRevisionId}
|
||||
onChange={(v) => patch({ policyRevisionId: v })}
|
||||
placeholder="按作品分类默认,未匹配时按类型默认"
|
||||
options={templates.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="下载">
|
||||
<FormField label="站点范围" htmlFor={f("sites")} hint="不选 = 全部启用的站点">
|
||||
{sites.isPending ? (
|
||||
@@ -695,50 +699,23 @@ export function SubscriptionFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</Section>
|
||||
|
||||
<Section title="洗版">
|
||||
<FormField
|
||||
label="洗版"
|
||||
htmlFor={f("upgrade")}
|
||||
hint="已整理的低于规则组最高档时继续找更好的,拿到最高档即完成"
|
||||
>
|
||||
<Pick
|
||||
id={f("upgrade")}
|
||||
value={d.upgrade}
|
||||
onChange={(v) => {
|
||||
setUpgradeTouched(true);
|
||||
patch({ upgrade: (v || "off") as UpgradeStrategy });
|
||||
}}
|
||||
options={(isMovie ? (["off", "movie"] as const) : (["off", "per_unit", "full_pack"] as const)).map(
|
||||
(v) => ({ value: v, label: UPGRADE_LABEL[v] }),
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="mt-4 min-w-0 space-y-5">
|
||||
<Section title="过滤">
|
||||
<FormField label="规则组" htmlFor={f("groups")} hint="不选时用默认规则组">
|
||||
{groups.isPending ? (
|
||||
<p className="text-caption text-muted-foreground">读取规则组…</p>
|
||||
) : (groups.data?.items ?? []).length === 0 ? (
|
||||
<Section title="共同过滤与搜索词">
|
||||
<p className="text-meta text-muted-foreground">包含和排除对这个订阅的全部收录版本同时生效。</p>
|
||||
{legacyQuality ? (
|
||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有规则组。
|
||||
<Link className="underline underline-offset-4" to={consolePath("rules")}>
|
||||
去规则页
|
||||
</Link>
|
||||
{clearQualityPreset
|
||||
? "保存后取消旧预设限制,保留各版本单独调整过的规则。"
|
||||
: `旧质量预设仍生效:${legacyQuality}`}
|
||||
</p>
|
||||
) : (
|
||||
<ChipToggleGroup
|
||||
label="规则组"
|
||||
ordered
|
||||
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
|
||||
value={d.ruleGroupIds}
|
||||
onChange={(v) => patch({ ruleGroupIds: v })}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<Button variant="outline" size="sm" onClick={() => setClearQualityPreset((v) => !v)}>
|
||||
{clearQualityPreset ? "保留旧预设" : "清除旧质量预设"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="包含" htmlFor={f("inc")} hint="每行一个正则,匹配标题、副标题和标签">
|
||||
<Textarea
|
||||
@@ -759,47 +736,6 @@ export function SubscriptionFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<FormField label="分辨率" htmlFor={f("res")}>
|
||||
<Pick
|
||||
id={f("res")}
|
||||
value={d.resolution}
|
||||
onChange={(v) => patch({ resolution: v })}
|
||||
placeholder="不限"
|
||||
options={Object.entries(RESOLUTION_LABEL).map(([value, label]) => ({
|
||||
value: value as keyof typeof RESOLUTION_LABEL,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="资源质量" htmlFor={f("src")}>
|
||||
<Pick
|
||||
id={f("src")}
|
||||
value={d.source}
|
||||
onChange={(v) => patch({ source: v })}
|
||||
placeholder="不限"
|
||||
options={Object.entries(SOURCE_PRESET_LABEL).map(([value, label]) => ({
|
||||
value: value as keyof typeof SOURCE_PRESET_LABEL,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="特效" htmlFor={f("fx")}>
|
||||
<Pick
|
||||
id={f("fx")}
|
||||
value={d.effect}
|
||||
onChange={(v) => patch({ effect: v })}
|
||||
placeholder="不限"
|
||||
options={Object.entries(EFFECT_LABEL).map(([value, label]) => ({
|
||||
value: value as keyof typeof EFFECT_LABEL,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
分辨率、资源质量、特效写进这个订阅自己的收录版本;UHD 和杜比全景声按标题匹配,与「包含」同时生效。
|
||||
</p>
|
||||
<FormField
|
||||
label="搜索关键词"
|
||||
htmlFor={f("kw")}
|
||||
@@ -843,62 +779,16 @@ export function SubscriptionFormDialog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<details className="group min-w-0 border-t border-border/60 pt-4">
|
||||
<summary className="cursor-pointer text-meta font-semibold tracking-wide text-muted-foreground">
|
||||
多版本(一般不用改)
|
||||
</summary>
|
||||
<div className="mt-3 space-y-5">
|
||||
<Section title="收录版本">
|
||||
{editing ? (
|
||||
slotsQ.isPending ? (
|
||||
<p className="text-caption text-muted-foreground">读取收录版本…</p>
|
||||
) : subSlots.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">还没有收录版本,保存时自动建一个默认版本。</p>
|
||||
) : (
|
||||
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
|
||||
<SlotOrderList
|
||||
items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))}
|
||||
onChange={setOrderState}
|
||||
disabled={pending}
|
||||
/>
|
||||
</FormField>
|
||||
)
|
||||
) : (
|
||||
<FormField
|
||||
label="模板"
|
||||
htmlFor={f("tpl")}
|
||||
hint={
|
||||
target?.kind === "entity"
|
||||
? "按模板顺序给本订阅创建收录版本,第一个是主版本;没有模板时建一个默认版本"
|
||||
: "作品加入媒体库后按模板创建收录版本"
|
||||
}
|
||||
>
|
||||
<Pick
|
||||
id={f("tpl")}
|
||||
value={d.policyRevisionId}
|
||||
onChange={(v) => patch({ policyRevisionId: v })}
|
||||
placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"}
|
||||
options={templates.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="优先级" htmlFor={f("prio")} hint="只影响下载排队先后">
|
||||
<Pick
|
||||
id={f("prio")}
|
||||
value={d.priority}
|
||||
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
|
||||
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
|
||||
/>
|
||||
</FormField>
|
||||
</Section>
|
||||
<Section title="自动补全其他版本">
|
||||
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
|
||||
</Section>
|
||||
</div>
|
||||
</details>
|
||||
<Section title="下载优先级">
|
||||
<FormField label="优先级" htmlFor={f("prio")} hint="只影响下载排队先后">
|
||||
<Pick
|
||||
id={f("prio")}
|
||||
value={d.priority}
|
||||
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
|
||||
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
|
||||
/>
|
||||
</FormField>
|
||||
</Section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
@@ -935,9 +825,9 @@ export function AutoFillFields({
|
||||
主版本缺的照常追更。开启后,主版本已有的单元也会为其他版本搜索下载;关闭时只记为缺失,可手动补全。
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<FormField label="自动补全" htmlFor={`${idPrefix}-af`}>
|
||||
<fieldset aria-label="自动补全" className="m-0 flex gap-1 border-0 p-0" id={`${idPrefix}-af`}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="自动补全其他版本" htmlFor={`${idPrefix}-af`} className="sm:col-span-2">
|
||||
<fieldset aria-label="自动补全" className="m-0 flex flex-wrap gap-1 border-0 p-0" id={`${idPrefix}-af`}>
|
||||
{(["inherit", "on", "off"] as AutoFillMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
@@ -956,30 +846,38 @@ export function AutoFillFields({
|
||||
))}
|
||||
</fieldset>
|
||||
</FormField>
|
||||
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
|
||||
<Input
|
||||
id={`${idPrefix}-units`}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
value={d.unitsPerDay}
|
||||
aria-invalid={!!errors.unitsPerDay}
|
||||
onChange={(e) => patch({ unitsPerDay: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}>
|
||||
<Input
|
||||
id={`${idPrefix}-gb`}
|
||||
inputMode="decimal"
|
||||
placeholder="不限"
|
||||
value={d.gbPerDay}
|
||||
aria-invalid={!!errors.gbPerDay}
|
||||
onChange={(e) => patch({ gbPerDay: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<details
|
||||
className="sm:col-span-2"
|
||||
open={!!d.unitsPerDay || !!d.gbPerDay || !!errors.unitsPerDay || !!errors.gbPerDay}
|
||||
>
|
||||
<summary className="cursor-pointer text-caption text-muted-foreground">补全预算(选填)</summary>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="每天最多(个单元)" htmlFor={`${idPrefix}-units`} error={errors.unitsPerDay}>
|
||||
<Input
|
||||
id={`${idPrefix}-units`}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
value={d.unitsPerDay}
|
||||
aria-invalid={!!errors.unitsPerDay}
|
||||
onChange={(e) => patch({ unitsPerDay: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="每天最多(GB)" htmlFor={`${idPrefix}-gb`} error={errors.gbPerDay}>
|
||||
<Input
|
||||
id={`${idPrefix}-gb`}
|
||||
inputMode="decimal"
|
||||
placeholder="不限"
|
||||
value={d.gbPerDay}
|
||||
aria-invalid={!!errors.gbPerDay}
|
||||
onChange={(e) => patch({ gbPerDay: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<p className="mt-2 text-meta text-muted-foreground">
|
||||
预算只算自动补全发起的下载,超出的延到第二天;也受每日总预算限制。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
预算只算自动补全发起的下载,超出的延到第二天;也受每日总预算限制。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
|
||||
import type { WorkbenchRelease } from "@umt/contracts";
|
||||
import { useSubmitRelease } from "~/api/releases.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { ManualDownloadDialog, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx";
|
||||
import { ManualDownloadActions, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx";
|
||||
import { kindHintOf } from "~/components/downloads/manual-download-model.ts";
|
||||
import { hrOf, promotionLabel } from "./release-fields.ts";
|
||||
|
||||
@@ -25,22 +25,11 @@ export function workbenchResource(r: WorkbenchRelease): ManualDownloadResource {
|
||||
}
|
||||
|
||||
/** 工作台使用独立手动下载,不要求先建立订阅或收录版本。 */
|
||||
export function DownloadDialog({
|
||||
release,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
slots: AcquisitionSlot[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
export function DownloadActions({ release }: { release: WorkbenchRelease }) {
|
||||
const submit = useSubmitRelease();
|
||||
return (
|
||||
<ManualDownloadDialog
|
||||
<ManualDownloadActions
|
||||
key={release.id}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
resource={workbenchResource(release)}
|
||||
onDelegate={() =>
|
||||
openAgentDrawer({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Bot, Check, Download, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Bot, Check, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntityDetail } from "~/api/inventory.ts";
|
||||
@@ -15,7 +15,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { DownloadDialog } from "./download-dialog.tsx";
|
||||
import { DownloadActions } from "./download-dialog.tsx";
|
||||
import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts";
|
||||
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
|
||||
import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx";
|
||||
@@ -136,10 +136,8 @@ export function ReleaseDetailSheet({
|
||||
const rid = release?.id ?? releaseId;
|
||||
const detail = useReleaseDetail(siteId, rid);
|
||||
const refresh = useRefreshReleaseDetail();
|
||||
const [dl, setDl] = useState(false);
|
||||
const techOpen = useUiPrefs().techInfoExpanded;
|
||||
const r = detail.data?.release ?? release;
|
||||
const entity = useEntityDetail(r?.entity?.id);
|
||||
|
||||
// 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次)
|
||||
const autoTried = useRef(new Set<string>());
|
||||
@@ -191,10 +189,7 @@ export function ReleaseDetailSheet({
|
||||
<Bot />
|
||||
问智能体
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setDl(true)}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
<DownloadActions key={r.id} release={r} />
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
@@ -321,7 +316,6 @@ export function ReleaseDetailSheet({
|
||||
<JsonView label="解析结果" value={r.parsed} defaultOpen={techOpen} />
|
||||
</>
|
||||
)}
|
||||
{r ? <DownloadDialog release={r} slots={entity.data?.slots ?? []} open={dl} onOpenChange={setDl} /> : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user