新增图片画布项目页
新增 /project 项目页和我的页项目入口 补齐图片画布工程列表、重命名和删除 API 支持 /editor/canvas 按 projectid 加载指定工程 更新图片画布文档、TRACKING 和对应测试
This commit is contained in:
@@ -32,6 +32,8 @@
|
|||||||
- [x] 实现核心吸附线和拖拽吸附。
|
- [x] 实现核心吸附线和拖拽吸附。
|
||||||
- [x] 实现生成资源元数据窗口和真实修改右侧结果。
|
- [x] 实现生成资源元数据窗口和真实修改右侧结果。
|
||||||
- [x] 执行并记录验证命令。
|
- [x] 执行并记录验证命令。
|
||||||
|
- [x] 新增 `/project` 项目页,接入项目列表、单项重命名 / 删除、批量选择和批量删除。
|
||||||
|
- [x] 接入“我的”页项目入口与 `/editor/canvas?projectid=<projectId>` 精准加载。
|
||||||
|
|
||||||
## 决策记录
|
## 决策记录
|
||||||
|
|
||||||
@@ -71,3 +73,4 @@
|
|||||||
- 2026-06-13 Lovart 小地图与背景色修正:画布左下角补回背景色圆点、小地图开关和小地图预览;小地图展示图层缩略分布与当前视口框,点击执行显示所有元素,背景色菜单可切换工作区底色且不恢复网格 / 棋盘底纹。
|
- 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 小地图与背景色 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 路由与数据归属修正:图片画布页面路由改为 `/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 边界
|
## V2 边界
|
||||||
|
|
||||||
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
|
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
|
||||||
|
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`。
|
||||||
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
|
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
|
||||||
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
||||||
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。
|
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。
|
||||||
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
|
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
|
||||||
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
|
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
|
||||||
|
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
|
||||||
|
|
||||||
## 数据与持久化
|
## 数据与持久化
|
||||||
|
|
||||||
@@ -42,9 +44,12 @@
|
|||||||
## 后端接口
|
## 后端接口
|
||||||
|
|
||||||
- `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。
|
- `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。
|
||||||
|
- `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。
|
||||||
- `POST /api/editor/projects`:创建图片画布工程。
|
- `POST /api/editor/projects`:创建图片画布工程。
|
||||||
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
|
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
|
||||||
- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照。
|
- `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/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。
|
||||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片,返回 data URL、尺寸、prompt、model、provider 和 taskId。
|
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片,返回 data URL、尺寸、prompt、model、provider 和 taskId。
|
||||||
- `POST /api/editor/images/edits`:按提示词和当前生成图 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
|
- `POST /api/editor/images/edits`:按提示词和当前生成图 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
|
||||||
@@ -70,6 +75,7 @@
|
|||||||
- 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。
|
- 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。
|
||||||
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
|
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
|
||||||
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
|
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
|
||||||
|
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
|
||||||
|
|
||||||
## 后续扩展点
|
## 后续扩展点
|
||||||
|
|
||||||
|
|||||||
@@ -433,8 +433,8 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`EditorProject`
|
- Rust 结构体:`EditorProject`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||||
- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写。
|
- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据。
|
||||||
- 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程。
|
- 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程和项目页工程列表。
|
||||||
|
|
||||||
### `editor_canvas`
|
### `editor_canvas`
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
|
|||||||
use shared_kernel::build_prefixed_uuid_id;
|
use shared_kernel::build_prefixed_uuid_id;
|
||||||
use spacetime_client::{
|
use spacetime_client::{
|
||||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
||||||
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
|
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||||
|
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
|
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +54,12 @@ pub struct EditorProjectLayoutSaveRequest {
|
|||||||
layers: Value,
|
layers: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorProjectRenameRequest {
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct EditorProjectResourceCreateRequest {
|
pub struct EditorProjectResourceCreateRequest {
|
||||||
@@ -95,6 +102,18 @@ pub struct EditorProjectRecentResponse {
|
|||||||
project: Option<EditorProjectPayload>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct EditorProjectResourceResponse {
|
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(
|
pub async fn create_editor_project(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
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(
|
pub async fn create_editor_project_resource(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(project_id): Path<String>,
|
Path(project_id): Path<String>,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
routing::{get, post},
|
routing::{get, patch, post},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::require_bearer_auth,
|
auth::require_bearer_auth,
|
||||||
editor_project::{
|
editor_project::{
|
||||||
create_editor_project, create_editor_project_resource, edit_editor_image,
|
create_editor_project, create_editor_project_resource, delete_editor_project,
|
||||||
generate_editor_image, get_editor_project, load_recent_editor_project,
|
edit_editor_image, generate_editor_image, get_editor_project, list_editor_projects,
|
||||||
save_editor_project_layout,
|
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -24,7 +24,9 @@ pub fn router(state: AppState) -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/editor/projects",
|
"/api/editor/projects",
|
||||||
post(create_editor_project).route_layer(middleware::from_fn_with_state(
|
get(list_editor_projects)
|
||||||
|
.post(create_editor_project)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
@@ -33,11 +35,19 @@ pub fn router(state: AppState) -> Router<AppState> {
|
|||||||
"/api/editor/projects/{project_id}",
|
"/api/editor/projects/{project_id}",
|
||||||
get(get_editor_project)
|
get(get_editor_project)
|
||||||
.patch(save_editor_project_layout)
|
.patch(save_editor_project_layout)
|
||||||
|
.delete(delete_editor_project)
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
require_bearer_auth,
|
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(
|
.route(
|
||||||
"/api/editor/projects/{project_id}/resources",
|
"/api/editor/projects/{project_id}/resources",
|
||||||
post(create_editor_project_resource).route_layer(middleware::from_fn_with_state(
|
post(create_editor_project_resource).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -70,6 +70,75 @@ impl SpacetimeClient {
|
|||||||
.await
|
.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(
|
pub async fn save_editor_project_layout(
|
||||||
&self,
|
&self,
|
||||||
input: EditorProjectLayoutSaveRecordInput,
|
input: EditorProjectLayoutSaveRecordInput,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ pub use mapper::{
|
|||||||
CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord,
|
CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord,
|
||||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||||
CustomWorldWorkSummaryRecord, EditorCanvasRecord, EditorCanvasViewportRecord,
|
CustomWorldWorkSummaryRecord, EditorCanvasRecord, EditorCanvasViewportRecord,
|
||||||
EditorProjectCreateRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput,
|
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||||
EditorProjectRecord, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
|
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||||
|
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
|
||||||
ExternalGenerationJobClaimRecordInput,
|
ExternalGenerationJobClaimRecordInput,
|
||||||
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
|
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
|
||||||
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
|
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ pub use self::combat::{
|
|||||||
};
|
};
|
||||||
pub use self::editor_project::{
|
pub use self::editor_project::{
|
||||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
||||||
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
|
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||||
|
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
|
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
|
||||||
};
|
};
|
||||||
pub use self::common::{
|
pub use self::common::{
|
||||||
@@ -193,6 +194,7 @@ pub(crate) use self::custom_world::{
|
|||||||
parse_rpg_agent_stage_record,
|
parse_rpg_agent_stage_record,
|
||||||
};
|
};
|
||||||
pub(crate) use self::editor_project::{
|
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_optional_procedure_result, map_editor_project_required_procedure_result,
|
||||||
map_editor_project_resource_procedure_result,
|
map_editor_project_resource_procedure_result,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ pub struct EditorProjectGetRecordInput {
|
|||||||
pub owner_user_id: String,
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct EditorProjectLayoutSaveRecordInput {
|
pub struct EditorProjectLayoutSaveRecordInput {
|
||||||
pub project_id: String,
|
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>
|
impl From<EditorProjectLayoutSaveRecordInput>
|
||||||
for crate::module_bindings::EditorProjectLayoutSaveInput
|
for crate::module_bindings::EditorProjectLayoutSaveInput
|
||||||
{
|
{
|
||||||
@@ -174,6 +208,33 @@ pub(crate) fn map_editor_project_required_procedure_result(
|
|||||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布工程快照"))
|
.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(
|
pub(crate) fn map_editor_project_resource_procedure_result(
|
||||||
result: EditorProjectResourceProcedureResult,
|
result: EditorProjectResourceProcedureResult,
|
||||||
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
|
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ pub mod delete_bark_battle_work_procedure;
|
|||||||
pub mod delete_big_fish_work_procedure;
|
pub mod delete_big_fish_work_procedure;
|
||||||
pub mod delete_custom_world_agent_session_procedure;
|
pub mod delete_custom_world_agent_session_procedure;
|
||||||
pub mod delete_custom_world_profile_and_return_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_jump_hop_work_procedure;
|
||||||
pub mod delete_match_3_d_work_procedure;
|
pub mod delete_match_3_d_work_procedure;
|
||||||
pub mod delete_puzzle_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_table;
|
||||||
pub mod editor_canvas_type;
|
pub mod editor_canvas_type;
|
||||||
pub mod editor_project_create_input_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_input_type;
|
||||||
pub mod editor_project_get_recent_input_type;
|
pub mod editor_project_get_recent_input_type;
|
||||||
pub mod editor_project_layout_save_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_procedure_result_type;
|
||||||
|
pub mod editor_project_rename_input_type;
|
||||||
pub mod editor_project_resource_create_input_type;
|
pub mod editor_project_resource_create_input_type;
|
||||||
pub mod editor_project_resource_procedure_result_type;
|
pub mod editor_project_resource_procedure_result_type;
|
||||||
pub mod editor_project_resource_snapshot_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_gallery_entries_procedure;
|
||||||
pub mod list_custom_world_profiles_procedure;
|
pub mod list_custom_world_profiles_procedure;
|
||||||
pub mod list_custom_world_works_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_jump_hop_works_procedure;
|
||||||
pub mod list_match_3_d_works_procedure;
|
pub mod list_match_3_d_works_procedure;
|
||||||
pub mod list_platform_browse_history_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_big_fish_work_procedure;
|
||||||
pub mod remix_custom_world_profile_procedure;
|
pub mod remix_custom_world_profile_procedure;
|
||||||
pub mod remix_puzzle_work_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 renew_external_generation_job_lease_and_return_procedure;
|
||||||
pub mod resolve_combat_action_and_return_procedure;
|
pub mod resolve_combat_action_and_return_procedure;
|
||||||
pub mod resolve_combat_action_input_type;
|
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_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_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_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_jump_hop_work_procedure::delete_jump_hop_work;
|
||||||
pub use delete_match_3_d_work_procedure::delete_match_3_d_work;
|
pub use delete_match_3_d_work_procedure::delete_match_3_d_work;
|
||||||
pub use delete_puzzle_work_procedure::delete_puzzle_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_table::*;
|
||||||
pub use editor_canvas_type::EditorCanvas;
|
pub use editor_canvas_type::EditorCanvas;
|
||||||
pub use editor_project_create_input_type::EditorProjectCreateInput;
|
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_input_type::EditorProjectGetInput;
|
||||||
pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput;
|
pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput;
|
||||||
pub use editor_project_layout_save_input_type::EditorProjectLayoutSaveInput;
|
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_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_create_input_type::EditorProjectResourceCreateInput;
|
||||||
pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult;
|
pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult;
|
||||||
pub use editor_project_resource_snapshot_type::EditorProjectResourceSnapshot;
|
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_gallery_entries_procedure::list_custom_world_gallery_entries;
|
||||||
pub use list_custom_world_profiles_procedure::list_custom_world_profiles;
|
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_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_jump_hop_works_procedure::list_jump_hop_works;
|
||||||
pub use list_match_3_d_works_procedure::list_match_3_d_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;
|
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_big_fish_work_procedure::remix_big_fish_work;
|
||||||
pub use remix_custom_world_profile_procedure::remix_custom_world_profile;
|
pub use remix_custom_world_profile_procedure::remix_custom_world_profile;
|
||||||
pub use remix_puzzle_work_procedure::remix_puzzle_work;
|
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 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_and_return_procedure::resolve_combat_action_and_return;
|
||||||
pub use resolve_combat_action_input_type::ResolveCombatActionInput;
|
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,
|
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)]
|
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||||
pub struct EditorProjectLayoutSaveInput {
|
pub struct EditorProjectLayoutSaveInput {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
@@ -172,6 +191,20 @@ pub struct EditorProjectProcedureResult {
|
|||||||
pub error_message: Option<String>,
|
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)]
|
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||||
pub struct EditorProjectResourceProcedureResult {
|
pub struct EditorProjectResourceProcedureResult {
|
||||||
pub ok: bool,
|
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]
|
#[spacetimedb::procedure]
|
||||||
pub fn get_editor_project_and_return(
|
pub fn get_editor_project_and_return(
|
||||||
ctx: &mut ProcedureContext,
|
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]
|
#[spacetimedb::procedure]
|
||||||
pub fn create_editor_project_resource_and_return(
|
pub fn create_editor_project_resource_and_return(
|
||||||
ctx: &mut ProcedureContext,
|
ctx: &mut ProcedureContext,
|
||||||
@@ -309,6 +391,32 @@ fn get_recent_editor_project(
|
|||||||
.transpose()
|
.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(
|
fn get_editor_project(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: EditorProjectGetInput,
|
input: EditorProjectGetInput,
|
||||||
@@ -319,6 +427,68 @@ fn get_editor_project(
|
|||||||
build_project_snapshot(ctx, project.project_id.as_str())
|
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(
|
fn save_editor_project_layout(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: EditorProjectLayoutSaveInput,
|
input: EditorProjectLayoutSaveInput,
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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 { ApiClientError } from '../../services/apiClient';
|
||||||
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||||
|
|
||||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||||
const editEditorImageMock = 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 () => {
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||||
const actual = await vi.importActual<
|
const actual = await vi.importActual<
|
||||||
@@ -18,6 +20,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
editEditorImage: editEditorImageMock,
|
editEditorImage: editEditorImageMock,
|
||||||
generateEditorImage: generateEditorImageMock,
|
generateEditorImage: generateEditorImageMock,
|
||||||
|
loadEditorProject: loadEditorProjectMock,
|
||||||
|
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,10 +40,47 @@ function dispatchPointerEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ImageCanvasEditorView', () => {
|
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(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
generateEditorImageMock.mockReset();
|
generateEditorImageMock.mockReset();
|
||||||
editEditorImageMock.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', () => {
|
it('toggles the shared sidebar from canvas panel buttons', () => {
|
||||||
@@ -196,6 +237,9 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
value: createObjectUrlSpy,
|
value: createObjectUrlSpy,
|
||||||
});
|
});
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||||
|
|
||||||
@@ -344,11 +388,15 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
|
||||||
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||||||
expect(anchoredGenerateDialog).toBeTruthy();
|
expect(anchoredGenerateDialog).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
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();
|
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||||
const metadataButtons = screen.getAllByRole('button', {
|
const metadataButtons = screen.getAllByRole('button', {
|
||||||
name: /查看生成图片 .*元数据/,
|
name: /查看生成图片 .*元数据/,
|
||||||
@@ -369,6 +417,9 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
taskId: 'editor-drag-frame-1',
|
taskId: 'editor-drag-frame-1',
|
||||||
});
|
});
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||||||
const initialComposerTop = Number.parseFloat(
|
const initialComposerTop = Number.parseFloat(
|
||||||
@@ -409,10 +460,10 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(anchoredGenerateDialog).toBeTruthy();
|
expect(anchoredGenerateDialog).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
||||||
).toBeCloseTo(draggedComposerTop, 1);
|
).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top));
|
||||||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||||
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(700);
|
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(300);
|
||||||
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(150);
|
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the generation composer when selecting another image', () => {
|
it('keeps the generation composer when selecting another image', () => {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
type EditorImageGenerationResult,
|
type EditorImageGenerationResult,
|
||||||
type EditorProjectLayerSnapshot,
|
type EditorProjectLayerSnapshot,
|
||||||
generateEditorImage,
|
generateEditorImage,
|
||||||
|
loadEditorProject,
|
||||||
loadOrCreateRecentEditorProject,
|
loadOrCreateRecentEditorProject,
|
||||||
saveEditorProjectLayout,
|
saveEditorProjectLayout,
|
||||||
} from '../../services/image-editor/editorProjectClient';
|
} from '../../services/image-editor/editorProjectClient';
|
||||||
@@ -639,7 +640,16 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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) => {
|
.then((project) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
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 UnifiedCreationWorkspace = lazy(async () => {
|
||||||
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
||||||
return {
|
return {
|
||||||
@@ -15162,6 +15169,26 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.div>
|
</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' && (
|
{selectionStage === 'platform' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="platform-home"
|
key="platform-home"
|
||||||
@@ -15274,6 +15301,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}}
|
}}
|
||||||
onOpenPlayedWork={openPlayedWork}
|
onOpenPlayedWork={openPlayedWork}
|
||||||
onOpenFeedback={openProfileFeedback}
|
onOpenFeedback={openProfileFeedback}
|
||||||
|
onOpenProjects={() => setSelectionStage('project')}
|
||||||
onOpenProfileDashboardCard={(cardKey) => {
|
onOpenProfileDashboardCard={(cardKey) => {
|
||||||
if (cardKey === 'playedWorks') {
|
if (cardKey === 'playedWorks') {
|
||||||
openProfilePlayedWorks();
|
openProfilePlayedWorks();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
|||||||
|
|
||||||
export type SelectionStage =
|
export type SelectionStage =
|
||||||
| 'platform'
|
| 'platform'
|
||||||
|
| 'project'
|
||||||
| 'image-editor'
|
| 'image-editor'
|
||||||
| 'profile-feedback'
|
| 'profile-feedback'
|
||||||
| 'work-detail'
|
| 'work-detail'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
|
|||||||
|
|
||||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||||
platform: true,
|
platform: true,
|
||||||
|
project: true,
|
||||||
'image-editor': true,
|
'image-editor': true,
|
||||||
'profile-feedback': false,
|
'profile-feedback': false,
|
||||||
'work-detail': true,
|
'work-detail': true,
|
||||||
|
|||||||
125
src/components/project/ProjectGalleryView.test.tsx
Normal file
125
src/components/project/ProjectGalleryView.test.tsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
415
src/components/project/ProjectGalleryView.tsx
Normal file
415
src/components/project/ProjectGalleryView.tsx
Normal file
@@ -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<EditorProjectSnapshot[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [activeMenuProjectId, setActiveMenuProjectId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [renameDraft, setRenameDraft] = useState<RenameDraft | null>(null);
|
||||||
|
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(
|
||||||
|
() => 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 (
|
||||||
|
<article
|
||||||
|
key={project.projectId}
|
||||||
|
className={[
|
||||||
|
'project-gallery__card',
|
||||||
|
selected ? 'project-gallery__card--selected' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="project-gallery__card-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
toggleProjectSelection(project.projectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenProject(project.projectId);
|
||||||
|
}}
|
||||||
|
aria-label={`打开项目${project.title}`}
|
||||||
|
>
|
||||||
|
<span className="project-gallery__preview">
|
||||||
|
{preview ? (
|
||||||
|
<img src={preview.imageSrc} alt="" />
|
||||||
|
) : (
|
||||||
|
<span className="project-gallery__preview-empty" />
|
||||||
|
)}
|
||||||
|
{isSelectionMode ? (
|
||||||
|
<span className="project-gallery__checkbox">
|
||||||
|
{selected ? <Check className="h-4 w-4" /> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="project-gallery__meta">
|
||||||
|
<span>{project.title}</span>
|
||||||
|
<span>{formatProjectUpdatedAt(project.updatedAt)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!isSelectionMode ? (
|
||||||
|
<div className="project-gallery__card-menu-wrap">
|
||||||
|
<PlatformIconButton
|
||||||
|
label={`打开项目${project.title}菜单`}
|
||||||
|
icon={<MoreHorizontal className="h-4 w-4" />}
|
||||||
|
variant="surfaceFloating"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setActiveMenuProjectId((currentProjectId) =>
|
||||||
|
currentProjectId === project.projectId
|
||||||
|
? null
|
||||||
|
: project.projectId,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{activeMenuProjectId === project.projectId ? (
|
||||||
|
<div className="project-gallery__card-menu" role="menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() =>
|
||||||
|
setRenameDraft({
|
||||||
|
projectId: project.projectId,
|
||||||
|
title: project.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
<span>重命名</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => deleteProjects([project.projectId])}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
<span>删除</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeMenuProjectId,
|
||||||
|
deleteProjects,
|
||||||
|
isSelectionMode,
|
||||||
|
onOpenProject,
|
||||||
|
projects,
|
||||||
|
selectedProjectIds,
|
||||||
|
toggleProjectSelection,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="project-gallery" aria-label="项目">
|
||||||
|
<header className="project-gallery__header">
|
||||||
|
<div>
|
||||||
|
<h1>项目</h1>
|
||||||
|
<span>{projects.length} 个画布项目</span>
|
||||||
|
</div>
|
||||||
|
<div className="project-gallery__header-actions">
|
||||||
|
<PlatformActionButton
|
||||||
|
tone="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSelectionMode(true)}
|
||||||
|
disabled={projects.length === 0}
|
||||||
|
>
|
||||||
|
选择
|
||||||
|
</PlatformActionButton>
|
||||||
|
<PlatformActionButton size="sm" onClick={createProject}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新建
|
||||||
|
</PlatformActionButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="project-gallery__error" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<PlatformEmptyState surface="subpanel" size="panel">
|
||||||
|
正在读取项目
|
||||||
|
</PlatformEmptyState>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="project-gallery__new-card"
|
||||||
|
onClick={createProject}
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
<span>新建项目</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<section className="project-gallery__grid">{projectCards}</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renameDraft ? (
|
||||||
|
<div className="project-gallery__modal-backdrop" role="presentation">
|
||||||
|
<form
|
||||||
|
className="project-gallery__rename-dialog"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submitRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="project-gallery__rename-header">
|
||||||
|
<h2>重命名</h2>
|
||||||
|
<PlatformIconButton
|
||||||
|
label="关闭重命名"
|
||||||
|
icon={<X className="h-4 w-4" />}
|
||||||
|
variant="surfaceFloating"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setRenameDraft(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
aria-label="项目名称"
|
||||||
|
value={renameDraft.title}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRenameDraft((currentDraft) =>
|
||||||
|
currentDraft
|
||||||
|
? { ...currentDraft, title: event.target.value }
|
||||||
|
: currentDraft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="project-gallery__rename-actions">
|
||||||
|
<PlatformActionButton
|
||||||
|
type="button"
|
||||||
|
tone="secondary"
|
||||||
|
onClick={() => setRenameDraft(null)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</PlatformActionButton>
|
||||||
|
<PlatformActionButton type="submit">保存</PlatformActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isSelectionMode ? (
|
||||||
|
<div className="project-gallery__batch-toolbar" role="toolbar" aria-label="批量操作">
|
||||||
|
<PlatformActionButton
|
||||||
|
tone="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjectIds(
|
||||||
|
allSelected
|
||||||
|
? new Set()
|
||||||
|
: new Set(projects.map((project) => project.projectId)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allSelected ? (
|
||||||
|
<CheckSquare className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{selectedCount > 0
|
||||||
|
? `${allSelected ? '取消全选' : '全选'} · 已选 ${selectedCount}`
|
||||||
|
: '全选'}
|
||||||
|
</PlatformActionButton>
|
||||||
|
<PlatformActionButton
|
||||||
|
tone="warning"
|
||||||
|
size="sm"
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
onClick={() => deleteProjects([...selectedProjectIds])}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
删除
|
||||||
|
</PlatformActionButton>
|
||||||
|
<PlatformActionButton tone="secondary" size="sm" onClick={closeSelectionMode}>
|
||||||
|
取消
|
||||||
|
</PlatformActionButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectGalleryView;
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Coins,
|
Coins,
|
||||||
Compass,
|
Compass,
|
||||||
Crown,
|
Crown,
|
||||||
|
FolderKanban,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
GitFork,
|
GitFork,
|
||||||
Heart,
|
Heart,
|
||||||
@@ -272,6 +273,7 @@ export interface RpgEntryHomeViewProps {
|
|||||||
onCloseProfilePlayStats?: () => void;
|
onCloseProfilePlayStats?: () => void;
|
||||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||||
onOpenFeedback?: () => void;
|
onOpenFeedback?: () => void;
|
||||||
|
onOpenProjects?: () => void;
|
||||||
onRechargeSuccess?: () => void | Promise<void>;
|
onRechargeSuccess?: () => void | Promise<void>;
|
||||||
profileTaskRefreshKey?: number;
|
profileTaskRefreshKey?: number;
|
||||||
createTabContent?: ReactNode;
|
createTabContent?: ReactNode;
|
||||||
@@ -2566,6 +2568,7 @@ export function RpgEntryHomeView({
|
|||||||
onCloseProfilePlayStats,
|
onCloseProfilePlayStats,
|
||||||
onOpenPlayedWork,
|
onOpenPlayedWork,
|
||||||
onOpenFeedback,
|
onOpenFeedback,
|
||||||
|
onOpenProjects,
|
||||||
onRechargeSuccess,
|
onRechargeSuccess,
|
||||||
profileTaskRefreshKey = 0,
|
profileTaskRefreshKey = 0,
|
||||||
createTabContent,
|
createTabContent,
|
||||||
@@ -4361,6 +4364,12 @@ export function RpgEntryHomeView({
|
|||||||
imageSrc={profileCommunityImage}
|
imageSrc={profileCommunityImage}
|
||||||
onClick={() => openProfilePopupPanel('community')}
|
onClick={() => openProfilePopupPanel('community')}
|
||||||
/>
|
/>
|
||||||
|
<ProfileShortcutButton
|
||||||
|
label="项目"
|
||||||
|
subLabel="画布项目"
|
||||||
|
icon={FolderKanban}
|
||||||
|
onClick={onOpenProjects}
|
||||||
|
/>
|
||||||
<ProfileShortcutButton
|
<ProfileShortcutButton
|
||||||
label="反馈与建议"
|
label="反馈与建议"
|
||||||
subLabel="帮我们优化产品"
|
subLabel="帮我们优化产品"
|
||||||
|
|||||||
287
src/index.css
287
src/index.css
@@ -2973,6 +2973,293 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-gallery-stage-shell {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__header span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
padding-bottom: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-button,
|
||||||
|
.project-gallery__new-card {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111827;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-button {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-button:hover,
|
||||||
|
.project-gallery__card--selected .project-gallery__card-button,
|
||||||
|
.project-gallery__new-card:hover {
|
||||||
|
border-color: rgba(75, 181, 170, 0.58);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__preview {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
place-items: center;
|
||||||
|
background: #f3f5f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__preview-empty {
|
||||||
|
width: 36%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(75, 181, 170, 0.28), transparent 58%),
|
||||||
|
#e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__meta {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__meta span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 860;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__meta span + span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-menu-wrap {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.6rem;
|
||||||
|
bottom: 3.7rem;
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card:hover .project-gallery__card-menu-wrap,
|
||||||
|
.project-gallery__card-menu-wrap:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 2.4rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 7rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-menu button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 760;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__card-menu button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__checkbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.65rem;
|
||||||
|
right: 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.18);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #238a82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__batch-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 1.1rem;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
padding: 0.55rem;
|
||||||
|
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__new-card {
|
||||||
|
place-items: center;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(22rem, calc(100% - 2rem));
|
||||||
|
min-height: 13rem;
|
||||||
|
margin: auto;
|
||||||
|
color: #238a82;
|
||||||
|
font-weight: 860;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__error {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 1rem 1.5rem 0;
|
||||||
|
border: 1px solid #fecdd3;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #fff1f2;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
color: #be123c;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__rename-dialog {
|
||||||
|
display: grid;
|
||||||
|
width: min(24rem, calc(100% - 2rem));
|
||||||
|
gap: 0.9rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__rename-header,
|
||||||
|
.project-gallery__rename-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__rename-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__rename-dialog input {
|
||||||
|
min-height: 2.65rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 760;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__rename-dialog input:focus {
|
||||||
|
border-color: #4bb5aa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(75, 181, 170, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
.image-canvas-editor {
|
.image-canvas-editor {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ describe('appPageRoutes', () => {
|
|||||||
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor/canvas');
|
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', () => {
|
it('resolves jump-hop creation, gallery and runtime routes', () => {
|
||||||
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
|
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
|
||||||
'jump-hop-workspace',
|
'jump-hop-workspace',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
|
|||||||
|
|
||||||
const STAGE_ROUTE_ENTRIES = [
|
const STAGE_ROUTE_ENTRIES = [
|
||||||
['platform', '/'],
|
['platform', '/'],
|
||||||
|
['project', '/project'],
|
||||||
['image-editor', '/editor/canvas'],
|
['image-editor', '/editor/canvas'],
|
||||||
['profile-feedback', '/profile/feedback'],
|
['profile-feedback', '/profile/feedback'],
|
||||||
['work-detail', '/works/detail'],
|
['work-detail', '/works/detail'],
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
createEditorProject,
|
createEditorProject,
|
||||||
createEditorProjectResource,
|
createEditorProjectResource,
|
||||||
|
deleteEditorProject,
|
||||||
editEditorImage,
|
editEditorImage,
|
||||||
generateEditorImage,
|
generateEditorImage,
|
||||||
|
listEditorProjects,
|
||||||
|
loadEditorProject,
|
||||||
loadOrCreateRecentEditorProject,
|
loadOrCreateRecentEditorProject,
|
||||||
|
renameEditorProject,
|
||||||
saveEditorProjectLayout,
|
saveEditorProjectLayout,
|
||||||
} from './editorProjectClient';
|
} 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 () => {
|
it('creates a resource with upload or generated metadata', async () => {
|
||||||
requestJsonMock.mockResolvedValueOnce({
|
requestJsonMock.mockResolvedValueOnce({
|
||||||
resource: {
|
resource: {
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export async function loadRecentEditorProject() {
|
||||||
return requestJson<EditorProjectRecentResponse>(
|
return requestJson<EditorProjectRecentResponse>(
|
||||||
`${EDITOR_PROJECT_API_BASE}/recent`,
|
`${EDITOR_PROJECT_API_BASE}/recent`,
|
||||||
@@ -158,6 +168,36 @@ export async function loadOrCreateRecentEditorProject() {
|
|||||||
return createEditorProject({ title: DEFAULT_PROJECT_TITLE });
|
return createEditorProject({ title: DEFAULT_PROJECT_TITLE });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadEditorProject(projectId: string) {
|
||||||
|
const response = await requestJson<EditorProjectResponse>(
|
||||||
|
`${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<EditorProjectResponse>(
|
||||||
|
`${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(
|
export async function saveEditorProjectLayout(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: EditorProjectLayoutSaveInput,
|
input: EditorProjectLayoutSaveInput,
|
||||||
|
|||||||
Reference in New Issue
Block a user