diff --git a/TRACKING.md b/TRACKING.md index 16907ae7..03cc05af 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -32,6 +32,8 @@ - [x] 实现核心吸附线和拖拽吸附。 - [x] 实现生成资源元数据窗口和真实修改右侧结果。 - [x] 执行并记录验证命令。 +- [x] 新增 `/project` 项目页,接入项目列表、单项重命名 / 删除、批量选择和批量删除。 +- [x] 接入“我的”页项目入口与 `/editor/canvas?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=`,并补齐项目列表、重命名、删除和批量删除 API。`GET /api/editor/projects` 在重启后的 api-server 上返回未登录 401,不再是旧进程的 405;`/project` 前端路由 smoke 可渲染项目页白底布局。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index a5a5e550..3c99af4f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -7,6 +7,7 @@ ## V2 边界 - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 +- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?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=` 并按 query 加载该工程。 ## 后续扩展点 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 32b645f7..02b9cde2 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 2e533b2c..07ca790b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectListResponse { + projects: Vec, +} + +#[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, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, Extension(request_context): Extension, @@ -261,6 +301,53 @@ pub async fn save_editor_project_layout( )) } +pub async fn rename_editor_project( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, Path(project_id): Path, diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index df039b7e..d1de7506 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -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 { ) .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( diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index f5d36fef..9712f231 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -70,6 +70,75 @@ impl SpacetimeClient { .await } + pub async fn list_editor_projects( + &self, + owner_user_id: String, + ) -> Result, 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 { + 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 { + 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, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index d8b03f10..ed32edcb 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 79771c17..970a27c4 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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, }; diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 8041ac87..1a692a81 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -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 for crate::module_bindings::EditorProject } } +impl From 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 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 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, 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 { + 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 { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 296decbb..d85293d0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs new file mode 100644 index 00000000..529aec92 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectDeleteProcedureResult>( + "delete_editor_project_and_return", + DeleteEditorProjectAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_delete_input_type.rs new file mode 100644 index 00000000..0a173f91 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_delete_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_delete_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_delete_procedure_result_type.rs new file mode 100644 index 00000000..bf9e00c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_delete_procedure_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectDeleteProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_list_input_type.rs new file mode 100644 index 00000000..71b4c172 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_list_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_list_procedure_result_type.rs new file mode 100644 index 00000000..bc02124b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_list_procedure_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_rename_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_rename_input_type.rs new file mode 100644 index 00000000..de1c6e82 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_rename_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs new file mode 100644 index 00000000..fca9595d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectListProcedureResult>( + "list_editor_projects_and_return", + ListEditorProjectsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs new file mode 100644 index 00000000..40812298 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( + "rename_editor_project_and_return", + RenameEditorProjectAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 8b6903b9..0cf4d4db 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -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, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorProjectListProcedureResult { + pub ok: bool, + pub projects: Vec, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectDeleteProcedureResult { + pub ok: bool, + pub deleted_project_id: Option, + pub error_message: Option, +} + #[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, 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::>(); + + 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 { + 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 { + 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::>(); + let resource_ids = ctx + .db + .editor_project_resource() + .by_editor_project_resource_project_id() + .filter(&project_key) + .map(|resource| resource.resource_id) + .collect::>(); + + 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, diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 6dafcd4e..28baa1cb 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -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(); + + 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(); + 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(); + 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', () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4f01071c..9468b9d7 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 5d0b6e40..b811ee70 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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({ )} + {selectionStage === 'project' && ( + + }> + { + setSelectionStage('image-editor'); + pushAppHistoryPath( + `/editor/canvas?projectid=${encodeURIComponent(projectId)}`, + ); + }} + /> + + + )} {selectionStage === 'platform' && ( setSelectionStage('project')} onOpenProfileDashboardCard={(cardKey) => { if (cardKey === 'playedWorks') { openProfilePlayedWorks(); diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 0b9ea645..e95ddc6d 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -13,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = { export type SelectionStage = | 'platform' + | 'project' | 'image-editor' | 'profile-feedback' | 'work-detail' diff --git a/src/components/platform-entry/platformSelectionStageModel.ts b/src/components/platform-entry/platformSelectionStageModel.ts index b5ecf384..9f4bfc92 100644 --- a/src/components/platform-entry/platformSelectionStageModel.ts +++ b/src/components/platform-entry/platformSelectionStageModel.ts @@ -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, diff --git a/src/components/project/ProjectGalleryView.test.tsx b/src/components/project/ProjectGalleryView.test.tsx new file mode 100644 index 00000000..fd81ad23 --- /dev/null +++ b/src/components/project/ProjectGalleryView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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'); + }); +}); diff --git a/src/components/project/ProjectGalleryView.tsx b/src/components/project/ProjectGalleryView.tsx new file mode 100644 index 00000000..a3b00e38 --- /dev/null +++ b/src/components/project/ProjectGalleryView.tsx @@ -0,0 +1,415 @@ +import { + Check, + CheckSquare, + MoreHorizontal, + Pencil, + Plus, + Square, + Trash2, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + createEditorProject, + deleteEditorProject, + listEditorProjects, + renameEditorProject, + type EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; +import { PlatformIconButton } from '../common/PlatformIconButton'; + +type ProjectGalleryViewProps = { + onOpenProject: (projectId: string) => void; +}; + +type RenameDraft = { + projectId: string; + title: string; +}; + +function resolveProjectPreview(project: EditorProjectSnapshot) { + const layerResourceIds = new Set( + project.layers + .map((layer) => layer.resourceId) + .filter((resourceId) => resourceId.trim().length > 0), + ); + return ( + project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ?? + project.resources[0] ?? + null + ); +} + +function formatProjectUpdatedAt(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const [activeMenuProjectId, setActiveMenuProjectId] = useState( + null, + ); + const [renameDraft, setRenameDraft] = useState(null); + const [selectedProjectIds, setSelectedProjectIds] = useState>( + () => new Set(), + ); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const selectedCount = selectedProjectIds.size; + const allSelected = + projects.length > 0 && selectedProjectIds.size === projects.length; + + const refreshProjects = useCallback(() => { + setIsLoading(true); + setErrorMessage(null); + listEditorProjects() + .then((items) => { + setProjects(items); + setSelectedProjectIds((currentIds) => { + const availableIds = new Set(items.map((project) => project.projectId)); + return new Set( + [...currentIds].filter((projectId) => availableIds.has(projectId)), + ); + }); + }) + .catch((error: unknown) => { + setErrorMessage( + error instanceof Error ? error.message : '读取项目列表失败', + ); + }) + .finally(() => setIsLoading(false)); + }, []); + + useEffect(() => { + refreshProjects(); + }, [refreshProjects]); + + const createProject = useCallback(() => { + setErrorMessage(null); + createEditorProject() + .then((project) => onOpenProject(project.projectId)) + .catch((error: unknown) => { + setErrorMessage( + error instanceof Error ? error.message : '创建项目失败', + ); + }); + }, [onOpenProject]); + + const closeSelectionMode = useCallback(() => { + setIsSelectionMode(false); + setSelectedProjectIds(new Set()); + }, []); + + const toggleProjectSelection = useCallback((projectId: string) => { + setSelectedProjectIds((currentIds) => { + const nextIds = new Set(currentIds); + if (nextIds.has(projectId)) { + nextIds.delete(projectId); + } else { + nextIds.add(projectId); + } + return nextIds; + }); + }, []); + + const deleteProjects = useCallback( + (projectIds: string[]) => { + if (projectIds.length === 0) { + return; + } + setErrorMessage(null); + void Promise.all(projectIds.map((projectId) => deleteEditorProject(projectId))) + .then(() => { + setProjects((currentProjects) => + currentProjects.filter( + (project) => !projectIds.includes(project.projectId), + ), + ); + setSelectedProjectIds((currentIds) => { + const nextIds = new Set(currentIds); + projectIds.forEach((projectId) => nextIds.delete(projectId)); + return nextIds; + }); + setActiveMenuProjectId(null); + }) + .catch((error: unknown) => { + setErrorMessage( + error instanceof Error ? error.message : '删除项目失败', + ); + }); + }, + [], + ); + + const submitRename = useCallback(() => { + if (!renameDraft) { + return; + } + const title = renameDraft.title.trim(); + if (!title) { + return; + } + setErrorMessage(null); + renameEditorProject(renameDraft.projectId, title) + .then((project) => { + setProjects((currentProjects) => + currentProjects.map((item) => + item.projectId === project.projectId ? project : item, + ), + ); + setRenameDraft(null); + setActiveMenuProjectId(null); + }) + .catch((error: unknown) => { + setErrorMessage( + error instanceof Error ? error.message : '重命名项目失败', + ); + }); + }, [renameDraft]); + + const projectCards = useMemo( + () => + projects.map((project) => { + const preview = resolveProjectPreview(project); + const selected = selectedProjectIds.has(project.projectId); + return ( +
+ + {!isSelectionMode ? ( +
+ } + variant="surfaceFloating" + className="h-8 w-8" + onClick={(event) => { + event.stopPropagation(); + setActiveMenuProjectId((currentProjectId) => + currentProjectId === project.projectId + ? null + : project.projectId, + ); + }} + /> + {activeMenuProjectId === project.projectId ? ( +
+ + +
+ ) : null} +
+ ) : null} +
+ ); + }), + [ + activeMenuProjectId, + deleteProjects, + isSelectionMode, + onOpenProject, + projects, + selectedProjectIds, + toggleProjectSelection, + ], + ); + + return ( +
+
+
+

项目

+ {projects.length} 个画布项目 +
+
+ setIsSelectionMode(true)} + disabled={projects.length === 0} + > + 选择 + + + + 新建 + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {isLoading ? ( + + 正在读取项目 + + ) : projects.length === 0 ? ( + + ) : ( +
{projectCards}
+ )} + + {renameDraft ? ( +
+
{ + event.preventDefault(); + submitRename(); + }} + > +
+

重命名

+ } + variant="surfaceFloating" + className="h-8 w-8" + onClick={() => setRenameDraft(null)} + /> +
+ + setRenameDraft((currentDraft) => + currentDraft + ? { ...currentDraft, title: event.target.value } + : currentDraft, + ) + } + autoFocus + /> +
+ setRenameDraft(null)} + > + 取消 + + 保存 +
+
+
+ ) : null} + + {isSelectionMode ? ( +
+ { + setSelectedProjectIds( + allSelected + ? new Set() + : new Set(projects.map((project) => project.projectId)), + ); + }} + > + {allSelected ? ( + + ) : ( + + )} + {selectedCount > 0 + ? `${allSelected ? '取消全选' : '全选'} · 已选 ${selectedCount}` + : '全选'} + + deleteProjects([...selectedProjectIds])} + > + + 删除 + + + 取消 + +
+ ) : null} +
+ ); +} + +export default ProjectGalleryView; diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index f649b0d4..b480ca09 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -8,6 +8,7 @@ import { Coins, Compass, Crown, + FolderKanban, Gamepad2, GitFork, Heart, @@ -272,6 +273,7 @@ export interface RpgEntryHomeViewProps { onCloseProfilePlayStats?: () => void; onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void; onOpenFeedback?: () => void; + onOpenProjects?: () => void; onRechargeSuccess?: () => void | Promise; profileTaskRefreshKey?: number; createTabContent?: ReactNode; @@ -2566,6 +2568,7 @@ export function RpgEntryHomeView({ onCloseProfilePlayStats, onOpenPlayedWork, onOpenFeedback, + onOpenProjects, onRechargeSuccess, profileTaskRefreshKey = 0, createTabContent, @@ -4361,6 +4364,12 @@ export function RpgEntryHomeView({ imageSrc={profileCommunityImage} onClick={() => openProfilePopupPanel('community')} /> + { expect(resolvePathForSelectionStage('image-editor')).toBe('/editor/canvas'); }); + it('resolves the project route', () => { + expect(resolveSelectionStageFromPath('/project')).toBe('project'); + expect(resolveSelectionStageFromPath('/PROJECT/')).toBe('project'); + expect(resolvePathForSelectionStage('project')).toBe('/project'); + }); + it('resolves jump-hop creation, gallery and runtime routes', () => { expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe( 'jump-hop-workspace', diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index af881b6a..a6e91512 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work'; const STAGE_ROUTE_ENTRIES = [ ['platform', '/'], + ['project', '/project'], ['image-editor', '/editor/canvas'], ['profile-feedback', '/profile/feedback'], ['work-detail', '/works/detail'], diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 1fd14e91..c385df1f 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -3,9 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createEditorProject, createEditorProjectResource, + deleteEditorProject, editEditorImage, generateEditorImage, + listEditorProjects, + loadEditorProject, loadOrCreateRecentEditorProject, + renameEditorProject, saveEditorProjectLayout, } from './editorProjectClient'; @@ -139,6 +143,121 @@ describe('editorProjectClient', () => { ); }); + it('lists editor projects from the project API', async () => { + requestJsonMock.mockResolvedValueOnce({ + projects: [ + { + projectId: 'editor-project-1', + title: '角色设定板', + canvas: { + canvasId: 'editor-project-1:canvas:default', + projectId: 'editor-project-1', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + ], + }); + + const projects = await listEditorProjects(); + + expect(projects).toHaveLength(1); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/projects', + { method: 'GET' }, + '读取图片画布工程列表失败', + expect.objectContaining({ + clearAuthOnUnauthorized: false, + notifyAuthStateChange: false, + }), + ); + }); + + it('loads an explicit project by id', async () => { + requestJsonMock.mockResolvedValueOnce({ + project: { + projectId: 'editor-project-1', + title: '角色设定板', + canvas: { + canvasId: 'editor-project-1:canvas:default', + projectId: 'editor-project-1', + title: '默认画布', + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }); + + const project = await loadEditorProject('editor-project-1'); + + expect(project.viewport.scale).toBe(1.5); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/projects/editor-project-1', + { method: 'GET' }, + '读取图片画布工程失败', + expect.objectContaining({ + clearAuthOnUnauthorized: false, + notifyAuthStateChange: false, + }), + ); + }); + + it('renames and deletes an editor project', async () => { + requestJsonMock + .mockResolvedValueOnce({ + project: { + projectId: 'editor-project-1', + title: '新标题', + canvas: { + canvasId: 'editor-project-1:canvas:default', + projectId: 'editor-project-1', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }) + .mockResolvedValueOnce({ deletedProjectId: 'editor-project-1' }); + + await renameEditorProject('editor-project-1', '新标题'); + await deleteEditorProject('editor-project-1'); + + expect(requestJsonMock).toHaveBeenNthCalledWith( + 1, + '/api/editor/projects/editor-project-1/metadata', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: '新标题' }), + }), + '重命名图片画布工程失败', + expect.any(Object), + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 2, + '/api/editor/projects/editor-project-1', + { method: 'DELETE' }, + '删除图片画布工程失败', + expect.any(Object), + ); + }); + it('creates a resource with upload or generated metadata', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index a9f10212..63ba674b 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -131,6 +131,16 @@ function jsonRequest(method: 'POST' | 'PATCH', body: Record) { }; } +export async function listEditorProjects() { + const response = await requestJson<{ projects: EditorProjectSnapshot[] }>( + EDITOR_PROJECT_API_BASE, + { method: 'GET' }, + '读取图片画布工程列表失败', + EDITOR_PROJECT_REQUEST_OPTIONS, + ); + return response.projects; +} + export async function loadRecentEditorProject() { return requestJson( `${EDITOR_PROJECT_API_BASE}/recent`, @@ -158,6 +168,36 @@ export async function loadOrCreateRecentEditorProject() { return createEditorProject({ title: DEFAULT_PROJECT_TITLE }); } +export async function loadEditorProject(projectId: string) { + const response = await requestJson( + `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`, + { method: 'GET' }, + '读取图片画布工程失败', + EDITOR_PROJECT_REQUEST_OPTIONS, + ); + return response.project; +} + +export async function renameEditorProject(projectId: string, title: string) { + const response = await requestJson( + `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}/metadata`, + jsonRequest('PATCH', { title }), + '重命名图片画布工程失败', + EDITOR_PROJECT_REQUEST_OPTIONS, + ); + return response.project; +} + +export async function deleteEditorProject(projectId: string) { + const response = await requestJson<{ deletedProjectId: string }>( + `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`, + { method: 'DELETE' }, + '删除图片画布工程失败', + EDITOR_PROJECT_REQUEST_OPTIONS, + ); + return response.deletedProjectId; +} + export async function saveEditorProjectLayout( projectId: string, input: EditorProjectLayoutSaveInput,