合并图片画布素材分支

将 codex/editor-asset-library 合并到 dev-jenken

保留编辑器生成规范、角色形象和图标素材能力

补回画布布局轻量保存和小地图拖拽手感修复
This commit is contained in:
2026-06-16 17:08:28 +08:00
12 changed files with 1370 additions and 34 deletions

View File

@@ -21,7 +21,9 @@
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。
`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md)。
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。

View File

@@ -0,0 +1,158 @@
# 图片画布素材导出方案
日期2026-06-15
## 背景
`/editor/canvas` 已承载项目画布、账号级素材库、生成图片、图层、分组、隐藏、锁定、翻转、缩放和右键菜单等编辑能力。用户需要一次性下载当前画布中使用到的全部素材,用于本地归档、外部编辑或跨工具交接。
素材导出应作为画布级能力,而不是单个图层的“导出为”。单图导出仍保留在目标右键菜单;全部素材导出放在画布标题栏,入口稳定、可发现且不打断画布操作。
## 入口设计
- 在画布右上角标题栏内增加下载图标按钮。
- 图标使用 `lucide-react``Download`
- 按钮 `aria-label` 使用 `下载画布素材`
- 入口与返回项目页、项目名称同属标题栏,但视觉上靠右,不进入左下角画布工具组,也不进入底部 AI 工具栏。
- 标题栏空间不足时,下载图标保持固定尺寸,项目名称使用省略号收缩。
## 第一版范围
第一版实现“导出画布素材 ZIP”
- 导出当前画布中有效图层引用的图片。
- 包含上传图、生成图、修改生成结果。
- 默认包含隐藏图层。
- 跳过已被素材库删除且已判定无效的上传图层。
- 锁定、分组、翻转等状态不影响图片文件导出,但写入元数据。
- 空画布时按钮置灰,或点击后显示轻提示。
暂不实现:
- 画布整体截图 PNG。
- PSD / Figma / 工程包导出。
- 后端异步打包任务。
- 导入导出包恢复画布。
## ZIP 结构
导出文件名:
```text
项目名-画布素材-YYYYMMDD.zip
```
包内结构:
```text
项目名-画布素材/
├─ images/
│ ├─ 001-拼图素材.png
│ ├─ 002-生成图片.png
│ └─ 003-修改结果.png
├─ metadata.json
└─ manifest.txt
```
## 元数据结构
`metadata.json` 记录项目、导出时间、图层和图片文件映射:
```json
{
"projectId": "editor-project-default",
"projectTitle": "默认项目",
"exportedAt": "2026-06-15T00:00:00.000Z",
"layers": [
{
"layerId": "layer-generated-1",
"title": "生成图片 1",
"file": "images/002-生成图片.png",
"width": 1024,
"height": 1024,
"canvas": {
"x": 120,
"y": 80,
"width": 420,
"height": 420,
"zIndex": 12,
"hidden": false,
"locked": false,
"flipX": false,
"flipY": false,
"groupId": null
},
"sourceType": "generated",
"prompt": "一张明亮的拼图主视觉",
"actualPrompt": "一张明亮的拼图主视觉",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "editor-real-task-1"
}
]
}
```
`manifest.txt` 用于人工快速查看:
```text
项目:默认项目
导出时间2026-06-15T00:00:00.000Z
素材数量3
图层数量5
```
## 去重规则
同一张图片被多个图层引用时,只导出一份图片,所有图层在 `metadata.json` 中指向同一个 `file`
图片去重 key 优先级:
```text
assetObjectId > objectKey > sourceAssetId > src
```
文件命名按图层 zIndex 从低到高排序,遇到重复名称追加序号。
## 前端实现
第一版优先使用客户端 ZIP
1. 从当前 `layers` 取目标图层。
2. 过滤无效图层,保留隐藏图层。
3. 按去重 key 合并图片源。
4. 对每个图片源读取 Blob
- `data:image/...` 直接转换为 Blob。
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
- 拉取失败时记录失败项,不中断整个导出。
5. 使用 `JSZip` 写入 `images/``metadata.json``manifest.txt`
6. `zip.generateAsync({ type: 'blob' })` 生成文件。
7. 用临时 `<a download>` 触发下载。
若当前仓库未安装 `jszip`,新增依赖时应只引入 `jszip`,不引入额外下载库。
## 错误处理
- 空画布:按钮置灰或显示短提示。
- 部分图片下载失败ZIP 仍生成,`metadata.json` 记录失败图片UI 显示“部分素材未能导出”。
- 全部图片失败:不下载 ZIP显示失败提示。
- 浏览器不支持 Blob 下载:显示失败提示。
## 测试计划
- 标题栏右上角显示 `下载画布素材` 图标入口。
- 空画布时入口不可执行或提示为空。
- 上传图和生成图都写入 ZIP。
- 多图层引用同一素材时,图片文件只写一份。
- 隐藏图层默认写入 `metadata.json`
- 图层的锁定、翻转、分组状态写入元数据。
- `data:image` 图片不经过网络请求即可导出。
- URL 图片 fetch 失败时不中断其他素材导出。
## 后续扩展
- 导出当前选中素材。
- 导出画布快照 PNG。
- 导出可恢复工程包。
- 导入工程包恢复画布。
- 后端异步打包大项目,前端只轮询下载结果。

View File

@@ -0,0 +1,488 @@
# Editor Agent Mock Agent P1 落地计划
更新时间:`2026-06-15`
## 背景
`/editor/agent` 的长期目标是浏览器内 AI Web 工程编辑器:用户通过自然语言和代码编辑生成一个完整 Web 工程,并在独立沙箱中预览。当前基础方案已确定为“平台编辑器壳 + api-server 控制面 + 独立 runner + 独立 preview origin”的四层结构。
P1 阶段先不接真实 AI Agent不调用 LLM、不接 `platform-agent`、不做 Agent 记忆、工具调用或多轮规划。P1 使用确定性的 mock Agent 生成结构化 patch把风险集中在真正需要先验证的工程链路项目、快照、patch 校验、静态构建、artifact、preview gateway、iframe 预览和刷新恢复。
## P1 目标
P1 完成一个可端到端验收的最小纵切:
```text
/editor/agent 输入 mock 指令
-> api-server mock Agent 生成结构化 patch
-> patch 校验并保存新 snapshot
-> 创建 preview build
-> runner 静态构建固定模板
-> 产出 immutable artifact
-> preview gateway 签发独立预览 URL
-> 前端 iframe 切换预览
```
P1 结束时,真实 Agent 仍可后置;后续只替换“需求文本 -> 结构化 patch”的来源不推翻快照、构建和预览主链路。
## 非目标
P1 明确不做:
- 真实 LLM / Agent 调用。
- LangChain-Rust / `platform-agent` 接入。
- 任意 npm 依赖安装。
- AI 自定义 `package.json` scripts。
- HMR、长驻 dev server、WebSocket 代理。
- 终端 shell、后端服务、任意端口代理。
- Web project 作品化发布。
- 完整 lease / controller / worker 持久任务队列。
- 与主站同源预览。
- 把 AI 生成工程写入当前仓库源码目录。
## 阶段边界
P1 使用 mock Agent但后续链路必须按生产架构实现
| 能力 | P1 做法 | 后续替换点 |
| --- | --- | --- |
| 需求理解 | api-server 内确定性 mock 规则 | Phase 2/3 接真实 Agent |
| patch 生成 | mock Agent 返回结构化 patch | 保留同一 patch DTO |
| patch 校验 | api-server 真实校验 | 继续复用 |
| 快照保存 | SpacetimeDB 真实持久化 | 可拆对象存储优化 |
| 静态构建 | runner 真实构建固定模板 | 可换成持久队列领取 |
| artifact | 本地 / 受控 artifact store | 可换 OSS 或专用 artifact store |
| 预览 | 独立 origin / 独立端口 iframe | 生产接独立域名 |
## 与原始设计一致性检查
P1 与 2026-06-13 的技术方案、威胁模型和验收清单总体一致mock Agent 只替换“需求文本 -> 结构化 patch”的来源不改变“编辑器壳 -> api-server 控制面 -> runner 执行面 -> preview gateway”的信任边界。
为避免实现时放宽安全边界P1 额外明确以下硬约束:
| 主题 | 原始设计要求 | P1 执行口径 |
| --- | --- | --- |
| Agent | AI 只能提交结构化 patch | mock Agent 也只能返回结构化 patch并必须经过同一后端校验 |
| api-server | 控制面不执行 AI 工程代码 | api-server 可以创建 job 和触发 runner但不得直接执行 `npm` / `vite` / 用户工程代码 |
| 允许命令 | 平台固定构建命令,禁止 AI 自定义脚本 | runner 只允许固定 argv 命令清单,不经 shell不执行 `npm run``npx` 或用户传入命令 |
| 依赖 | 固定模板依赖,不开放任意 npm | P1 默认离线使用 runner 管理的模板依赖缓存;如需 registry只能访问平台受控 mirror |
| 工作区 | 独立临时目录或容器,无宿主源码挂载 | 每个 job 使用 runner 创建的任务级临时目录;禁止挂载当前仓库源码、`.env`、Docker socket 和宿主 `node_modules` |
| 网络 | 构建期默认无外网 | 默认 deny all只允许受控 npm mirror 和只读资产签名域必须阻断内网、metadata、api-server、SpacetimeDB、Docker daemon |
| artifact | immutable artifact不复用临时目录 | artifact root 由 runner 配置不来自请求preview 只服务 artifact不服务工作区 |
| 预览 | 独立 preview origin | 开发态也必须使用独立端口 / origin不得把预览挂在主站同源路径下 |
| 状态机 | 失败不覆盖上一版预览 | 只有当前 active snapshot 的 `succeeded` build 能推进 active preview |
## 契约设计
P1 新增 Web Project 契约Rust `shared-contracts` 与前端 `packages/shared` 保持同名字段。
核心 DTO
```text
WebProject
WebProjectSnapshot
WebProjectFile
WebProjectPatch
WebProjectPatchOperation
WebProjectPreviewBuild
WebProjectPreviewBuildEvent
MockAgentTurnRequest
MockAgentTurnResponse
```
P1 字段建议:
- `projectId`:不可枚举字符串 ID。
- `ownerUserId`:项目归属用户。
- `templateKey`:固定为 `react-vite-ts-static`
- `activeSnapshotId`:当前编辑快照。
- `activePreviewBuildId`:当前成功预览构建。
- `files`P1 可先存为 `Vec<WebProjectFile>` / JSON只允许小型文本源码。
- `patchSummary`mock Agent 或用户编辑摘要。
- `buildStatus``queued | running | succeeded | failed | cancelled | expired | stale`
- `previewUrl`:由 api-server 返回给前端,不由前端拼接。
路径和内容限制必须写入契约注释和后端校验:
- 只允许相对路径。
- 拒绝绝对路径和 `..`
- 拒绝 `.env``.npmrc``.git``.ssh`
- 拒绝符号链接语义。
- 限制目录深度、单文件大小、snapshot 总大小。
- P1 只允许文本源码和少量静态资源。
- `package.json` 不是事实源;新增依赖和 scripts 在 P1 拒绝或忽略。
## 后端存储
P1 新增 SpacetimeDB 表:
```text
web_project
web_project_snapshot
web_project_preview_build
```
`web_project`
- `project_id`
- `owner_user_id`
- `title`
- `template_key`
- `active_snapshot_id`
- `active_preview_build_id`
- `created_at`
- `updated_at`
`web_project_snapshot`
- `snapshot_id`
- `project_id`
- `owner_user_id`
- `parent_snapshot_id`
- `template_key`
- `files_json`
- `patch_summary`
- `created_by`
- `created_at`
`web_project_preview_build`
- `job_id`
- `project_id`
- `snapshot_id`
- `owner_user_id`
- `status`
- `logs_json`
- `artifact_id`
- `preview_token_id`
- `preview_url`
- `error_summary`
- `created_at`
- `started_at`
- `finished_at`
- `updated_at`
落点:
- `server-rs/crates/spacetime-module/src/web_project.rs`
- `server-rs/crates/spacetime-module/src/lib.rs`
- `server-rs/crates/spacetime-module/src/migration.rs`
- `server-rs/crates/spacetime-client/src/mapper/web_project.rs`
- `server-rs/crates/spacetime-client/src/web_project.rs`
如果已有表新增字段字段必须放在结构体最后并设置明确默认值修改字段名或字段类型前必须确认迁移计划。schema 修改后必须执行:
```bash
npm run spacetime:generate
npm run check:spacetime-schema
```
## api-server 控制面
新增模块:
```text
server-rs/crates/api-server/src/web_project.rs
server-rs/crates/api-server/src/web_project_mock_agent.rs
server-rs/crates/api-server/src/modules/web_project.rs
```
P1 API
```text
POST /api/creation/web-project/projects
GET /api/creation/web-project/projects/{projectId}
GET /api/creation/web-project/projects/{projectId}/snapshot
PATCH /api/creation/web-project/projects/{projectId}/files
POST /api/creation/web-project/projects/{projectId}/mock-agent-turns
POST /api/runtime/web-project/projects/{projectId}/preview-builds
GET /api/runtime/web-project/preview-builds/{jobId}
GET /api/runtime/web-project/preview-builds/{jobId}/events
```
控制面职责:
- 鉴权并校验项目 owner。
- 创建固定模板项目。
- 读取 active snapshot。
- 校验用户编辑和 mock patch。
- 保存新 snapshot。
- 创建 preview build。
- 触发 P1 runner 构建。
- 记录构建日志和状态。
- 构建成功后签发 preview URL。
- 构建失败时保留上一版 active preview。
`/events` 复用 `src/services/sseStream.ts` 的事件口径。P1 可以先用 api-server 内存广播或短轮询兼容,但外部契约必须保持 SSE
```text
queued
running
log
succeeded
failed
cancelled
expired
stale
```
## Mock Agent 规则
mock Agent 必须确定性、可测试、不可绕过 patch 校验。
输入:
```json
{
"prompt": "做一个蓝色计数按钮页面",
"baseSnapshotId": "snapshot_xxx"
}
```
输出:
```json
{
"snapshot": "...",
"patch": {
"operations": [
{
"type": "updateFile",
"path": "src/App.tsx",
"content": "..."
}
]
},
"summary": "更新首页计数按钮示例"
}
```
P1 至少支持以下 mock 指令族:
- `计数` / `按钮`:生成带计数按钮的 React 页面。
- `卡片` / `列表`:生成卡片列表页面。
- `蓝色` / `绿色` / `粉色`:调整 CSS 主题色。
- `破坏构建`:生成一个 TypeScript 编译错误,用于验收失败保留上一版预览。
- 其它输入:只更新标题和说明文案,避免 mock 分支过多。
mock Agent 输出仍必须经过统一 `validate_web_project_patch(...)`,不得直接写表。
## Runner 与构建
P1 runner 可以先采用“api-server 创建 job 后触发一次性 runner 进程”的方式,不做持久 worker 队列;但执行面必须独立于 api-server、主站源码目录和预览域。api-server 只传递 job 身份和最小任务能力,不得在自身进程内执行构建命令。
建议新增:
```text
server-rs/crates/web-project-runner
```
P1 runner 输入:
- `jobId`
- `projectId`
- `snapshotId`
- snapshot files 包。
- artifact root 配置键或受控 artifact 写入能力。
请求体、snapshot 或 mock Agent 输出中不得携带 runner 命令、构建参数、工作区路径或 artifact 输出路径。
P1 runner 步骤:
1. 创建任务级临时目录。
2. 用 runner 内置模板或 runner 管理的只读模板缓存写入固定 React / Vite / TypeScript 模板。
3. 规范化 snapshot 文件路径,写入前确认最终路径仍在任务临时目录内。
4. 忽略或重写用户 `package.json` 的危险字段。
5. 使用环境变量白名单启动子进程,清空平台密钥、`.env`、token、代理和私有 registry 配置。
6. 执行平台固定命令清单。
7. 限制 CPU、内存、磁盘、进程数、打开文件数、构建时间、日志长度、单文件大小和 artifact 大小。
8. 成功后把 `dist/` 复制到 immutable artifact 目录artifact 目录由 runner 配置计算,不接受请求指定。
9. 失败时返回错误摘要和日志片段,日志需脱敏并避免完整宿主路径。
10. 清理临时目录。
P1 允许的构建命令只能来自平台常量,使用 argv 方式执行,不经 shell、不拼接字符串、不执行用户传入命令
```text
npm ci --ignore-scripts --offline --no-audit --fund=false
npm exec --offline -- vite build
```
如果离线缓存不足P1 只能改为访问平台受控 npm registry mirror并由 runner 写入受控 `.npmrc`;用户 snapshot 中的 `.npmrc`、registry、proxy、`package-lock.json` 篡改和新增依赖必须拒绝或重写。禁止为了开发速度复用当前仓库的 `node_modules`、源码目录或本机全局 npm cache 作为 runner 工作区输入。
P1 默认网络策略为 deny all。仅当使用平台受控 registry mirror 或资产只读签名域时开放精确白名单;必须阻断 RFC1918 内网、云 metadata 地址、api-server 管理端口、SpacetimeDB、生产数据库、Docker daemon并覆盖 HTTP redirect 和 DNS rebinding 到内网的情况。
## Preview Gateway
P1 开发态 preview 可以使用独立端口:
```text
http://127.0.0.1:<previewPort>/p/<previewToken>/
```
生产形态继续对齐独立域名:
```text
https://sandbox.genarrative.world/p/<previewToken>/
```
gateway 必须:
- 校验 token。
- 绑定 owner / project / snapshot / artifact。
- 禁止 path traversal。
- MIME 类型按白名单输出。
- `index.html` fallback 只在 artifact 根内生效。
- token 过期或 artifact 删除后返回确定错误。
- 输出 CSP默认 `connect-src 'none'`,并禁用 Service Worker 和持久缓存。
- 不向预览页注入平台 access token、用户 cookie、SpacetimeDB、OSS 写权限或 LLM provider 密钥。
preview gateway 可以与 api-server 共享代码仓库或部署单元,但服务静态 artifact 的入口必须是独立 listener / 端口 / vhost / origin不得把 preview artifact 挂到主站同源路径下。
前端 iframe
```text
sandbox="allow-scripts"
```
P1 不增加 `allow-same-origin``allow-downloads``allow-popups``allow-top-navigation`
## 前端落点
新增:
```text
src/components/editor/agent/WebProjectAgentEditorPage.tsx
src/components/editor/agent/WebProjectAgentEditorPage.test.tsx
src/components/editor/agent/webProjectAgentViewModel.ts
src/services/web-project/webProjectClient.ts
src/services/web-project/webProjectSse.ts
src/services/web-project/webProjectClient.test.ts
```
入口路由:
```text
/editor/agent
```
P1 桌面布局:
- 左侧文件树。
- 中间代码编辑区P1 可先用 `<textarea>`
- 下方或右侧 mock Agent 输入区。
- 构建日志区。
- 右侧 iframe 预览。
P1 移动端布局:
- 使用 tabs文件、代码、预览、日志。
- 不默认展示大段功能说明文案。
- 预览 iframe 保持可见尺寸和明确加载 / 失败状态。
前端状态原则:
- active preview 来自后端返回,不由前端自行推断。
- 构建失败不清空已有 iframe。
- 刷新后先读取项目和 active snapshot再恢复未终态 job 订阅。
- 用户连续编辑触发新 snapshot 时,旧 build 只能显示为旧日志,不得覆盖新 preview。
## 任务拆分
| 编号 | 任务 | 主要文件 | 完成标准 |
| --- | --- | --- | --- |
| P1-01 | 契约与 DTO | `shared-contracts``packages/shared` | 前后端类型通过,字段和状态枚举一致 |
| P1-02 | SpacetimeDB 表与 procedure/facade | `spacetime-module``spacetime-client` | 能创建项目、保存 snapshot、记录 build |
| P1-03 | api-server Web Project 模块 | `api-server/src/web_project.rs` | API 鉴权、owner 校验、读写 snapshot |
| P1-04 | mock Agent | `api-server/src/web_project_mock_agent.rs` | 指令生成 patch 且走统一校验 |
| P1-05 | P1 runner | `server-rs/crates/web-project-runner` | 固定模板可构建,失败有日志摘要 |
| P1-06 | preview gateway | `api-server` 或独立 runner/gateway | 独立 URL 可服务 artifacttoken 校验生效 |
| P1-07 | `/editor/agent` 页面 | `src/components/editor/agent` | 端到端可创建、编辑、构建、预览 |
| P1-08 | 自动化与 smoke | 测试文件、验收脚本 | happy path 和失败保留旧预览通过 |
## 验收场景
P1 必须通过:
1. 打开 `/editor/agent`,创建固定模板项目。
2. 输入“做一个蓝色计数按钮页面”。
3. mock Agent 返回结构化 patch。
4. api-server 保存新 snapshot。
5. 前端创建 preview build。
6. runner 构建成功并产出 immutable artifact。
7. SSE 返回 `queued -> running -> succeeded`
8. iframe 切换到新 preview URL。
9. 输入“破坏构建”。
10. 新 build 进入 failed页面展示错误摘要。
11. 上一版 active preview 仍保留。
12. 刷新 `/editor/agent`项目、active snapshot、active preview 和未终态 job 状态可恢复。
安全验收必须覆盖:
- 绝对路径被拒绝。
- `..` 被拒绝。
- `.env` / `.npmrc` / `.git` / `.ssh` 被拒绝。
- 修改 `package.json` 新增依赖被拒绝或忽略。
- 用户传入构建命令、scripts、registry、proxy 被拒绝或忽略。
- runner 构建命令不经 shell且只来自平台 allowlist。
- runner 无平台密钥环境变量、无宿主源码挂载、无 Docker socket、无宿主 `node_modules` 输入。
- runner 只能在任务级临时目录写入 snapshot路径解析后不能逃逸工作区。
- 构建期默认无外网;允许网络时只能访问平台白名单域名。
- artifact root 不来自请求preview 不服务 runner 临时工作区。
- preview iframe 与主站不同 origin。
- preview gateway 输出 CSP禁止 Service Worker 和未白名单 `connect-src`
- failed / cancelled / expired / stale 不覆盖 active preview。
## 验证命令
后端与 schema
```bash
npm run spacetime:generate
npm run check:spacetime-schema
cargo test -p spacetime-module web_project --manifest-path server-rs/Cargo.toml
cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml
cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml
```
前端:
```bash
npm run test -- src/services/web-project
npm run test -- src/components/editor/agent
npm run typecheck
npm run check:encoding
git diff --check
```
浏览器 smoke
```text
打开 /editor/agent
创建模板项目
提交一次 mock Agent 指令
等待静态构建成功
确认 iframe 展示新预览
提交一次破坏构建的 mock 指令
确认错误出现且上一版预览仍保留
刷新页面确认项目、日志和 active preview 可恢复
```
## 风险与处理
- Runner 与 api-server 边界被做薄P1 可以由 api-server 触发 runner但 runner 仍必须独立工作区执行,不能在主站源码目录构建。
- mock Agent 绕过校验mock 输出必须走同一 patch DTO 和后端校验函数。
- P1 为赶进度直接同源预览:禁止;开发态也要独立端口或独立 origin。
- snapshot 存大 JSON 后续膨胀P1 限制总大小P2 再拆 digest/object store。
- 失败覆盖成功预览:状态机必须以 active snapshot 和 succeeded build 双条件推进 active preview。
## 后续衔接
P1 完成后进入 P2 时,优先补:
- `web_project_runtime_job` 持久任务表。
- lease / controller / worker 模式。
- 手动取消、stale、expired 和 runner crash 恢复。
- 构建日志分页与可重连 SSE。
- 真实 Agent 接入,但继续产出同一结构化 patch。
真实 Agent 接入前不得扩大 P1 的执行权限任意依赖安装、HMR、dev server 和作品化发布必须进入后续独立评审。

View File

@@ -29,6 +29,7 @@ MVP 明确不做:
- HMR / 长驻 dev server。
- 任意 npm 依赖安装。
- AI 自定义 build shell script。
- diff 视图。
- 与主站同源的预览 iframe。
- 将 AI 生成工程写入当前 Genarrative 仓库源码目录。
@@ -38,8 +39,9 @@ MVP 明确不做:
入口路由固定为 `/editor/agent`。这一层只负责前端体验:
- 文件树、代码编辑器、AI 指令输入
- 当前项目版本、diff、构建日志和预览 iframe。
- 左侧聊天、中间预览、右侧 IDE 的水平三分布局
- AI 指令输入、构建日志、当前项目版本和预览 iframe。
- 右侧 IDE 默认只显示文件树;展开后显示当前文件内容,并隐藏左侧聊天栏。
- 保存用户编辑和 AI patch。
- 订阅构建 job SSE 状态。
- 在构建成功后切换 iframe 的 `previewUrl`
@@ -49,6 +51,74 @@ MVP 明确不做:
如果后续接入现有创作链路,入口仍应走 `play_flow` 主干和 `shared-contracts` DTO不在前端壳或 `app.rs` 中新建平行业务流程。
### 智能体编排器
`/editor/agent` 的智能体不是普通聊天页,也不是直接生成整包代码的黑盒。它是“工程改动编排器”,负责把用户意图转成可审计、可校验、可回滚的 Web 工程 patch。
智能体职责:
- 理解用户自然语言目标,并结合当前 snapshot、文件树、选中文件和最近构建结果形成任务上下文。
- 输出结构化 patch plan而不是直接写真实目录或执行 shell。
- 将 patch plan 提交给 api-server 校验,校验通过后生成新 snapshot。
- 创建 preview build job并订阅 job SSE。
- 读取构建日志、错误摘要和 preview 状态,失败时继续生成修复 patch成功时推动预览切换。
- 保留每轮 agent turn 的用户输入、上下文摘要、patch 摘要、校验结果、jobId 和最终状态,便于刷新恢复和审计。
智能体最小闭环:
```text
用户在左侧聊天输入需求
-> 前端提交 agent turn
-> api-server 读取当前 snapshot 和允许暴露的上下文
-> LLM 返回结构化 patch plan
-> api-server 校验 patch plan
-> 校验通过后生成新 snapshot
-> 创建 preview build job
-> runner 构建
-> SSE 回传 queued / running / log / succeeded / failed
-> 成功时中间预览 iframe reload
-> 失败时智能体基于错误摘要进入下一轮修复
```
智能体输出只允许以下 patch 操作:
- `create_file`
- `update_file`
- `delete_file`
- `rename_file`
- `package_manifest_request`
`package_manifest_request` 只是依赖请求不是事实源。MVP 中 api-server 必须拒绝或忽略任意新增依赖、生命周期脚本、私有 registry、`git:` / `file:` / `http:` 依赖和 AI 自定义构建命令。
智能体不得:
- 直接写入当前 Genarrative 仓库源码目录。
- 直接访问 runner 临时工作区。
- 直接执行 shell、npm script 或任意构建命令。
- 直接拿平台 access token、用户 cookie、SpacetimeDB 连接、OSS 写权限或 LLM provider 密钥。
- 绕过 api-server 的路径、依赖、大小、敏感文件和 snapshot 校验。
### 页面布局
`/editor/agent` MVP 使用水平三分布局,不做 diff 视图:
```text
┌───────────────┬──────────────────────────────┬────────────────┐
│ 左侧聊天 │ 中间预览 │ 右侧 IDE │
│ Agent 对话 │ sandbox iframe + 状态日志 │ 默认文件树 │
└───────────────┴──────────────────────────────┴────────────────┘
```
布局规则:
- 左侧聊天承载用户输入、智能体回复、构建状态摘要和失败修复建议。
- 中间预览始终是主工作区,展示当前 active preview iframe构建失败时保留上一版 succeeded preview并在聊天或日志区域展示失败摘要。
- 右侧 IDE 默认只显示文件树,突出当前工程结构,不默认展示代码内容。
- 用户展开右侧 IDE 后,右侧显示文件树和当前文件内容,左侧聊天栏隐藏,中间预览与右侧 IDE 形成双栏工作模式。
- 用户收起右侧文件内容后,恢复左侧聊天 + 中间预览 + 右侧文件树三栏。
- 文件内容查看用于理解和轻量编辑当前文件,不提供 diff 视图;版本变化通过 agent turn 摘要、snapshot 时间线和构建状态表达。
- 移动端优先保持预览和聊天可用,右侧 IDE 通过抽屉或分段入口进入;展开文件内容时同样隐藏聊天。
### api-server 控制面
控制面只管理权限、快照、任务和状态,不执行 AI 工程代码:
@@ -128,6 +198,7 @@ MVP 只服务静态构建产物。HMR、WebSocket 代理和长驻 dev server 后
POST /api/creation/web-project/projects
GET /api/creation/web-project/projects/{projectId}/snapshot
PATCH /api/creation/web-project/projects/{projectId}/files
POST /api/creation/web-project/projects/{projectId}/agent-turns
POST /api/runtime/web-project/projects/{projectId}/preview-builds
GET /api/runtime/web-project/preview-builds/{jobId}
GET /api/runtime/web-project/preview-builds/{jobId}/events
@@ -146,6 +217,27 @@ GET /api/runtime/web-project/preview-builds/{jobId}/events
前端刷新恢复时,先读取项目 active snapshot 和 active preview再按未终态 job 继续订阅 SSE。
`agent-turns` 的请求建议包含:
- `projectId`
- `baseSnapshotId`
- `message`
- `selectedFilePath`
- `visibleFilePaths`
- `recentBuildJobId`
`agent-turns` 的响应建议包含:
- `turnId`
- `status`
- `assistantMessage`
- `patchSummary`
- `validationErrors[]`
- `snapshotId`
- `buildJobId`
当 patch 校验失败时,不生成 snapshot不创建 build job只返回可展示的校验错误和智能体修正建议。
## 虚拟文件系统
平台内的“文件系统”是虚拟工作区,不是真实目录长期挂载。

View File

@@ -14,6 +14,7 @@ MVP 必须只支持:
- 独立 runner 静态构建。
- 独立 preview origin iframe 预览。
- 失败保留上一版可用预览。
- 左侧聊天、中间预览、右侧 IDE 的 `/editor/agent` 三分布局;右侧 IDE 展开文件内容后隐藏左侧聊天。
MVP 不支持:
@@ -23,19 +24,39 @@ MVP 不支持:
- 任意端口代理。
- 任意 npm 安装。
- AI 自定义 package scripts。
- diff 视图。
- Service Worker。
- 主站同源预览。
## Happy Path
- [ ] 用户打开 `/editor/agent` 能看到文件树、编辑器、AI 输入区、构建日志区和预览 iframe
- [ ] 用户打开 `/editor/agent` 能看到左侧聊天、中间预览和右侧 IDE 文件树
- [ ] 右侧 IDE 默认只显示文件树,不默认显示文件内容。
- [ ] 用户展开右侧 IDE 后能看到当前文件内容,左侧聊天栏隐藏。
- [ ] 用户收起右侧文件内容后恢复左侧聊天、中间预览和右侧文件树三栏。
- [ ] 页面不展示 diff 视图。
- [ ] 创建新 Web project 后得到固定模板文件树。
- [ ] AI patch 新增或修改一个 React 组件后api-server 生成新 snapshot
- [ ] 用户在左侧聊天提交需求后创建 agent turn
- [ ] 智能体结合当前 snapshot、选中文件和最近构建状态生成结构化 patch plan。
- [ ] AI patch plan 新增或修改一个 React 组件后api-server 校验通过并生成新 snapshot。
- [ ] patch 校验失败时不生成 snapshot不创建 build job并在聊天中展示可读校验错误。
- [ ] 前端 debounce 后创建 preview build job。
- [ ] runner 构建成功并产出 immutable artifact。
- [ ] SSE 返回 `queued -> running -> succeeded`
- [ ] iframe 切换到新 preview URL。
- [ ] 刷新 `/editor/agent` 后能恢复当前 project、active snapshot、active preview 和未完成 job 状态。
- [ ] 刷新 `/editor/agent` 后能恢复当前 project、active snapshot、active preview、agent turn 历史和未完成 job 状态。
## 智能体编排
- [ ] 智能体只通过 `agent-turns` 或等价受控接口提交用户需求,不直接写真实目录。
- [ ] 智能体输出只包含 `create_file/update_file/delete_file/rename_file/package_manifest_request` 这类结构化 patch 操作。
- [ ] 智能体不输出或执行 shell 命令。
- [ ] 智能体不直接触发任意 npm script。
- [ ] 智能体不直接访问 runner 临时工作区。
- [ ] 智能体不接收平台 access token、用户 cookie、SpacetimeDB 连接、OSS 写权限或 LLM provider 密钥。
- [ ] 每轮 agent turn 记录用户输入、上下文摘要、patch 摘要、校验结果、snapshotId、jobId 和最终状态。
- [ ] 构建失败后,智能体基于受脱敏的错误摘要生成下一轮修复 patch。
- [ ] 构建成功后,智能体不会覆盖非当前 active snapshot 的 preview。
## Patch 与路径校验

180
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@vitejs/plugin-react": "^5.0.4",
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",
@@ -2749,6 +2750,12 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3925,6 +3932,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3964,8 +3977,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
@@ -4029,6 +4041,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -4203,6 +4221,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4225,6 +4285,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -4886,6 +4955,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5114,6 +5189,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -5463,6 +5544,12 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5996,9 +6083,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
@@ -9503,6 +9588,11 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10331,6 +10421,11 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -10360,8 +10455,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
"version": "1.3.8",
@@ -10408,6 +10502,11 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -10536,6 +10635,46 @@
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"dev": true
},
"jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"requires": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
},
"dependencies": {
"readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10555,6 +10694,14 @@
"type-check": "~0.4.0"
}
},
"lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"requires": {
"immediate": "~3.0.5"
}
},
"lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -10944,6 +11091,11 @@
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11099,6 +11251,11 @@
}
}
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -11334,6 +11491,11 @@
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -11701,9 +11863,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"optional": true
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"v8-to-istanbul": {
"version": "9.3.0",

View File

@@ -72,6 +72,7 @@
"@vitejs/plugin-react": "^5.0.4",
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",

View File

@@ -1,6 +1,7 @@
import type {
ComponentType,
DragEventHandler,
MouseEventHandler,
PointerEventHandler,
ReactNode,
} from 'react';
@@ -68,9 +69,16 @@ export type SidebarMediaItemProps = {
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
previewOverlay?: ReactNode;
footerNode?: ReactNode;
draggable?: boolean;
onDragStart?: DragEventHandler<HTMLElement>;
onDragEnd?: DragEventHandler<HTMLElement>;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
onPointerDown?: PointerEventHandler<HTMLDivElement>;
onPointerEnter?: PointerEventHandler<HTMLDivElement>;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
};
export function SidebarMediaItem({
@@ -87,22 +95,37 @@ export function SidebarMediaItem({
primaryClassName,
actions,
titleNode,
previewOverlay,
footerNode,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onPointerDown,
onPointerEnter,
onContextMenu,
}: SidebarMediaItemProps) {
return (
<div
className={`${rowClassName} ${selected ? `${rowClassName}--selected` : ''}`}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onContextMenu={onContextMenu}
>
<button
type="button"
className={primaryClassName}
onClick={onPrimaryClick}
aria-label={primaryLabel}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<PlatformMediaFrame
src={imageSrc}
@@ -111,11 +134,13 @@ export function SidebarMediaItem({
aspect="square"
surface="none"
className={thumbnailClassName}
previewOverlay={previewOverlay}
/>
</button>
<div className={metaClassName}>
{titleNode ?? <span>{title}</span>}
<span>{detail}</span>
{footerNode}
</div>
{actions}
</div>

View File

@@ -784,6 +784,48 @@ describe('ImageCanvasEditorView', () => {
).toBeTruthy();
});
it('saves canvas layout without embedding image payloads in layer snapshots', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-data-heavy',
folderId: 'project',
label: '大图素材',
imageSrc: 'data:image/png;base64,'.concat('a'.repeat(4000)),
width: 1024,
height: 768,
sourceType: 'uploaded',
},
],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('button', { name: '添加大图素材' });
fireEvent.click(screen.getByRole('button', { name: '添加大图素材' }));
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(lastLayout.layers).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
src: expect.stringMatching(/^data:image/u),
}),
]),
);
});
it('offers Lovart-style zoom menu commands', async () => {
render(<ImageCanvasEditorView />);
@@ -954,6 +996,53 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
});
it('keeps minimap drag direction stable after pausing and reversing', () => {
render(<ImageCanvasEditorView />);
const minimap = screen.getByRole('button', { name: '画布小地图' });
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 132,
bottom: 84,
width: 132,
height: 84,
toJSON: () => ({}),
});
const world = screen
.getByLabelText('画布工作区')
.querySelector('.image-canvas-editor__world') as HTMLElement;
const readTranslateX = () => {
const match = /translate\(([-\d.]+)px,/u.exec(world.style.transform);
return match ? Number(match[1]) : 0;
};
dispatchPointerEvent(minimap, 'pointerdown', {
button: 0,
pointerId: 72,
clientX: 60,
clientY: 42,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
button: 0,
pointerId: 72,
clientX: 120,
clientY: 42,
});
const translateAfterRightDrag = readTranslateX();
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
button: 0,
pointerId: 72,
clientX: 90,
clientY: 42,
});
expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
});
it('persists layer groups in the canvas layer snapshot', async () => {
render(<ImageCanvasEditorView />);

View File

@@ -305,6 +305,11 @@ type DragState =
| {
kind: 'minimap';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
minimapScale: number;
moved: boolean;
};
const EDITOR_ASSETS: EditorAsset[] = [
@@ -416,6 +421,7 @@ const SNAP_THRESHOLD_SCREEN_PX = 18;
const FIT_VIEW_PADDING = 10;
const MINIMAP_SIZE = { width: 132, height: 84 };
const MINIMAP_PADDING = 8;
const MINIMAP_DRAG_SENSITIVITY = 0.3;
const SPEC_GENERATION_COST = 5;
const SPEC_GENERATION_SIZE = '2048x1152';
const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
@@ -601,7 +607,6 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
layerId: layer.id,
resourceId: layer.resourceId,
title: layer.title,
src: layer.src,
x: layer.x,
y: layer.y,
width: layer.width,
@@ -625,11 +630,13 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, { imageSrc: string }>,
): CanvasLayer | null {
const resourceId =
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
const src = typeof snapshot.src === 'string' ? snapshot.src : '';
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || '';
const title =
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
if (!resourceId || !layerId || !src) {
@@ -1543,8 +1550,14 @@ export function ImageCanvasEditorView() {
createProjectResourceForLayer(layer, options);
});
setViewport(project.viewport);
const resourcesById = new Map(
project.resources.map((resource) => [
resource.resourceId,
{ imageSrc: resource.imageSrc },
]),
);
const hydratedLayers = project.layers
.map(hydrateLayer)
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
if (hydratedLayers.length > 0) {
layerCounterRef.current = hydratedLayers.length;
@@ -3367,6 +3380,28 @@ export function ImageCanvasEditorView() {
}));
};
const moveViewportFromMinimapDrag = (
dragState: Extract<DragState, { kind: 'minimap' }>,
clientX: number,
clientY: number,
) => {
const deltaWorldX =
((clientX - dragState.startClientX) / dragState.minimapScale) *
MINIMAP_DRAG_SENSITIVITY;
const deltaWorldY =
((clientY - dragState.startClientY) / dragState.minimapScale) *
MINIMAP_DRAG_SENSITIVITY;
setViewport({
...dragState.startViewport,
x:
dragState.startViewport.x -
deltaWorldX * dragState.startViewport.scale,
y:
dragState.startViewport.y -
deltaWorldY * dragState.startViewport.scale,
});
};
const handleMinimapPointerDown = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
@@ -3377,8 +3412,12 @@ export function ImageCanvasEditorView() {
dragStateRef.current = {
kind: 'minimap',
pointerId: getPointerId(event),
startClientX: pointer.x,
startClientY: pointer.y,
startViewport: { ...viewport },
minimapScale: minimapModel?.scale ?? 1,
moved: false,
};
moveViewportFromMinimapPointer(pointer.x, pointer.y);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -3463,7 +3502,14 @@ export function ImageCanvasEditorView() {
if (dragState.kind === 'minimap') {
const pointer = getPointerClient(event);
moveViewportFromMinimapPointer(pointer.x, pointer.y);
const deltaX = pointer.x - dragState.startClientX;
const deltaY = pointer.y - dragState.startClientY;
if (!dragState.moved && Math.hypot(deltaX, deltaY) >= 2) {
dragState.moved = true;
}
if (dragState.moved) {
moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
}
return;
}
@@ -3534,6 +3580,10 @@ export function ImageCanvasEditorView() {
pointerId < 0 ||
dragState.pointerId === pointerId)
) {
if (dragState.kind === 'minimap' && !dragState.moved) {
const pointer = getPointerClient(event);
moveViewportFromMinimapPointer(pointer.x, pointer.y);
}
dragStateRef.current = null;
setIsPanning(false);
setSnapGuide(null);

View File

@@ -2,8 +2,11 @@
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 { ApiClientError } from '../../services/apiClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ProjectGalleryView } from './ProjectGalleryView';
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
@@ -11,6 +14,29 @@ const createEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
return {
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn((action: () => void) => action()),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(),
musicVolume: 0.5,
setMusicVolume: vi.fn(),
platformTheme: 'light' as const,
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
...overrides,
};
}
vi.mock('../../services/image-editor/editorProjectClient', () => ({
listEditorProjects: listEditorProjectsMock,
createEditorProject: createEditorProjectMock,
@@ -67,6 +93,115 @@ describe('ProjectGalleryView', () => {
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('uses canvas-center layer composition as the project cover', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-cover',
title: '封面项目',
viewport: { x: 120, y: -60, scale: 0.8 },
layers: [
{
layerId: 'layer-cover-back',
resourceId: 'resource-back',
title: '背景图',
x: 200,
y: 160,
width: 300,
height: 180,
zIndex: 1,
},
{
layerId: 'layer-cover-front',
resourceId: 'resource-front',
title: '前景图',
x: 420,
y: 260,
width: 160,
height: 120,
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
{
resourceId: 'resource-back',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,back',
width: 300,
height: 180,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
await screen.findByText('封面项目');
const cover = document.querySelector('.project-gallery__canvas-cover');
const coverLayers = document.querySelectorAll(
'.project-gallery__canvas-cover-layer',
);
expect(cover).toBeTruthy();
expect(coverLayers).toHaveLength(2);
expect(
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
).toBe('data:image/png;base64,back');
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
});
it('opens the login modal when project list loading is unauthorized', async () => {
const openLoginModal = vi.fn();
listEditorProjectsMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
<ProjectGalleryView onOpenProject={vi.fn()} />
</AuthUiContext.Provider>,
);
await waitFor(() => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
expect(screen.queryByRole('alert')).toBeNull();
});
it('requires login before opening a project card while logged out', async () => {
const onOpenProject = vi.fn();
const requireAuth = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
render(
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
<ProjectGalleryView onOpenProject={onOpenProject} />
</AuthUiContext.Provider>,
);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
expect(onOpenProject).not.toHaveBeenCalled();
});
it('renders project loading errors through the shared status message', async () => {
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));

View File

@@ -14,8 +14,13 @@ import {
deleteEditorProject,
listEditorProjects,
renameEditorProject,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
@@ -29,6 +34,9 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
};
@@ -38,16 +46,110 @@ type RenameDraft = {
title: string;
};
function resolveProjectPreview(project: EditorProjectSnapshot) {
const layerResourceIds = new Set(
project.layers
.map((layer) => layer.resourceId)
.filter((resourceId) => resourceId.trim().length > 0),
function isUnauthorizedError(error: unknown) {
return error instanceof ApiClientError && error.status === 401;
}
type ProjectCoverLayer = {
layerId: string;
title: string;
imageSrc: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
};
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
const value = layer[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
const value = layer[key];
return typeof value === 'string' ? value.trim() : '';
}
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
return layer.hidden === true;
}
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
project.resources.map((resource) => [resource.resourceId, resource]),
);
return project.layers
.filter((layer) => !isLayerHidden(layer))
.map((layer) => {
const resource = resourcesById.get(layer.resourceId);
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
if (!imageSrc) {
return null;
}
return {
layerId: layer.layerId,
title: stringFromLayer(layer, 'title') || '画布图片',
imageSrc,
x: numberFromLayer(layer, 'x', 0),
y: numberFromLayer(layer, 'y', 0),
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
zIndex: numberFromLayer(layer, 'zIndex', 0),
} satisfies ProjectCoverLayer;
})
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
.sort((left, right) => left.zIndex - right.zIndex);
}
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
const coverLayers = resolveProjectCoverLayers(project);
if (!coverLayers.length) {
return <span className="project-gallery__preview-empty" />;
}
const safeScale =
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
? project.viewport.scale
: 1;
const viewportCenterX =
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
const viewportCenterY =
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
const scale = Math.min(
PROJECT_COVER_SIZE.width / worldPreviewWidth,
PROJECT_COVER_SIZE.height / worldPreviewHeight,
);
const offsetX = -previewMinX * scale;
const offsetY = -previewMinY * scale;
return (
project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ??
project.resources[0] ??
null
<span className="project-gallery__canvas-cover" aria-hidden="true">
{coverLayers.map((layer) => (
<span
key={layer.layerId}
className="project-gallery__canvas-cover-layer"
style={{
left: offsetX + layer.x * scale,
top: offsetY + layer.y * scale,
width: layer.width * scale,
height: layer.height * scale,
zIndex: layer.zIndex,
}}
>
<ResolvedAssetImage
src={layer.imageSrc}
alt=""
className="project-gallery__canvas-cover-image"
/>
</span>
))}
</span>
);
}
@@ -65,6 +167,7 @@ function formatProjectUpdatedAt(value: string) {
}
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const authUi = useAuthUi();
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -94,18 +197,26 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
});
})
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(refreshProjects);
return;
}
setErrorMessage(
error instanceof Error ? error.message : '读取项目列表失败',
);
})
.finally(() => setIsLoading(false));
}, []);
}, [authUi]);
useEffect(() => {
refreshProjects();
}, [refreshProjects]);
const createProject = useCallback(() => {
if (authUi && !authUi.user) {
authUi.openLoginModal(createProject);
return;
}
setErrorMessage(null);
createEditorProject()
.then((project) => onOpenProject(project.projectId))
@@ -114,7 +225,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
error instanceof Error ? error.message : '创建项目失败',
);
});
}, [onOpenProject]);
}, [authUi, onOpenProject]);
const closeSelectionMode = useCallback(() => {
setIsSelectionMode(false);
@@ -191,7 +302,6 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const projectCards = useMemo(
() =>
projects.map((project) => {
const preview = resolveProjectPreview(project);
const selected = selectedProjectIds.has(project.projectId);
return (
<article
@@ -211,18 +321,22 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
toggleProjectSelection(project.projectId);
return;
}
if (authUi) {
authUi.requireAuth(() => onOpenProject(project.projectId));
return;
}
onOpenProject(project.projectId);
}}
aria-label={`打开项目${project.title}`}
>
<PlatformMediaFrame
src={preview?.imageSrc}
src={null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
className="project-gallery__preview"
fallbackContent={<span className="project-gallery__preview-empty" />}
fallbackContent={<ProjectCanvasCover project={project} />}
>
{isSelectionMode ? (
<span className="project-gallery__checkbox">
@@ -279,6 +393,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
}),
[
activeMenuProjectId,
authUi,
deleteProjects,
isSelectionMode,
onOpenProject,