新增图片画布项目页

新增 /project 项目页和我的页项目入口

补齐图片画布工程列表、重命名和删除 API

支持 /editor/canvas 按 projectid 加载指定工程

更新图片画布文档、TRACKING 和对应测试
This commit is contained in:
2026-06-14 00:11:36 +08:00
parent b2122481ff
commit 85834a423d
32 changed files with 1800 additions and 20 deletions
+3
View File
@@ -32,6 +32,8 @@
- [x] 实现核心吸附线和拖拽吸附。
- [x] 实现生成资源元数据窗口和真实修改右侧结果。
- [x] 执行并记录验证命令。
- [x] 新增 `/project` 项目页,接入项目列表、单项重命名 / 删除、批量选择和批量删除。
- [x] 接入“我的”页项目入口与 `/editor/canvas?projectid=<projectId>` 精准加载。
## 决策记录
@@ -71,3 +73,4 @@
- 2026-06-13 Lovart 小地图与背景色修正:画布左下角补回背景色圆点、小地图开关和小地图预览;小地图展示图层缩略分布与当前视口框,点击执行显示所有元素,背景色菜单可切换工作区底色且不恢复网格 / 棋盘底纹。
- 2026-06-13 小地图与背景色 smoke`http://127.0.0.1:10003/editor` 可见左下角小地图、背景色按钮和小地图开关;切换暖灰后画布工作区 `background-color``rgb(243, 240, 234)``background-image` 仍为 `none`;截图留存于 `output/playwright/editor-minimap-background-warm.png`
- 2026-06-13 路由与数据归属修正:图片画布页面路由改为 `/editor/canvas`;新增 `editor_canvas` 表作为 project 下的画布数据表,当前工程创建时同步创建默认画布,保存 layout 时写入默认画布,API 响应同时返回 `project.canvas` 和兼容顶层 `viewport/layers`
- 2026-06-13 项目页修正:新增 `/project` 作为图片画布工程列表入口;从“我的”页进入项目页,项目卡片进入 `/editor/canvas?projectid=<projectId>`,并补齐项目列表、重命名、删除和批量删除 API。`GET /api/editor/projects` 在重启后的 api-server 上返回未登录 401,不再是旧进程的 405;`/project` 前端路由 smoke 可渲染项目页白底布局。
@@ -7,6 +7,7 @@
## V2 边界
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
@@ -28,6 +29,7 @@
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
## 数据与持久化
@@ -42,9 +44,12 @@
## 后端接口
- `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`
- `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。
- `POST /api/editor/projects`:创建图片画布工程。
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照。
- `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。
- `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。
- `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片,返回 data URL、尺寸、prompt、model、provider 和 taskId。
- `POST /api/editor/images/edits`:按提示词和当前生成图 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
@@ -70,6 +75,7 @@
- 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
## 后续扩展点
@@ -433,8 +433,8 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorProject`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写。
- 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程。
- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据
- 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程和项目页工程列表
### `editor_canvas`
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
};
@@ -53,6 +54,12 @@ pub struct EditorProjectLayoutSaveRequest {
layers: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectRenameRequest {
title: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceCreateRequest {
@@ -95,6 +102,18 @@ pub struct EditorProjectRecentResponse {
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectDeleteResponse {
deleted_project_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceResponse {
@@ -179,6 +198,27 @@ pub async fn load_recent_editor_project(
))
}
pub async fn list_editor_projects(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let projects = state
.spacetime_client()
.list_editor_projects(owner_user_id)
.await
.map_err(map_editor_project_error)?
.into_iter()
.map(editor_project_payload_from_record)
.collect();
Ok(json_success_body(
Some(&request_context),
EditorProjectListResponse { projects },
))
}
pub async fn create_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -261,6 +301,53 @@ pub async fn save_editor_project_layout(
))
}
pub async fn rename_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectRenameRequest>,
) -> Result<Json<Value>, AppError> {
let project = state
.spacetime_client()
.rename_editor_project(EditorProjectRenameRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
title: payload.title,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn delete_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let deleted_project_id = state
.spacetime_client()
.delete_editor_project(EditorProjectDeleteRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectDeleteResponse { deleted_project_id },
))
}
pub async fn create_editor_project_resource(
State(state): State<AppState>,
Path(project_id): Path<String>,
@@ -1,14 +1,14 @@
use axum::{
Router, middleware,
routing::{get, post},
routing::{get, patch, post},
};
use crate::{
auth::require_bearer_auth,
editor_project::{
create_editor_project, create_editor_project_resource, edit_editor_image,
generate_editor_image, get_editor_project, load_recent_editor_project,
save_editor_project_layout,
create_editor_project, create_editor_project_resource, delete_editor_project,
edit_editor_image, generate_editor_image, get_editor_project, list_editor_projects,
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
},
state::AppState,
};
@@ -24,20 +24,30 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/projects",
post(create_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
get(list_editor_projects)
.post(create_editor_project)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/projects/{project_id}",
get(get_editor_project)
.patch(save_editor_project_layout)
.delete(delete_editor_project)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/projects/{project_id}/metadata",
patch(rename_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/projects/{project_id}/resources",
post(create_editor_project_resource).route_layer(middleware::from_fn_with_state(
@@ -70,6 +70,75 @@ impl SpacetimeClient {
.await
}
pub async fn list_editor_projects(
&self,
owner_user_id: String,
) -> Result<Vec<EditorProjectRecord>, SpacetimeClientError> {
let procedure_input = EditorProjectListInput { owner_user_id };
self.call_after_connect(
"list_editor_projects_and_return",
move |connection, sender| {
connection.procedures().list_editor_projects_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_list_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn rename_editor_project(
&self,
input: EditorProjectRenameRecordInput,
) -> Result<EditorProjectRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"rename_editor_project_and_return",
move |connection, sender| {
connection.procedures().rename_editor_project_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_required_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn delete_editor_project(
&self,
input: EditorProjectDeleteRecordInput,
) -> Result<String, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"delete_editor_project_and_return",
move |connection, sender| {
connection.procedures().delete_editor_project_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_delete_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn save_editor_project_layout(
&self,
input: EditorProjectLayoutSaveRecordInput,
+3 -2
View File
@@ -31,8 +31,9 @@ pub use mapper::{
CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, EditorCanvasRecord, EditorCanvasViewportRecord,
EditorProjectCreateRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput,
EditorProjectRecord, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
ExternalGenerationJobClaimRecordInput,
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
@@ -44,7 +44,8 @@ pub use self::combat::{
};
pub use self::editor_project::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
};
pub use self::common::{
@@ -193,6 +194,7 @@ pub(crate) use self::custom_world::{
parse_rpg_agent_stage_record,
};
pub(crate) use self::editor_project::{
map_editor_project_delete_procedure_result, map_editor_project_list_procedure_result,
map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result,
map_editor_project_resource_procedure_result,
};
@@ -65,6 +65,20 @@ pub struct EditorProjectGetRecordInput {
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorProjectRenameRecordInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorProjectDeleteRecordInput {
pub project_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorProjectLayoutSaveRecordInput {
pub project_id: String,
@@ -114,6 +128,26 @@ impl From<EditorProjectGetRecordInput> for crate::module_bindings::EditorProject
}
}
impl From<EditorProjectRenameRecordInput> for crate::module_bindings::EditorProjectRenameInput {
fn from(input: EditorProjectRenameRecordInput) -> Self {
Self {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
title: input.title,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<EditorProjectDeleteRecordInput> for crate::module_bindings::EditorProjectDeleteInput {
fn from(input: EditorProjectDeleteRecordInput) -> Self {
Self {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
}
}
}
impl From<EditorProjectLayoutSaveRecordInput>
for crate::module_bindings::EditorProjectLayoutSaveInput
{
@@ -174,6 +208,33 @@ pub(crate) fn map_editor_project_required_procedure_result(
.ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布工程快照"))
}
pub(crate) fn map_editor_project_list_procedure_result(
result: EditorProjectListProcedureResult,
) -> Result<Vec<EditorProjectRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.projects
.into_iter()
.map(map_editor_project_snapshot)
.collect()
}
pub(crate) fn map_editor_project_delete_procedure_result(
result: EditorProjectDeleteProcedureResult,
) -> Result<String, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.deleted_project_id
.filter(|project_id| !project_id.trim().is_empty())
.ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布工程删除结果"))
}
pub(crate) fn map_editor_project_resource_procedure_result(
result: EditorProjectResourceProcedureResult,
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
@@ -338,6 +338,7 @@ pub mod delete_bark_battle_work_procedure;
pub mod delete_big_fish_work_procedure;
pub mod delete_custom_world_agent_session_procedure;
pub mod delete_custom_world_profile_and_return_procedure;
pub mod delete_editor_project_and_return_procedure;
pub mod delete_jump_hop_work_procedure;
pub mod delete_match_3_d_work_procedure;
pub mod delete_puzzle_work_procedure;
@@ -351,10 +352,15 @@ pub mod editor_canvas_snapshot_type;
pub mod editor_canvas_table;
pub mod editor_canvas_type;
pub mod editor_project_create_input_type;
pub mod editor_project_delete_input_type;
pub mod editor_project_delete_procedure_result_type;
pub mod editor_project_get_input_type;
pub mod editor_project_get_recent_input_type;
pub mod editor_project_layout_save_input_type;
pub mod editor_project_list_input_type;
pub mod editor_project_list_procedure_result_type;
pub mod editor_project_procedure_result_type;
pub mod editor_project_rename_input_type;
pub mod editor_project_resource_create_input_type;
pub mod editor_project_resource_procedure_result_type;
pub mod editor_project_resource_snapshot_type;
@@ -521,6 +527,7 @@ pub mod list_big_fish_works_procedure;
pub mod list_custom_world_gallery_entries_procedure;
pub mod list_custom_world_profiles_procedure;
pub mod list_custom_world_works_procedure;
pub mod list_editor_projects_and_return_procedure;
pub mod list_jump_hop_works_procedure;
pub mod list_match_3_d_works_procedure;
pub mod list_platform_browse_history_procedure;
@@ -817,6 +824,7 @@ pub mod release_puzzle_background_compile_task_procedure;
pub mod remix_big_fish_work_procedure;
pub mod remix_custom_world_profile_procedure;
pub mod remix_puzzle_work_procedure;
pub mod rename_editor_project_and_return_procedure;
pub mod renew_external_generation_job_lease_and_return_procedure;
pub mod resolve_combat_action_and_return_procedure;
pub mod resolve_combat_action_input_type;
@@ -1496,6 +1504,7 @@ pub use delete_bark_battle_work_procedure::delete_bark_battle_work;
pub use delete_big_fish_work_procedure::delete_big_fish_work;
pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session;
pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return;
pub use delete_editor_project_and_return_procedure::delete_editor_project_and_return;
pub use delete_jump_hop_work_procedure::delete_jump_hop_work;
pub use delete_match_3_d_work_procedure::delete_match_3_d_work;
pub use delete_puzzle_work_procedure::delete_puzzle_work;
@@ -1509,10 +1518,15 @@ pub use editor_canvas_snapshot_type::EditorCanvasSnapshot;
pub use editor_canvas_table::*;
pub use editor_canvas_type::EditorCanvas;
pub use editor_project_create_input_type::EditorProjectCreateInput;
pub use editor_project_delete_input_type::EditorProjectDeleteInput;
pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult;
pub use editor_project_get_input_type::EditorProjectGetInput;
pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput;
pub use editor_project_layout_save_input_type::EditorProjectLayoutSaveInput;
pub use editor_project_list_input_type::EditorProjectListInput;
pub use editor_project_list_procedure_result_type::EditorProjectListProcedureResult;
pub use editor_project_procedure_result_type::EditorProjectProcedureResult;
pub use editor_project_rename_input_type::EditorProjectRenameInput;
pub use editor_project_resource_create_input_type::EditorProjectResourceCreateInput;
pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult;
pub use editor_project_resource_snapshot_type::EditorProjectResourceSnapshot;
@@ -1679,6 +1693,7 @@ pub use list_big_fish_works_procedure::list_big_fish_works;
pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries;
pub use list_custom_world_profiles_procedure::list_custom_world_profiles;
pub use list_custom_world_works_procedure::list_custom_world_works;
pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return;
pub use list_jump_hop_works_procedure::list_jump_hop_works;
pub use list_match_3_d_works_procedure::list_match_3_d_works;
pub use list_platform_browse_history_procedure::list_platform_browse_history;
@@ -1975,6 +1990,7 @@ pub use release_puzzle_background_compile_task_procedure::release_puzzle_backgro
pub use remix_big_fish_work_procedure::remix_big_fish_work;
pub use remix_custom_world_profile_procedure::remix_custom_world_profile;
pub use remix_puzzle_work_procedure::remix_puzzle_work;
pub use rename_editor_project_and_return_procedure::rename_editor_project_and_return;
pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return;
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
pub use resolve_combat_action_input_type::ResolveCombatActionInput;
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_project_delete_input_type::EditorProjectDeleteInput;
use super::editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct DeleteEditorProjectAndReturnArgs {
pub input: EditorProjectDeleteInput,
}
impl __sdk::InModule for DeleteEditorProjectAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `delete_editor_project_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait delete_editor_project_and_return {
fn delete_editor_project_and_return(&self, input: EditorProjectDeleteInput) {
self.delete_editor_project_and_return_then(input, |_, _| {});
}
fn delete_editor_project_and_return_then(
&self,
input: EditorProjectDeleteInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectDeleteProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl delete_editor_project_and_return for super::RemoteProcedures {
fn delete_editor_project_and_return_then(
&self,
input: EditorProjectDeleteInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectDeleteProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectDeleteProcedureResult>(
"delete_editor_project_and_return",
DeleteEditorProjectAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectDeleteInput {
pub project_id: String,
pub owner_user_id: String,
}
impl __sdk::InModule for EditorProjectDeleteInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectDeleteProcedureResult {
pub ok: bool,
pub deleted_project_id: Option<String>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorProjectDeleteProcedureResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectListInput {
pub owner_user_id: String,
}
impl __sdk::InModule for EditorProjectListInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_project_snapshot_type::EditorProjectSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectListProcedureResult {
pub ok: bool,
pub projects: Vec<EditorProjectSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorProjectListProcedureResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectRenameInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for EditorProjectRenameInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_project_list_input_type::EditorProjectListInput;
use super::editor_project_list_procedure_result_type::EditorProjectListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListEditorProjectsAndReturnArgs {
pub input: EditorProjectListInput,
}
impl __sdk::InModule for ListEditorProjectsAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_editor_projects_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_editor_projects_and_return {
fn list_editor_projects_and_return(&self, input: EditorProjectListInput) {
self.list_editor_projects_and_return_then(input, |_, _| {});
}
fn list_editor_projects_and_return_then(
&self,
input: EditorProjectListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl list_editor_projects_and_return for super::RemoteProcedures {
fn list_editor_projects_and_return_then(
&self,
input: EditorProjectListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectListProcedureResult>(
"list_editor_projects_and_return",
ListEditorProjectsAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_project_procedure_result_type::EditorProjectProcedureResult;
use super::editor_project_rename_input_type::EditorProjectRenameInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RenameEditorProjectAndReturnArgs {
pub input: EditorProjectRenameInput,
}
impl __sdk::InModule for RenameEditorProjectAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `rename_editor_project_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait rename_editor_project_and_return {
fn rename_editor_project_and_return(&self, input: EditorProjectRenameInput) {
self.rename_editor_project_and_return_then(input, |_, _| {});
}
fn rename_editor_project_and_return_then(
&self,
input: EditorProjectRenameInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl rename_editor_project_and_return for super::RemoteProcedures {
fn rename_editor_project_and_return_then(
&self,
input: EditorProjectRenameInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectProcedureResult>(
"rename_editor_project_and_return",
RenameEditorProjectAndReturnArgs { input },
__callback,
);
}
}
@@ -94,6 +94,25 @@ pub struct EditorProjectGetRecentInput {
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectListInput {
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectRenameInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectDeleteInput {
pub project_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectLayoutSaveInput {
pub project_id: String,
@@ -172,6 +191,20 @@ pub struct EditorProjectProcedureResult {
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectListProcedureResult {
pub ok: bool,
pub projects: Vec<EditorProjectSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectDeleteProcedureResult {
pub ok: bool,
pub deleted_project_id: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectResourceProcedureResult {
pub ok: bool,
@@ -201,6 +234,25 @@ pub fn get_recent_editor_project_and_return(
}
}
#[spacetimedb::procedure]
pub fn list_editor_projects_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectListInput,
) -> EditorProjectListProcedureResult {
match ctx.try_with_tx(|tx| list_editor_projects(tx, input.clone())) {
Ok(projects) => EditorProjectListProcedureResult {
ok: true,
projects,
error_message: None,
},
Err(message) => EditorProjectListProcedureResult {
ok: false,
projects: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_editor_project_and_return(
ctx: &mut ProcedureContext,
@@ -223,6 +275,36 @@ pub fn save_editor_project_layout_and_return(
}
}
#[spacetimedb::procedure]
pub fn rename_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectRenameInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| rename_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn delete_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectDeleteInput,
) -> EditorProjectDeleteProcedureResult {
match ctx.try_with_tx(|tx| delete_editor_project(tx, input.clone())) {
Ok(project_id) => EditorProjectDeleteProcedureResult {
ok: true,
deleted_project_id: Some(project_id),
error_message: None,
},
Err(message) => EditorProjectDeleteProcedureResult {
ok: false,
deleted_project_id: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn create_editor_project_resource_and_return(
ctx: &mut ProcedureContext,
@@ -309,6 +391,32 @@ fn get_recent_editor_project(
.transpose()
}
fn list_editor_projects(
ctx: &ReducerContext,
input: EditorProjectListInput,
) -> Result<Vec<EditorProjectSnapshot>, String> {
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let mut projects = ctx
.db
.editor_project()
.by_editor_project_owner_user_id()
.filter(&owner_user_id)
.collect::<Vec<_>>();
projects.sort_by(|left, right| {
right
.updated_at
.to_micros_since_unix_epoch()
.cmp(&left.updated_at.to_micros_since_unix_epoch())
.then_with(|| right.project_id.cmp(&left.project_id))
});
projects
.into_iter()
.map(|project| build_project_snapshot(ctx, project.project_id.as_str()))
.collect()
}
fn get_editor_project(
ctx: &ReducerContext,
input: EditorProjectGetInput,
@@ -319,6 +427,68 @@ fn get_editor_project(
build_project_snapshot(ctx, project.project_id.as_str())
}
fn rename_editor_project(
ctx: &ReducerContext,
input: EditorProjectRenameInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db.editor_project().project_id().delete(&project_id);
ctx.db.editor_project().insert(EditorProject {
project_id: project.project_id.clone(),
owner_user_id: project.owner_user_id,
title: normalize_title(&input.title),
viewport_x: project.viewport_x,
viewport_y: project.viewport_y,
viewport_scale: project.viewport_scale,
layers_json: project.layers_json,
created_at: project.created_at,
updated_at: now,
});
build_project_snapshot(ctx, project_id.as_str())
}
fn delete_editor_project(
ctx: &ReducerContext,
input: EditorProjectDeleteInput,
) -> Result<String, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
let project_key = project_id.clone();
let canvas_ids = ctx
.db
.editor_canvas()
.by_editor_canvas_project_id()
.filter(&project_key)
.map(|canvas| canvas.canvas_id)
.collect::<Vec<_>>();
let resource_ids = ctx
.db
.editor_project_resource()
.by_editor_project_resource_project_id()
.filter(&project_key)
.map(|resource| resource.resource_id)
.collect::<Vec<_>>();
for canvas_id in canvas_ids {
ctx.db.editor_canvas().canvas_id().delete(&canvas_id);
}
for resource_id in resource_ids {
ctx.db
.editor_project_resource()
.resource_id()
.delete(&resource_id);
}
ctx.db.editor_project().project_id().delete(&project_id);
Ok(project_id)
}
fn save_editor_project_layout(
ctx: &ReducerContext,
input: EditorProjectLayoutSaveInput,
@@ -2,13 +2,15 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -18,6 +20,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
...actual,
editEditorImage: editEditorImageMock,
generateEditorImage: generateEditorImageMock,
loadEditorProject: loadEditorProjectMock,
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
};
});
@@ -36,10 +40,47 @@ function dispatchPointerEvent(
}
describe('ImageCanvasEditorView', () => {
beforeEach(() => {
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
});
afterEach(() => {
vi.restoreAllMocks();
generateEditorImageMock.mockReset();
editEditorImageMock.mockReset();
loadEditorProjectMock.mockReset();
loadOrCreateRecentEditorProjectMock.mockReset();
window.history.replaceState(null, '', '/editor/canvas');
});
it('loads the project from projectid query before falling back to recent project', async () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-query',
title: '查询项目',
viewport: { x: 12, y: 16, scale: 0.8 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-query',
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-query');
});
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
});
it('toggles the shared sidebar from canvas panel buttons', () => {
@@ -196,6 +237,9 @@ describe('ImageCanvasEditorView', () => {
value: createObjectUrlSpy,
});
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
@@ -344,11 +388,15 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(anchoredGenerateDialog).toBeTruthy();
expect(
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
).toBeCloseTo(initialComposerTop, 1);
).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top));
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.top),
).toBeLessThan(initialComposerTop);
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
const metadataButtons = screen.getAllByRole('button', {
name: /查看生成图片 .*元数据/,
@@ -369,6 +417,9 @@ describe('ImageCanvasEditorView', () => {
taskId: 'editor-drag-frame-1',
});
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
const initialComposerTop = Number.parseFloat(
@@ -409,10 +460,10 @@ describe('ImageCanvasEditorView', () => {
expect(anchoredGenerateDialog).toBeTruthy();
expect(
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
).toBeCloseTo(draggedComposerTop, 1);
).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top));
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(700);
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(150);
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(300);
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180);
});
it('keeps the generation composer when selecting another image', () => {
@@ -42,6 +42,7 @@ import {
type EditorImageGenerationResult,
type EditorProjectLayerSnapshot,
generateEditorImage,
loadEditorProject,
loadOrCreateRecentEditorProject,
saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient';
@@ -639,7 +640,16 @@ export function ImageCanvasEditorView() {
useEffect(() => {
let cancelled = false;
loadOrCreateRecentEditorProject()
const projectIdFromQuery =
typeof window === 'undefined'
? null
: new URLSearchParams(window.location.search).get('projectid')?.trim() ||
null;
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
if (cancelled) {
return;
@@ -1278,6 +1278,13 @@ const ImageCanvasEditorView = lazy(async () => {
};
});
const ProjectGalleryView = lazy(async () => {
const module = await import('../project/ProjectGalleryView');
return {
default: module.ProjectGalleryView,
};
});
const UnifiedCreationWorkspace = lazy(async () => {
const module = await import('../unified-creation/UnifiedCreationWorkspace');
return {
@@ -15162,6 +15169,26 @@ export function PlatformEntryFlowShellImpl({
</Suspense>
</motion.div>
)}
{selectionStage === 'project' && (
<motion.div
key="project-gallery"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="project-gallery-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
>
<Suspense fallback={<LazyPanelFallback label="正在加载项目..." />}>
<ProjectGalleryView
onOpenProject={(projectId) => {
setSelectionStage('image-editor');
pushAppHistoryPath(
`/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
);
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'platform' && (
<motion.div
key="platform-home"
@@ -15274,6 +15301,7 @@ export function PlatformEntryFlowShellImpl({
}}
onOpenPlayedWork={openPlayedWork}
onOpenFeedback={openProfileFeedback}
onOpenProjects={() => setSelectionStage('project')}
onOpenProfileDashboardCard={(cardKey) => {
if (cardKey === 'playedWorks') {
openProfilePlayedWorks();
@@ -13,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'project'
| 'image-editor'
| 'profile-feedback'
| 'work-detail'
@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
platform: true,
project: true,
'image-editor': true,
'profile-feedback': false,
'work-detail': true,
@@ -0,0 +1,125 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProjectGalleryView } from './ProjectGalleryView';
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
const createEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', () => ({
listEditorProjects: listEditorProjectsMock,
createEditorProject: createEditorProjectMock,
renameEditorProject: renameEditorProjectMock,
deleteEditorProject: deleteEditorProjectMock,
}));
const projectItems = [
{
projectId: 'editor-project-1',
title: '角色设定板',
viewport: { x: 0, y: 0, scale: 1 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1' }],
resources: [
{
resourceId: 'resource-1',
projectId: 'editor-project-1',
imageSrc: 'data:image/png;base64,one',
width: 1024,
height: 1024,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
{
projectId: 'editor-project-2',
title: '场景草图',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T07:00:00.000Z',
},
];
describe('ProjectGalleryView', () => {
afterEach(() => {
listEditorProjectsMock.mockReset();
createEditorProjectMock.mockReset();
renameEditorProjectMock.mockReset();
deleteEditorProjectMock.mockReset();
});
it('opens a project from the gallery card', async () => {
const onOpenProject = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={onOpenProject} />);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('renames and deletes a project from the hover menu', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
renameEditorProjectMock.mockResolvedValueOnce({
...projectItems[0],
title: '新角色设定板',
});
deleteEditorProjectMock.mockResolvedValueOnce('editor-project-2');
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' }));
await user.click(screen.getByRole('menuitem', { name: //u }));
await user.clear(screen.getByLabelText('项目名称'));
await user.type(screen.getByLabelText('项目名称'), '新角色设定板');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(renameEditorProjectMock).toHaveBeenCalledWith(
'editor-project-1',
'新角色设定板',
);
await screen.findByText('新角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目场景草图菜单' }));
await user.click(screen.getByRole('menuitem', { name: //u }));
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
await waitFor(() => {
expect(screen.queryByText('场景草图')).toBeNull();
});
});
it('supports batch selection actions from the bottom toolbar', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
deleteEditorProjectMock.mockResolvedValue('deleted');
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '选择' }));
const toolbar = screen.getByRole('toolbar', { name: '批量操作' });
await user.click(within(toolbar).getByRole('button', { name: '全选' }));
expect(
within(toolbar).getByRole('button', { name: '取消全选 · 已选 2' }),
).toBeTruthy();
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
});
});

Some files were not shown because too many files have changed in this diff Show More