From 32fc4089cbe1abd652612f3df92a414c38f29fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Wed, 24 Jun 2026 17:25:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=98=AF=E5=90=A6=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=B5=84=E6=BA=90=E7=9A=84=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=B2=BE=E9=80=89=E5=88=97=E8=A1=A8=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布项目资源公开展示开关和公开精选资源接口。 左侧素材右键菜单支持公开展示勾选与删除操作。 陶泥儿精选改为读取全站公开生成资源并过滤同源同媒体副本。 拖拽生成素材入画布时保留源资源关系并复用同源资源。 同步 SpacetimeDB 迁移、绑定、前端测试和相关文档。 --- .../shared-memory/decision-log.md | 8 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- ...作】创作主页与项目入口改版计划-2026-06-18.md | 13 +- .../crates/api-server/src/editor_project.rs | 82 +++- .../api-server/src/external_editor_api.rs | 1 + .../api-server/src/modules/editor_project.rs | 15 +- .../spacetime-client/src/editor_project.rs | 50 ++ server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 7 +- .../src/mapper/editor_project.rs | 56 +++ .../spacetime-client/src/module_bindings.rs | 12 +- .../editor_asset_create_input_type.rs | 1 + .../editor_asset_snapshot_type.rs | 2 + .../src/module_bindings/editor_asset_type.rs | 3 + ...ect_resource_list_procedure_result_type.rs | 19 + ...esource_public_showcase_list_input_type.rs | 15 + ...ect_resource_showcase_update_input_type.rs | 18 + .../editor_project_resource_snapshot_type.rs | 1 + .../editor_project_resource_type.rs | 6 + ..._project_resources_and_return_procedure.rs | 62 +++ ..._resource_showcase_and_return_procedure.rs | 62 +++ .../src/editor_project_storage.rs | 431 +++++++++++++++++- .../crates/spacetime-module/src/migration.rs | 16 + .../CreationLandingView.test.tsx | 226 +++++---- .../creation-home/CreationLandingView.tsx | 33 +- .../creationShowcaseModel.test.ts | 57 +++ .../creation-home/creationShowcaseModel.ts | 58 ++- .../ImageCanvasAssetFolderSectionView.tsx | 6 + .../ImageCanvasAssetLibraryPanelView.tsx | 6 + .../ImageCanvasAssetRowView.test.tsx | 90 +++- .../image-editor/ImageCanvasAssetRowView.tsx | 107 ++++- ...mageCanvasEditorAssetsIntegration.test.tsx | 95 +++- .../image-editor/ImageCanvasEditorModel.ts | 3 +- .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorTypes.ts | 1 + .../image-editor/ImageCanvasEditorView.tsx | 2 + .../ImageCanvasSidebarView.test.tsx | 1 + .../image-editor/ImageCanvasSidebarView.tsx | 6 + .../useImageCanvasAssetLibrary.test.tsx | 145 +++++- .../useImageCanvasAssetLibrary.ts | 34 ++ src/index.css | 11 + .../image-editor/editorProjectClient.test.ts | 67 +++ .../image-editor/editorProjectClient.ts | 35 ++ 44 files changed, 1689 insertions(+), 190 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7eed4c0c5..71cf20114 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -40,12 +40,12 @@ - 验证方式:运行品牌标识、绑定手机号页和平台首页相关前端测试,确认品牌图 `src` 为 `/branding/taonier-product-ip.png`;执行 `npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`。 -## 2026-06-22 创作主页精选只展示项目内画布生成资源 +## 2026-06-22 创作主页精选展示全站公开画布生成资源 - 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。 -- 决策:`陶泥儿精选` 只从用户图片画布项目中的 `editor_project_resource` 构建素材包和素材,且只保留 `sourceType="generated"` 的画布生成资源;账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。 -- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、最近项目列表资源读取、创作主页改版计划和精选素材相关测试。 -- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts` 与 `CreationLandingView.test.tsx`,确认项目内生成资源展示、上传素材过滤、公开作品不再 fallback。 +- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间倒序展示。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除` 和 `公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`。 +- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、公开精选 BFF、图片画布素材列表右键菜单、账号素材库快照、创作主页改版计划和精选素材相关测试。 +- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts`、`CreationLandingView.test.tsx`、`ImageCanvasAssetRowView.test.tsx`、`useImageCanvasAssetLibrary.test.tsx` 与 `src/services/image-editor/editorProjectClient.test.ts`,确认公开生成资源展示、上传素材过滤、公开开关隐藏资源、删除入口在右键菜单中、公开作品不再 fallback。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-06-22 编辑器生成模型默认定价调整 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1e729c8e4..33f5743bb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -23,6 +23,14 @@ - 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 +## 陶泥儿精选重复先查同源同媒体画布副本 + +- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。 +- 原因:素材拖入画布会为图层实例准备 `editor_project_resource`;如果该素材本来带 `sourceResourceId` 指向原始生成资源,而新资源仍按普通 generated 资源公开,精选就会把原件和每次拖拽产生的同源同媒体副本都展示出来。 +- 处理:创建项目资源时保留 `source_resource_id`,并在同项目已有同源同媒体资源时复用已有 resource;确需创建同源同媒体副本时默认 `public_showcase_enabled = false`。公开精选读取和前端精选模型都跳过 `sourceResourceId` 指回同一媒体原件的副本,但不要按图片地址全局去重,避免不同生成步骤共享占位图时被误合并。 +- 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId`;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/creation-home/creationShowcaseModel.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 + ## 画板外部生成排队超时不是失败 - 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index cef069bba..cb5e2c70e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -468,7 +468,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind` 和 `generation_inputs_json`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` @@ -482,7 +482,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind` 和 `generation_inputs_json`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,前端只用该快照更新素材栏。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `inventory_slot` diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index f7d289a10..f968603d5 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -15,7 +15,7 @@ - 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 - 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。 - 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 -- `陶泥儿精选` 只展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 +- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 - 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 ## 页面结构 @@ -61,9 +61,9 @@ ### 陶泥儿精选 -`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 +`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 的项目资源,代表用户通过图片画布生成并已经落入某个项目的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 Tab: @@ -100,10 +100,11 @@ Tab: 数据来源: -- 读取用户项目列表中的 `editor_project_resource` 快照,只保留 `sourceType="generated"` 的画布生成资源。 +- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。 - 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。 -- 未登录、项目为空或项目内没有画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 +- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 +- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。菜单内 `公开展示该素材` 默认勾选,取消勾选后调用 `PATCH /api/editor/project-resources/{resourceId}/showcase` 写回 `public_showcase_enabled = false`,该资源从 `陶泥儿精选` 移除。 - 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。 - 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。 @@ -114,7 +115,7 @@ Tab: - 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。 - 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。 - 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。 -- `陶泥儿精选` 读取项目内画布生成资源;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。 +- `陶泥儿精选` 读取后端公开精选接口返回的全站公开画布生成资源;公开开关以后端 `editor_project_resource.public_showcase_enabled` 为准,前端只做展示过滤、同源同媒体副本兜底过滤和乐观交互回滚。 - `/creation/` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。 ## 路由与导航 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b15c30b55..25b3c27ec 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -35,7 +35,8 @@ use spacetime_client::{ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, SpacetimeClientError, + EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, SpacetimeClientError, }; use crate::{ @@ -142,6 +143,12 @@ pub struct EditorProjectResourceCreateRequest { generation_inputs: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectResourceShowcaseUpdateRequest { + public_showcase_enabled: bool, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderCreateRequest { @@ -298,6 +305,12 @@ pub struct EditorProjectResourceResponse { resource: EditorProjectResourcePayload, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectResourceListResponse { + resources: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetLibraryResponse { @@ -479,6 +492,7 @@ pub struct EditorProjectResourcePayload { source_resource_id: Option, asset_kind: Option, generation_inputs: Option, + public_showcase_enabled: bool, created_at: String, updated_at: String, } @@ -521,6 +535,8 @@ pub struct EditorAssetPayload { task_id: Option, asset_kind: Option, generation_inputs: Option, + source_resource_id: Option, + public_showcase_enabled: Option, created_at: String, updated_at: String, } @@ -587,6 +603,28 @@ pub async fn list_editor_projects( )) } +pub async fn list_public_editor_project_resources( + State(state): State, + Extension(request_context): Extension, +) -> Result, AppError> { + let resources = state + .spacetime_client() + .list_public_editor_project_resources(EditorProjectResourcePublicShowcaseListRecordInput { + limit: 36, + }) + .await + .map_err(map_editor_project_error)?; + let resources = resources + .into_iter() + .map(editor_project_resource_payload_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorProjectResourceListResponse { resources }, + )) +} + pub async fn create_editor_project( State(state): State, Extension(request_context): Extension, @@ -763,6 +801,32 @@ pub async fn create_editor_project_resource( )) } +pub async fn update_editor_project_resource_showcase( + State(state): State, + Path(resource_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let resource = state + .spacetime_client() + .update_editor_project_resource_showcase(EditorProjectResourceShowcaseUpdateRecordInput { + resource_id, + owner_user_id: current_owner_user_id(&authenticated), + public_showcase_enabled: payload.public_showcase_enabled, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorProjectResourceResponse { + resource: editor_project_resource_payload_from_record(resource), + }, + )) +} + pub async fn get_editor_asset_library( State(state): State, Extension(request_context): Extension, @@ -892,6 +956,7 @@ pub async fn create_editor_asset( task_id: normalize_optional_string(payload.task_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, + source_resource_id: None, now_micros: current_utc_micros(), }) .await @@ -2431,6 +2496,7 @@ pub(crate) fn editor_project_resource_payload_from_record( generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), + public_showcase_enabled: record.public_showcase_enabled, created_at: record.created_at, updated_at: record.updated_at, } @@ -2489,6 +2555,8 @@ pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> Edi generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), + source_resource_id: record.source_resource_id, + public_showcase_enabled: record.public_showcase_enabled, created_at: record.created_at, updated_at: record.updated_at, } @@ -2652,6 +2720,10 @@ async fn persist_editor_generated_asset( let folder_id = normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str()); + let created_resource_id = resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .or_else(|| normalize_optional_string(input.source_resource_id.clone())); let asset = if let Some(folder_id) = folder_id { let record = state .spacetime_client() @@ -2673,6 +2745,7 @@ async fn persist_editor_generated_asset( task_id: Some(input.task_id), asset_kind: normalize_optional_string(input.asset_kind), generation_inputs_json, + source_resource_id: created_resource_id, now_micros, }) .await @@ -3798,6 +3871,7 @@ mod tests { source_resource_id: None, asset_kind: Some("spec".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }]; @@ -3868,6 +3942,7 @@ mod tests { source_resource_id: None, asset_kind: Some("spec".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }]; @@ -3934,6 +4009,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }; @@ -4393,6 +4469,7 @@ mod tests { "fields": [{"title": "角色设定", "value": "银发游侠"}], "references": [] })), + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; @@ -4489,6 +4566,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; @@ -4569,6 +4647,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; @@ -4690,6 +4769,7 @@ mod tests { source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, + public_showcase_enabled: true, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 0b313d690..84c8f97f2 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -490,6 +490,7 @@ pub async fn create_external_editor_asset( task_id: normalize_optional_string(payload.task_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, + source_resource_id: None, now_micros: current_utc_micros(), }) .await diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index a8650abd2..2969c40b3 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -13,8 +13,9 @@ use crate::{ delete_editor_project, edit_editor_image, extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image, get_editor_asset_library, get_editor_generation_pricing, get_editor_project, list_editor_projects, - load_recent_editor_project, rename_editor_project, save_editor_project_layout, - update_editor_asset, update_editor_asset_folder, + list_public_editor_project_resources, load_recent_editor_project, rename_editor_project, + save_editor_project_layout, update_editor_asset, update_editor_asset_folder, + update_editor_project_resource_showcase, }, state::AppState, }; @@ -27,6 +28,10 @@ pub fn router(state: AppState) -> Router { "/api/editor/generation-pricing", get(get_editor_generation_pricing), ) + .route( + "/api/editor/showcase/resources", + get(list_public_editor_project_resources), + ) .route( "/api/editor/projects/recent", get(load_recent_editor_project).route_layer(middleware::from_fn_with_state( @@ -67,6 +72,12 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/project-resources/{resource_id}/showcase", + patch(update_editor_project_resource_showcase).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/editor/assets/library", get(get_editor_asset_library).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index fba5f9c20..a8a877600 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -235,6 +235,56 @@ impl SpacetimeClient { .await } + pub async fn update_editor_project_resource_showcase( + &self, + input: EditorProjectResourceShowcaseUpdateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "update_editor_project_resource_showcase_and_return", + move |connection, sender| { + connection + .procedures() + .update_editor_project_resource_showcase_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_resource_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_public_editor_project_resources( + &self, + input: EditorProjectResourcePublicShowcaseListRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "list_public_editor_project_resources_and_return", + move |connection, sender| { + connection + .procedures() + .list_public_editor_project_resources_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_resource_list_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_editor_asset_library( &self, owner_user_id: String, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index a4fbd17e6..34110353d 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -38,7 +38,8 @@ pub use mapper::{ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput, + EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index da294c8d3..e284107b2 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -81,7 +81,8 @@ pub use self::editor_project::{ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, + EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, }; pub use self::external_api_key::{ ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, @@ -210,7 +211,9 @@ pub(crate) use self::editor_project::{ map_editor_asset_library_procedure_result, map_editor_asset_procedure_result, map_editor_project_delete_procedure_result, map_editor_project_layout_save_procedure_result, map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result, - map_editor_project_required_procedure_result, map_editor_project_resource_procedure_result, + map_editor_project_required_procedure_result, + map_editor_project_resource_list_procedure_result, + map_editor_project_resource_procedure_result, }; pub(crate) use self::external_api_key::{ map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 85c6fb64e..c17b47969 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -57,6 +57,7 @@ pub struct EditorProjectResourceRecord { pub source_resource_id: Option, pub asset_kind: Option, pub generation_inputs: Option, + pub public_showcase_enabled: bool, pub created_at: String, pub updated_at: String, } @@ -90,6 +91,8 @@ pub struct EditorAssetRecord { pub task_id: Option, pub asset_kind: Option, pub generation_inputs: Option, + pub source_resource_id: Option, + pub public_showcase_enabled: Option, pub created_at: String, pub updated_at: String, } @@ -169,6 +172,19 @@ pub struct EditorProjectResourceMediaRepairRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorProjectResourceShowcaseUpdateRecordInput { + pub resource_id: String, + pub owner_user_id: String, + pub public_showcase_enabled: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorProjectResourcePublicShowcaseListRecordInput { + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorAssetFolderCreateRecordInput { pub folder_id: String, @@ -213,6 +229,7 @@ pub struct EditorAssetCreateRecordInput { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, pub now_micros: i64, } @@ -341,6 +358,27 @@ impl From } } +impl From + for crate::module_bindings::EditorProjectResourceShowcaseUpdateInput +{ + fn from(input: EditorProjectResourceShowcaseUpdateRecordInput) -> Self { + Self { + resource_id: input.resource_id, + owner_user_id: input.owner_user_id, + public_showcase_enabled: input.public_showcase_enabled, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for crate::module_bindings::EditorProjectResourcePublicShowcaseListInput +{ + fn from(input: EditorProjectResourcePublicShowcaseListRecordInput) -> Self { + Self { limit: input.limit } + } +} + impl From for crate::module_bindings::EditorAssetFolderCreateInput { @@ -401,6 +439,7 @@ impl From for crate::module_bindings::EditorAssetC task_id: input.task_id, asset_kind: input.asset_kind, generation_inputs_json: input.generation_inputs_json, + source_resource_id: input.source_resource_id, now_micros: input.now_micros, } } @@ -518,6 +557,20 @@ pub(crate) fn map_editor_project_resource_procedure_result( .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布资源快照")) } +pub(crate) fn map_editor_project_resource_list_procedure_result( + result: EditorProjectResourceListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .resources + .into_iter() + .map(map_editor_project_resource_snapshot) + .collect() +} + pub(crate) fn map_editor_asset_library_procedure_result( result: EditorAssetLibraryProcedureResult, ) -> Result { @@ -638,6 +691,7 @@ fn map_editor_project_resource_snapshot( source_resource_id: snapshot.source_resource_id, asset_kind: snapshot.asset_kind, generation_inputs, + public_showcase_enabled: snapshot.public_showcase_enabled, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) @@ -698,6 +752,8 @@ fn map_editor_asset_snapshot( task_id: snapshot.task_id, asset_kind: snapshot.asset_kind, generation_inputs, + source_resource_id: snapshot.source_resource_id, + public_showcase_enabled: snapshot.public_showcase_enabled, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b0c6686ec..4a647bd63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.5.0 (commit ca16958ef0a5f8c816700d2255a0b20ecacff901). +// This was generated using spacetimedb cli version 2.5.0 (commit 19cbf2b219ac9fd7fdca6c5e2c61a3a2f0319515). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -391,8 +391,11 @@ pub mod editor_project_list_procedure_result_type; pub mod editor_project_procedure_result_type; pub mod editor_project_rename_input_type; pub mod editor_project_resource_create_input_type; +pub mod editor_project_resource_list_procedure_result_type; pub mod editor_project_resource_media_repair_input_type; pub mod editor_project_resource_procedure_result_type; +pub mod editor_project_resource_public_showcase_list_input_type; +pub mod editor_project_resource_showcase_update_input_type; pub mod editor_project_resource_snapshot_type; pub mod editor_project_resource_table; pub mod editor_project_resource_type; @@ -578,6 +581,7 @@ pub mod list_match_3_d_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; +pub mod list_public_editor_project_resources_and_return_procedure; pub mod list_puzzle_clear_works_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; @@ -1120,6 +1124,7 @@ pub mod unpublish_custom_world_profile_reducer; pub mod update_bark_battle_draft_config_procedure; pub mod update_editor_asset_and_return_procedure; pub mod update_editor_asset_folder_and_return_procedure; +pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_jump_hop_work_procedure; pub mod update_match_3_d_work_procedure; pub mod update_puzzle_clear_work_procedure; @@ -1614,8 +1619,11 @@ pub use editor_project_list_procedure_result_type::EditorProjectListProcedureRes 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_list_procedure_result_type::EditorProjectResourceListProcedureResult; pub use editor_project_resource_media_repair_input_type::EditorProjectResourceMediaRepairInput; pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult; +pub use editor_project_resource_public_showcase_list_input_type::EditorProjectResourcePublicShowcaseListInput; +pub use editor_project_resource_showcase_update_input_type::EditorProjectResourceShowcaseUpdateInput; pub use editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; pub use editor_project_resource_table::*; pub use editor_project_resource_type::EditorProjectResource; @@ -1801,6 +1809,7 @@ 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_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; +pub use list_public_editor_project_resources_and_return_procedure::list_public_editor_project_resources_and_return; pub use list_puzzle_clear_works_procedure::list_puzzle_clear_works; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; @@ -2343,6 +2352,7 @@ pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; pub use update_bark_battle_draft_config_procedure::update_bark_battle_draft_config; pub use update_editor_asset_and_return_procedure::update_editor_asset_and_return; pub use update_editor_asset_folder_and_return_procedure::update_editor_asset_folder_and_return; +pub use update_editor_project_resource_showcase_and_return_procedure::update_editor_project_resource_showcase_and_return; pub use update_jump_hop_work_procedure::update_jump_hop_work; pub use update_match_3_d_work_procedure::update_match_3_d_work; pub use update_puzzle_clear_work_procedure::update_puzzle_clear_work; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs index f37faf5d1..5d39f1fcc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs @@ -24,6 +24,7 @@ pub struct EditorAssetCreateInput { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, pub now_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs index 85cb1de4a..419250158 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs @@ -23,6 +23,8 @@ pub struct EditorAssetSnapshot { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, + pub public_showcase_enabled: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs index 04e959907..ad126e534 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs @@ -26,6 +26,7 @@ pub struct EditorAsset { pub updated_at: __sdk::Timestamp, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, } impl __sdk::InModule for EditorAsset { @@ -55,6 +56,7 @@ pub struct EditorAssetCols { pub updated_at: __sdk::__query_builder::Col, pub asset_kind: __sdk::__query_builder::Col>, pub generation_inputs_json: __sdk::__query_builder::Col>, + pub source_resource_id: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorAsset { @@ -83,6 +85,7 @@ impl __sdk::__query_builder::HasCols for EditorAsset { table_name, "generation_inputs_json", ), + source_resource_id: __sdk::__query_builder::Col::new(table_name, "source_resource_id"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs new file mode 100644 index 000000000..10e3d136c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourceListProcedureResult { + pub ok: bool, + pub resources: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectResourceListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs new file mode 100644 index 000000000..764c4eed9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_public_showcase_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourcePublicShowcaseListInput { + pub limit: u32, +} + +impl __sdk::InModule for EditorProjectResourcePublicShowcaseListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs new file mode 100644 index 000000000..b2d655893 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_showcase_update_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourceShowcaseUpdateInput { + pub resource_id: String, + pub owner_user_id: String, + pub public_showcase_enabled: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectResourceShowcaseUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs index 085ff2b4c..b1af43048 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -23,6 +23,7 @@ pub struct EditorProjectResourceSnapshot { pub source_resource_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub public_showcase_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs index a47526ce4..4ef4a0a43 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs @@ -26,6 +26,7 @@ pub struct EditorProjectResource { pub updated_at: __sdk::Timestamp, pub asset_kind: Option, pub generation_inputs_json: Option, + pub public_showcase_enabled: bool, } impl __sdk::InModule for EditorProjectResource { @@ -55,6 +56,7 @@ pub struct EditorProjectResourceCols { pub updated_at: __sdk::__query_builder::Col, pub asset_kind: __sdk::__query_builder::Col>, pub generation_inputs_json: __sdk::__query_builder::Col>, + pub public_showcase_enabled: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for EditorProjectResource { @@ -83,6 +85,10 @@ impl __sdk::__query_builder::HasCols for EditorProjectResource { table_name, "generation_inputs_json", ), + public_showcase_enabled: __sdk::__query_builder::Col::new( + table_name, + "public_showcase_enabled", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs new file mode 100644 index 000000000..70b2895cf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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_resource_list_procedure_result_type::EditorProjectResourceListProcedureResult; +use super::editor_project_resource_public_showcase_list_input_type::EditorProjectResourcePublicShowcaseListInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPublicEditorProjectResourcesAndReturnArgs { + pub input: EditorProjectResourcePublicShowcaseListInput, +} + +impl __sdk::InModule for ListPublicEditorProjectResourcesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_public_editor_project_resources_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_public_editor_project_resources_and_return { + fn list_public_editor_project_resources_and_return( + &self, + input: EditorProjectResourcePublicShowcaseListInput, + ) { + self.list_public_editor_project_resources_and_return_then(input, |_, _| {}); + } + + fn list_public_editor_project_resources_and_return_then( + &self, + input: EditorProjectResourcePublicShowcaseListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_public_editor_project_resources_and_return for super::RemoteProcedures { + fn list_public_editor_project_resources_and_return_then( + &self, + input: EditorProjectResourcePublicShowcaseListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectResourceListProcedureResult>( + "list_public_editor_project_resources_and_return", + ListPublicEditorProjectResourcesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs new file mode 100644 index 000000000..200432a78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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_resource_procedure_result_type::EditorProjectResourceProcedureResult; +use super::editor_project_resource_showcase_update_input_type::EditorProjectResourceShowcaseUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateEditorProjectResourceShowcaseAndReturnArgs { + pub input: EditorProjectResourceShowcaseUpdateInput, +} + +impl __sdk::InModule for UpdateEditorProjectResourceShowcaseAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_editor_project_resource_showcase_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_editor_project_resource_showcase_and_return { + fn update_editor_project_resource_showcase_and_return( + &self, + input: EditorProjectResourceShowcaseUpdateInput, + ) { + self.update_editor_project_resource_showcase_and_return_then(input, |_, _| {}); + } + + fn update_editor_project_resource_showcase_and_return_then( + &self, + input: EditorProjectResourceShowcaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_editor_project_resource_showcase_and_return for super::RemoteProcedures { + fn update_editor_project_resource_showcase_and_return_then( + &self, + input: EditorProjectResourceShowcaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( + "update_editor_project_resource_showcase_and_return", + UpdateEditorProjectResourceShowcaseAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 28379ddd3..3d8585bab 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -74,6 +74,8 @@ pub struct EditorProjectResource { asset_kind: Option, #[default(None::)] generation_inputs_json: Option, + #[default(true)] + public_showcase_enabled: bool, } #[spacetimedb::table( @@ -120,6 +122,8 @@ pub struct EditorAsset { asset_kind: Option, #[default(None::)] generation_inputs_json: Option, + #[default(None::)] + source_resource_id: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -208,6 +212,19 @@ pub struct EditorProjectResourceMediaRepairInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectResourceShowcaseUpdateInput { + pub resource_id: String, + pub owner_user_id: String, + pub public_showcase_enabled: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectResourcePublicShowcaseListInput { + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceSnapshot { pub resource_id: String, @@ -226,6 +243,7 @@ pub struct EditorProjectResourceSnapshot { pub source_resource_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub public_showcase_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } @@ -259,6 +277,8 @@ pub struct EditorAssetSnapshot { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, + pub public_showcase_enabled: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } @@ -319,6 +339,7 @@ pub struct EditorAssetCreateInput { pub task_id: Option, pub asset_kind: Option, pub generation_inputs_json: Option, + pub source_resource_id: Option, pub now_micros: i64, } @@ -411,6 +432,13 @@ pub struct EditorProjectResourceProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorProjectResourceListProcedureResult { + pub ok: bool, + pub resources: Vec, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetLibraryProcedureResult { pub ok: bool, @@ -575,6 +603,44 @@ pub fn repair_editor_project_resource_media_and_return( } } +#[spacetimedb::procedure] +pub fn update_editor_project_resource_showcase_and_return( + ctx: &mut ProcedureContext, + input: EditorProjectResourceShowcaseUpdateInput, +) -> EditorProjectResourceProcedureResult { + match ctx.try_with_tx(|tx| update_editor_project_resource_showcase(tx, input.clone())) { + Ok(resource) => EditorProjectResourceProcedureResult { + ok: true, + resource: Some(resource), + error_message: None, + }, + Err(message) => EditorProjectResourceProcedureResult { + ok: false, + resource: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_public_editor_project_resources_and_return( + ctx: &mut ProcedureContext, + input: EditorProjectResourcePublicShowcaseListInput, +) -> EditorProjectResourceListProcedureResult { + match ctx.try_with_tx(|tx| list_public_editor_project_resources(tx, input.clone())) { + Ok(resources) => EditorProjectResourceListProcedureResult { + ok: true, + resources, + error_message: None, + }, + Err(message) => EditorProjectResourceListProcedureResult { + ok: false, + resources: Vec::new(), + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn get_editor_asset_library_and_return( ctx: &mut ProcedureContext, @@ -918,6 +984,27 @@ fn create_editor_project_resource( if input.width == 0 || input.height == 0 { return Err("画布资源尺寸必须大于 0".to_string()); } + let asset_object_id = normalize_optional(input.asset_object_id); + let object_key = normalize_optional(input.object_key); + let source_resource_id = normalize_optional(input.source_resource_id); + let prompt = normalize_optional(input.prompt); + let actual_prompt = normalize_optional(input.actual_prompt); + let model = normalize_optional(input.model); + let provider = normalize_optional(input.provider); + let task_id = normalize_optional(input.task_id); + let asset_kind = normalize_optional(input.asset_kind); + let generation_inputs_json = normalize_optional(input.generation_inputs_json); + if let Some(existing_resource) = find_reusable_project_resource_for_input( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + source_resource_id.as_ref(), + asset_object_id.as_ref(), + object_key.as_ref(), + image_src.as_str(), + ) { + return Ok(resource_snapshot_from_row(existing_resource)); + } if ctx .db .editor_project_resource() @@ -928,6 +1015,15 @@ fn create_editor_project_resource( return Err("画布资源已存在".to_string()); } + let public_showcase_enabled = source_type != "generated" + || !project_resource_input_copies_source_media( + ctx, + owner_user_id.as_str(), + source_resource_id.as_ref(), + asset_object_id.as_ref(), + object_key.as_ref(), + image_src.as_str(), + ); let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .editor_project_resource() @@ -935,22 +1031,23 @@ fn create_editor_project_resource( resource_id: resource_id.clone(), project_id, owner_user_id, - asset_object_id: normalize_optional(input.asset_object_id), + asset_object_id, image_src, - object_key: normalize_optional(input.object_key), + object_key, width: input.width, height: input.height, source_type, - prompt: normalize_optional(input.prompt), - actual_prompt: normalize_optional(input.actual_prompt), - model: normalize_optional(input.model), - provider: normalize_optional(input.provider), - task_id: normalize_optional(input.task_id), - source_resource_id: normalize_optional(input.source_resource_id), + prompt, + actual_prompt, + model, + provider, + task_id, + source_resource_id, created_at: now, updated_at: now, - asset_kind: normalize_optional(input.asset_kind), - generation_inputs_json: normalize_optional(input.generation_inputs_json), + asset_kind, + generation_inputs_json, + public_showcase_enabled, }); ctx.db @@ -1007,6 +1104,7 @@ fn repair_editor_project_resource_media( updated_at: now, asset_kind: resource.asset_kind, generation_inputs_json: resource.generation_inputs_json, + public_showcase_enabled: resource.public_showcase_enabled, }); ctx.db @@ -1167,12 +1265,13 @@ fn create_editor_asset( updated_at: now, asset_kind: normalize_optional(input.asset_kind), generation_inputs_json: normalize_optional(input.generation_inputs_json), + source_resource_id: normalize_optional(input.source_resource_id), }); ctx.db .editor_asset() .asset_id() .find(&asset_id) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .ok_or_else(|| "素材创建失败".to_string()) } @@ -1214,12 +1313,13 @@ fn update_editor_asset( updated_at: now, asset_kind: asset.asset_kind, generation_inputs_json: asset.generation_inputs_json, + source_resource_id: asset.source_resource_id, }); ctx.db .editor_asset() .asset_id() .find(&asset_id) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .ok_or_else(|| "素材更新失败".to_string()) } @@ -1257,13 +1357,14 @@ fn repair_editor_asset_media( updated_at: now, asset_kind: asset.asset_kind, generation_inputs_json: asset.generation_inputs_json, + source_resource_id: asset.source_resource_id, }); ctx.db .editor_asset() .asset_id() .find(&asset_id) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .ok_or_else(|| "素材媒体修复失败".to_string()) } @@ -1275,7 +1376,7 @@ fn delete_editor_asset( let owner_user_id = normalize_required(&input.owner_user_id, "editor_asset.owner_user_id")?; let asset = require_owned_asset(ctx, asset_id.as_str(), owner_user_id.as_str())?; ctx.db.editor_asset().asset_id().delete(&asset_id); - Ok(asset_snapshot_from_row(asset)) + Ok(asset_snapshot_from_row(ctx, asset)) } fn build_project_snapshot( @@ -1320,6 +1421,69 @@ fn build_project_snapshot( }) } +fn update_editor_project_resource_showcase( + ctx: &ReducerContext, + input: EditorProjectResourceShowcaseUpdateInput, +) -> Result { + let resource_id = + normalize_required(&input.resource_id, "editor_project_resource.resource_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "editor_project_resource.owner_user_id", + )?; + let resource = + require_owned_project_resource(ctx, resource_id.as_str(), owner_user_id.as_str())?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db + .editor_project_resource() + .resource_id() + .delete(&resource_id); + ctx.db + .editor_project_resource() + .insert(EditorProjectResource { + public_showcase_enabled: input.public_showcase_enabled, + updated_at: now, + ..resource + }); + + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .map(resource_snapshot_from_row) + .ok_or_else(|| "画布资源公开展示状态更新失败".to_string()) +} + +fn list_public_editor_project_resources( + ctx: &ReducerContext, + input: EditorProjectResourcePublicShowcaseListInput, +) -> Result, String> { + let limit = input.limit.clamp(1, 120) as usize; + let mut resources = ctx + .db + .editor_project_resource() + .iter() + .filter(|resource| { + resource.public_showcase_enabled + && resource.source_type == "generated" + && !resource.image_src.trim().is_empty() + && !project_resource_copies_source_media(ctx, resource) + }) + .map(resource_snapshot_from_row) + .collect::>(); + + resources.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| right.resource_id.cmp(&left.resource_id)) + }); + resources.truncate(limit); + + Ok(resources) +} + fn ensure_default_canvas( ctx: &ReducerContext, project_id: &str, @@ -1509,7 +1673,7 @@ fn build_asset_library_snapshot( .editor_asset() .by_editor_asset_owner_user_id() .filter(&owner_key) - .map(asset_snapshot_from_row) + .map(|asset| asset_snapshot_from_row(ctx, asset)) .collect::>(); assets.sort_by(|left, right| { left.created_at_micros @@ -1531,7 +1695,15 @@ fn asset_folder_snapshot_from_row(row: EditorAssetFolder) -> EditorAssetFolderSn } } -fn asset_snapshot_from_row(row: EditorAsset) -> EditorAssetSnapshot { +fn asset_snapshot_from_row(ctx: &ReducerContext, row: EditorAsset) -> EditorAssetSnapshot { + let linked_resource = find_asset_source_project_resource(ctx, &row); + let inferred_source_resource_id = linked_resource + .as_ref() + .map(|resource| resource.resource_id.clone()); + let public_showcase_enabled = linked_resource + .as_ref() + .map(|resource| resource.public_showcase_enabled); + EditorAssetSnapshot { asset_id: row.asset_id, folder_id: row.folder_id, @@ -1549,11 +1721,237 @@ fn asset_snapshot_from_row(row: EditorAsset) -> EditorAssetSnapshot { task_id: row.task_id, asset_kind: row.asset_kind, generation_inputs_json: row.generation_inputs_json, + source_resource_id: row.source_resource_id.or(inferred_source_resource_id), + public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } +fn find_asset_source_project_resource( + ctx: &ReducerContext, + row: &EditorAsset, +) -> Option { + if let Some(resource_id) = row + .source_resource_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + if let Some(resource) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id.to_string()) + .filter(|resource| resource.owner_user_id == row.owner_user_id) + { + return Some(resource); + } + } + + if row.source_type != "generated" { + return None; + } + + let owner_key = row.owner_user_id.clone(); + let mut matches = ctx + .db + .editor_project_resource() + .by_editor_project_resource_owner_user_id() + .filter(&owner_key) + .filter(|resource| { + resource.source_type == "generated" + && project_resource_matches_asset_media(resource, row) + }) + .collect::>(); + matches.sort_by(|left, right| { + right + .created_at + .to_micros_since_unix_epoch() + .cmp(&left.created_at.to_micros_since_unix_epoch()) + .then_with(|| right.resource_id.cmp(&left.resource_id)) + }); + matches.into_iter().next() +} + +fn project_resource_matches_asset_media( + resource: &EditorProjectResource, + asset: &EditorAsset, +) -> bool { + project_resource_matches_media( + resource, + asset.asset_object_id.as_ref(), + asset.object_key.as_ref(), + asset.image_src.as_str(), + ) +} + +fn project_resource_matches_media( + resource: &EditorProjectResource, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> bool { + if matching_optional_text_ref(resource.asset_object_id.as_ref(), asset_object_id) { + return true; + } + if matching_optional_media_ref_ref(resource.object_key.as_ref(), object_key) { + return true; + } + if let Some(asset_object_key) = object_key.map(String::as_str) { + if matching_media_ref(resource.image_src.as_str(), asset_object_key) { + return true; + } + } + if let Some(resource_object_key) = resource.object_key.as_deref() { + if matching_media_ref(resource_object_key, image_src) { + return true; + } + } + matching_media_ref(resource.image_src.as_str(), image_src) +} + +fn project_resource_copies_source_media( + ctx: &ReducerContext, + resource: &EditorProjectResource, +) -> bool { + project_resource_input_copies_source_media( + ctx, + resource.owner_user_id.as_str(), + resource.source_resource_id.as_ref(), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + ) +} + +fn project_resource_input_copies_source_media( + ctx: &ReducerContext, + owner_user_id: &str, + source_resource_id: Option<&String>, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> bool { + let Some(source_resource_id) = source_resource_id + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + ctx.db + .editor_project_resource() + .resource_id() + .find(&source_resource_id.to_string()) + .filter(|source_resource| source_resource.owner_user_id == owner_user_id) + .is_some_and(|source_resource| { + project_resource_matches_media( + &source_resource, + asset_object_id, + object_key, + image_src, + ) + }) +} + +fn find_reusable_project_resource_for_input( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + source_resource_id: Option<&String>, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> Option { + let source_resource_id = source_resource_id + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let project_key = project_id.to_string(); + let mut matches = ctx + .db + .editor_project_resource() + .by_editor_project_resource_project_id() + .filter(&project_key) + .filter(|resource| { + resource.owner_user_id == owner_user_id + && (resource.resource_id == source_resource_id + || resource + .source_resource_id + .as_deref() + .map(str::trim) + .is_some_and(|value| value == source_resource_id)) + && project_resource_matches_media( + resource, + asset_object_id, + object_key, + image_src, + ) + }) + .collect::>(); + matches.sort_by(|left, right| { + let left_is_source = left.resource_id == source_resource_id; + let right_is_source = right.resource_id == source_resource_id; + right_is_source + .cmp(&left_is_source) + .then_with(|| { + left.created_at + .to_micros_since_unix_epoch() + .cmp(&right.created_at.to_micros_since_unix_epoch()) + }) + .then_with(|| left.resource_id.cmp(&right.resource_id)) + }); + matches.into_iter().next() +} + +fn matching_optional_text_ref(left: Option<&String>, right: Option<&String>) -> bool { + match (normalize_optional_ref(left), normalize_optional_ref(right)) { + (Some(left_value), Some(right_value)) => left_value == right_value, + _ => false, + } +} + +fn matching_optional_media_ref_ref(left: Option<&String>, right: Option<&String>) -> bool { + match (normalize_optional_ref(left), normalize_optional_ref(right)) { + (Some(left_value), Some(right_value)) => matching_media_ref(left_value, right_value), + _ => false, + } +} + +fn normalize_optional_ref(value: Option<&String>) -> Option<&str> { + value + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn matching_media_ref(left: &str, right: &str) -> bool { + let left_value = normalize_media_ref(left); + let right_value = normalize_media_ref(right); + !left_value.is_empty() && left_value == right_value +} + +fn normalize_media_ref(value: &str) -> String { + let trimmed_value = value + .trim() + .split('?') + .next() + .unwrap_or("") + .trim(); + let path_value = if let Some(protocol_index) = trimmed_value.find("://") { + let after_protocol = &trimmed_value[protocol_index + 3..]; + after_protocol + .find('/') + .map(|path_index| &after_protocol[path_index..]) + .unwrap_or("") + } else { + trimmed_value + }; + + path_value.trim_start_matches('/').to_string() +} + fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResourceSnapshot { EditorProjectResourceSnapshot { resource_id: row.resource_id, @@ -1572,6 +1970,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour source_resource_id: row.source_resource_id, asset_kind: row.asset_kind, generation_inputs_json: row.generation_inputs_json, + public_showcase_enabled: row.public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f1babe02a..b76f1ab31 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1394,6 +1394,22 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "editor_project_resource" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:精选公开开关晚于画布资源表加入,旧生成资源按默认公开兼容。 + object + .entry("public_showcase_enabled".to_string()) + .or_insert_with(|| serde_json::Value::Bool(true)); + } + } + if table_name == "editor_asset" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:账号素材与项目资源绑定晚于素材库表加入,旧素材按未绑定兼容。 + object + .entry("source_resource_id".to_string()) + .or_insert(serde_json::Value::Null); + } + } next_value } diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 31997e2d1..40eda8514 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -3,12 +3,13 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ContextType } from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthUiContext } from '../auth/AuthUiContext'; import { CreationLandingView } from './CreationLandingView'; const listEditorProjectsMock = vi.hoisted(() => vi.fn()); +const listPublicEditorProjectResourcesMock = vi.hoisted(() => vi.fn()); const createEditorProjectMock = vi.hoisted(() => vi.fn()); type AuthValue = NonNullable>; @@ -45,6 +46,7 @@ function createAuthValue(overrides: Partial = {}): AuthValue { vi.mock('../../services/image-editor/editorProjectClient', () => ({ listEditorProjects: listEditorProjectsMock, + listPublicEditorProjectResources: listPublicEditorProjectResourcesMock, createEditorProject: createEditorProjectMock, })); @@ -88,8 +90,13 @@ function renderCreationLanding({ } describe('CreationLandingView', () => { + beforeEach(() => { + listPublicEditorProjectResourcesMock.mockResolvedValue([]); + }); + afterEach(() => { listEditorProjectsMock.mockReset(); + listPublicEditorProjectResourcesMock.mockReset(); createEditorProjectMock.mockReset(); }); @@ -212,28 +219,24 @@ describe('CreationLandingView', () => { it('renders generated project resources in showcase tabs without mock rows', async () => { const user = userEvent.setup(); - listEditorProjectsMock.mockResolvedValueOnce([ + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ { - ...projectItems[0]!, - resources: [ - { - resourceId: 'resource-character', - projectId: 'project-newest', - label: '角色英雄', - imageSrc: 'data:image/png;base64,character', - width: 512, - height: 512, - sourceType: 'generated', - prompt: 'character hero prompt', - actualPrompt: null, - model: 'character-model', - provider: 'provider', - taskId: 'task-1', - assetKind: 'character', - authorName: '创作者A', - priceMudPoints: 20, - }, - ], + resourceId: 'resource-character', + projectId: 'project-newest', + label: '角色英雄', + imageSrc: 'data:image/png;base64,character', + width: 512, + height: 512, + sourceType: 'generated', + prompt: 'character hero prompt', + actualPrompt: null, + model: 'character-model', + provider: 'provider', + taskId: 'task-1', + assetKind: 'character', + authorName: '创作者A', + priceMudPoints: 20, }, ]); @@ -251,55 +254,51 @@ describe('CreationLandingView', () => { it('opens a fullscreen asset preview modal with thumbnail navigation', async () => { const user = userEvent.setup(); - listEditorProjectsMock.mockResolvedValueOnce([ + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ { - ...projectItems[0]!, - resources: [ - { - resourceId: 'resource-character', - projectId: 'project-newest', - label: '角色英雄', - imageSrc: 'data:image/png;base64,character', - width: 512, - height: 512, - sourceType: 'generated', - prompt: 'character hero prompt', - actualPrompt: null, - model: 'character-model', - provider: 'provider', - taskId: 'task-1', - assetKind: 'character', - authorName: '创作者A', - priceMudPoints: 20, - }, - { - resourceId: 'resource-animation', - projectId: 'project-newest', - label: '待机动画', - imageSrc: 'data:image/png;base64,animation', - width: 512, - height: 512, - sourceType: 'generated', - prompt: 'idle animation prompt', - actualPrompt: null, - model: 'seedance2.0-fast', - provider: 'ark', - taskId: 'task-2', - assetKind: 'character-animation', - priceMudPoints: 40, - generationInputs: { - fields: [{ title: '动作描述', value: '待机动作' }], - references: [ - { - title: '角色图片', - label: '角色英雄', - refType: 'project-resource', - refId: 'resource-character', - }, - ], + resourceId: 'resource-character', + projectId: 'project-newest', + label: '角色英雄', + imageSrc: 'data:image/png;base64,character', + width: 512, + height: 512, + sourceType: 'generated', + prompt: 'character hero prompt', + actualPrompt: null, + model: 'character-model', + provider: 'provider', + taskId: 'task-1', + assetKind: 'character', + authorName: '创作者A', + priceMudPoints: 20, + }, + { + resourceId: 'resource-animation', + projectId: 'project-newest', + label: '待机动画', + imageSrc: 'data:image/png;base64,animation', + width: 512, + height: 512, + sourceType: 'generated', + prompt: 'idle animation prompt', + actualPrompt: null, + model: 'seedance2.0-fast', + provider: 'ark', + taskId: 'task-2', + assetKind: 'character-animation', + priceMudPoints: 40, + generationInputs: { + fields: [{ title: '动作描述', value: '待机动作' }], + references: [ + { + title: '角色图片', + label: '角色英雄', + refType: 'project-resource', + refId: 'resource-character', }, - }, - ], + ], + }, }, ]); @@ -325,58 +324,49 @@ describe('CreationLandingView', () => { }); it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => { - listEditorProjectsMock.mockResolvedValueOnce([ + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ { - ...projectItems[0]!, - resources: [ - { - resourceId: 'resource-pack-a', - projectId: 'project-newest', - label: '素材包 A', - imageSrc: 'data:image/png;base64,a', - width: 512, - height: 640, - sourceType: 'generated', - prompt: '素材包提示词', - actualPrompt: null, - model: null, - provider: null, - taskId: null, - }, - { - resourceId: 'resource-uploaded', - projectId: 'project-newest', - label: '上传素材', - imageSrc: 'data:image/png;base64,uploaded', - width: 512, - height: 512, - sourceType: 'uploaded', - prompt: null, - actualPrompt: null, - model: null, - provider: null, - taskId: null, - }, - ], + resourceId: 'resource-pack-a', + projectId: 'project-newest', + label: '素材包 A', + imageSrc: 'data:image/png;base64,a', + width: 512, + height: 640, + sourceType: 'generated', + prompt: '素材包提示词', + actualPrompt: null, + model: null, + provider: null, + taskId: null, }, { - ...projectItems[1]!, - resources: [ - { - resourceId: 'resource-pack-b', - projectId: 'project-old', - label: '素材包 B', - imageSrc: 'data:image/png;base64,b', - width: 512, - height: 512, - sourceType: 'generated', - prompt: '第二组素材', - actualPrompt: null, - model: null, - provider: null, - taskId: null, - }, - ], + resourceId: 'resource-uploaded', + projectId: 'project-newest', + label: '上传素材', + imageSrc: 'data:image/png;base64,uploaded', + width: 512, + height: 512, + sourceType: 'uploaded', + prompt: null, + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + { + resourceId: 'resource-pack-b', + projectId: 'project-old', + label: '素材包 B', + imageSrc: 'data:image/png;base64,b', + width: 512, + height: 512, + sourceType: 'generated', + prompt: '第二组素材', + actualPrompt: null, + model: null, + provider: null, + taskId: null, }, ]); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 981a31f7b..096b07439 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { createEditorProject, + listPublicEditorProjectResources, listEditorProjects, type EditorProjectResourceSnapshot, type EditorProjectSnapshot, @@ -364,6 +365,7 @@ export function CreationLandingView({ [], ); const [isLoadingProjects, setIsLoadingProjects] = useState(false); + const [isLoadingShowcase, setIsLoadingShowcase] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); const [projectError, setProjectError] = useState(null); const [projectShowcaseResources, setProjectShowcaseResources] = useState< @@ -379,14 +381,11 @@ export function CreationLandingView({ const refreshRecentProjects = useCallback(() => { if (!isAuthenticated) { setRecentProjects([]); - setProjectShowcaseResources([]); setProjectError(null); - setAssetError(null); return; } setIsLoadingProjects(true); setProjectError(null); - setAssetError(null); listEditorProjects() .then((projects) => { const sortedProjects = [...projects].sort( @@ -395,9 +394,6 @@ export function CreationLandingView({ new Date(left.updatedAt).getTime(), ); setRecentProjects(sortedProjects.slice(0, 4)); - setProjectShowcaseResources( - sortedProjects.flatMap((project) => project.resources ?? []), - ); }) .catch((error: unknown) => { if (isUnauthorizedError(error)) { @@ -409,7 +405,6 @@ export function CreationLandingView({ setProjectError( message, ); - setAssetError(message); }) .finally(() => setIsLoadingProjects(false)); }, [authUi, isAuthenticated]); @@ -418,6 +413,24 @@ export function CreationLandingView({ refreshRecentProjects(); }, [refreshRecentProjects]); + const refreshShowcaseResources = useCallback(() => { + setIsLoadingShowcase(true); + setAssetError(null); + listPublicEditorProjectResources() + .then((resources) => setProjectShowcaseResources(resources)) + .catch((error: unknown) => { + setProjectShowcaseResources([]); + setAssetError( + error instanceof Error ? error.message : '读取陶泥儿精选失败', + ); + }) + .finally(() => setIsLoadingShowcase(false)); + }, []); + + useEffect(() => { + refreshShowcaseResources(); + }, [refreshShowcaseResources]); + const createProject = useCallback(() => { if (authUi && !authUi.user) { authUi.openLoginModal(createProject); @@ -444,12 +457,12 @@ export function CreationLandingView({ buildCreationShowcaseItems({ activeTab: activeShowcaseTab, projectResources: projectShowcaseResources, - fallbackAuthorName: authUi?.user?.displayName, + fallbackAuthorName: null, }), - [activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName], + [activeShowcaseTab, projectShowcaseResources], ); - const isShowcaseLoading = isAuthenticated && isLoadingProjects; + const isShowcaseLoading = isLoadingShowcase; const showcaseEmptyText = '暂无素材'; const renderAssetShowcase = () => { diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index 30f6d8c96..0bfe74cc1 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -53,6 +53,63 @@ describe('creationShowcaseModel', () => { expect(items[0]?.label).toBe('画布生成素材'); }); + it('filters out generated resources that are not public showcase enabled', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'packs', + projectResources: [ + createResource({ + resourceId: 'public-generated', + label: '公开素材', + sourceType: 'generated', + publicShowcaseEnabled: true, + }), + createResource({ + resourceId: 'private-generated', + label: '不公开素材', + sourceType: 'generated', + publicShowcaseEnabled: false, + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('公开素材'); + }); + + it('deduplicates canvas copies that point back to the same generated resource', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'characters', + projectResources: [ + createResource({ + resourceId: 'resource-original', + label: '原始角色图', + imageSrc: '/generated/character.png', + objectKey: 'generated/character.png', + assetObjectId: 'asset-object-character', + assetKind: 'character', + sourceType: 'generated', + publicShowcaseEnabled: true, + }), + createResource({ + resourceId: 'resource-copy', + label: '拖拽副本角色图', + imageSrc: + 'https://oss.example.test/generated/character.png?x-oss-signature=1', + objectKey: '/generated/character.png', + assetObjectId: 'asset-object-character', + assetKind: 'character', + sourceType: 'generated', + sourceResourceId: 'resource-original', + publicShowcaseEnabled: true, + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.previews).toHaveLength(1); + expect(items[0]?.previews[0]?.id).toBe('resource-original'); + }); + it('groups pack items by the same referenced spec image and sums known cost', () => { const items = buildCreationShowcaseItems({ activeTab: 'packs', diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index bd5fa4a69..22fc6f608 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -210,15 +210,70 @@ function projectResourceToShowcaseAsset( } as EditorAssetSnapshot; } +function normalizeShowcaseMediaRef(value: string | null | undefined) { + const trimmedValue = value?.trim().split(/[?#]/u)[0]?.trim() ?? ''; + if (!trimmedValue) { + return ''; + } + const pathValue = /^https?:\/\//iu.test(trimmedValue) + ? (() => { + try { + return new URL(trimmedValue).pathname; + } catch { + return trimmedValue; + } + })() + : trimmedValue; + return pathValue.replace(/^\/+/u, ''); +} + +function getProjectResourceMediaKey(resource: EditorProjectResourceSnapshot) { + const assetObjectId = resource.assetObjectId?.trim(); + if (assetObjectId) { + return `asset-object:${assetObjectId}`; + } + const objectKey = normalizeShowcaseMediaRef(resource.objectKey); + if (objectKey) { + return `object:${objectKey}`; + } + const imageSrc = normalizeShowcaseMediaRef(resource.imageSrc); + return imageSrc ? `src:${imageSrc}` : ''; +} + +function isCopiedProjectResource( + resource: EditorProjectResourceSnapshot, + resourceById: Map, +) { + const sourceResourceId = resource.sourceResourceId?.trim(); + if (!sourceResourceId) { + return false; + } + const sourceResource = resourceById.get(sourceResourceId); + if (!sourceResource) { + return false; + } + return ( + getProjectResourceMediaKey(sourceResource) === + getProjectResourceMediaKey(resource) + ); +} + function buildGeneratedProjectResourceAssets( resources: EditorProjectResourceSnapshot[], ) { const seenResourceIds = new Set(); + const resourceById = new Map( + resources + .filter((resource) => resource.resourceId.trim()) + .map((resource) => [resource.resourceId, resource]), + ); return resources .filter( (resource) => - resource.sourceType === 'generated' && resource.imageSrc.trim(), + resource.publicShowcaseEnabled !== false && + resource.sourceType === 'generated' && + resource.imageSrc.trim(), ) .filter((resource) => { if (seenResourceIds.has(resource.resourceId)) { @@ -227,6 +282,7 @@ function buildGeneratedProjectResourceAssets( seenResourceIds.add(resource.resourceId); return true; }) + .filter((resource) => !isCopiedProjectResource(resource, resourceById)) .map(projectResourceToShowcaseAsset); } diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx index cff04617e..fe371467c 100644 --- a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -60,6 +60,10 @@ type ImageCanvasAssetFolderSectionViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; }; @@ -90,6 +94,7 @@ export function ImageCanvasAssetFolderSectionView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, }: ImageCanvasAssetFolderSectionViewProps) { @@ -248,6 +253,7 @@ export function ImageCanvasAssetFolderSectionView({ startRenamingAsset={startRenamingAsset} commitAssetRename={commitAssetRename} deleteUploadedAsset={deleteUploadedAsset} + setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} /> diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index c8e02577c..ac4320a0c 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -90,6 +90,10 @@ export type ImageCanvasAssetLibraryPanelViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; toggleAllAssetsSelected: () => void; @@ -136,6 +140,7 @@ export function ImageCanvasAssetLibraryPanelView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, @@ -226,6 +231,7 @@ export function ImageCanvasAssetLibraryPanelView({ startRenamingAsset={startRenamingAsset} commitAssetRename={commitAssetRename} deleteUploadedAsset={deleteUploadedAsset} + setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} /> diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index 3df07e5b5..976c88abb 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -56,6 +56,8 @@ function renderAssetRow({ addAssetLayer = vi.fn(), toggleAssetSelected = vi.fn(), commitAssetRename = vi.fn(), + deleteUploadedAsset = vi.fn(), + setAssetPublicShowcaseEnabled = vi.fn(), }: { asset?: EditorAsset; isAssetSelectionMode?: boolean; @@ -65,6 +67,11 @@ function renderAssetRow({ addAssetLayer?: (asset: EditorAsset) => void; toggleAssetSelected?: (assetId: string) => void; commitAssetRename?: (asset: EditorAsset) => void; + deleteUploadedAsset?: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled?: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; } = {}) { const props = { asset, @@ -82,7 +89,8 @@ function renderAssetRow({ moveAssetToFolder: vi.fn(), startRenamingAsset: vi.fn(), commitAssetRename, - deleteUploadedAsset: vi.fn(), + deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, }; @@ -207,4 +215,84 @@ describe('ImageCanvasAssetRowView', () => { 'generated-character-drafts/editor/spec.png', ); }); + + it('opens asset actions from the right click menu', () => { + const asset = createAsset({ + sourceResourceId: 'resource-asset-1', + publicShowcaseEnabled: true, + }); + const deleteUploadedAsset = vi.fn(); + const setAssetPublicShowcaseEnabled = vi.fn(); + renderAssetRow({ + asset, + deleteUploadedAsset, + setAssetPublicShowcaseEnabled, + }); + + expect( + screen.queryByRole('button', { name: '删除素材账号素材A' }), + ).toBeNull(); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + + const publicMenuItem = screen.getByRole('menuitemcheckbox', { + name: /公开展示该素材/u, + }); + expect(publicMenuItem.getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(publicMenuItem); + expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + expect(deleteUploadedAsset).toHaveBeenCalledWith(asset); + }); + + it('disables public showcase toggle when the asset is not linked to a project resource', () => { + const setAssetPublicShowcaseEnabled = vi.fn(); + renderAssetRow({ setAssetPublicShowcaseEnabled }); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + + const publicMenuItem = screen.getByRole('menuitemcheckbox', { + name: /公开展示该素材/u, + }); + expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(publicMenuItem); + expect(setAssetPublicShowcaseEnabled).not.toHaveBeenCalled(); + }); + + it('keeps public showcase toggle enabled for legacy generated assets once a source resource is inferred', () => { + const asset = createAsset({ + sourceType: 'generated', + sourceResourceId: 'resource-inferred-from-media', + publicShowcaseEnabled: null, + }); + const setAssetPublicShowcaseEnabled = vi.fn(); + renderAssetRow({ asset, setAssetPublicShowcaseEnabled }); + + fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { + clientX: 120, + clientY: 80, + }); + + const publicMenuItem = screen.getByRole('menuitemcheckbox', { + name: /公开展示该素材/u, + }); + expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false); + expect(publicMenuItem.getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(publicMenuItem); + expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false); + }); }); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 2217afa40..de5516afc 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -1,4 +1,5 @@ -import { Check, Music2, Pencil, Trash2, Video, X } from 'lucide-react'; +import { Check, Music2, Pencil, Video, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { PlatformTextField } from '../common/PlatformTextField'; @@ -41,6 +42,10 @@ export type ImageCanvasAssetRowViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; }; @@ -62,12 +67,51 @@ export function ImageCanvasAssetRowView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, }: ImageCanvasAssetRowViewProps) { + const rowRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ + x: number; + y: number; + } | null>(null); const isRenaming = renamingAsset?.assetId === asset.id; const isUploadingAsset = asset.uploadStatus === 'uploading'; const isFailedUpload = asset.uploadStatus === 'failed'; + const canShowcaseAsset = Boolean(asset.sourceResourceId); + const publicShowcaseEnabled = asset.publicShowcaseEnabled ?? true; + + const closeMenu = useCallback(() => { + setMenuPosition(null); + }, []); + + useEffect(() => { + if (!menuPosition) { + return undefined; + } + const handlePointerDown = (event: PointerEvent) => { + if (rowRef.current?.contains(event.target as Node)) { + return; + } + closeMenu(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeMenu(); + } + }; + window.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('scroll', closeMenu, true); + window.addEventListener('resize', closeMenu); + return () => { + window.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('scroll', closeMenu, true); + window.removeEventListener('resize', closeMenu); + }; + }, [closeMenu, menuPosition]); const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100); const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset); const mediaPreviewClassName = @@ -146,19 +190,22 @@ export function ImageCanvasAssetRowView({ icon={Pencil} onClick={() => startRenamingAsset(asset)} /> - {asset.sourceKind === 'uploaded' ? ( - deleteUploadedAsset(asset)} - /> - ) : null} ); return ( -
+
{ + if (isUploadingAsset || isFailedUpload || isRenaming) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }} + > + {menuPosition ? ( +
+ + {asset.sourceKind === 'uploaded' ? ( + + ) : null} +
+ ) : null}
); } diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 30f37fa60..8aae3250f 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -327,18 +327,22 @@ describe('ImageCanvasEditorView asset library integration', () => { expect( within(customFolder).getByRole('button', { name: '添加角色草图.png' }), ).toBeTruthy(); - expect( - within(customFolder).getByRole('button', { - name: '删除素材角色草图.png', - }), - ).toBeTruthy(); }); - await user.click( - within(customFolder).getByRole('button', { - name: '删除素材角色草图.png', - }), + const uploadedAssetButton = within(customFolder).getByRole('button', { + name: '添加角色草图.png', + }); + const uploadedAssetRow = uploadedAssetButton.closest( + '[data-asset-id="persisted-角色草图.png"]', ); + if (!uploadedAssetRow) { + throw new Error('uploaded asset row should exist'); + } + fireEvent.contextMenu(uploadedAssetRow, { + clientX: 120, + clientY: 180, + }); + await user.click(screen.getByRole('menuitem', { name: '删除' })); expect( screen.queryByRole('button', { name: '添加角色草图.png' }), @@ -867,7 +871,20 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '删除素材生成资源A' })); + const generatedAssetButton = screen.getByRole('button', { + name: '添加生成资源A', + }); + const generatedAssetRow = generatedAssetButton.closest( + '[data-asset-id="asset-generated-a"]', + ); + if (!generatedAssetRow) { + throw new Error('generated asset row should exist'); + } + fireEvent.contextMenu(generatedAssetRow, { + clientX: 120, + clientY: 180, + }); + await user.click(screen.getByRole('menuitem', { name: '删除' })); await waitFor(() => { expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull(); @@ -1144,4 +1161,62 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('keeps the source resource id when dragging a generated asset onto the viewport', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createTestImageAsset({ + assetId: 'asset-generated-character', + label: '生成角色素材', + imageSrc: '/generated/character.png', + objectKey: 'generated/character.png', + assetObjectId: 'asset-object-character', + sourceType: 'generated', + sourceResourceId: 'resource-original-character', + }), + ], + }); + render(); + + const sourceAsset = await screen.findByRole('button', { + name: '添加生成角色素材', + }); + const sourceAssetRow = sourceAsset.closest( + '.image-canvas-editor__asset-row', + ); + const viewport = screen.getByLabelText('画布工作区'); + const dataTransfer = createDataTransferStub(); + + if (!sourceAssetRow) { + throw new Error('asset row should exist'); + } + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.drop(viewport, { + clientX: 520, + clientY: 300, + dataTransfer, + }); + + expect(await screen.findByAltText('画布图片:生成角色素材')).toBeTruthy(); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + imageSrc: '/generated/character.png', + sourceType: 'generated', + sourceResourceId: 'resource-original-character', + }), + ); + }); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + }); + }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 4bcdf4f18..b3466e6a7 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -710,7 +710,8 @@ export function mapAssetLibrarySnapshot( taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, - sourceResourceId: asset.sourceResourceId ?? undefined, + sourceResourceId: asset.sourceResourceId ?? null, + publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), durationSeconds: asset.durationSeconds ?? undefined, diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 91ce99ff7..96e038a0b 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -77,6 +77,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { startRenamingAsset: vi.fn(), commitAssetRename: vi.fn(), deleteUploadedAsset: vi.fn(), + setAssetPublicShowcaseEnabled: vi.fn(), toggleAssetSelected: vi.fn(), addAssetLayer: vi.fn(), toggleAllAssetsSelected: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 441761985..303719a0a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -48,6 +48,7 @@ export type EditorAsset = { objectKey?: string; assetObjectId?: string; sourceResourceId?: string | null; + publicShowcaseEnabled?: boolean | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; durationSeconds?: number; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 24152109f..e9be6c4f6 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -382,6 +382,7 @@ export function ImageCanvasEditorView() { toggleAssetFolder, commitNewAssetFolder, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, startRenamingFolder, commitFolderRename, deleteAssetFolder, @@ -1317,6 +1318,7 @@ export function ImageCanvasEditorView() { startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx index 4fabeb456..0c348da35 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -115,6 +115,7 @@ function createSidebarProps( startRenamingAsset: vi.fn(), commitAssetRename: vi.fn(), deleteUploadedAsset: vi.fn(), + setAssetPublicShowcaseEnabled: vi.fn(), toggleAssetSelected: vi.fn(), addAssetLayer: vi.fn(), toggleAllAssetsSelected: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx index 49bc454cc..89054f40a 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.tsx @@ -96,6 +96,10 @@ export type ImageCanvasSidebarViewProps = { startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; + setAssetPublicShowcaseEnabled: ( + asset: EditorAsset, + publicShowcaseEnabled: boolean, + ) => void; toggleAssetSelected: (assetId: string) => void; addAssetLayer: (asset: EditorAsset) => void; toggleAllAssetsSelected: () => void; @@ -160,6 +164,7 @@ export function ImageCanvasSidebarView({ startRenamingAsset, commitAssetRename, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, @@ -313,6 +318,7 @@ export function ImageCanvasSidebarView({ startRenamingAsset={startRenamingAsset} commitAssetRename={commitAssetRename} deleteUploadedAsset={deleteUploadedAsset} + setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled} toggleAssetSelected={toggleAssetSelected} addAssetLayer={addAssetLayer} toggleAllAssetsSelected={toggleAllAssetsSelected} diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx index 7f6a7c084..03e001e48 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx @@ -15,6 +15,7 @@ const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); +const updateEditorProjectResourceShowcaseMock = vi.hoisted(() => vi.fn()); const defaultOpenEditorLoginModal = () => {}; const defaultOnDeleteAssets = () => {}; @@ -31,6 +32,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { loadEditorAssetLibrary: loadEditorAssetLibraryMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, + updateEditorProjectResourceShowcase: updateEditorProjectResourceShowcaseMock, }; }); @@ -107,7 +109,12 @@ function AssetLibraryHarness({ {assetLibrary.assets - .map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`) + .map( + (asset) => + `${asset.id}:${asset.label}:${asset.folderId}:${ + asset.publicShowcaseEnabled ?? 'unset' + }`, + ) .join('|')} @@ -154,6 +161,19 @@ function AssetLibraryHarness({ > delete asset + @@ -186,6 +206,15 @@ describe('useImageCanvasAssetLibrary', () => { systemDefault: false, }); updateEditorAssetMock.mockResolvedValue(createAssetSnapshot()); + updateEditorProjectResourceShowcaseMock.mockResolvedValue({ + resourceId: 'resource-a', + projectId: 'project-1', + imageSrc: 'data:image/png;base64,YQ==', + width: 320, + height: 240, + sourceType: 'generated', + publicShowcaseEnabled: false, + }); deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot()); deleteEditorAssetFolderMock.mockResolvedValue({ folders: [ @@ -236,7 +265,7 @@ describe('useImageCanvasAssetLibrary', () => { ); }); expect(screen.getByTestId('assets').textContent).toBe( - 'asset-a:素材A:project', + 'asset-a:素材A:project:unset', ); expect(screen.getByTestId('active-upload-folder').textContent).toBe( 'project', @@ -342,7 +371,7 @@ describe('useImageCanvasAssetLibrary', () => { act(() => screen.getByRole('button', { name: 'move asset' }).click()); expect(screen.getByTestId('assets').textContent).toBe( - 'asset-a:素材A:folder-role', + 'asset-a:素材A:folder-role:unset', ); expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', { folderId: 'folder-role', @@ -393,7 +422,7 @@ describe('useImageCanvasAssetLibrary', () => { act(() => screen.getByRole('button', { name: 'delete selected' }).click()); expect(screen.getByTestId('assets').textContent).toBe( - 'asset-b:素材B:project', + 'asset-b:素材B:project:unset', ); expect(onDeleteAssets).toHaveBeenCalledWith([ expect.objectContaining({ id: 'asset-a' }), @@ -401,4 +430,112 @@ describe('useImageCanvasAssetLibrary', () => { expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b'); }); + + it('updates public showcase state through the linked project resource', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + sourceResourceId: 'resource-a', + publicShowcaseEnabled: true, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'hide asset from showcase' }).click(), + ); + + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:project:false', + ); + expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith( + 'resource-a', + false, + ); + }); + + it('updates legacy generated asset showcase state when backend infers source resource id', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + sourceResourceId: 'resource-inferred', + publicShowcaseEnabled: null, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'hide asset from showcase' }).click(), + ); + + expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith( + 'resource-inferred', + false, + ); + }); + + it('rolls back public showcase state when update fails', async () => { + updateEditorProjectResourceShowcaseMock.mockRejectedValueOnce( + new Error('network'), + ); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + sourceResourceId: 'resource-a', + publicShowcaseEnabled: true, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'hide asset from showcase' }).click(), + ); + await waitFor(() => { + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:project:true', + ); + }); + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 38eb96380..29b76d57b 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -17,6 +17,7 @@ import { loadEditorAssetLibrary, updateEditorAsset, updateEditorAssetFolder, + updateEditorProjectResourceShowcase, } from '../../services/image-editor/editorProjectClient'; import { EDITOR_ASSET_FOLDERS, @@ -320,6 +321,38 @@ export function useImageCanvasAssetLibrary({ [onDeleteAssets], ); + const setAssetPublicShowcaseEnabled = useCallback( + (asset: EditorAsset, publicShowcaseEnabled: boolean) => { + if (!asset.sourceResourceId) { + return; + } + const previousValue = asset.publicShowcaseEnabled ?? true; + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === asset.id + ? { ...currentAsset, publicShowcaseEnabled } + : currentAsset, + ), + ); + updateEditorProjectResourceShowcase( + asset.sourceResourceId, + publicShowcaseEnabled, + ).catch(() => { + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === asset.id + ? { + ...currentAsset, + publicShowcaseEnabled: previousValue, + } + : currentAsset, + ), + ); + }); + }, + [], + ); + const startRenamingFolder = useCallback((folder: EditorAssetFolder) => { setRenamingFolder({ folderId: folder.id, @@ -558,6 +591,7 @@ export function useImageCanvasAssetLibrary({ toggleAssetFolder, commitNewAssetFolder, deleteUploadedAsset, + setAssetPublicShowcaseEnabled, startRenamingFolder, commitFolderRename, deleteAssetFolder, diff --git a/src/index.css b/src/index.css index 0bb2cef61..ef88a31ee 100644 --- a/src/index.css +++ b/src/index.css @@ -7743,6 +7743,17 @@ button.image-canvas-editor__reference-chip:disabled { color: #a3acba; } +.image-canvas-editor__asset-context-menu { + z-index: 42; +} + +.image-canvas-editor__asset-context-menu button span[aria-hidden='true'] { + display: inline-flex; + width: 1rem; + flex: 0 0 1rem; + justify-content: center; +} + .image-canvas-editor__context-menu hr { width: 100%; height: 1px; diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index d27b06db2..449a9b34a 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -17,6 +17,7 @@ import { generateEditorSoundEffect, generateEditorVideo, listEditorProjects, + listPublicEditorProjectResources, loadEditorGenerationPricing, loadEditorAssetLibrary, loadEditorProject, @@ -25,6 +26,7 @@ import { saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, + updateEditorProjectResourceShowcase, } from './editorProjectClient'; const requestJsonMock = vi.hoisted(() => vi.fn()); @@ -212,6 +214,39 @@ describe('editorProjectClient', () => { ); }); + it('loads public showcase project resources without requiring auth', async () => { + requestJsonMock.mockResolvedValueOnce({ + resources: [ + { + resourceId: 'resource-public-1', + projectId: 'editor-project-1', + label: '公开角色素材', + imageSrc: '/generated-editor-assets/public.png', + width: 512, + height: 512, + sourceType: 'generated', + publicShowcaseEnabled: true, + }, + ], + }); + + const resources = await listPublicEditorProjectResources(); + + expect(resources).toHaveLength(1); + expect(resources[0]?.resourceId).toBe('resource-public-1'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/showcase/resources', + { method: 'GET' }, + '读取陶泥儿精选素材失败', + { + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, + ); + }); + it('loads an explicit project by id', async () => { requestJsonMock.mockResolvedValueOnce({ project: { @@ -341,6 +376,38 @@ describe('editorProjectClient', () => { ); }); + it('updates project resource showcase visibility', async () => { + requestJsonMock.mockResolvedValueOnce({ + resource: { + resourceId: 'resource-generated-1', + projectId: 'editor-project-1', + label: '角色素材', + imageSrc: '/generated-editor-assets/project/image.png', + objectKey: 'generated-editor-assets/project/image.png', + width: 2048, + height: 2048, + sourceType: 'generated', + publicShowcaseEnabled: false, + }, + }); + + const resource = await updateEditorProjectResourceShowcase( + 'resource-generated-1', + false, + ); + + expect(resource.publicShowcaseEnabled).toBe(false); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/project-resources/resource-generated-1/showcase', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicShowcaseEnabled: false }), + }), + '更新素材公开展示状态失败', + ); + }); + it('loads and mutates the account-level asset library', async () => { requestJsonMock .mockResolvedValueOnce({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 88fd675e2..b0ce14151 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -4,6 +4,8 @@ import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; const EDITOR_PROJECT_API_BASE = '/api/editor/projects'; const EDITOR_ASSET_API_BASE = '/api/editor/assets'; +const EDITOR_SHOWCASE_RESOURCE_API = '/api/editor/showcase/resources'; +const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources'; const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations'; const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_ICON_SPRITESHEET_GENERATION_API = @@ -72,6 +74,7 @@ export type EditorProjectResourceSnapshot = { sourceResourceId?: string | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; + publicShowcaseEnabled?: boolean; createdAt?: string; updatedAt?: string; }; @@ -105,6 +108,7 @@ export type EditorAssetSnapshot = { sourceResourceId?: string | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; + publicShowcaseEnabled?: boolean | null; createdAt?: string; updatedAt?: string; }; @@ -477,6 +481,10 @@ type EditorProjectResourceResponse = { resource: EditorProjectResourceSnapshot; }; +type EditorProjectResourceListResponse = { + resources: EditorProjectResourceSnapshot[]; +}; + type EditorAssetLibraryResponse = { library: EditorAssetLibrarySnapshot; }; @@ -516,6 +524,21 @@ export async function listEditorProjects() { return response.projects; } +export async function listPublicEditorProjectResources() { + const response = await requestJson( + EDITOR_SHOWCASE_RESOURCE_API, + { method: 'GET' }, + '读取陶泥儿精选素材失败', + { + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, + ); + return response.resources; +} + export async function loadRecentEditorProject() { return requestJson( `${EDITOR_PROJECT_API_BASE}/recent`, @@ -612,6 +635,18 @@ export async function createEditorProjectResource( return response.resource; } +export async function updateEditorProjectResourceShowcase( + resourceId: string, + publicShowcaseEnabled: boolean, +) { + const response = await requestJson( + `${EDITOR_PROJECT_RESOURCE_API_BASE}/${encodeURIComponent(resourceId)}/showcase`, + jsonRequest('PATCH', { publicShowcaseEnabled }), + '更新素材公开展示状态失败', + ); + return response.resource; +} + export async function loadEditorAssetLibrary() { const response = await requestJson( `${EDITOR_ASSET_API_BASE}/library`,