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:
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,
|
||||
]);
|
||||
}
|
||||
Reference in new issue
Block a user