feat(web): organize records page parity with MP history (HIS-1)

Poster, source -> target, size, downloader; type/time filters; status
counts; group by work; row menu; batch select, delete and reorganize;
delete confirm dialog with dry-run summary; detail delete and download info.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 23:57:19 +08:00
1 parent 51e82f1cd9
commit 03c651085f
8 files changed
+898 -110

No files matched your search

+30 -1
View File
@@ -1,4 +1,11 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
cursorPage,
type FileEntry,
@@ -281,6 +288,28 @@ export function useRecordSources(recordId: string | null) {
});
}
/** HIS-1 批量重新整理:多条记录的源路径一并换算(合并 items / unresolved) */
export function useRecordSourcesMany(recordIds: string[]) {
return useQueries({
queries: recordIds.map((id) => ({
queryKey: ["files", "record-sources", id],
retry: false,
queryFn: ({ signal }: { signal: AbortSignal }) =>
call(FilesRecordSourcesResult, "/files/record-sources", { params: { recordId: id }, signal }),
})),
combine: combineRecordSources,
});
}
// 模块级函数:引用稳定,结果不变时 useQueries 不重算(避免下游 useMemo 每次渲染都变)
function combineRecordSources(rs: { isPending: boolean; error: unknown; data?: FilesRecordSourcesResult }[]) {
return {
isPending: rs.some((r) => r.isPending),
error: rs.find((r) => r.error)?.error ?? null,
data: rs.every((r) => r.data) ? rs.map((r) => r.data!) : undefined,
};
}
/** 作品选择器里的本地作品 */
export type LocalEntityHit = { id: string; title: string; year: number | null; kind: string; alias: string | null };
+45
View File
@@ -4,6 +4,9 @@ import {
cursorPage,
LibraryItem,
LibraryRoot,
type OrganizationDeleteRequest,
OrganizationDeleteResult,
OrganizationStats,
type OrganizationStatus,
type OrganizationTrigger,
OrganizeAccepted,
@@ -23,6 +26,10 @@ export type RecordFilters = {
trigger?: OrganizationTrigger;
mediaEntityId?: string;
q?: string;
kind?: "movie" | "series" | "none";
/** ISO 时间:只看之后创建的 */
since?: string;
organizeOnly?: boolean;
};
export const recordKeys = {
@@ -30,8 +37,34 @@ export const recordKeys = {
list: (f: RecordFilters) => ["records", "list", f] as const,
one: (id: string) => ["records", "one", id] as const,
entity: (id: string) => ["records", "entity", id] as const,
stats: (f: Omit<RecordFilters, "status">) => ["records", "stats", f] as const,
};
/** HIS-1:按状态计数(筛选同列表,不含状态) */
export function useRecordStats(f: Omit<RecordFilters, "status">) {
return useQuery({
queryKey: recordKeys.stats(f),
placeholderData: keepPreviousData,
queryFn: ({ signal }) =>
apiParsed(OrganizationStats, "/api/v1/organization-records/stats", { params: cleanParams(f), signal }),
});
}
/** HIS-1:删除整理记录(dryRun 只核对会删什么) */
export function useDeleteRecords() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: OrganizationDeleteRequest) =>
apiMutate("/api/v1/organization-records/delete", { body }).then((d) => OrganizationDeleteResult.parse(d)),
onSuccess: (r) => {
if (!r.dryRun) {
void qc.invalidateQueries({ queryKey: recordKeys.all });
if (r.trashRecordId) void qc.invalidateQueries({ queryKey: ["files"] });
}
},
});
}
export function useRecords(f: RecordFilters) {
return useInfiniteQuery({
queryKey: recordKeys.list(f),
@@ -109,6 +142,18 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "智能体" };
/** 整理方式 / 文件操作(记录的 action) */
export const ACTION_LABEL = {
copy: "复制",
move: "移动",
rename: "重命名",
relocate: "目录调整",
hardlink: "硬链接",
trash: "移入回收站",
delete: "永久删除",
restore: "从回收站还原",
} as const;
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
export function useArtifact(id: string | null | undefined) {
return useQuery({