refactor: 拆分大文件(contracts 端点、整理服务、浏览器纯函数、整理弹窗来源)

- contracts/src/api.ts 2733 行 → api/*.ts 13 个领域文件 + shared.ts;api.ts 按原顺序合并 Api。
  新旧对比:354 个端点内容与顺序一致,分组键与对外导出一致
- acquisition-organize/services/organize.ts 2645 行 → organize-types / -plan / -exec / -finalize / -records,
  organize.ts 保留为汇总入口,外部引用不变
- browser/service.ts 类外的纯函数(视图映射、原因文案、坐标换算、图片尺寸、出口代理)移到 session-view.ts,
  service.ts 照旧转出
- organize-wizard:来源解析 useResolvedSource 移到 source.ts,FormSelect 移到 form-select.tsx

只挪位置不改逻辑;typecheck、lint、test:all、web build、server 编译与编译产物自检通过。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 17:40:43 +08:00
1 parent c9e8120fc6
commit 8bec4bad5d
26 files changed
+5906 -5696

No files matched your search

@@ -0,0 +1,36 @@
import { useId } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
/** 整理表单里的标签 + 下拉选择 */
export function FormSelect({
label,
value,
onChange,
choices,
disabled,
}: {
label: string;
value: string;
onChange: (value: string) => void;
choices: { value: string; label: string; disabled?: boolean }[];
disabled?: boolean;
}) {
const id = useId();
return (
<label htmlFor={id} className="space-y-1.5 text-caption">
<span className="block text-muted-foreground">{label}</span>
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} aria-label={label} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" collisionPadding={16}>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value} disabled={choice.disabled}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
@@ -1,18 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import {
type CursorPage,
EventType,
type IdentifyCompletedPayload,
type PathMapping,
TmdbMediaDetail,
} from "@umt/contracts";
import { EventType, type IdentifyCompletedPayload, TmdbMediaDetail } from "@umt/contracts";
import { Eye, FileStack, LoaderCircle, RotateCw, Search } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
type FileRef,
type FileRoot,
type FilesOrganizeGroup,
filesErrorReason,
filesErrorText,
@@ -20,12 +11,9 @@ import {
useFileRoots,
useIdentify,
useOrganizeFiles,
useRecordSources,
useRecordSourcesMany,
} from "~/api/files.ts";
import { useOrganizeSources } from "~/api/organize-sources.ts";
import { apiParsed } from "~/api/parse.ts";
import { useRecord } from "~/api/records.ts";
import { useSettings } from "~/api/settings.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
@@ -35,18 +23,16 @@ import { matchingOrganizeSources } from "~/components/organize/source-match.ts";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { apiData } from "~/lib/api.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
import { candidateEntity, EntityPicker } from "./entity-picker.tsx";
import { FormSelect } from "./form-select.tsx";
import {
applyMinSize,
DEFAULT_MIN_SIZE,
defaultOptions,
dialogRows,
downloadContentRef,
entityLabel,
groupExecutable,
MIB,
@@ -60,169 +46,10 @@ import {
type WizardRow,
wizardStats,
} from "./model.ts";
import { type OrganizeSource, useResolvedSource } from "./source.ts";
import { PreviewList, PreviewStatsBar } from "./step-preview.tsx";
/** 向导来源(§6):文件管理选中项 / 下载任务内容路径 / 整理记录源路径 */
export type OrganizeSource =
| {
kind: "files";
items: FileRef[];
hnrPaths?: string[];
label?: string;
preset?: WizardEntity | null;
sourceConfigId?: string;
defaultAction?: "copy" | "move";
scanSummary?: string;
}
| {
kind: "download";
taskId: string;
instanceId: string;
contentPath: string | null;
name: string;
/** 做种中且 H&R 未满足:只能复制 */
hnrBlocked?: boolean;
tags?: string[];
}
| { kind: "record"; recordId: string }
/** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */
| { kind: "records"; recordIds: string[]; preset?: WizardEntity | null };
type Resolved = {
items: FileRef[];
hnrPaths: string[];
unresolved: string[];
preset: WizardEntity | null;
profileId?: string;
label: string;
pending: boolean;
error: unknown;
};
/** 把三种来源换算成登记目录内的 FileRef */
function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined): Resolved {
const isDl = source.kind === "download";
const isRec = source.kind === "record";
const maps = useQuery({
queryKey: ["files", "wizard-path-mappings"],
enabled: isDl && !filesMockEnabled(),
retry: false,
queryFn: ({ signal }) => apiData<CursorPage<PathMapping>>("/path-mappings", { params: { limit: 200 }, signal }),
});
const recSrc = useRecordSources(isRec ? source.recordId : null);
const rec = useRecord(isRec ? source.recordId : undefined);
const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []);
return useMemo((): Resolved => {
const base = { unresolved: [], preset: null, pending: false, error: null };
if (source.kind === "files")
return {
...base,
items: source.items,
hnrPaths: source.hnrPaths ?? [],
preset: source.preset ?? null,
label:
source.label ??
(source.items.length === 1 ? baseName(source.items[0]?.path ?? "") : `${source.items.length} 项`),
};
if (source.kind === "download") {
if (!roots || (maps.isLoading && !filesMockEnabled()))
return { ...base, items: [], hnrPaths: [], label: source.name, pending: true };
const ref = downloadContentRef(source.contentPath, source.instanceId, maps.data?.items ?? [], roots);
return {
...base,
items: ref ? [ref] : [],
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
label: `下载任务 · ${source.name}`,
};
}
if (source.kind === "records") {
const label = `重新整理 ${source.recordIds.length} 条记录`;
if (many.isPending) return { ...base, items: [], hnrPaths: [], label, pending: true };
if (many.error) return { ...base, items: [], hnrPaths: [], label, error: many.error };
const seen = new Set<string>();
const items = (many.data ?? [])
.flatMap((r) => r.items)
.filter((it) => {
const k = `${it.root}\u0000${it.path}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return {
...base,
items,
hnrPaths: [],
unresolved: [...new Set((many.data ?? []).flatMap((r) => r.unresolved))],
preset: source.preset ?? null,
profileId: (many.data ?? []).every((r) => r.sourceConfigId === many.data?.[0]?.sourceConfigId)
? many.data?.[0]?.sourceConfigId
: undefined,
label,
};
}
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
const m = rec.data?.media;
return {
...base,
profileId: recSrc.data?.sourceConfigId,
items: recSrc.data?.items ?? [],
hnrPaths: [],
unresolved: recSrc.data?.unresolved ?? [],
preset:
m && recSrc.data?.mediaEntityId
? { id: recSrc.data.mediaEntityId, external: null, title: m.title, year: m.year, kind: m.kind }
: null,
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
};
}, [
source,
roots,
maps.isLoading,
maps.data,
recSrc.isPending,
recSrc.error,
recSrc.data,
rec.data,
many.isPending,
many.error,
many.data,
]);
}
function FormSelect({
label,
value,
onChange,
choices,
disabled,
}: {
label: string;
value: string;
onChange: (value: string) => void;
choices: { value: string; label: string; disabled?: boolean }[];
disabled?: boolean;
}) {
const id = useId();
return (
<label htmlFor={id} className="space-y-1.5 text-caption">
<span className="block text-muted-foreground">{label}</span>
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} aria-label={label} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" collisionPadding={16}>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value} disabled={choice.disabled}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
export type { OrganizeSource } from "./source.ts";
/** 所有入口共用一个表单;预览按需展开,提交仅加入现有后台队列。 */
export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; onClose: () => void }) {
@@ -0,0 +1,138 @@
// 整理弹窗的来源:各入口传入的上下文 → 待整理文件(文件管理选中项 / 下载内容 / 整理记录源路径)
import { useQuery } from "@tanstack/react-query";
import type { CursorPage, PathMapping } from "@umt/contracts";
import { useMemo } from "react";
import { type FileRef, type FileRoot, filesMockEnabled, useRecordSources, useRecordSourcesMany } from "~/api/files.ts";
import { useRecord } from "~/api/records.ts";
import { baseName } from "~/components/files/model.ts";
import { apiData } from "~/lib/api.ts";
import { downloadContentRef, entityLabel, type WizardEntity } from "./model.ts";
/** 向导来源(§6):文件管理选中项 / 下载任务内容路径 / 整理记录源路径 */
export type OrganizeSource =
| {
kind: "files";
items: FileRef[];
hnrPaths?: string[];
label?: string;
preset?: WizardEntity | null;
sourceConfigId?: string;
defaultAction?: "copy" | "move";
scanSummary?: string;
}
| {
kind: "download";
taskId: string;
instanceId: string;
contentPath: string | null;
name: string;
/** 做种中且 H&R 未满足:只能复制 */
hnrBlocked?: boolean;
tags?: string[];
}
| { kind: "record"; recordId: string }
/** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */
| { kind: "records"; recordIds: string[]; preset?: WizardEntity | null };
export type Resolved = {
items: FileRef[];
hnrPaths: string[];
unresolved: string[];
preset: WizardEntity | null;
profileId?: string;
label: string;
pending: boolean;
error: unknown;
};
/** 把三种来源换算成登记目录内的 FileRef */
export function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined): Resolved {
const isDl = source.kind === "download";
const isRec = source.kind === "record";
const maps = useQuery({
queryKey: ["files", "wizard-path-mappings"],
enabled: isDl && !filesMockEnabled(),
retry: false,
queryFn: ({ signal }) => apiData<CursorPage<PathMapping>>("/path-mappings", { params: { limit: 200 }, signal }),
});
const recSrc = useRecordSources(isRec ? source.recordId : null);
const rec = useRecord(isRec ? source.recordId : undefined);
const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []);
return useMemo((): Resolved => {
const base = { unresolved: [], preset: null, pending: false, error: null };
if (source.kind === "files")
return {
...base,
items: source.items,
hnrPaths: source.hnrPaths ?? [],
preset: source.preset ?? null,
label:
source.label ??
(source.items.length === 1 ? baseName(source.items[0]?.path ?? "") : `${source.items.length} 项`),
};
if (source.kind === "download") {
if (!roots || (maps.isLoading && !filesMockEnabled()))
return { ...base, items: [], hnrPaths: [], label: source.name, pending: true };
const ref = downloadContentRef(source.contentPath, source.instanceId, maps.data?.items ?? [], roots);
return {
...base,
items: ref ? [ref] : [],
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
label: `下载任务 · ${source.name}`,
};
}
if (source.kind === "records") {
const label = `重新整理 ${source.recordIds.length} 条记录`;
if (many.isPending) return { ...base, items: [], hnrPaths: [], label, pending: true };
if (many.error) return { ...base, items: [], hnrPaths: [], label, error: many.error };
const seen = new Set<string>();
const items = (many.data ?? [])
.flatMap((r) => r.items)
.filter((it) => {
const k = `${it.root}\u0000${it.path}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return {
...base,
items,
hnrPaths: [],
unresolved: [...new Set((many.data ?? []).flatMap((r) => r.unresolved))],
preset: source.preset ?? null,
profileId: (many.data ?? []).every((r) => r.sourceConfigId === many.data?.[0]?.sourceConfigId)
? many.data?.[0]?.sourceConfigId
: undefined,
label,
};
}
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
const m = rec.data?.media;
return {
...base,
profileId: recSrc.data?.sourceConfigId,
items: recSrc.data?.items ?? [],
hnrPaths: [],
unresolved: recSrc.data?.unresolved ?? [],
preset:
m && recSrc.data?.mediaEntityId
? { id: recSrc.data.mediaEntityId, external: null, title: m.title, year: m.year, kind: m.kind }
: null,
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
};
}, [
source,
roots,
maps.isLoading,
maps.data,
recSrc.isPending,
recSrc.error,
recSrc.data,
rec.data,
many.isPending,
many.error,
many.data,
]);
}