实现创作精选无限滚动

为陶泥儿精选公开资源接口增加 cursor 分页。

让创作主页瀑布流触底继续加载并追加去重。

补充加载更多失败保留已加载内容和重复触发防护。

更新精选分页契约文档和相关测试。
This commit is contained in:
2026-07-01 21:12:08 +08:00
parent c3dce25c24
commit f29edb610b
13 changed files with 515 additions and 53 deletions
@@ -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,
}
}
}
@@ -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;
}
+17
View File
@@ -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() {