From f29edb610bac156fdc4ca3344e228c69411aac98 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 1 Jul 2026 21:12:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=88=9B=E4=BD=9C=E7=B2=BE?= =?UTF-8?q?=E9=80=89=E6=97=A0=E9=99=90=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为陶泥儿精选公开资源接口增加 cursor 分页。 让创作主页瀑布流触底继续加载并追加去重。 补充加载更多失败保留已加载内容和重复触发防护。 更新精选分页契约文档和相关测试。 --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...作】创作主页与项目入口改版计划-2026-06-18.md | 2 + .../crates/api-server/src/editor_project.rs | 82 ++++++- .../src/mapper/editor_project.rs | 6 +- ...esource_public_showcase_list_input_type.rs | 1 + .../src/editor_project_storage.rs | 41 ++++ .../CreationLandingView.test.tsx | 146 ++++++++++++- .../creation-home/CreationLandingView.tsx | 204 +++++++++++++++--- .../creation-home/creationShowcaseModel.ts | 2 +- src/index.css | 17 ++ .../image-editor/editorProjectClient.test.ts | 37 +++- .../image-editor/editorProjectClient.ts | 26 ++- 13 files changed, 515 insertions(+), 53 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 732c9d87a..6a3f2b474 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -68,7 +68,7 @@ ## 2026-06-22 创作主页精选展示全站公开画布生成资源 - 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。 -- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间倒序展示。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除` 和 `公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`。 +- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间和资源 ID 倒序 cursor 分页展示,每页最多 36 条,有 `nextCursor` 时前端滚动到底部继续追加。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除` 和 `公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`。 - 影响范围:`/creation` 创作主页、`creationShowcaseModel`、公开精选 BFF、图片画布素材列表右键菜单、账号素材库快照、创作主页改版计划和精选素材相关测试。 - 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts`、`CreationLandingView.test.tsx`、`ImageCanvasAssetRowView.test.tsx`、`useImageCanvasAssetLibrary.test.tsx` 与 `src/services/image-editor/editorProjectClient.test.ts`,确认公开生成资源展示、上传素材过滤、公开开关隐藏资源、删除入口在右键菜单中、公开作品不再 fallback。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 0894e180b..2533a212a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -463,7 +463,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应的作者展示字段优先提供 `authorDisplayName` / `display_name`,没有展示名时提供 `authorPublicUserCode` / 陶泥号作为展示兜底,前端公开展示绝不能兜底到内部 `ownerUserId` / `user_id`。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` / `resource_id` 倒序 cursor 分页;每页最多 36 条,响应返回 `nextCursor`,下一页通过 `?cursor=...` 继续读取。公开响应的作者展示字段优先提供 `authorDisplayName` / `display_name`,没有展示名时提供 `authorPublicUserCode` / 陶泥号作为展示兜底,前端公开展示绝不能兜底到内部 `ownerUserId` / `user_id`。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 9767efbcc..ec9714319 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -75,6 +75,8 @@ 精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 +瀑布流通过 `GET /api/editor/showcase/resources` 按 `created_at` / `resource_id` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。 + Tab: - 素材包 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 54b788f83..59e2f7956 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::BTreeMap, io::Cursor, time::Instant}; use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Path, Query, State}, http::StatusCode, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; @@ -70,6 +70,8 @@ use crate::{ work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID}, }; +const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36; + pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-"; pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-"; pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-"; @@ -333,6 +335,13 @@ pub struct EditorProjectResourceResponse { #[serde(rename_all = "camelCase")] pub struct EditorProjectResourceListResponse { resources: Vec, + next_cursor: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectResourceListQuery { + cursor: Option, } #[derive(Debug, Serialize)] @@ -675,25 +684,56 @@ pub async fn list_editor_projects( pub async fn list_public_editor_project_resources( State(state): State, Extension(request_context): Extension, + Query(query): Query, ) -> Result, AppError> { - let resources = state + let mut resource_records = state .spacetime_client() .list_public_editor_project_resources(EditorProjectResourcePublicShowcaseListRecordInput { - limit: 36, + limit: (EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE + 1) as u32, + cursor: normalize_optional_string(query.cursor), }) .await .map_err(map_editor_project_error)?; - let resources = resources + let next_cursor = if resource_records.len() > EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE { + resource_records + .get(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE - 1) + .and_then(editor_project_resource_showcase_cursor) + } else { + None + }; + resource_records.truncate(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE); + + let resources = resource_records .into_iter() .map(|record| editor_project_resource_public_payload_from_record(&state, record)) .collect(); Ok(json_success_body( Some(&request_context), - EditorProjectResourceListResponse { resources }, + EditorProjectResourceListResponse { + resources, + next_cursor, + }, )) } +fn editor_project_resource_showcase_cursor(record: &EditorProjectResourceRecord) -> Option { + let created_at_micros = parse_editor_timestamp_micros(record.created_at.as_str())?; + Some(format!("{created_at_micros}:{}", record.resource_id)) +} + +fn parse_editor_timestamp_micros(value: &str) -> Option { + let (seconds, micros) = value.trim().strip_suffix('Z')?.split_once('.')?; + if micros.len() != 6 { + return None; + } + seconds + .parse::() + .ok()? + .checked_mul(1_000_000)? + .checked_add(micros.parse::().ok()?) +} + pub async fn create_editor_project( State(state): State, Extension(request_context): Extension, @@ -4964,6 +5004,38 @@ mod tests { assert_eq!(value["ownerUserId"], json!("user-author-1")); } + #[test] + fn editor_project_resource_showcase_cursor_uses_microsecond_timestamp() { + let record = EditorProjectResourceRecord { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-author-1".to_string(), + asset_object_id: None, + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: None, + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: None, + generation_inputs: None, + public_showcase_enabled: true, + created_at: "1713686401.234567Z".to_string(), + updated_at: "1713686401.234567Z".to_string(), + }; + + assert_eq!( + editor_project_resource_showcase_cursor(&record).as_deref(), + Some("1713686401234567:resource-1") + ); + assert_eq!(parse_editor_timestamp_micros("bad-time"), None); + } + #[test] fn editor_project_resource_public_payload_uses_display_author_fields() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index f959b62f3..ce92aa49e 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -185,6 +185,7 @@ pub struct EditorProjectResourceShowcaseUpdateRecordInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorProjectResourcePublicShowcaseListRecordInput { pub limit: u32, + pub cursor: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -378,7 +379,10 @@ impl From for crate::module_bindings::EditorProjectResourcePublicShowcaseListInput { fn from(input: EditorProjectResourcePublicShowcaseListRecordInput) -> Self { - Self { limit: input.limit } + Self { + limit: input.limit, + cursor: input.cursor, + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs index 764c4eed9..eb4ba42ce 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs @@ -8,6 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct EditorProjectResourcePublicShowcaseListInput { pub limit: u32, + pub cursor: Option, } impl __sdk::InModule for EditorProjectResourcePublicShowcaseListInput { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index e88e2f61e..ba0fbb3cf 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -225,6 +225,7 @@ pub struct EditorProjectResourceShowcaseUpdateInput { #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourcePublicShowcaseListInput { pub limit: u32, + pub cursor: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1468,6 +1469,7 @@ fn list_public_editor_project_resources( input: EditorProjectResourcePublicShowcaseListInput, ) -> Result, String> { let limit = input.limit.clamp(1, 120) as usize; + let cursor = parse_public_showcase_cursor(input.cursor.as_deref()); let mut resources = ctx .db .editor_project_resource() @@ -1477,6 +1479,11 @@ fn list_public_editor_project_resources( && resource.source_type == "generated" && !resource.image_src.trim().is_empty() && !project_resource_copies_source_media(ctx, resource) + && is_after_public_showcase_cursor( + resource.created_at.to_micros_since_unix_epoch(), + resource.resource_id.as_str(), + cursor.as_ref(), + ) }) .map(resource_snapshot_from_row) .collect::>(); @@ -1492,6 +1499,29 @@ fn list_public_editor_project_resources( Ok(resources) } +fn parse_public_showcase_cursor(cursor: Option<&str>) -> Option<(i64, String)> { + let (created_at_micros, resource_id) = cursor?.trim().split_once(':')?; + let created_at_micros = created_at_micros.parse::().ok()?; + let resource_id = resource_id.trim(); + if resource_id.is_empty() { + return None; + } + Some((created_at_micros, resource_id.to_string())) +} + +fn is_after_public_showcase_cursor( + created_at_micros: i64, + resource_id: &str, + cursor: Option<&(i64, String)>, +) -> bool { + let Some((cursor_created_at_micros, cursor_resource_id)) = cursor else { + return true; + }; + created_at_micros < *cursor_created_at_micros + || (created_at_micros == *cursor_created_at_micros + && resource_id < cursor_resource_id.as_str()) +} + fn ensure_default_canvas( ctx: &ReducerContext, project_id: &str, @@ -2485,4 +2515,15 @@ mod tests { assert_eq!(dialog["status"], json!("generating")); assert_eq!(dialog["prompt"], json!("继续生成一版")); } + + #[test] + fn public_showcase_cursor_filters_strictly_after_desc_position() { + let cursor = parse_public_showcase_cursor(Some("2000:resource-b")); + + assert!(is_after_public_showcase_cursor(1999, "resource-z", cursor.as_ref())); + assert!(is_after_public_showcase_cursor(2000, "resource-a", cursor.as_ref())); + assert!(!is_after_public_showcase_cursor(2000, "resource-b", cursor.as_ref())); + assert!(!is_after_public_showcase_cursor(2000, "resource-c", cursor.as_ref())); + assert!(!is_after_public_showcase_cursor(2001, "resource-a", cursor.as_ref())); + } } diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index d4fcae6ae..b88f331fc 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ContextType } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -89,6 +89,54 @@ function renderCreationLanding({ return result; } +function installIntersectionObserverMock() { + const observers: Array<{ + callback: IntersectionObserverCallback; + instance: IntersectionObserver; + }> = []; + + class MockIntersectionObserver { + readonly root = null; + readonly rootMargin: string; + readonly thresholds: ReadonlyArray; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + constructor( + callback: IntersectionObserverCallback, + options: IntersectionObserverInit = {}, + ) { + this.rootMargin = options.rootMargin ?? ''; + this.thresholds = Array.isArray(options.threshold) + ? options.threshold + : [options.threshold ?? 0]; + observers.push({ + callback, + instance: this as unknown as IntersectionObserver, + }); + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + return observers; +} + +function triggerIntersection( + observer: { + callback: IntersectionObserverCallback; + instance: IntersectionObserver; + }, +) { + act(() => { + observer.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + observer.instance, + ); + }); +} + describe('CreationLandingView', () => { beforeEach(() => { listPublicEditorProjectResourcesMock.mockResolvedValue([]); @@ -98,6 +146,7 @@ describe('CreationLandingView', () => { listEditorProjectsMock.mockReset(); listPublicEditorProjectResourcesMock.mockReset(); createEditorProjectMock.mockReset(); + vi.unstubAllGlobals(); }); it('renders the Taonier creation landing modules without external branding', async () => { @@ -464,6 +513,101 @@ describe('CreationLandingView', () => { expect(screen.queryByText('拼图关卡创作')).toBeNull(); }); + it('loads more featured resources when the waterfall reaches the end', async () => { + const observers = installIntersectionObserverMock(); + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock + .mockResolvedValueOnce({ + resources: [ + { + resourceId: 'resource-pack-a', + projectId: 'project-newest', + label: '素材包 A', + imageSrc: 'data:image/png;base64,a', + width: 512, + height: 640, + sourceType: 'generated', + prompt: '第一页素材', + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + ], + nextCursor: 'cursor-page-2', + }) + .mockResolvedValueOnce({ + resources: [ + { + resourceId: 'resource-pack-b', + projectId: 'project-old', + label: '素材包 B', + imageSrc: 'data:image/png;base64,b', + width: 512, + height: 512, + sourceType: 'generated', + prompt: '第二页素材', + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + ], + nextCursor: null, + }); + + renderCreationLanding(); + + expect(await screen.findByText('素材包 A')).toBeTruthy(); + await waitFor(() => expect(observers).toHaveLength(1)); + expect(screen.queryByText('素材包 B')).toBeNull(); + triggerIntersection(observers[0]!); + triggerIntersection(observers[0]!); + expect(await screen.findByText('素材包 B')).toBeTruthy(); + await waitFor(() => + expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({ + cursor: 'cursor-page-2', + }), + ); + expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2); + }); + + it('keeps rendered featured resources when loading more fails', async () => { + const observers = installIntersectionObserverMock(); + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock + .mockResolvedValueOnce({ + resources: [ + { + resourceId: 'resource-pack-a', + projectId: 'project-newest', + label: '素材包 A', + imageSrc: 'data:image/png;base64,a', + width: 512, + height: 640, + sourceType: 'generated', + prompt: '第一页素材', + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + ], + nextCursor: 'cursor-page-2', + }) + .mockRejectedValueOnce(new Error('网络失败')); + + renderCreationLanding(); + + expect(await screen.findByText('素材包 A')).toBeTruthy(); + await waitFor(() => expect(observers).toHaveLength(1)); + triggerIntersection(observers[0]!); + + expect(await screen.findByRole('button', { name: '读取更多素材失败,点击重试' })).toBeTruthy(); + expect(screen.getByText('素材包 A')).toBeTruthy(); + expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy(); + }); + it('keeps the featured waterfall empty when project resources are empty', async () => { renderCreationLanding({ authValue: createAuthValue({ diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 6c41cfb94..b11d5b179 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -1,10 +1,11 @@ import { Film, Image as ImageIcon, Music2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { ApiClientError } from '../../services/apiClient'; import { createEditorProject, + type EditorProjectResourceListPage, type EditorProjectResourceSnapshot, type EditorProjectSnapshot, listEditorProjects, @@ -119,6 +120,31 @@ function isUnauthorizedError(error: unknown) { return error instanceof ApiClientError && error.status === 401; } +function normalizeShowcaseResourcePage( + page: EditorProjectResourceListPage | EditorProjectResourceSnapshot[], +): EditorProjectResourceListPage { + return Array.isArray(page) + ? { resources: page, nextCursor: null } + : page; +} + +function mergeShowcaseResources( + current: EditorProjectResourceSnapshot[], + incoming: EditorProjectResourceSnapshot[], +) { + const seenResourceIds = new Set(current.map((resource) => resource.resourceId)); + return [ + ...current, + ...incoming.filter((resource) => { + if (seenResourceIds.has(resource.resourceId)) { + return false; + } + seenResourceIds.add(resource.resourceId); + return true; + }), + ]; +} + function formatProjectUpdatedAt(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -387,12 +413,15 @@ export function CreationLandingView({ ); const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [isLoadingShowcase, setIsLoadingShowcase] = useState(false); + const [isLoadingMoreShowcase, setIsLoadingMoreShowcase] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); const [projectError, setProjectError] = useState(null); const [projectShowcaseResources, setProjectShowcaseResources] = useState< EditorProjectResourceSnapshot[] >([]); + const [showcaseNextCursor, setShowcaseNextCursor] = useState(null); const [assetError, setAssetError] = useState(null); + const [showcaseLoadMoreError, setShowcaseLoadMoreError] = useState(null); const [activeShowcaseTab, setActiveShowcaseTab] = useState('packs'); const [selectedShowcaseItem, setSelectedShowcaseItem] = @@ -400,6 +429,9 @@ export function CreationLandingView({ const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0); const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] = useState(false); + const showcaseLoadMoreRef = useRef(null); + const showcaseLoadingMoreCursorRef = useRef(null); + const showcaseFailedLoadMoreCursorRef = useRef(null); const refreshRecentProjects = useCallback(() => { if (!isAuthenticated) { @@ -438,11 +470,21 @@ export function CreationLandingView({ const refreshShowcaseResources = useCallback(() => { setIsLoadingShowcase(true); + setIsLoadingMoreShowcase(false); + showcaseLoadingMoreCursorRef.current = null; + showcaseFailedLoadMoreCursorRef.current = null; setAssetError(null); + setShowcaseLoadMoreError(null); listPublicEditorProjectResources() - .then((resources) => setProjectShowcaseResources(resources)) + .then((page) => { + const normalizedPage = normalizeShowcaseResourcePage(page); + setProjectShowcaseResources(normalizedPage.resources); + setShowcaseNextCursor(normalizedPage.nextCursor); + }) .catch((error: unknown) => { setProjectShowcaseResources([]); + setShowcaseNextCursor(null); + setShowcaseLoadMoreError(null); setAssetError( error instanceof Error ? error.message : '读取陶泥儿精选失败', ); @@ -454,6 +496,73 @@ export function CreationLandingView({ refreshShowcaseResources(); }, [refreshShowcaseResources]); + const loadMoreShowcaseResources = useCallback((options: { force?: boolean } = {}) => { + const cursor = showcaseNextCursor; + if (!cursor || isLoadingShowcase || isLoadingMoreShowcase) { + return; + } + if (showcaseLoadingMoreCursorRef.current === cursor) { + return; + } + if (!options.force && showcaseFailedLoadMoreCursorRef.current === cursor) { + return; + } + showcaseLoadingMoreCursorRef.current = cursor; + setIsLoadingMoreShowcase(true); + setShowcaseLoadMoreError(null); + listPublicEditorProjectResources({ cursor }) + .then((page) => { + const normalizedPage = normalizeShowcaseResourcePage(page); + setProjectShowcaseResources((current) => + mergeShowcaseResources(current, normalizedPage.resources), + ); + setShowcaseNextCursor(normalizedPage.nextCursor); + showcaseFailedLoadMoreCursorRef.current = null; + }) + .catch((error: unknown) => { + showcaseFailedLoadMoreCursorRef.current = cursor; + setShowcaseLoadMoreError( + error instanceof Error ? error.message : '读取陶泥儿精选失败', + ); + }) + .finally(() => { + if (showcaseLoadingMoreCursorRef.current === cursor) { + showcaseLoadingMoreCursorRef.current = null; + } + setIsLoadingMoreShowcase(false); + }); + }, [isLoadingMoreShowcase, isLoadingShowcase, showcaseNextCursor]); + + useEffect(() => { + const element = showcaseLoadMoreRef.current; + if (!element || !showcaseNextCursor || isLoadingShowcase || isLoadingMoreShowcase) { + return; + } + if (showcaseFailedLoadMoreCursorRef.current === showcaseNextCursor) { + return; + } + if (typeof window === 'undefined' || !('IntersectionObserver' in window)) { + loadMoreShowcaseResources(); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + loadMoreShowcaseResources(); + } + }, + { rootMargin: '520px 0px', threshold: 0 }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [ + isLoadingMoreShowcase, + isLoadingShowcase, + loadMoreShowcaseResources, + showcaseNextCursor, + ]); + const createProject = useCallback((tool?: CreationFeatureTool) => { if (authUi && !authUi.user) { authUi.openLoginModal(() => createProject(tool)); @@ -500,6 +609,25 @@ export function CreationLandingView({ const isShowcaseLoading = isLoadingShowcase; const showcaseEmptyText = '暂无素材'; + const renderShowcaseLoadMoreSentinel = () => + showcaseNextCursor || isLoadingMoreShowcase || showcaseLoadMoreError ? ( +
+ {isLoadingMoreShowcase ? '正在读取更多素材' : null} + {!isLoadingMoreShowcase && showcaseLoadMoreError ? ( + + ) : null} +
+ ) : null; const renderAssetShowcase = () => { if (assetError) { @@ -518,42 +646,50 @@ export function CreationLandingView({ } if (showcaseItems.length === 0) { return ( - - {showcaseEmptyText} - + <> + + {showcaseNextCursor || isLoadingMoreShowcase + ? '正在读取更多素材' + : showcaseEmptyText} + + {renderShowcaseLoadMoreSentinel()} + ); } return ( -
- {showcaseItems.map((item) => ( - - ))} -
+ <> +
+ {showcaseItems.map((item) => ( + + ))} +
+ {renderShowcaseLoadMoreSentinel()} + ); }; diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index e042a47d5..b27ec0773 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -739,5 +739,5 @@ export function buildCreationShowcaseItems({ .map((group) => toLibraryShowcaseItem(group, fallbackAuthorName)) .filter((item): item is ShowcaseAssetItem => item !== null); - return projectItems.slice(0, 36); + return projectItems; } diff --git a/src/index.css b/src/index.css index 9e119649b..6419dde25 100644 --- a/src/index.css +++ b/src/index.css @@ -3824,6 +3824,23 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 20px 40px rgba(124, 72, 35, 0.14); } +.creation-landing__asset-load-more { + min-height: 2.75rem; + padding: 0.75rem; + color: #8b6a52; + font-size: 0.82rem; + font-weight: 760; + text-align: center; +} + +.creation-landing__asset-load-more-button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} + .creation-landing__asset-preview { position: relative; display: grid; diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 6dc91bf1d..8a2598cd5 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -232,15 +232,17 @@ describe('editorProjectClient', () => { publicShowcaseEnabled: true, }, ], + nextCursor: 'cursor-next', }); - const resources = await listPublicEditorProjectResources(); + const page = await listPublicEditorProjectResources(); - expect(resources).toHaveLength(1); - expect(resources[0]?.resourceId).toBe('resource-public-1'); - expect(resources[0]?.ownerUserId).toBe('user-public-author'); - expect(resources[0]?.authorDisplayName).toBe('公开作者'); - expect(resources[0]?.authorPublicUserCode).toBe('SY-00000042'); + expect(page.resources).toHaveLength(1); + expect(page.resources[0]?.resourceId).toBe('resource-public-1'); + expect(page.resources[0]?.ownerUserId).toBe('user-public-author'); + expect(page.resources[0]?.authorDisplayName).toBe('公开作者'); + expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042'); + expect(page.nextCursor).toBe('cursor-next'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources', { method: 'GET' }, @@ -254,6 +256,29 @@ describe('editorProjectClient', () => { ); }); + it('loads public showcase project resources after a cursor', async () => { + requestJsonMock.mockResolvedValueOnce({ + resources: [], + nextCursor: null, + }); + + const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ' }); + + expect(page.resources).toEqual([]); + expect(page.nextCursor).toBeNull(); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/showcase/resources?cursor=cursor-1', + { method: 'GET' }, + '读取陶泥儿精选素材失败', + { + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, + ); + }); + it('loads an explicit project by id', async () => { requestJsonMock.mockResolvedValueOnce({ project: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 3a40e4152..77c5eee7c 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -527,6 +527,12 @@ type EditorProjectResourceResponse = { type EditorProjectResourceListResponse = { resources: EditorProjectResourceSnapshot[]; + nextCursor?: string | null; +}; + +export type EditorProjectResourceListPage = { + resources: EditorProjectResourceSnapshot[]; + nextCursor: string | null; }; type EditorAssetLibraryResponse = { @@ -569,9 +575,20 @@ export async function listEditorProjects() { return response.projects; } -export async function listPublicEditorProjectResources() { +export async function listPublicEditorProjectResources({ + cursor, +}: { + cursor?: string | null; +} = {}): Promise { + const params = new URLSearchParams(); + if (cursor?.trim()) { + params.set('cursor', cursor.trim()); + } + const url = params.toString() + ? `${EDITOR_SHOWCASE_RESOURCE_API}?${params.toString()}` + : EDITOR_SHOWCASE_RESOURCE_API; const response = await requestJson( - EDITOR_SHOWCASE_RESOURCE_API, + url, { method: 'GET' }, '读取陶泥儿精选素材失败', { @@ -581,7 +598,10 @@ export async function listPublicEditorProjectResources() { clearAuthOnUnauthorized: false, }, ); - return response.resources; + return { + resources: response.resources, + nextCursor: response.nextCursor?.trim() || null, + }; } export async function loadRecentEditorProject() {