实现创作精选无限滚动
为陶泥儿精选公开资源接口增加 cursor 分页。 让创作主页瀑布流触底继续加载并追加去重。 补充加载更多失败保留已加载内容和重复触发防护。 更新精选分页契约文档和相关测试。
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
- 素材包
|
||||
|
||||
@@ -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<EditorProjectResourcePayload>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectResourceListQuery {
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -675,25 +684,56 @@ pub async fn list_editor_projects(
|
||||
pub async fn list_public_editor_project_resources(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Query(query): Query<EditorProjectResourceListQuery>,
|
||||
) -> Result<Json<Value>, 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<String> {
|
||||
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<i64> {
|
||||
let (seconds, micros) = value.trim().strip_suffix('Z')?.split_once('.')?;
|
||||
if micros.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
seconds
|
||||
.parse::<i64>()
|
||||
.ok()?
|
||||
.checked_mul(1_000_000)?
|
||||
.checked_add(micros.parse::<i64>().ok()?)
|
||||
}
|
||||
|
||||
pub async fn create_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -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");
|
||||
|
||||
@@ -185,6 +185,7 @@ pub struct EditorProjectResourceShowcaseUpdateRecordInput {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorProjectResourcePublicShowcaseListRecordInput {
|
||||
pub limit: u32,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -378,7 +379,10 @@ impl From<EditorProjectResourcePublicShowcaseListRecordInput>
|
||||
for crate::module_bindings::EditorProjectResourcePublicShowcaseListInput
|
||||
{
|
||||
fn from(input: EditorProjectResourcePublicShowcaseListRecordInput) -> Self {
|
||||
Self { limit: input.limit }
|
||||
Self {
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorProjectResourcePublicShowcaseListInput {
|
||||
|
||||
@@ -225,6 +225,7 @@ pub struct EditorProjectResourceShowcaseUpdateInput {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct EditorProjectResourcePublicShowcaseListInput {
|
||||
pub limit: u32,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -1468,6 +1469,7 @@ fn list_public_editor_project_resources(
|
||||
input: EditorProjectResourcePublicShowcaseListInput,
|
||||
) -> Result<Vec<EditorProjectResourceSnapshot>, 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::<Vec<_>>();
|
||||
@@ -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::<i64>().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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>;
|
||||
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({
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||||
EditorProjectResourceSnapshot[]
|
||||
>([]);
|
||||
const [showcaseNextCursor, setShowcaseNextCursor] = useState<string | null>(null);
|
||||
const [assetError, setAssetError] = useState<string | null>(null);
|
||||
const [showcaseLoadMoreError, setShowcaseLoadMoreError] = useState<string | null>(null);
|
||||
const [activeShowcaseTab, setActiveShowcaseTab] =
|
||||
useState<ShowcaseTabId>('packs');
|
||||
const [selectedShowcaseItem, setSelectedShowcaseItem] =
|
||||
@@ -400,6 +429,9 @@ export function CreationLandingView({
|
||||
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
|
||||
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
|
||||
useState(false);
|
||||
const showcaseLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const showcaseLoadingMoreCursorRef = useRef<string | null>(null);
|
||||
const showcaseFailedLoadMoreCursorRef = useRef<string | null>(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 ? (
|
||||
<div
|
||||
ref={showcaseLoadMoreRef}
|
||||
className="creation-landing__asset-load-more"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isLoadingMoreShowcase ? '正在读取更多素材' : null}
|
||||
{!isLoadingMoreShowcase && showcaseLoadMoreError ? (
|
||||
<button
|
||||
type="button"
|
||||
className="creation-landing__asset-load-more-button"
|
||||
onClick={() => loadMoreShowcaseResources({ force: true })}
|
||||
>
|
||||
读取更多素材失败,点击重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const renderAssetShowcase = () => {
|
||||
if (assetError) {
|
||||
@@ -518,42 +646,50 @@ export function CreationLandingView({
|
||||
}
|
||||
if (showcaseItems.length === 0) {
|
||||
return (
|
||||
<PlatformEmptyState surface="subpanel" size="inline">
|
||||
{showcaseEmptyText}
|
||||
</PlatformEmptyState>
|
||||
<>
|
||||
<PlatformEmptyState surface="subpanel" size="inline">
|
||||
{showcaseNextCursor || isLoadingMoreShowcase
|
||||
? '正在读取更多素材'
|
||||
: showcaseEmptyText}
|
||||
</PlatformEmptyState>
|
||||
{renderShowcaseLoadMoreSentinel()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="creation-landing__asset-waterfall" aria-label="用户素材瀑布流">
|
||||
{showcaseItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="creation-landing__asset-card"
|
||||
onClick={() => {
|
||||
setSelectedShowcaseItem(item);
|
||||
setSelectedShowcaseIndex(0);
|
||||
}}
|
||||
>
|
||||
<ShowcaseBundlePreview item={item} />
|
||||
<div className="creation-landing__asset-meta">
|
||||
<h3>{item.label}</h3>
|
||||
<p>{item.prompt}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>作者</dt>
|
||||
<dd>{item.author}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>生成总成本</dt>
|
||||
<dd>{item.cost}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="creation-landing__asset-waterfall" aria-label="用户素材瀑布流">
|
||||
{showcaseItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="creation-landing__asset-card"
|
||||
onClick={() => {
|
||||
setSelectedShowcaseItem(item);
|
||||
setSelectedShowcaseIndex(0);
|
||||
}}
|
||||
>
|
||||
<ShowcaseBundlePreview item={item} />
|
||||
<div className="creation-landing__asset-meta">
|
||||
<h3>{item.label}</h3>
|
||||
<p>{item.prompt}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>作者</dt>
|
||||
<dd>{item.author}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>生成总成本</dt>
|
||||
<dd>{item.cost}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{renderShowcaseLoadMoreSentinel()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<EditorProjectResourceListPage> {
|
||||
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<EditorProjectResourceListResponse>(
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user