diff --git a/docs/04-architecture-v1.md b/docs/04-architecture-v1.md index b610880e..866025eb 100644 --- a/docs/04-architecture-v1.md +++ b/docs/04-architecture-v1.md @@ -27,6 +27,7 @@ Last updated: 2026-03-02 - 音乐模式可视化已升级为双运行时架构:全局 `musicVisualizerRuntimeMode` 控制 `legacy | plugin`,并保留自动回退到 legacy 的安全阀。 - Shader 参数入口已收口:音乐主区仅保留“打开设置”快捷入口,实际模式切换/输入映射/预览统一在设置面板 `Shader` 分页完成。 - 缩略图变体链路已落地:`resolveMediaResource` 支持 `original/thumbnail` 变体,thumbnail 由 Main 使用 Sharp 生成 WebP 并落盘缓存。 +- 图片读链路已结构性分页:`readImageSidebarTree` 响应仅携带每源 `image_count` + 封面 locator(不再携带全库 `images[]`);渲染进程通过 `readSourceImages` 按访问到的源懒加载并在会话内缓存(`useSourceImageCache`),使单文件夹浏览代价与入库总量解耦。向量检索与 AdReview 等跨源视图会按需预加载其涉及的源。 - 运行时依赖预检已落地:Main 暴露依赖可用性与降级策略矩阵(`sharp/ffmpeg/ffprobe/archive-wasm/powershell`),Renderer 在降级时显示告警。 - `rar/7z` 归一化调度采用“双优先级队列”:默认低优先级(交互空闲后按路径排序执行),用户显式打开目标包时提升为高优先级后台处理。 - Main 通过 `libraryChanged + archiveLoadStatus` 向 Renderer 推送/暴露归一化进度状态,UI 可在不阻塞交互的前提下显示 pending/running。 diff --git a/docs/06-backend-integration-guardrails.md b/docs/06-backend-integration-guardrails.md index 48d484c5..ce8647e2 100644 --- a/docs/06-backend-integration-guardrails.md +++ b/docs/06-backend-integration-guardrails.md @@ -1,6 +1,6 @@ # 后端接入规避方案(强制执行) -Last updated: 2026-02-18 +Last updated: 2026-05-30 ## 适用范围 @@ -58,6 +58,12 @@ Last updated: 2026-02-18 - `App` 入口链路(`src/App.tsx`、`useAppController`、`useAppDataPipeline`)仅保留编排,不承载业务细节。 - 同一文件若同时出现跨层职责并持续膨胀,必须先拆分再继续叠加需求。 +12. 图片读链路结构性分页(强制) + - `readImageSidebarTree` 响应禁止携带全库 `images[]`:侧边栏源仅含 `image_count` 与封面 `cover_media_locator`(对应 `imageSourceSidebarDtoSchema`)。 + - 渲染进程通过 `readSourceImages(source_id)` 按“访问到的源”懒加载图片并在会话内缓存(`useSourceImageCache`),禁止恢复“整库 images 常驻渲染进程”的旧模型。 + - 任何同时展示多源缩略图的视图(向量检索结果、AdReview 聚合结果等)必须把涉及的源 id 纳入按需加载集合(`useAppSidebarScopeState` 的 `neededSourceIds`),并把其有效 image id 纳入 `validImageIdSet`,否则会出现缩略图空白与管理选择被误剪(默认全选被清空)。 + - 计数/枚举(页数、导航边界、ref 枚举)必须使用 `resolveSourceImageCount`(= `imageCount ?? images.length`),不得依赖已加载的 `images.length`。 + ## 新增能力实施顺序(建议按序) 1. 固化接口 diff --git a/electron/channels.ts b/electron/channels.ts index 92a22247..2fe32e1d 100644 --- a/electron/channels.ts +++ b/electron/channels.ts @@ -3,6 +3,7 @@ export const BACKEND_CHANNELS = { readLibrarySnapshotLite: "backend:readLibrarySnapshotLite", readImageSidebarTree: "backend:readImageSidebarTree", readImagePage: "backend:readImagePage", + readSourceImages: "backend:readSourceImages", readImageMetadata: "backend:readImageMetadata", resolveMediaResource: "backend:resolveMediaResource", updatePerformanceConfig: "backend:updatePerformanceConfig", diff --git a/electron/facade/FileSystemLibraryHandlers.ts b/electron/facade/FileSystemLibraryHandlers.ts index b0a9ffbb..6424c2c3 100644 --- a/electron/facade/FileSystemLibraryHandlers.ts +++ b/electron/facade/FileSystemLibraryHandlers.ts @@ -3,6 +3,8 @@ import { type ReadImageMetadataResponseDto, type ReadImagePageRequestDto, type ReadImagePageResponseDto, + type ReadSourceImagesRequestDto, + type ReadSourceImagesResponseDto, type ReadImageSidebarTreeRequestDto, type ReadImageSidebarTreeResponseDto, type ReadPlaylistResponseDto, @@ -70,13 +72,22 @@ export class FileSystemLibraryHandlers { async readLibrarySnapshotLite(): Promise { this.context.markInteractiveRead() - const snapshot = await this.context.ensureSnapshotLoaded() const dbResult = this.context.database.readSnapshotLite() - // DB 为空(扫描期间 replaceSnapshot 未完成)但缓存有数据时,从缓存构造 lite DTO + const dbHasContent = + dbResult.image_packages.length > 0 || + dbResult.image_directories.length > 0 || + dbResult.videos.length > 0 || + (dbResult.audios?.length ?? 0) > 0 + // DB 有数据时直接返回,避免触发整库 readSnapshot(含所有图片项)进内存。 + if (dbHasContent) { + return dbResult + } + // DB 全空(首次启动或扫描尚未写入):触发快照加载/warmup, + // 并在缓存(含扫描预览)有图片时回退构造 lite,避免前端拿到空 sidebar。 + const snapshot = await this.context.ensureSnapshotLoaded() if ( - dbResult.image_packages.length === 0 && - dbResult.image_directories.length === 0 && - (snapshot.image_packages.length > 0 || snapshot.image_directories.length > 0) + snapshot.image_packages.length > 0 || + snapshot.image_directories.length > 0 ) { return buildLiteDtoFromSnapshot(snapshot) } @@ -94,6 +105,10 @@ export class FileSystemLibraryHandlers { return this.context.libraryReadWriteService.readImagePage(request, signal) } + async readSourceImages(request: ReadSourceImagesRequestDto): Promise { + return this.context.libraryReadWriteService.readSourceImages(request) + } + async readImageMetadata( request: ReadImageMetadataRequestDto, ): Promise { diff --git a/electron/fileSystemReadFacade.impl.ts b/electron/fileSystemReadFacade.impl.ts index 6f7573eb..0fcdc049 100644 --- a/electron/fileSystemReadFacade.impl.ts +++ b/electron/fileSystemReadFacade.impl.ts @@ -33,6 +33,8 @@ import { type ReadArchiveLoadStatusResponseDto, type ReadImagePageRequestDto, type ReadImagePageResponseDto, + type ReadSourceImagesRequestDto, + type ReadSourceImagesResponseDto, type ReadImageSidebarTreeRequestDto, type ReadImageSidebarTreeResponseDto, type ResolveMediaResourceRequestDto, @@ -1314,6 +1316,12 @@ export class FileSystemMediaReadService { return this.libraryHandlers.readImageMetadata(request); } + async readSourceImages( + request: ReadSourceImagesRequestDto, + ): Promise { + return this.libraryHandlers.readSourceImages(request); + } + async writePackageGrade( request: WritePackageGradeRequestDto, ): Promise { diff --git a/electron/fileSystemReadService.impl.management-audit.test.ts b/electron/fileSystemReadService.impl.management-audit.test.ts index aaa908de..4af7f478 100644 --- a/electron/fileSystemReadService.impl.management-audit.test.ts +++ b/electron/fileSystemReadService.impl.management-audit.test.ts @@ -68,7 +68,10 @@ describe("FileSystemMediaReadService", () => { if (!source) { throw new Error("source not found"); } - const firstImage = source.images[0]; + const sourceImages = await service.readSourceImages({ + source_id: source.id, + }); + const firstImage = sourceImages.images[0]; expect(firstImage?.media_locator.kind).toBe("filesystem"); if (!firstImage || firstImage.media_locator.kind !== "filesystem") { throw new Error("image locator not found"); diff --git a/electron/fileSystemReadService.impl.test.ts b/electron/fileSystemReadService.impl.test.ts index 71bc5b0d..c04ceb68 100644 --- a/electron/fileSystemReadService.impl.test.ts +++ b/electron/fileSystemReadService.impl.test.ts @@ -101,8 +101,8 @@ describe("FileSystemMediaReadService", () => { expect(serializedTree).toContain("かな!@#"); const sourceId = - sidebar.image_packages.find((item) => item.images.length > 0)?.id ?? - sidebar.image_directories.find((item) => item.images.length > 0)?.id; + sidebar.image_packages.find((item) => item.image_count > 0)?.id ?? + sidebar.image_directories.find((item) => item.image_count > 0)?.id; expect(sourceId).toBeTruthy(); const page = await service.readImagePage({ diff --git a/electron/mediaLibrarySnapshotStore.ts b/electron/mediaLibrarySnapshotStore.ts index 1debe733..e43ce853 100644 --- a/electron/mediaLibrarySnapshotStore.ts +++ b/electron/mediaLibrarySnapshotStore.ts @@ -70,13 +70,8 @@ export class MediaLibrarySnapshotStore { videos, audios, }; - const totalImageCount = - imagePackages.reduce((sum, item) => sum + item.images.length, 0) + - imageDirectories.reduce((sum, item) => sum + item.images.length, 0); - const shouldSkipDeepClone = totalImageCount >= 20_000; - if (!shouldSkipDeepClone) { - return librarySnapshotDtoSchema.parse(snapshot); - } + // 校验但不深拷贝:mappers 已填好各字段默认值,原对象形状完整, + // 直接返回原对象可避免整库对象图的 Zod clone 放大堆压力。 const validated = librarySnapshotDtoSchema.safeParse(snapshot); if (!validated.success) { throw validated.error; @@ -108,14 +103,7 @@ export class MediaLibrarySnapshotStore { videos, audios, }; - const totalItems = - imagePackages.length + - imageDirectories.length + - videos.length + - audios.length; - if (totalItems < 5_000) { - return librarySnapshotLiteDtoSchema.parse(snapshot); - } + // 校验但不深拷贝,避免整库对象图的 Zod clone 放大堆压力。 const validated = librarySnapshotLiteDtoSchema.safeParse(snapshot); if (!validated.success) { throw validated.error; diff --git a/electron/preload.ts b/electron/preload.ts index 26f9e978..f3b21bd6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -78,6 +78,8 @@ import { readImageMetadataResponseSchema, readImagePageRequestSchema, readImagePageResponseSchema, + readSourceImagesRequestSchema, + readSourceImagesResponseSchema, readImageSidebarTreeRequestSchema, readImageSidebarTreeResponseSchema, resolveMediaResourceRequestSchema, @@ -217,6 +219,14 @@ const backendApi = { ); return readImagePageResponseSchema.parse(response); }, + readSourceImages: async (request: unknown) => { + const parsed = readSourceImagesRequestSchema.parse(request); + const response = await ipcRenderer.invoke( + BACKEND_CHANNELS.readSourceImages, + parsed, + ); + return readSourceImagesResponseSchema.parse(response); + }, readImageMetadata: async (request: unknown) => { const parsed = readImageMetadataRequestSchema.parse(request); const response = await ipcRenderer.invoke( diff --git a/electron/registerBackendIpcHandlers.ts b/electron/registerBackendIpcHandlers.ts index 64e4cd77..31d78c07 100644 --- a/electron/registerBackendIpcHandlers.ts +++ b/electron/registerBackendIpcHandlers.ts @@ -89,6 +89,8 @@ import { readImageMetadataResponseSchema, readImagePageRequestSchema, readImagePageResponseSchema, + readSourceImagesRequestSchema, + readSourceImagesResponseSchema, readImageSidebarTreeRequestSchema, readImageSidebarTreeResponseSchema, setImageHiddenRequestSchema, @@ -469,7 +471,10 @@ export function registerBackendIpcHandlers(): void { requestSchema: ParseSchema, responseSchema: ParseSchema, action: (request: TRequest) => Promise | unknown, - options?: { fallbackEmptyPayloadToObject?: boolean }, + options?: { + fallbackEmptyPayloadToObject?: boolean; + skipResponseSchemaParse?: boolean; + }, ): void => { ipcMain.handle(channel, async (_event, payload: unknown) => { const normalizedPayload = @@ -479,6 +484,9 @@ export function registerBackendIpcHandlers(): void { : payload; const request = requestSchema.parse(normalizedPayload); const response = await action(request); + if (options?.skipResponseSchemaParse) { + return response as TResponse; + } return responseSchema.parse(response); }); }; @@ -519,6 +527,7 @@ export function registerBackendIpcHandlers(): void { readImageSidebarTreeRequestSchema, readImageSidebarTreeResponseSchema, (request) => ensureService().readImageSidebarTree(request), + { skipResponseSchemaParse: true }, ); registerIpcCommand( @@ -528,6 +537,13 @@ export function registerBackendIpcHandlers(): void { (request) => ensureService().readImagePage(request), ); + registerIpcCommand( + BACKEND_CHANNELS.readSourceImages, + readSourceImagesRequestSchema, + readSourceImagesResponseSchema, + (request) => ensureService().readSourceImages(request), + ); + registerIpcCommand( BACKEND_CHANNELS.readImageMetadata, readImageMetadataRequestSchema, diff --git a/electron/services/file-system-read/libraryReadWriteServiceImpl.ts b/electron/services/file-system-read/libraryReadWriteServiceImpl.ts index 579309eb..e072d0bd 100644 --- a/electron/services/file-system-read/libraryReadWriteServiceImpl.ts +++ b/electron/services/file-system-read/libraryReadWriteServiceImpl.ts @@ -9,7 +9,6 @@ import { readAppStateResponseSchema, readImageMetadataResponseSchema, readImagePageResponseSchema, - readImageSidebarTreeResponseSchema, readPlaylistResponseSchema, saveVideoCoverResponseSchema, writeAppStateResponseSchema, @@ -17,6 +16,8 @@ import { writePackageGradeResponseSchema, writePlaylistResponseSchema, type LibrarySnapshotDto, + type ImagePackageDto, + type ImageSourceSidebarDto, type ListVideoSubtitlesRequestDto, type ListVideoSubtitlesResponseDto, type MediaLocatorDto, @@ -28,6 +29,8 @@ import { type ReadImageMetadataResponseDto, type ReadImagePageRequestDto, type ReadImagePageResponseDto, + type ReadSourceImagesRequestDto, + type ReadSourceImagesResponseDto, type ReadImageSidebarTreeRequestDto, type ReadImageSidebarTreeResponseDto, type ReadManageSubtitleCleanupTaskRequestDto, @@ -203,6 +206,16 @@ function mergePreferenceMetricsState( }; } +function toSidebarSource(source: ImagePackageDto): ImageSourceSidebarDto { + const { images, ...rest } = source; + const cover = images[0] ?? null; + return { + ...rest, + image_count: images.length, + cover_media_locator: cover ? cover.media_locator : null, + }; +} + export class LibraryReadWriteService { private readonly subtitleCleanupTaskService: LibrarySubtitleCleanupTaskService; @@ -275,11 +288,11 @@ export class LibraryReadWriteService { ); ensureNotAborted(signal); - return readImageSidebarTreeResponseSchema.parse({ - image_packages: filteredPackages, - image_directories: filteredDirectories, + return { + image_packages: filteredPackages.map(toSidebarSource), + image_directories: filteredDirectories.map(toSidebarSource), tree: buildImageSidebarTree(filteredPackages, filteredDirectories), - }); + }; } async readImagePage( @@ -419,6 +432,26 @@ export class LibraryReadWriteService { ); } + async readSourceImages( + request: ReadSourceImagesRequestDto, + ): Promise { + this.options.markInteractiveRead(); + const includeHidden = request.include_hidden ?? false; + const snapshot = await this.options.ensureSnapshotLoaded(); + const allSources = [ + ...snapshot.image_packages, + ...snapshot.image_directories, + ]; + const source = allSources.find((item) => item.id === request.source_id); + const visibleSource = source + ? filterHiddenImagesFromSource(source, includeHidden) + : null; + return { + source_id: request.source_id, + images: visibleSource ? visibleSource.images : [], + }; + } + async writePackageGrade( request: WritePackageGradeRequestDto, ): Promise { diff --git a/src/backend-api.d.ts b/src/backend-api.d.ts index ec1ede43..a7fe4523 100644 --- a/src/backend-api.d.ts +++ b/src/backend-api.d.ts @@ -70,6 +70,8 @@ import type { ReadImageMetadataResponseDto, ReadImagePageRequestDto, ReadImagePageResponseDto, + ReadSourceImagesRequestDto, + ReadSourceImagesResponseDto, ReadImageSidebarTreeRequestDto, ReadImageSidebarTreeResponseDto, MediaAccessAuditResponseDto, @@ -190,6 +192,9 @@ interface MediaPlayerBackendApi { readImagePage: ( request: ReadImagePageRequestDto, ) => Promise; + readSourceImages?: ( + request: ReadSourceImagesRequestDto, + ) => Promise; readImageMetadata: ( request: ReadImageMetadataRequestDto, ) => Promise; diff --git a/src/components/ImageMainSection.tsx b/src/components/ImageMainSection.tsx index 77b4d839..460da527 100644 --- a/src/components/ImageMainSection.tsx +++ b/src/components/ImageMainSection.tsx @@ -20,6 +20,7 @@ import type { ImageMainSectionProps } from "./ImageMainSection.types"; import { useManageImageSelectionInteractions } from "../features/management/useManageImageSelectionInteractions"; import { useI18n } from "../i18n/useI18n"; import type { FocusedImageRef } from "../types"; +import { resolveSourceImageCount } from "../utils/mediaHelpers"; import { type ThumbnailGridSession, buildThumbnailGridSession, @@ -765,11 +766,11 @@ function ImageMainSection({ }, [toggleImageConvertPanel]); const activePackageImageProgress = (() => { - if (!activePackage || activePackage.images.length === 0) { + if (!activePackage || resolveSourceImageCount(activePackage) === 0) { return null; } - const total = activePackage.images.length; + const total = resolveSourceImageCount(activePackage); if (focusedRef?.packageId === activePackage.id) { const current = Math.max(1, Math.min(total, focusedRef.imageIndex + 1)); diff --git a/src/components/metadata/MetadataImageEditor.tsx b/src/components/metadata/MetadataImageEditor.tsx index be69cada..1430616a 100644 --- a/src/components/metadata/MetadataImageEditor.tsx +++ b/src/components/metadata/MetadataImageEditor.tsx @@ -5,6 +5,7 @@ import { import { useEffect, useRef, useState } from "react"; import type { ParsedExternalMetadata } from "../../features/metadata/parseExternalMetadata"; import type { ImageItem, ImagePackage } from "../../types"; +import { resolveSourceImageCount } from "../../utils/mediaHelpers"; import { MetadataRatingGroup } from "./MetadataRatingGroup"; import { useI18n } from "../../i18n/useI18n"; import { useMetadataImageParsedDraft } from "./useMetadataImageParsedDraft"; @@ -186,7 +187,8 @@ export function MetadataImageEditor({ const imagePagesRead = Math.max(0, imagePreference?.pagesRead ?? 0); const imageTotalPages = Math.max( 0, - imagePreference?.totalPages ?? focusedImagePackage?.images.length ?? 0, + imagePreference?.totalPages ?? + (focusedImagePackage ? resolveSourceImageCount(focusedImagePackage) : 0), ); const imageCompletionPercent = `${(Math.max(0, Math.min(1, imagePreference?.completionRatio ?? 0)) * 100).toFixed(1)}%`; const imagePagesReadSummary = `${imagePagesRead} / ${imageTotalPages}`; diff --git a/src/contracts/backend.schemas.ts b/src/contracts/backend.schemas.ts index 8732c6da..7bb62036 100644 --- a/src/contracts/backend.schemas.ts +++ b/src/contracts/backend.schemas.ts @@ -235,6 +235,12 @@ export const imageSourceLiteDtoSchema = z.object({ preference_metrics: imagePreferenceMetricsDtoSchema.nullable().optional(), }); +// 侧边栏源:lite 字段 + 可见图片数 + 封面 locator(不携带全库 images,按需加载) +export const imageSourceSidebarDtoSchema = imageSourceLiteDtoSchema.extend({ + image_count: nonNegativeIntSchema, + cover_media_locator: mediaLocatorDtoSchema.nullable(), +}); + export const videoItemDtoSchema = z.object({ id: z.string().min(1), file_name: z.string().min(1), @@ -346,8 +352,8 @@ export const readImageSidebarTreeRequestSchema = z.object({ }); export const readImageSidebarTreeResponseSchema = z.object({ - image_packages: z.array(imagePackageDtoSchema), - image_directories: z.array(imagePackageDtoSchema), + image_packages: z.array(imageSourceSidebarDtoSchema), + image_directories: z.array(imageSourceSidebarDtoSchema), tree: z.array(sidebarNodeDtoSchema), }); @@ -369,6 +375,16 @@ export const readImagePageResponseSchema = z.object({ refs: z.array(focusedImageRefDtoSchema), }); +export const readSourceImagesRequestSchema = z.object({ + source_id: z.string().min(1), + include_hidden: z.boolean().optional(), +}); + +export const readSourceImagesResponseSchema = z.object({ + source_id: z.string().min(1), + images: z.array(imageItemDtoSchema), +}); + export const readImageMetadataRequestSchema = z.object({ package_id: z.string().min(1), image_index: nonNegativeIntSchema, diff --git a/src/contracts/backend.types.ts b/src/contracts/backend.types.ts index abe5230f..d4b579f0 100644 --- a/src/contracts/backend.types.ts +++ b/src/contracts/backend.types.ts @@ -9,6 +9,9 @@ export type MediaLocatorDto = Infer; export type ImageItemDto = Infer; export type ImagePackageDto = Infer; export type ImageSourceLiteDto = Infer; +export type ImageSourceSidebarDto = Infer< + typeof Backend.imageSourceSidebarDtoSchema +>; export type VideoItemDto = Infer; export type AudioItemDto = Infer; export type FocusedImageRefDto = Infer; @@ -29,6 +32,12 @@ export type ReadImagePageRequestDto = Infer< export type ReadImagePageResponseDto = Infer< typeof Backend.readImagePageResponseSchema >; +export type ReadSourceImagesRequestDto = Infer< + typeof Backend.readSourceImagesRequestSchema +>; +export type ReadSourceImagesResponseDto = Infer< + typeof Backend.readSourceImagesResponseSchema +>; export type ReadImageMetadataRequestDto = Infer< typeof Backend.readImageMetadataRequestSchema >; diff --git a/src/features/app/buildImageNodeLoadState.ts b/src/features/app/buildImageNodeLoadState.ts index e45ea9c9..b1dc7e66 100644 --- a/src/features/app/buildImageNodeLoadState.ts +++ b/src/features/app/buildImageNodeLoadState.ts @@ -1,4 +1,5 @@ import type { ImagePackage, SidebarNode } from '../../types' +import { resolveSourceImageCount } from '../../utils/mediaHelpers' interface BuildImageNodeLoadStateParams { archiveLoadStatus: { @@ -34,7 +35,7 @@ export function buildImageNodeLoadState({ continue } - if (pendingPathSet.has(normalizedPath) || source.images.length === 0) { + if (pendingPathSet.has(normalizedPath) || resolveSourceImageCount(source) === 0) { packageLoadStateBySourceId.set(source.id, 'pending') } } diff --git a/src/features/app/useAppDisplayPageResources.ts b/src/features/app/useAppDisplayPageResources.ts index 3578b785..29a5280f 100644 --- a/src/features/app/useAppDisplayPageResources.ts +++ b/src/features/app/useAppDisplayPageResources.ts @@ -156,10 +156,13 @@ export function useAppDisplayPageResources({ continue; } - const image = source.images.find( + // images 已加载时按 id 精确匹配;否则回退到源上的封面 locator + // (结构性分页后 images 可能尚未按需加载)。 + const matchedImage = source.images.find( (item) => item.id === imageId && !item.hidden, ); - if (!image) { + const locator = matchedImage?.mediaLocator ?? source.coverMediaLocator ?? null; + if (!locator) { continue; } @@ -167,7 +170,7 @@ export function useAppDisplayPageResources({ nodeBrowseCoverThumbnailLocators.push({ sourceId, imageId, - locator: image.mediaLocator, + locator, }); } } diff --git a/src/features/app/useAppEffects.ts b/src/features/app/useAppEffects.ts index 6fdaedc5..076da027 100644 --- a/src/features/app/useAppEffects.ts +++ b/src/features/app/useAppEffects.ts @@ -9,6 +9,7 @@ import { } from "react"; import type { AppSettings } from "../../contracts/settings"; +import { resolveSourceImageCount } from "../../utils/mediaHelpers"; import { resolveAncestorNodeIds, resolveFirstAudioId, @@ -386,7 +387,7 @@ export function useAppEffects({ const focused = clamp( focusByPackage[activePackage.id] ?? 0, 0, - activePackage.images.length - 1, + resolveSourceImageCount(activePackage) - 1, ); const nextPage = Math.floor(focused / pagedPageSize); setPageByPackage((previous) => { @@ -516,7 +517,7 @@ export function useAppEffects({ if (!rootScopedPackageIds.has(selectedPackageId)) { const firstReadyPackage = orderedRootScopedPackages.find( - (item) => item.images.length > 0, + (item) => resolveSourceImageCount(item) > 0, ); setSelectedPackageId( (firstReadyPackage ?? orderedRootScopedPackages[0]).id, diff --git a/src/features/app/useAppInteractionLayer.ts b/src/features/app/useAppInteractionLayer.ts index d3edb286..6302bb60 100644 --- a/src/features/app/useAppInteractionLayer.ts +++ b/src/features/app/useAppInteractionLayer.ts @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { normalizeSeriesId, pickFirstBySeriesId } from "./workspaceSharedUtils"; import { isEditableTarget } from "../../utils/ui"; import { computeRenameDialogParams } from "./renameDialogLogic"; +import { resolveSourceImageCount } from "../../utils/mediaHelpers"; import type { AppSettingsStoreSnapshot } from "./useAppSettingsStore"; import type { AppSessionStateResult } from "./useAppSessionState"; import type { MediaStateResult } from "../media/useMediaState"; @@ -304,10 +305,11 @@ export function useAppInteractionLayer({ ); const isImagePackageNode = node.kind === "package" || node.imageNodeType === "package"; - const hasUsableImages = sourceId - ? (packageByIdEffective - .get(sourceId) - ?.images.some((image) => !image.hidden) ?? false) + const usableSource = sourceId + ? (packageByIdEffective.get(sourceId) ?? null) + : null; + const hasUsableImages = usableSource + ? resolveSourceImageCount(usableSource) > 0 : false; if (sourceId && isImagePackageNode && !isCoverNode && hasUsableImages) { @@ -332,38 +334,49 @@ export function useAppInteractionLayer({ findFirstImageNodeFromTree(imageTreeForSidebar); const findFirstVisibleImageIndex = (packageId: string): number | null => { - const images = packageByIdEffective.get(packageId)?.images ?? []; - const firstVisibleIndex = images.findIndex((image) => !image.hidden); - return firstVisibleIndex >= 0 ? firstVisibleIndex : null; + const source = packageByIdEffective.get(packageId); + if (!source) { + return null; + } + if (source.images.length > 0) { + const firstVisibleIndex = source.images.findIndex( + (image) => !image.hidden, + ); + return firstVisibleIndex >= 0 ? firstVisibleIndex : null; + } + // 源未按需加载(images 为空)时,可见序列首项即下标 0 + return resolveSourceImageCount(source) > 0 ? 0 : null; }; + const selectedPackageSource = + packageByIdEffective.get(selectedPackageId) ?? null; const selectedPackageUsable = rootScopedPackageIds.has(selectedPackageId) && - (packageByIdEffective - .get(selectedPackageId) - ?.images.some((image) => !image.hidden) ?? - false); + Boolean( + selectedPackageSource && + resolveSourceImageCount(selectedPackageSource) > 0, + ); const firstSidebarPackageId = firstImageSidebarNode?.imageSourceId?.trim() ?? ""; const fallbackPackageId = preferSidebarFirst ? firstSidebarPackageId || (selectedPackageUsable ? selectedPackageId : "") || - orderedRootScopedPackages.find((pkg) => - pkg.images.some((image) => !image.hidden), + orderedRootScopedPackages.find( + (pkg) => resolveSourceImageCount(pkg) > 0, )?.id || - scopedImageSourcesEffective.find((source) => - source.images.some((image) => !image.hidden), + scopedImageSourcesEffective.find( + (source) => resolveSourceImageCount(source) > 0, )?.id || "" : selectedPackageUsable ? selectedPackageId : firstSidebarPackageId || - orderedRootScopedPackages.find((pkg) => - pkg.images.some((image) => !image.hidden), + orderedRootScopedPackages.find( + (pkg) => resolveSourceImageCount(pkg) > 0, )?.id || - scopedImageSourcesEffective.find((source) => - source.images.some((image) => !image.hidden), + scopedImageSourcesEffective.find( + (source) => resolveSourceImageCount(source) > 0, )?.id || ""; diff --git a/src/features/app/useAppManageBindings.ts b/src/features/app/useAppManageBindings.ts index 8426eaf0..4293154a 100644 --- a/src/features/app/useAppManageBindings.ts +++ b/src/features/app/useAppManageBindings.ts @@ -49,6 +49,8 @@ export function useAppManageBindings({ setAdReviewPanelOpen, adReviewPanelOpen, adReviewFocusTaskId, + setAdReviewResultSourceIds, + setAdReviewResultImageIds, setManageOperationHint, setDeleteConfirmOpen, } = sessionState; @@ -135,6 +137,8 @@ export function useAppManageBindings({ clearAllSelections, replaceImageCheckedIds, setManageOperationHint, + setAdReviewResultSourceIds, + setAdReviewResultImageIds, adReviewPanelOpen, adReviewFocusTaskId, onDeleteRoundCompleted: () => { diff --git a/src/features/app/useAppNavigationState.ts b/src/features/app/useAppNavigationState.ts index 3365f784..bb4d4dd8 100644 --- a/src/features/app/useAppNavigationState.ts +++ b/src/features/app/useAppNavigationState.ts @@ -124,6 +124,8 @@ export function useAppNavigationState({ setVectorPage, gradeByPackage, setGradeByPackage, + adReviewResultSourceIds, + adReviewResultImageIds, appBodyRef, workspaceRef, workspaceBodyRef, @@ -197,6 +199,11 @@ export function useAppNavigationState({ } = useAppSidebarScopeState({ backendRead, mode, + mediaRepository: repositoryBootstrap.mediaRepository, + selectedPackageId, + includeHidden: sessionState.manageMode && mode === "image", + adReviewResultSourceIds, + adReviewResultImageIds, fullscreenActive, fullscreenDisplay, bootstrapLibrarySnapshot, diff --git a/src/features/app/useAppSessionState.ts b/src/features/app/useAppSessionState.ts index e1d6ed51..8febcf67 100644 --- a/src/features/app/useAppSessionState.ts +++ b/src/features/app/useAppSessionState.ts @@ -141,6 +141,14 @@ export function useAppSessionState({ null, ); const [adReviewPageIndex, setAdReviewPageIndex] = useState(0); + // ad-review 结果涉及的源 id(候选包),用于按需预加载这些源的图片 + const [adReviewResultSourceIds, setAdReviewResultSourceIds] = useState< + string[] + >([]); + // ad-review 候选 image id,纳入 validImageIdSet 防止按需加载窗口内被剪枝 + const [adReviewResultImageIds, setAdReviewResultImageIds] = useState< + string[] + >([]); const [dismissedImportTaskIds, setDismissedImportTaskIds] = useState< Record >({}); @@ -285,6 +293,10 @@ export function useAppSessionState({ setAdReviewFocusTaskId, adReviewPageIndex, setAdReviewPageIndex, + adReviewResultSourceIds, + setAdReviewResultSourceIds, + adReviewResultImageIds, + setAdReviewResultImageIds, dismissedImportTaskIds, setDismissedImportTaskIds, importTaskPanelOpen, diff --git a/src/features/app/useAppSidebarScopeState.ts b/src/features/app/useAppSidebarScopeState.ts index e4891b78..fcfd30a9 100644 --- a/src/features/app/useAppSidebarScopeState.ts +++ b/src/features/app/useAppSidebarScopeState.ts @@ -1,5 +1,8 @@ import { + useEffect, useMemo, + useRef, + useState, type Dispatch, type RefObject, type SetStateAction, @@ -29,6 +32,9 @@ import { useAudioSidebarState } from "./useAudioSidebarState"; import { useManageSelection } from "../management/useManageSelection"; import { useSidebarNavigation } from "../sidebar/useSidebarNavigation"; import { resolvePreferredSidebarSources } from "./sidebarSourceSelection"; +import { useSourceImageCache } from "./useSourceImageCache"; +import { resolveSourceImageCount } from "../../utils/mediaHelpers"; +import type { MediaRepository } from "../backend/repository"; interface ReadSliceSnapshot { data: T | null; @@ -43,6 +49,11 @@ interface AppSidebarBackendReadState { interface UseAppSidebarScopeStateParams { backendRead: AppSidebarBackendReadState; mode: BrowserMode; + mediaRepository: MediaRepository; + selectedPackageId: string; + includeHidden: boolean; + adReviewResultSourceIds: string[]; + adReviewResultImageIds: string[]; fullscreenActive: boolean; fullscreenDisplay: "dual" | "video-only" | "image-only"; bootstrapLibrarySnapshot: LibrarySnapshotViewModel | null; @@ -157,6 +168,11 @@ function buildImageSourceNodeIdMapFromSources( export function useAppSidebarScopeState({ backendRead, mode, + mediaRepository, + selectedPackageId, + includeHidden, + adReviewResultSourceIds, + adReviewResultImageIds, fullscreenActive, fullscreenDisplay, bootstrapLibrarySnapshot, @@ -253,11 +269,79 @@ export function useAppSidebarScopeState({ ); const videosEffective = librarySnapshotEffective?.videos ?? bootstrapVideos; const audiosEffective = librarySnapshotEffective?.audios ?? bootstrapAudios; - const packageByIdEffective = useMemo( - () => - new Map(scopedImageSourcesEffective.map((source) => [source.id, source])), - [scopedImageSourcesEffective], - ); + // 结构性分页:侧边栏源不再携带全库 images,按需加载当前包并合并。 + // 仅当源已知、images 为空(未加载)且确有图片(imageCount>0)时才触发加载, + // 因此在旧链路(源已带 images)下本段完全惰性、零运行时影响。 + const neededSourceIds = useMemo(() => { + const candidateIds = new Set(); + if (selectedPackageId) { + candidateIds.add(selectedPackageId); + } + // 向量检索结果横跨多个源,逐个确保加载以正常显示缩略图 + if (vectorResultsActive) { + for (const candidate of vectorSearchResults) { + candidateIds.add(candidate.packageId); + } + } + // ad-review 结果横跨多个源(按候选包),逐源加载以正常显示与默认选中计数 + for (const sourceId of adReviewResultSourceIds) { + candidateIds.add(sourceId); + } + if (candidateIds.size === 0) { + return []; + } + const sourceById = new Map( + scopedImageSourcesEffective.map((item) => [item.id, item]), + ); + const result: string[] = []; + for (const id of candidateIds) { + const source = sourceById.get(id); + // 仅当源已知、images 为空(未加载)且确有图片时才需要按需加载 + if ( + source && + source.images.length === 0 && + resolveSourceImageCount(source) > 0 + ) { + result.push(id); + } + } + return result; + }, [ + scopedImageSourcesEffective, + selectedPackageId, + vectorResultsActive, + vectorSearchResults, + adReviewResultSourceIds, + ]); + const sidebarSnapshotForGeneration = + backendRead.sidebar.data ?? backendRead.sidebar.snapshot; + const [sourceCacheGeneration, setSourceCacheGeneration] = useState(0); + const prevSidebarSnapshotRef = useRef(sidebarSnapshotForGeneration); + useEffect(() => { + if (prevSidebarSnapshotRef.current !== sidebarSnapshotForGeneration) { + prevSidebarSnapshotRef.current = sidebarSnapshotForGeneration; + setSourceCacheGeneration((value) => value + 1); + } + }, [sidebarSnapshotForGeneration]); + const sourceImageCache = useSourceImageCache({ + repository: mediaRepository, + neededSourceIds, + includeHidden, + generation: sourceCacheGeneration, + }); + const packageByIdEffective = useMemo(() => { + const map = new Map(); + for (const source of scopedImageSourcesEffective) { + const cachedImages = sourceImageCache.get(source.id); + map.set( + source.id, + cachedImages && source.images.length === 0 + ? { ...source, images: cachedImages } + : source, + ); + } + return map; + }, [scopedImageSourcesEffective, sourceImageCache]); const validImageIdSet = useMemo(() => { if (isMusicMode) { return new Set(audiosEffective.map((audio) => audio.id)); @@ -268,13 +352,23 @@ export function useAppSidebarScopeState({ } const next = new Set(); - for (const source of scopedImageSourcesEffective) { + for (const source of packageByIdEffective.values()) { for (const image of source.images) { next.add(image.id); } } + // ad-review 候选 id 始终视为有效,避免按需加载窗口内被 useManageSelection 剪枝 + for (const imageId of adReviewResultImageIds) { + next.add(imageId); + } return next; - }, [audiosEffective, isImageMode, isMusicMode, scopedImageSourcesEffective]); + }, [ + audiosEffective, + isImageMode, + isMusicMode, + packageByIdEffective, + adReviewResultImageIds, + ]); const videoByIdEffective = useMemo( () => new Map(videosEffective.map((video) => [video.id, video])), [videosEffective], diff --git a/src/features/app/useEffectiveDisplayState.ts b/src/features/app/useEffectiveDisplayState.ts index 5d34a267..aefaaeb5 100644 --- a/src/features/app/useEffectiveDisplayState.ts +++ b/src/features/app/useEffectiveDisplayState.ts @@ -1,5 +1,7 @@ import { useMemo } from 'react' +import { resolveSourceImageCount } from '../../utils/mediaHelpers' + import type { ImageMetadataViewModel, ImagePageViewModel, @@ -115,7 +117,7 @@ export function useEffectiveDisplayState({ const normalizeRefs = (refs: FocusedImageRef[]): FocusedImageRef[] => refs.filter((ref) => { const pkg = packageById.get(ref.packageId) - return Boolean(pkg && ref.imageIndex >= 0 && ref.imageIndex < pkg.images.length) + return Boolean(pkg && ref.imageIndex >= 0 && ref.imageIndex < resolveSourceImageCount(pkg)) }) const activePackageForDisplay = diff --git a/src/features/app/useImageBrowserViewModel.ts b/src/features/app/useImageBrowserViewModel.ts index fac86141..e4359209 100644 --- a/src/features/app/useImageBrowserViewModel.ts +++ b/src/features/app/useImageBrowserViewModel.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import type { BrowserMode, FocusedImageRef, ImageItem, ImagePackage, VectorCandidate } from '../../types' import { clamp } from '../../utils/ui' +import { resolveSourceImageCount } from '../../utils/mediaHelpers' import { resolveFullscreenImageNavigationEnabled, type FullscreenImageNavigationSource, @@ -115,7 +116,7 @@ export function useImageBrowserViewModel({ return { packageId: activePackage.id, - imageIndex: clamp(focusByPackage[activePackage.id] ?? 0, 0, activePackage.images.length - 1), + imageIndex: clamp(focusByPackage[activePackage.id] ?? 0, 0, resolveSourceImageCount(activePackage) - 1), } }, [activePackage, activeVectorRef, focusByPackage, imageFocusActive, mode, vectorResultsActive]) @@ -153,7 +154,7 @@ export function useImageBrowserViewModel({ return [] } - return activePackage.images.map((_, imageIndex) => ({ + return Array.from({ length: resolveSourceImageCount(activePackage) }, (_, imageIndex) => ({ packageId: activePackage.id, imageIndex, })) @@ -173,7 +174,7 @@ export function useImageBrowserViewModel({ return } - const clampedIndex = clamp(imageIndex, 0, pkg.images.length - 1) + const clampedIndex = clamp(imageIndex, 0, resolveSourceImageCount(pkg) - 1) setImageFocusActive(true) setSelectedPackageId(packageId) setFocusByPackage((previous) => ({ @@ -233,7 +234,7 @@ export function useImageBrowserViewModel({ } const currentIndex = orderedRootScopedImageRefs.findIndex( - (ref) => ref.packageId === activePackage.id && ref.imageIndex === clamp(current, 0, activePackage.images.length - 1), + (ref) => ref.packageId === activePackage.id && ref.imageIndex === clamp(current, 0, resolveSourceImageCount(activePackage) - 1), ) if (currentIndex < 0) { @@ -303,8 +304,8 @@ export function useImageBrowserViewModel({ return } - if (candidate >= activePackage.images.length) { - setImageFocus(activePackage.id, activePackage.images.length - 1) + if (candidate >= resolveSourceImageCount(activePackage)) { + setImageFocus(activePackage.id, resolveSourceImageCount(activePackage) - 1) return } @@ -350,7 +351,7 @@ export function useImageBrowserViewModel({ return } - const nextIndex = target === 'first' ? 0 : activePackage.images.length - 1 + const nextIndex = target === 'first' ? 0 : resolveSourceImageCount(activePackage) - 1 setImageFocus(activePackage.id, nextIndex) }, [activePackage, canNavigateImageInCurrentContext, setImageFocus, setVectorFocusIndex, vectorResultsActive, vectorSearchResults], diff --git a/src/features/app/useManageAdReviewActions.ts b/src/features/app/useManageAdReviewActions.ts index 1019d021..439acf03 100644 --- a/src/features/app/useManageAdReviewActions.ts +++ b/src/features/app/useManageAdReviewActions.ts @@ -59,6 +59,8 @@ interface UseManageAdReviewActionsParams { clearAllSelections: () => void; replaceImageCheckedIds: (imageIds: string[], append?: boolean) => void; setManageOperationHint: (message: string | null) => void; + setAdReviewResultSourceIds?: (sourceIds: string[]) => void; + setAdReviewResultImageIds?: (imageIds: string[]) => void; adReviewPanelOpen?: boolean; adReviewFocusTaskId?: string | null; onDeleteRoundCompleted?: (payload: { @@ -279,6 +281,8 @@ export function useManageAdReviewActions({ clearAllSelections, replaceImageCheckedIds, setManageOperationHint, + setAdReviewResultSourceIds, + setAdReviewResultImageIds, adReviewPanelOpen = true, adReviewFocusTaskId = null, onDeleteRoundCompleted, @@ -639,6 +643,33 @@ export function useManageAdReviewActions({ task, ]); + // 同步 ad-review 候选包 id 给会话状态,供按需缓存预加载这些源的图片 + // (结构性分页后侧边栏不再携带 images,跨源结果需逐源加载才能显示与计数) + const syncedSourceSignatureRef = useRef(""); + useEffect(() => { + const status = task?.status; + const active = + Boolean(task) && + (status === "running" || status === "paused" || status === "review"); + const imageIds = + active && task + ? task.candidates.map((candidate) => candidate.image_id) + : []; + const sourceIds = + active && task + ? Array.from( + new Set(task.candidates.map((candidate) => candidate.package_id)), + ) + : []; + const signature = imageIds.join("|"); + if (signature === syncedSourceSignatureRef.current) { + return; + } + syncedSourceSignatureRef.current = signature; + setAdReviewResultSourceIds?.(sourceIds); + setAdReviewResultImageIds?.(imageIds); + }, [task, setAdReviewResultSourceIds, setAdReviewResultImageIds]); + useEffect(() => { if (!selectionLoaded || !task) { return; diff --git a/src/features/app/usePersistedSessionCursor.ts b/src/features/app/usePersistedSessionCursor.ts index ffba2575..8aecd882 100644 --- a/src/features/app/usePersistedSessionCursor.ts +++ b/src/features/app/usePersistedSessionCursor.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BrowserMode } from "../../types"; import type { MediaRepository } from "../backend/repository"; +import { resolveSourceImageCount } from "../../utils/mediaHelpers"; import { getBenchSettings } from "../perf/benchSettings"; const SESSION_CURSOR_STATE_KEY = "ui_session_cursor_v1"; @@ -288,10 +289,10 @@ export function usePersistedSessionCursor({ if ( !resolvedImagePackageId || !resolvedImagePackage || - !resolvedImagePackage.images.some((image) => !image.hidden) + resolveSourceImageCount(resolvedImagePackage) === 0 ) { for (const candidate of packageByIdEffective.values()) { - if (!candidate.images.some((image) => !image.hidden)) { + if (resolveSourceImageCount(candidate) === 0) { continue; } resolvedImagePackageId = candidate.id; @@ -301,7 +302,7 @@ export function usePersistedSessionCursor({ } if (resolvedImagePackageId && resolvedImagePackage) { - const maxIndex = Math.max(0, resolvedImagePackage.images.length - 1); + const maxIndex = Math.max(0, resolveSourceImageCount(resolvedImagePackage) - 1); const nextImageIndex = Math.min(persisted.image.imageIndex, maxIndex); if (resolvedImagePackageId !== selectedPackageId) { setSelectedPackageId(resolvedImagePackageId); diff --git a/src/features/app/usePreferenceMetricsBuffer.ts b/src/features/app/usePreferenceMetricsBuffer.ts index 34f02ff5..7571bd25 100644 --- a/src/features/app/usePreferenceMetricsBuffer.ts +++ b/src/features/app/usePreferenceMetricsBuffer.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { MediaRepository } from "../backend/repository"; import type { BrowserMode, ImagePackage, VideoItem } from "../../types"; +import { resolveSourceImageCount } from "../../utils/mediaHelpers"; const PREFERENCE_METRICS_STATE_KEY = "xp_preference_metrics_v1"; const PREFERENCE_RUNTIME_HEARTBEAT_MS = 2_000; @@ -186,7 +187,7 @@ export function usePreferenceMetricsBuffer({ useEffect(() => { for (const source of packageById.values()) { - const totalPages = clampNonNegativeInt(source.images.length); + const totalPages = clampNonNegativeInt(resolveSourceImageCount(source)); const existing = imageMetricsBySourceIdRef.current.get(source.id); if (existing) { if (totalPages > 0 && totalPages > existing.totalPages) { @@ -550,7 +551,7 @@ export function usePreferenceMetricsBuffer({ } else if (shouldTrackImage && focusedImageRef) { const currentPackage = packageById.get(focusedImageRef.packageId) ?? null; const totalPages = clampNonNegativeInt( - currentPackage?.images.length ?? 0, + currentPackage ? resolveSourceImageCount(currentPackage) : 0, ); if (!currentImageSession.active) { imageSessionRef.current = { diff --git a/src/features/app/useRootScopedImageData.ts b/src/features/app/useRootScopedImageData.ts index a3a9533b..0d418ecc 100644 --- a/src/features/app/useRootScopedImageData.ts +++ b/src/features/app/useRootScopedImageData.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { FocusedImageRef, ImagePackage, SidebarNode } from '../../types' -import { collectImageSourceIds } from '../../utils/mediaHelpers' +import { collectImageSourceIds, resolveSourceImageCount } from '../../utils/mediaHelpers' interface UseRootScopedImageDataParams { imageRootNode: SidebarNode | null @@ -33,9 +33,10 @@ export function useRootScopedImageData({ const allScopedRefs = useMemo(() => { const refs: FocusedImageRef[] = [] for (const pkg of rootScopedPackages) { - pkg.images.forEach((_, imageIndex) => { + const count = resolveSourceImageCount(pkg) + for (let imageIndex = 0; imageIndex < count; imageIndex += 1) { refs.push({ packageId: pkg.id, imageIndex }) - }) + } } return refs }, [rootScopedPackages]) diff --git a/src/features/app/useScopedImageSourceStateSync.ts b/src/features/app/useScopedImageSourceStateSync.ts index c9da7b12..56738b4b 100644 --- a/src/features/app/useScopedImageSourceStateSync.ts +++ b/src/features/app/useScopedImageSourceStateSync.ts @@ -2,6 +2,7 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react' import type { ImagePackage } from '../../types' import { clamp } from '../../utils/ui' +import { resolveSourceImageCount } from '../../utils/mediaHelpers' interface UseScopedImageSourceStateSyncParams { scopedImageSources: ImagePackage[] @@ -26,7 +27,7 @@ export function useScopedImageSourceStateSync({ for (const source of scopedImageSources) { const hadPrev = Object.prototype.hasOwnProperty.call(previous, source.id) const prevValue = previous[source.id] ?? 0 - const nextValue = clamp(prevValue, 0, Math.max(0, source.images.length - 1)) + const nextValue = clamp(prevValue, 0, Math.max(0, resolveSourceImageCount(source) - 1)) next[source.id] = nextValue if (!hadPrev || nextValue !== prevValue) { changed = true diff --git a/src/features/app/useSourceImageCache.ts b/src/features/app/useSourceImageCache.ts new file mode 100644 index 00000000..ee449bbd --- /dev/null +++ b/src/features/app/useSourceImageCache.ts @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { mapImageItemDto } from "../backend/mappers"; +import type { + MediaRepository, + SynchronousMediaRepository, +} from "../backend/repository"; +import type { ImageItem } from "../../types"; + +interface UseSourceImageCacheParams { + repository: MediaRepository; + /** 需要确保已加载图片的源 id(通常是当前选中包,可含全屏/向量等扩展) */ + neededSourceIds: string[]; + includeHidden: boolean; + /** 库版本号:变化时清空缓存重载(删除/隐藏/移动后避免脏数据) */ + generation: number; +} + +function isSynchronousRepository( + repository: MediaRepository, +): repository is SynchronousMediaRepository { + return ( + "readSourceImagesSync" in repository && + typeof (repository as SynchronousMediaRepository).readSourceImagesSync === + "function" + ); +} + +/** + * 会话级按需图片缓存。 + * + * 结构性分页后侧边栏不再携带全库 images,本 hook 按 source id 懒加载并在会话内保留, + * 使渲染进程持有的图片量与「访问过的源」成正比,而非与入库总量成正比。 + * 已访问的源不主动卸载,保证 validImageIdSet / 跨包导航 / 管理选择覆盖访问集合。 + * + * 同步仓库(测试)下走同步取数,避免异步加载引入的渲染时序问题。 + */ +export function useSourceImageCache({ + repository, + neededSourceIds, + includeHidden, + generation, +}: UseSourceImageCacheParams): ReadonlyMap { + // 同步仓库(测试):直接同步取数,结果随渲染即时可用 + const syncCache = useMemo | null>(() => { + // generation 变化(库删除/隐藏/移动)时强制重算,读取最新同步快照 + void generation; + if (!isSynchronousRepository(repository)) { + return null; + } + const map = new Map(); + for (const sourceId of neededSourceIds) { + if (!sourceId) { + continue; + } + try { + const response = repository.readSourceImagesSync({ + source_id: sourceId, + include_hidden: includeHidden, + }); + map.set(sourceId, response.images.map(mapImageItemDto)); + } catch { + // 忽略,源保持为空 + } + } + return map; + // generation 纳入依赖以在库变更后重算 + }, [repository, neededSourceIds, includeHidden, generation]); + + const [cache, setCache] = useState>( + () => new Map(), + ); + const inFlightRef = useRef>(new Set()); + + // includeHidden / 库版本变化会改变可见图片集,清空异步缓存重载 + useEffect(() => { + setCache((prev) => (prev.size === 0 ? prev : new Map())); + inFlightRef.current = new Set(); + }, [includeHidden, generation]); + + useEffect(() => { + if (syncCache) { + return; + } + const readSourceImages = repository.readSourceImages; + if (!readSourceImages) { + return; + } + + let cancelled = false; + for (const sourceId of neededSourceIds) { + if ( + !sourceId || + cache.has(sourceId) || + inFlightRef.current.has(sourceId) + ) { + continue; + } + + inFlightRef.current.add(sourceId); + readSourceImages( + { source_id: sourceId, include_hidden: includeHidden }, + { timeoutMs: 8_000 }, + ) + .then((response) => { + if (cancelled) { + return; + } + const images = response.images.map(mapImageItemDto); + setCache((prev) => { + const next = new Map(prev); + next.set(sourceId, images); + return next; + }); + }) + .catch(() => { + // 加载失败保持源为空,后续交互可重试 + }) + .finally(() => { + inFlightRef.current.delete(sourceId); + }); + } + + return () => { + cancelled = true; + }; + }, [repository, neededSourceIds, includeHidden, cache, syncCache]); + + return syncCache ?? cache; +} diff --git a/src/features/app/workspaceImageDerivations.ts b/src/features/app/workspaceImageDerivations.ts index 39bbc859..d66f7f47 100644 --- a/src/features/app/workspaceImageDerivations.ts +++ b/src/features/app/workspaceImageDerivations.ts @@ -82,15 +82,7 @@ export function buildNodeBrowseItems({ : resolveFirstVisibleImage(previewSourceIds, packageByIdEffective); const previewSourceId = coverSourceIdFromNode ?? firstVisibleImage?.sourceId ?? previewSourceIds[0] ?? null; - const ownSource = child.imageSourceId - ? packageByIdEffective.get(child.imageSourceId) - : null; - const visibleImageCount = ownSource - ? ownSource.images.reduce( - (count, image) => (image.hidden ? count : count + 1), - 0, - ) - : (child.directImageCount ?? 0); + const visibleImageCount = child.directImageCount ?? 0; const coverImageUrl = (previewSourceId ? sourceCoverImageUrlBySourceId[previewSourceId] diff --git a/src/features/app/workspaceMusicBooklet.ts b/src/features/app/workspaceMusicBooklet.ts index 57997910..b6b71ea0 100644 --- a/src/features/app/workspaceMusicBooklet.ts +++ b/src/features/app/workspaceMusicBooklet.ts @@ -1,4 +1,5 @@ import type { AudioItem, ImagePackage } from '../../types' +import { resolveSourceImageCount } from '../../utils/mediaHelpers' const MUSIC_BOOKLET_ROOT_LABEL = 'CD Booklet' export const MUSIC_BOOKLET_AUTO_VALUE = '__auto__' @@ -275,7 +276,7 @@ function buildMusicBookletCandidates(params: { sourceId: source.id, absolutePath: source.absolutePath, label: relativeLabel || baseName || source.displayName, - imageCount: source.images.length, + imageCount: resolveSourceImageCount(source), relativeDepth: relativeSegments.length, coverHint: hasKeyword(baseName, COVER_HINT_KEYWORDS), bookletHint: hasKeyword(baseName, BOOKLET_HINT_KEYWORDS), diff --git a/src/features/backend/mappers.ts b/src/features/backend/mappers.ts index b5e6c74f..ede53ad8 100644 --- a/src/features/backend/mappers.ts +++ b/src/features/backend/mappers.ts @@ -4,6 +4,7 @@ import type { ImageItemDto, ImagePackageDto, ImageSourceLiteDto, + ImageSourceSidebarDto, LibrarySnapshotDto, LibrarySnapshotLiteDto, ReadImageMetadataResponseDto, @@ -65,6 +66,8 @@ export function mapImageItemDto(item: ImageItemDto): ImageItem { } export function mapImagePackageDto(source: ImagePackageDto): ImagePackage { + const coverImage = + source.images.find((image) => !(image.hidden ?? false)) ?? source.images[0]; return { id: source.id, packageName: source.package_name, @@ -113,6 +116,10 @@ export function mapImagePackageDto(source: ImagePackageDto): ImagePackage { updatedAtMs: source.preference_metrics.updated_at_ms, } : null, + imageCount: source.images.length, + coverMediaLocator: coverImage + ? mapMediaLocatorDto(coverImage.media_locator) + : null, images: source.images.map(mapImageItemDto), }; } @@ -172,6 +179,18 @@ export function mapImageSourceLiteDto( }; } +export function mapImageSourceSidebarDto( + source: ImageSourceSidebarDto, +): ImagePackage { + return { + ...mapImageSourceLiteDto(source), + imageCount: source.image_count, + coverMediaLocator: source.cover_media_locator + ? mapMediaLocatorDto(source.cover_media_locator) + : null, + }; +} + export function mapVideoItemDto(video: VideoItemDto): VideoItem { const fallbackWorkTitle = deriveWorkTitleFromFileName(video.file_name); return { @@ -353,8 +372,10 @@ export function mapLibrarySnapshotAnyDto( export function mapImageSidebarTreeDto( response: ReadImageSidebarTreeResponseDto, ): ImageSidebarTreeViewModel { - const imagePackages = response.image_packages.map(mapImagePackageDto); - const imageDirectories = response.image_directories.map(mapImagePackageDto); + const imagePackages = response.image_packages.map(mapImageSourceSidebarDto); + const imageDirectories = response.image_directories.map( + mapImageSourceSidebarDto, + ); const sourceById = new Map([ ...imagePackages.map((source) => [source.id, source] as const), ...imageDirectories.map((source) => [source.id, source] as const), diff --git a/src/features/backend/repository/mock/MediaReadHandlers.ts b/src/features/backend/repository/mock/MediaReadHandlers.ts index d5baf325..544f87d2 100644 --- a/src/features/backend/repository/mock/MediaReadHandlers.ts +++ b/src/features/backend/repository/mock/MediaReadHandlers.ts @@ -1,6 +1,7 @@ import { readImageMetadataResponseSchema, readImagePageResponseSchema, + readSourceImagesResponseSchema, readImageSidebarTreeResponseSchema, readImportTasksResponseSchema, readPlaylistResponseSchema, @@ -11,6 +12,8 @@ import { type ReadImageMetadataResponseDto, type ReadImagePageRequestDto, type ReadImagePageResponseDto, + type ReadSourceImagesRequestDto, + type ReadSourceImagesResponseDto, type ReadImageSidebarTreeRequestDto, type ReadImageSidebarTreeResponseDto, type ReadPlaylistResponseDto, @@ -31,6 +34,7 @@ import { filterHiddenSources, filterHiddenImagesForSource, toMockImageDataUrl, + toSidebarSource, locatorPathKey, } from './utils' import { MOCK_LIBRARY_SNAPSHOT_REF, type MockRepositoryState } from './types' @@ -55,8 +59,8 @@ export class MockMediaReadHandlers { ).map(toSidebarNodeDto) return readImageSidebarTreeResponseSchema.parse({ - image_packages: filteredPackages, - image_directories: filteredDirectories, + image_packages: filteredPackages.map(toSidebarSource), + image_directories: filteredDirectories.map(toSidebarSource), tree, }) } @@ -131,6 +135,24 @@ export class MockMediaReadHandlers { ) } + readSourceImagesSync( + request: ReadSourceImagesRequestDto, + ): ReadSourceImagesResponseDto { + const snapshot = MOCK_LIBRARY_SNAPSHOT_REF.current + const includeHidden = request.include_hidden ?? false + const allSources = snapshot + ? [...snapshot.image_packages, ...snapshot.image_directories] + : [] + const source = allSources.find((item) => item.id === request.source_id) + const visibleSource = source + ? filterHiddenImagesForSource(source, includeHidden) + : null + return readSourceImagesResponseSchema.parse({ + source_id: request.source_id, + images: visibleSource ? visibleSource.images : [], + }) + } + resolveMediaResourceSync( request: ResolveMediaResourceRequestDto, ): ResolveMediaResourceResponseDto { diff --git a/src/features/backend/repository/mock/utils.ts b/src/features/backend/repository/mock/utils.ts index 9e3e86bc..dd7b4d66 100644 --- a/src/features/backend/repository/mock/utils.ts +++ b/src/features/backend/repository/mock/utils.ts @@ -9,6 +9,7 @@ import { } from "../../../../contracts/backend.shared"; import { type ImagePackageDto, + type ImageSourceSidebarDto, type MediaLocatorDto, type ReadImageSidebarTreeRequestDto, } from "../../../../contracts/backend"; @@ -107,6 +108,16 @@ export function filterHiddenSources( .filter((source) => source.images.length > 0); } +export function toSidebarSource(source: ImagePackageDto): ImageSourceSidebarDto { + const { images, ...rest } = source; + const cover = images[0] ?? null; + return { + ...rest, + image_count: images.length, + cover_media_locator: cover ? cover.media_locator : null, + }; +} + export function locatorPathKey(locator: MediaLocatorDto): string { return mediaLocatorDtoKey(locator); } diff --git a/src/features/backend/repository/mockRepository.ts b/src/features/backend/repository/mockRepository.ts index 632097a4..3cf28267 100644 --- a/src/features/backend/repository/mockRepository.ts +++ b/src/features/backend/repository/mockRepository.ts @@ -43,6 +43,8 @@ import { type ReadImageMetadataResponseDto, type ReadImagePageRequestDto, type ReadImagePageResponseDto, + type ReadSourceImagesRequestDto, + type ReadSourceImagesResponseDto, type ReadImageSidebarTreeRequestDto, type ReadImageSidebarTreeResponseDto, type ReadImportTasksResponseDto, @@ -290,6 +292,17 @@ export class MockMediaRepository implements MediaRepository, SynchronousMediaRep return resolveAsync(this.readImagePageSync(request), options) } + readSourceImagesSync(request: ReadSourceImagesRequestDto): ReadSourceImagesResponseDto { + return this.read.readSourceImagesSync(request) + } + + async readSourceImages( + request: ReadSourceImagesRequestDto, + options?: RepositoryRequestOptions, + ): Promise { + return resolveAsync(this.readSourceImagesSync(request), options) + } + readImageMetadataSync(request: ReadImageMetadataRequestDto): ReadImageMetadataResponseDto { return this.read.readImageMetadataSync(request) } diff --git a/src/features/backend/repository/realRepository.test.ts b/src/features/backend/repository/realRepository.test.ts index 6a29b901..d47c98ec 100644 --- a/src/features/backend/repository/realRepository.test.ts +++ b/src/features/backend/repository/realRepository.test.ts @@ -66,7 +66,14 @@ function createLibrarySnapshotDto(): LibrarySnapshotDto { function createSidebarResponseDto(): ReadImageSidebarTreeResponseDto { return { - image_packages: createLibrarySnapshotDto().image_packages, + image_packages: createLibrarySnapshotDto().image_packages.map((source) => { + const { images, ...rest } = source; + return { + ...rest, + image_count: images.length, + cover_media_locator: images[0]?.media_locator ?? null, + }; + }), image_directories: [], tree: [ { diff --git a/src/features/backend/repository/realRepository.ts b/src/features/backend/repository/realRepository.ts index e064def5..33f86616 100644 --- a/src/features/backend/repository/realRepository.ts +++ b/src/features/backend/repository/realRepository.ts @@ -54,7 +54,7 @@ import { prepareSubtitleTrackResponseSchema, readImageMetadataResponseSchema, readImagePageResponseSchema, - readImageSidebarTreeResponseSchema, + readSourceImagesResponseSchema, resolveMediaResourceResponseSchema, retryImportTaskResponseSchema, saveVideoCoverResponseSchema, @@ -166,6 +166,8 @@ import { type PrepareSubtitleTrackResponseDto, type ReadImagePageRequestDto, type ReadImagePageResponseDto, + type ReadSourceImagesRequestDto, + type ReadSourceImagesResponseDto, type ReadImageSidebarTreeRequestDto, type ReadImageSidebarTreeResponseDto, type ResolveMediaResourceRequestDto, @@ -315,7 +317,7 @@ export class RealMediaRepository implements MediaRepository { api.readImageSidebarTree(request), options, ); - return readImageSidebarTreeResponseSchema.parse(response); + return response; }); } @@ -330,6 +332,17 @@ export class RealMediaRepository implements MediaRepository { }); } + async readSourceImages( + request: ReadSourceImagesRequestDto, + options?: RepositoryRequestOptions, + ): Promise { + const readSourceImages = requireBackendMethod("readSourceImages"); + return withIpcTiming("readSourceImages", async () => { + const response = await withAbort(readSourceImages(request), options); + return readSourceImagesResponseSchema.parse(response); + }); + } + async readImageMetadata( request: ReadImageMetadataRequestDto, options?: RepositoryRequestOptions, diff --git a/src/features/backend/repository/types.ts b/src/features/backend/repository/types.ts index 44ec491e..9505cd54 100644 --- a/src/features/backend/repository/types.ts +++ b/src/features/backend/repository/types.ts @@ -129,6 +129,8 @@ import type { ReadPlaylistResponseDto, ReadImagePageRequestDto, ReadImagePageResponseDto, + ReadSourceImagesRequestDto, + ReadSourceImagesResponseDto, ReadImageSidebarTreeRequestDto, ReadImageSidebarTreeResponseDto, ResolveMediaResourceRequestDto, @@ -180,6 +182,10 @@ export interface MediaRepository { request: ReadImagePageRequestDto, options?: RepositoryRequestOptions, ): Promise; + readSourceImages?( + request: ReadSourceImagesRequestDto, + options?: RepositoryRequestOptions, + ): Promise; readImageMetadata( request: ReadImageMetadataRequestDto, options?: RepositoryRequestOptions, @@ -510,6 +516,9 @@ export interface SynchronousMediaRepository extends MediaRepository { request: ReadImageSidebarTreeRequestDto, ): ReadImageSidebarTreeResponseDto; readImagePageSync(request: ReadImagePageRequestDto): ReadImagePageResponseDto; + readSourceImagesSync( + request: ReadSourceImagesRequestDto, + ): ReadSourceImagesResponseDto; readImageMetadataSync( request: ReadImageMetadataRequestDto, ): ReadImageMetadataResponseDto; diff --git a/src/features/backend/useReadOnlyDataAccess.resilience-and-fallback.test.tsx b/src/features/backend/useReadOnlyDataAccess.resilience-and-fallback.test.tsx index d5063a6f..df95da5e 100644 --- a/src/features/backend/useReadOnlyDataAccess.resilience-and-fallback.test.tsx +++ b/src/features/backend/useReadOnlyDataAccess.resilience-and-fallback.test.tsx @@ -202,8 +202,15 @@ function createBaselineRepository( function createSidebarResponse( packageDto: ReturnType, ): ReadImageSidebarTreeResponseDto { + const { images, ...rest } = packageDto; return { - image_packages: [packageDto], + image_packages: [ + { + ...rest, + image_count: images.length, + cover_media_locator: images[0]?.media_locator ?? null, + }, + ], image_directories: [], tree: [ { @@ -215,7 +222,7 @@ function createSidebarResponse( children: [], package_id: packageDto.id, image_source_id: packageDto.id, - direct_image_count: packageDto.images.length, + direct_image_count: images.length, path_key: packageDto.tree_path.join("/"), }, ], diff --git a/src/features/backend/useReadOnlyDataAccess.test.tsx b/src/features/backend/useReadOnlyDataAccess.test.tsx index f40b7457..95db6d2f 100644 --- a/src/features/backend/useReadOnlyDataAccess.test.tsx +++ b/src/features/backend/useReadOnlyDataAccess.test.tsx @@ -195,8 +195,15 @@ function createBaselineRepository( function createSidebarResponse( packageDto: ReturnType, ): ReadImageSidebarTreeResponseDto { + const { images, ...rest } = packageDto; return { - image_packages: [packageDto], + image_packages: [ + { + ...rest, + image_count: images.length, + cover_media_locator: images[0]?.media_locator ?? null, + }, + ], image_directories: [], tree: [ { @@ -208,7 +215,7 @@ function createSidebarResponse( children: [], package_id: packageDto.id, image_source_id: packageDto.id, - direct_image_count: packageDto.images.length, + direct_image_count: images.length, path_key: packageDto.tree_path.join("/"), }, ], diff --git a/src/features/backend/useReadOnlyDataAccess.ts b/src/features/backend/useReadOnlyDataAccess.ts index e9c6546f..e47833e9 100644 --- a/src/features/backend/useReadOnlyDataAccess.ts +++ b/src/features/backend/useReadOnlyDataAccess.ts @@ -210,6 +210,8 @@ export function useReadOnlyDataAccess({ const deferredLibraryRefreshAfterLoadRef = useRef(false) const deferredSidebarRefreshAfterLoadRef = useRef(false) const pendingRefreshScopeRef = useRef(null) + // 记录上次"回到前台"刷新的时间戳,用于合并 focus + visibilitychange 的重复触发 + const lastForegroundRefreshAtRef = useRef(0) const [libraryRetryNonce, setLibraryRetryNonce] = useState(0) const [sidebarRetryNonce, setSidebarRetryNonce] = useState(0) @@ -662,10 +664,18 @@ export function useReadOnlyDataAccess({ return } + const FOREGROUND_REFRESH_MIN_INTERVAL_MS = 1_500 const refreshOnForeground = () => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { return } + // focus 与 visibilitychange 常对同一次切换同时触发;且 onLibraryChanged + // 已能实时刷新,此处仅作兜底,故加最小间隔去重,避免每次 alt-tab 全量重拉。 + const now = Date.now() + if (now - lastForegroundRefreshAtRef.current < FOREGROUND_REFRESH_MIN_INTERVAL_MS) { + return + } + lastForegroundRefreshAtRef.current = now triggerLibraryRefresh() triggerSidebarRefresh() triggerPageRefresh() diff --git a/src/features/backend/useReadOnlyDataAccess.visibility-and-import-throttle.test.tsx b/src/features/backend/useReadOnlyDataAccess.visibility-and-import-throttle.test.tsx index daef961b..a11216ae 100644 --- a/src/features/backend/useReadOnlyDataAccess.visibility-and-import-throttle.test.tsx +++ b/src/features/backend/useReadOnlyDataAccess.visibility-and-import-throttle.test.tsx @@ -78,8 +78,15 @@ function createLibrarySnapshot(): LibrarySnapshotDto { function createSidebarResponse( packageDto: LibrarySnapshotDto["image_packages"][number], ): ReadImageSidebarTreeResponseDto { + const { images, ...rest } = packageDto; return { - image_packages: [packageDto], + image_packages: [ + { + ...rest, + image_count: images.length, + cover_media_locator: images[0]?.media_locator ?? null, + }, + ], image_directories: [], tree: [ { @@ -91,7 +98,7 @@ function createSidebarResponse( children: [], package_id: packageDto.id, image_source_id: packageDto.id, - direct_image_count: packageDto.images.length, + direct_image_count: images.length, path_key: packageDto.tree_path.join("/"), }, ], diff --git a/src/types.ts b/src/types.ts index c8b1fdc3..5b5db500 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,6 +99,8 @@ export interface ImagePackage { lastEventTimeMs: number | null; updatedAtMs: number; } | null; + imageCount?: number; + coverMediaLocator?: MediaLocator | null; images: ImageItem[]; } diff --git a/src/utils/mediaHelpers.ts b/src/utils/mediaHelpers.ts index 1f1da29e..c5c452cf 100644 --- a/src/utils/mediaHelpers.ts +++ b/src/utils/mediaHelpers.ts @@ -179,6 +179,17 @@ export function collectImageSourceIds(node: SidebarNode): string[] { return [...current, ...node.children.flatMap((child) => collectImageSourceIds(child))] } +/** + * 解析某个图片源的可见图片数量。 + * 结构性分页后,未加载的源 images 为空但 imageCount 由后端权威给出; + * 已加载(或旧链路)时回退到 images.length,二者在同一 includeHidden 口径下一致。 + */ +export function resolveSourceImageCount( + source: { imageCount?: number; images: ReadonlyArray }, +): number { + return source.imageCount ?? source.images.length +} + export function buildInitialVideoCoverMap(videos: Array>): Record { const map: Record = {} for (const video of videos) {