1
This commit is contained in:
@@ -60,8 +60,9 @@ ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
|
|||||||
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
|
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
|
||||||
|
|
||||||
# Local Rust backend target for Vite dev proxy.
|
# Local Rust backend target for Vite dev proxy.
|
||||||
RUST_SERVER_TARGET="http://127.0.0.1:3100"
|
RUST_SERVER_TARGET="http://127.0.0.1:8082"
|
||||||
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
GENARRATIVE_API_TARGET="http://127.0.0.1:8082"
|
||||||
|
GENARRATIVE_API_PORT="8082"
|
||||||
|
|
||||||
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101"
|
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101"
|
||||||
GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr"
|
GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr"
|
||||||
|
|||||||
@@ -51,6 +51,22 @@
|
|||||||
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
|
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
|
||||||
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。
|
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。
|
||||||
|
|
||||||
|
## 拼图参考图没有影响生成时先查 action payload 和阶段日志
|
||||||
|
|
||||||
|
- 现象:拼图上传参考图后生成出的画面明显不像参考图,或结果页重新生成没有按保存的参考图走图生图。
|
||||||
|
- 原因:首图生成只通过 `compile_puzzle_draft.referenceImageSrc` 临时传 Data URL,不持久化到 SpacetimeDB;结果页重新生成则要把当前上传图或关卡 `pictureReference` 作为 `generate_puzzle_images.referenceImageSrc` 继续传给后端。
|
||||||
|
- 处理:浏览器 Network 里确认 action payload 带 `referenceImageSrc`;api-server 日志按同一 `session_id` 查看 `拼图参考图解析完成`、`拼图 VectorEngine 图片生成 HTTP 返回`、`拼图 VectorEngine 图片下载完成`、`拼图生成图片已写入 OSS 与资产索引`,可定位慢在参考图读取、VectorEngine、下载或 OSS。
|
||||||
|
- 验证:前端测试覆盖上传图 + AI 重绘、结果页保存的 `pictureReference` 重新生成;后端单测覆盖 VectorEngine 请求体 `image` 字段。
|
||||||
|
- 关联:`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle.rs`。
|
||||||
|
|
||||||
|
## 拼图首图生成后要把入口参考图写回 `pictureReference`
|
||||||
|
|
||||||
|
- 现象:入口页上传图后,首图看着像没吃到参考图;结果页重新生成时默认只沿用关卡旧图,没有继续带入口上传图。
|
||||||
|
- 原因:首图生成请求虽然已经把 `referenceImageSrc` 传给 VectorEngine,但如果后端只更新 `cover_image_src` / `selected_candidate_id` 而不回写首关 `pictureReference`,结果页后续重绘就会丢失参考图。
|
||||||
|
- 处理:在 `compile_puzzle_draft` 和 `generate_puzzle_images` 的成功与 SpacetimeDB 降级快照路径里,都把本次入口参考图写入首关 `pictureReference`。
|
||||||
|
- 验证:后端单测覆盖 `build_puzzle_levels_with_primary_update` 和 `apply_generated_puzzle_candidates_to_session_snapshot`;结果页重新生成应在未重新上传时继续带入 `level.pictureReference`。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/puzzle.rs`、`src/components/puzzle-result/PuzzleResultView.tsx`。
|
||||||
|
|
||||||
## 旧后端路线文档造成判断漂移
|
## 旧后端路线文档造成判断漂移
|
||||||
|
|
||||||
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
|
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
|
||||||
@@ -226,6 +242,14 @@
|
|||||||
- 验证:确认没有匹配 `C:\Genarrative\server-rs\target\debug\api-server.exe` 的进程后,`Remove-Item` 能删除旧 exe;随后 `npm run api-server` 启动并访问 `/healthz` 返回 200。
|
- 验证:确认没有匹配 `C:\Genarrative\server-rs\target\debug\api-server.exe` 的进程后,`Remove-Item` 能删除旧 exe;随后 `npm run api-server` 启动并访问 `/healthz` 返回 200。
|
||||||
- 关联:`scripts/api-server-dev.mjs`、`server-rs/crates/api-server/src/main.rs`。
|
- 关联:`scripts/api-server-dev.mjs`、`server-rs/crates/api-server/src/main.rs`。
|
||||||
|
|
||||||
|
## dev-rust-stack 端口被旧进程占用时会误判健康检查
|
||||||
|
|
||||||
|
- 现象:`node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh --skip-spacetime` 输出 `Port 3000 is in use, trying another one...`,随后 `api-server.exe` 报 `AddrInUse` / `code: 10048`。
|
||||||
|
- 原因:旧 `api-server` 仍监听默认 `8082` 时,脚本的 `/healthz` 探测会命中旧进程并误判新服务已就绪;旧 Vite 占住 `3000` 时,Vite 默认漂移到新端口,浏览器仍可能打开旧页面。
|
||||||
|
- 处理:`scripts/dev-rust-stack.sh` 已在 publish / 编译前预检 `api-server`、主站 Vite、后台 Vite 端口,并让 Vite 使用 `--strictPort`;遇到端口占用时按脚本打印的 PID 停止旧进程,或显式传入 `--api-port` / `--web-port` / `--admin-web-port`。
|
||||||
|
- 验证:默认端口被占用时,完整栈应在发布模块前直接失败并打印监听进程;清理端口后重新启动不再漂移端口或命中旧 `/healthz`。
|
||||||
|
- 关联:`scripts/dev-rust-stack.sh`、`docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md`。
|
||||||
|
|
||||||
## Windows debug 长 SSE Future 触发 api-server 断连
|
## Windows debug 长 SSE Future 触发 api-server 断连
|
||||||
|
|
||||||
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 报 `read ECONNRESET`,随后 `api-server.exe` 以 `0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。
|
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 报 `read ECONNRESET`,随后 `api-server.exe` 以 `0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。
|
||||||
|
|||||||
@@ -34,9 +34,11 @@
|
|||||||
- 主视口占据顶部栏与作品信息区之间的主要空间,使用平台主题 token 控制运行容器背景、边框、阴影、加载态文字和错误态按钮;亮色主题不得残留纯黑底白字加载块。
|
- 主视口占据顶部栏与作品信息区之间的主要空间,使用平台主题 token 控制运行容器背景、边框、阴影、加载态文字和错误态按钮;亮色主题不得残留纯黑底白字加载块。
|
||||||
- 主视口下方展示当前作品的游玩、点赞、评论/改造等紧凑指标、作者头像、作者名与作品名,不写规则说明类文案。
|
- 主视口下方展示当前作品的游玩、点赞、评论/改造等紧凑指标、作者头像、作者名与作品名,不写规则说明类文案。
|
||||||
- 作品信息区不再提供详情箭头或点击详情入口;点击该区域无效,上滑切换下一个推荐作品,下滑切换上一个推荐作品。
|
- 作品信息区不再提供详情箭头或点击详情入口;点击该区域无效,上滑切换下一个推荐作品,下滑切换上一个推荐作品。
|
||||||
|
- 推荐页切换参考短视频上下滑交互:当前运行画布位于中间,上一个/下一个作品的封面预览提前挂载在画布上下屏幕外;拖动作品信息区时三屏轨道跟随位移,松手后完成切换或回弹。
|
||||||
- 用户停留在推荐页时,底部当前 Tab 从“推荐”切换为“下一个”,图标使用向下的倒三角 / 双下箭头语义,点击后切换下一个推荐作品。
|
- 用户停留在推荐页时,底部当前 Tab 从“推荐”切换为“下一个”,图标使用向下的倒三角 / 双下箭头语义,点击后切换下一个推荐作品。
|
||||||
- 推荐页不再展示额外的底部作品切换块;当前作品的完整操作继续收敛在详情页和作品自身运行态中。
|
- 推荐页不再展示额外的底部作品切换块;当前作品的完整操作继续收敛在详情页和作品自身运行态中。
|
||||||
- 推荐页嵌入运行只调整平台外壳容器、主题注入和玩法壳层配色,不改写作品数据、关卡设定、道具设定或图片资产。
|
- 推荐页嵌入运行只调整平台外壳容器、主题注入和玩法壳层配色,不改写作品数据、关卡设定、道具设定或图片资产。
|
||||||
|
- 屏幕外预览只允许使用公开封面、作品名和类型,不提前启动其它作品 run,不触发道具、计时、存档或作品数据变更。
|
||||||
- 推荐页嵌入拼图玩法时隐藏拼图左上返回按钮,并在设置弹层中隐藏退出入口;作品切换前对当前拼图 run 执行既有“保存并退出”收口,正式 run 的交互状态以已写回后端的快照为准。
|
- 推荐页嵌入拼图玩法时隐藏拼图左上返回按钮,并在设置弹层中隐藏退出入口;作品切换前对当前拼图 run 执行既有“保存并退出”收口,正式 run 的交互状态以已写回后端的快照为准。
|
||||||
- 点赞、改造、复制作品号等完整操作继续收敛在详情页,详情入口由作品自身运行态或其它广场列表承接,推荐页作品信息区只负责展示和上下滑切换。
|
- 点赞、改造、复制作品号等完整操作继续收敛在详情页,详情入口由作品自身运行态或其它广场列表承接,推荐页作品信息区只负责展示和上下滑切换。
|
||||||
- 无数据、加载中、启动失败和暂不支持内嵌运行的作品沿用短状态文案。
|
- 无数据、加载中、启动失败和暂不支持内嵌运行的作品沿用短状态文案。
|
||||||
@@ -90,6 +92,7 @@
|
|||||||
10. 推荐页作品信息区点击无效,上滑切下一个、下滑切上一个;点击底部“下一个”也切下一个作品。
|
10. 推荐页作品信息区点击无效,上滑切下一个、下滑切上一个;点击底部“下一个”也切下一个作品。
|
||||||
11. 仅推荐页嵌入拼图态隐藏返回与设置内退出入口;详情页、新手引导和普通拼图运行态继续保留原有退出能力。
|
11. 仅推荐页嵌入拼图态隐藏返回与设置内退出入口;详情页、新手引导和普通拼图运行态继续保留原有退出能力。
|
||||||
12. 推荐页切换作品前,如果当前作品是拼图,必须先执行当前拼图 run 的退出收口,再启动下一作品。
|
12. 推荐页切换作品前,如果当前作品是拼图,必须先执行当前拼图 run 的退出收口,再启动下一作品。
|
||||||
|
13. 已登录推荐页的上/下滑切换必须展示相邻作品的屏幕外预览,并在拖动不足阈值时回弹;相邻预览不得提前启动玩法运行态。
|
||||||
|
|
||||||
## 8. 2026-05-07 未登录三栏补充
|
## 8. 2026-05-07 未登录三栏补充
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 本地 Rust 栈端口冲突预检
|
||||||
|
|
||||||
|
日期:`2026-05-09`
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
执行完整本地栈启动命令时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh --spacetime-port 3101 --skip-spacetime
|
||||||
|
```
|
||||||
|
|
||||||
|
如果本机已有旧的 `api-server`、主站 Vite 或后台 Vite 进程仍在监听默认端口,脚本可能出现混合日志:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Port 3000 is in use, trying another one...
|
||||||
|
Error: Os { code: 10048, kind: AddrInUse }
|
||||||
|
process didn't exit successfully: `server-rs\target\debug\api-server.exe`
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `wait_for_api_server` 只探测 `http://127.0.0.1:<api-port>/healthz`。当旧 `api-server` 仍监听 `8082` 时,健康检查会命中旧进程并误判新服务已就绪;随后新 `cargo run` 真正绑定 `8082` 时失败。与此同时,Vite 默认会在 `3000` 被占用时漂移到下一个端口,导致浏览器仍可能打开旧前端。
|
||||||
|
|
||||||
|
## 处理
|
||||||
|
|
||||||
|
`scripts/dev-rust-stack.sh` 在进入 SpacetimeDB publish 和 Rust 编译前,先检查三类端口是否可绑定:
|
||||||
|
|
||||||
|
1. Rust `api-server`:默认 `127.0.0.1:8082`。
|
||||||
|
2. 主站 Vite:默认 `0.0.0.0:3000`。
|
||||||
|
3. 后台 Vite:默认 `127.0.0.1:3102`。
|
||||||
|
|
||||||
|
端口被占用时,脚本会直接失败并打印监听进程。Windows 本地会通过 `Get-NetTCPConnection` 与 `Win32_Process` 输出 `pid / name / address / command`,方便精确停止旧进程。
|
||||||
|
|
||||||
|
主站和后台 Vite 也追加 `--strictPort`,避免默认漂移到 `3001`、`3103` 等端口后让浏览器继续访问旧页面。
|
||||||
|
|
||||||
|
## 排障步骤
|
||||||
|
|
||||||
|
PowerShell 查看默认端口占用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-NetTCPConnection -State Listen -LocalPort 3000,3102,8082,3101 -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object LocalAddress,LocalPort,OwningProcess |
|
||||||
|
Sort-Object LocalPort
|
||||||
|
```
|
||||||
|
|
||||||
|
查看进程命令行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-CimInstance Win32_Process |
|
||||||
|
Where-Object { $_.ProcessId -in @(3000端口PID, 8082端口PID) } |
|
||||||
|
Select-Object ProcessId,Name,CommandLine
|
||||||
|
```
|
||||||
|
|
||||||
|
停止确认可丢弃的旧本地开发进程:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Stop-Process -Id <pid> -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
如果确实需要保留旧栈,可显式换端口启动新栈:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh \
|
||||||
|
--skip-spacetime \
|
||||||
|
--spacetime-port 3101 \
|
||||||
|
--api-port 8090 \
|
||||||
|
--web-port 3001 \
|
||||||
|
--admin-web-port 3103
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `bash -n scripts/dev-rust-stack.sh` 通过。
|
||||||
|
2. 默认端口被占用时重新运行完整栈,脚本应在 publish 前失败并打印占用进程。
|
||||||
|
3. 清理占用进程或换端口后,重新启动时不再出现 Vite 端口漂移或 `api-server` `AddrInUse`。
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。
|
1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。
|
||||||
2. 新 session 的 `seedText` 允许为空;SpacetimeDB 侧用空锚点和空表单草稿初始化,不得把默认题材文案写入玩家草稿字段。
|
2. 新 session 的 `seedText` 允许为空;SpacetimeDB 侧用空锚点和空表单草稿初始化,不得把默认题材文案写入玩家草稿字段。
|
||||||
3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。参考图只保存在当前前端会话内,不落入 SpacetimeDB。
|
3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。生成前的参考图只保存在当前前端会话内;一旦用于首图生成并成功返回,后端必须把该参考图写入首关 `pictureReference`,供结果页后续重新生成继续复用。
|
||||||
4. 玩家在生成草稿前退出,再次从创作中心点击这条拼图草稿时,必须恢复到填表页,并回填之前自动保存的作品名称、作品描述和画面描述;只有执行 `compile_puzzle_draft` 且生成结果页草稿后,草稿入口才进入结果页。
|
4. 玩家在生成草稿前退出,再次从创作中心点击这条拼图草稿时,必须恢复到填表页,并回填之前自动保存的作品名称、作品描述和画面描述;只有执行 `compile_puzzle_draft` 且生成结果页草稿后,草稿入口才进入结果页。
|
||||||
5. 表单自动保存走 `save_puzzle_form_draft` action,不消耗光点,不生成图片,不改变 `stage = collecting_anchors`;生成草稿按钮仍单独触发 `compile_puzzle_draft` 并进入进度页。
|
5. 表单自动保存走 `save_puzzle_form_draft` action,不消耗光点,不生成图片,不改变 `stage = collecting_anchors`;生成草稿按钮仍单独触发 `compile_puzzle_draft` 并进入进度页。
|
||||||
6. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。
|
6. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## 文档列表
|
## 文档列表
|
||||||
|
|
||||||
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
|
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
|
||||||
|
- [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。
|
||||||
- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
||||||
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。
|
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。
|
||||||
- [AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md](./AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md):记录刷新网页后登录态失效和推荐页作品卡卡在加载中的联合修复,覆盖 `AuthGate` 本地 token 优先恢复、refresh 失败不清 token、推荐页启动请求版本保护和错误态收口。
|
- [AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md](./AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md):记录刷新网页后登录态失效和推荐页作品卡卡在加载中的联合修复,覆盖 `AuthGate` 本地 token 优先恢复、refresh 失败不清 token、推荐页启动请求版本保护和错误态收口。
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ VectorEngine 文档要求使用像素尺寸,不再使用 APIMart 的比例写
|
|||||||
- 拼图默认 `gpt-image-2` 前端值继续兼容,但上游请求模型统一映射到 `gpt-image-2-all`。
|
- 拼图默认 `gpt-image-2` 前端值继续兼容,但上游请求模型统一映射到 `gpt-image-2-all`。
|
||||||
- `nanobanana2` / `gemini-3.1-flash-image-preview` 不再走 APIMart;当前阶段统一回落到 VectorEngine GPT-image-2-all,避免保留旧图片网关。
|
- `nanobanana2` / `gemini-3.1-flash-image-preview` 不再走 APIMart;当前阶段统一回落到 VectorEngine GPT-image-2-all,避免保留旧图片网关。
|
||||||
- 错误 `details.provider` 改为 `vector-engine`。
|
- 错误 `details.provider` 改为 `vector-engine`。
|
||||||
|
- 入口页上传图若用于首图生成,后端必须在生成成功后同步写入首关 `pictureReference`,保证结果页重新生成默认继续带同一张参考图。
|
||||||
3. `.codex/skills/gpt-image-2-apimart/`
|
3. `.codex/skills/gpt-image-2-apimart/`
|
||||||
- 目录名暂不强制迁移,避免本地插件索引漂移;Skill 文案与脚本行为改为 VectorEngine。
|
- 目录名暂不强制迁移,避免本地插件索引漂移;Skill 文案与脚本行为改为 VectorEngine。
|
||||||
|
|
||||||
@@ -110,7 +111,18 @@ VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000
|
|||||||
2. 请求体 `model = gpt-image-2-all`,尺寸为 VectorEngine 支持的像素尺寸。
|
2. 请求体 `model = gpt-image-2-all`,尺寸为 VectorEngine 支持的像素尺寸。
|
||||||
3. 请求体不再包含 `official_fallback`。
|
3. 请求体不再包含 `official_fallback`。
|
||||||
4. 参考图字段使用 `image`,不再使用 APIMart 的 `image_urls`。
|
4. 参考图字段使用 `image`,不再使用 APIMart 的 `image_urls`。
|
||||||
5. 缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时返回 `503 SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。
|
5. 拼图入口页上传图生成首图后,返回的首关 `pictureReference` 保留该 Data URL;结果页重新生成在用户未重新上传参考图时会继续把 `pictureReference` 作为 `referenceImageSrc` 传给后端。
|
||||||
6. 上游错误映射为 `502 UPSTREAM_ERROR`,保留 `upstreamStatus`、业务 message 和截断后的 raw excerpt。
|
6. 缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时返回 `503 SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。
|
||||||
7. 运行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`。
|
7. 上游错误映射为 `502 UPSTREAM_ERROR`,保留 `upstreamStatus`、业务 message 和截断后的 raw excerpt。
|
||||||
8. 后端改动后使用 `npm run api-server` 重启,并确认 `/healthz`。
|
8. 运行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`。
|
||||||
|
9. 后端改动后使用 `npm run api-server` 重启,并确认 `/healthz`。
|
||||||
|
|
||||||
|
## 拼图链路排障日志
|
||||||
|
|
||||||
|
拼图 `compile_puzzle_draft` 与 `generate_puzzle_images` 进入图片生成时,api-server 会输出分阶段耗时日志。排查“是谁慢”时按同一 `session_id` 串联:
|
||||||
|
|
||||||
|
1. `拼图参考图解析完成` / `拼图参考图解析跳过`:确认前端是否传入参考图,以及 Data URL 解析或旧 `/generated-*` OSS 读取耗时;日志只记录 `reference_mime` 与 `reference_bytes`,不记录图片内容。
|
||||||
|
2. `拼图 VectorEngine 图片生成 HTTP 返回`:VectorEngine `POST /v1/images/generations` 的同步上游耗时,若这一段长,慢点在 VectorEngine 生图接口。
|
||||||
|
3. `拼图 VectorEngine 图片下载完成`:从 VectorEngine 返回的 `data[].url` 下载正式图耗时。
|
||||||
|
4. `拼图生成图片已写入 OSS 与资产索引`:正式图上传 OSS、确认资产对象与实体绑定耗时。
|
||||||
|
5. `拼图图片候选生成完成`:整段候选图生成总耗时。
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ VOLCENGINE_SPEECH_TTS_SSE_URL=https://openspeech.bytedance.com/api/v3/tts/unidir
|
|||||||
配置规则:
|
配置规则:
|
||||||
|
|
||||||
1. 优先使用新版控制台 `VOLCENGINE_SPEECH_API_KEY`,上游请求头写 `X-Api-Key`。
|
1. 优先使用新版控制台 `VOLCENGINE_SPEECH_API_KEY`,上游请求头写 `X-Api-Key`。
|
||||||
2. 若只配置旧版控制台信息,则使用 `VOLCENGINE_SPEECH_APP_ID` 和 `VOLCENGINE_SPEECH_ACCESS_KEY`。
|
2. 若只配置旧版控制台信息,则使用 `VOLCENGINE_SPEECH_APP_ID` 和 `VOLCENGINE_SPEECH_ACCESS_KEY`,上游请求头写 `X-Api-App-Key` 与 `X-Api-Access-Key`。
|
||||||
3. ASR 默认资源 ID 选 ASR 2.0 并发版;如账号是小时版,部署时改成 `volc.seedasr.sauc.duration`。
|
3. ASR 默认资源 ID 选 ASR 2.0 并发版;如账号是小时版,部署时改成 `volc.seedasr.sauc.duration`。
|
||||||
4. TTS 默认资源 ID 选 `seed-tts-2.0`;旧音色或 1.0 计费资源由部署环境覆盖。
|
4. TTS 默认资源 ID 选 `seed-tts-2.0`;旧音色或 1.0 计费资源由部署环境覆盖。
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,100 @@ Get-CimInstance Win32_Process |
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is_tcp_port_available() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
|
||||||
|
# 中文注释:用真实 bind 探测端口,覆盖 127.0.0.1 与 0.0.0.0 互相占用的情况。
|
||||||
|
node - "${host}" "${port}" <<'NODE'
|
||||||
|
const net = require('net');
|
||||||
|
const host = process.argv[2];
|
||||||
|
const port = Number(process.argv[3]);
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = net.createServer();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
server.close();
|
||||||
|
process.exit(1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
server.once('error', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.once('listening', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen({ host, port });
|
||||||
|
NODE
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_tcp_port_owner() {
|
||||||
|
local port="$1"
|
||||||
|
|
||||||
|
# 中文注释:Windows 下直接读取监听端口对应进程,便于用户精确停止旧 dev 栈。
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
GENARRATIVE_TCP_PORT="${port}" powershell.exe -NoProfile -Command '
|
||||||
|
$portNumber = [int]$env:GENARRATIVE_TCP_PORT
|
||||||
|
$connections = Get-NetTCPConnection -State Listen -LocalPort $portNumber -ErrorAction SilentlyContinue
|
||||||
|
$seen = @{}
|
||||||
|
foreach ($connection in $connections) {
|
||||||
|
$processId = [int]$connection.OwningProcess
|
||||||
|
if ($seen.ContainsKey($processId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$processId] = $true
|
||||||
|
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||||
|
if ($process) {
|
||||||
|
$commandLine = ($process.CommandLine -replace "\s+", " ").Trim()
|
||||||
|
"pid=$processId name=$($process.Name) address=$($connection.LocalAddress):$($connection.LocalPort) command=$commandLine"
|
||||||
|
} else {
|
||||||
|
"pid=$processId address=$($connection.LocalAddress):$($connection.LocalPort)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null |
|
||||||
|
awk 'NR > 1 { print "pid=" $2 " name=" $1 " address=" $9 }' || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
ss -ltnp "sport = :${port}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_tcp_port_available() {
|
||||||
|
local label="$1"
|
||||||
|
local host="$2"
|
||||||
|
local port="$3"
|
||||||
|
local owners
|
||||||
|
|
||||||
|
# 中文注释:完整栈不复用旧 api-server / Vite,避免健康检查命中旧进程后继续误判。
|
||||||
|
if is_tcp_port_available "${host}" "${port}"; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
owners="$(describe_tcp_port_owner "${port}")"
|
||||||
|
echo "[dev:rust] ${label} 端口已被占用,无法启动: ${host}:${port}" >&2
|
||||||
|
if [[ -n "${owners}" ]]; then
|
||||||
|
echo "[dev:rust] 当前监听进程:" >&2
|
||||||
|
echo "${owners}" >&2
|
||||||
|
fi
|
||||||
|
echo "[dev:rust] 请停止占用进程,或通过对应端口参数换端口后重试。" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
wait_for_api_server() {
|
wait_for_api_server() {
|
||||||
local health_url="$1"
|
local health_url="$1"
|
||||||
local timeout_seconds="$2"
|
local timeout_seconds="$2"
|
||||||
@@ -545,6 +639,10 @@ echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}"
|
|||||||
echo "[dev:rust] spacetime data: ${SPACETIME_DATA_DIR}"
|
echo "[dev:rust] spacetime data: ${SPACETIME_DATA_DIR}"
|
||||||
echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s"
|
echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s"
|
||||||
|
|
||||||
|
ensure_tcp_port_available "Rust api-server" "${API_HOST}" "${API_PORT}"
|
||||||
|
ensure_tcp_port_available "主站 Vite" "${WEB_HOST}" "${WEB_PORT}"
|
||||||
|
ensure_tcp_port_available "后台 Vite" "${ADMIN_WEB_HOST}" "${ADMIN_WEB_PORT}"
|
||||||
|
|
||||||
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
||||||
mkdir -p "${SPACETIME_ROOT_DIR}" "${SPACETIME_DATA_DIR}"
|
mkdir -p "${SPACETIME_ROOT_DIR}" "${SPACETIME_DATA_DIR}"
|
||||||
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")"
|
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")"
|
||||||
@@ -633,7 +731,7 @@ echo "[dev:rust] 启动 vite"
|
|||||||
ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \
|
ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \
|
||||||
ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \
|
ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \
|
||||||
VITE_DEV_HOST="${WEB_HOST}" \
|
VITE_DEV_HOST="${WEB_HOST}" \
|
||||||
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
|
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}" "--strictPort"
|
||||||
) &
|
) &
|
||||||
PIDS+=("$!")
|
PIDS+=("$!")
|
||||||
NAMES+=("vite")
|
NAMES+=("vite")
|
||||||
@@ -644,7 +742,7 @@ echo "[dev:rust] 启动 admin vite"
|
|||||||
ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \
|
ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \
|
||||||
GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \
|
GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \
|
||||||
GENARRATIVE_API_PORT="${API_PORT}" \
|
GENARRATIVE_API_PORT="${API_PORT}" \
|
||||||
exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}"
|
exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}" "--strictPort"
|
||||||
) &
|
) &
|
||||||
PIDS+=("$!")
|
PIDS+=("$!")
|
||||||
NAMES+=("admin-vite")
|
NAMES+=("admin-vite")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -912,7 +912,11 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
let generated_level_name = target_level.level_name.clone();
|
let generated_level_name = target_level.level_name.clone();
|
||||||
let levels_json_with_generated_name =
|
let levels_json_with_generated_name =
|
||||||
Some(serialize_puzzle_level_records_for_module(
|
Some(serialize_puzzle_level_records_for_module(
|
||||||
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
&build_puzzle_levels_with_primary_update(
|
||||||
|
&draft,
|
||||||
|
&target_level,
|
||||||
|
payload.reference_image_src.as_deref(),
|
||||||
|
),
|
||||||
)?);
|
)?);
|
||||||
let candidates_json = serde_json::to_string(
|
let candidates_json = serde_json::to_string(
|
||||||
&candidates
|
&candidates
|
||||||
@@ -962,6 +966,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
),
|
),
|
||||||
target_level.level_id.as_str(),
|
target_level.level_id.as_str(),
|
||||||
candidates.into_records(),
|
candidates.into_records(),
|
||||||
|
payload.reference_image_src.as_deref(),
|
||||||
now,
|
now,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -3081,9 +3086,10 @@ fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
|
|||||||
"奇境初见".to_string()
|
"奇境初见".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_puzzle_levels_with_primary_name(
|
fn build_puzzle_levels_with_primary_update(
|
||||||
draft: &PuzzleResultDraftRecord,
|
draft: &PuzzleResultDraftRecord,
|
||||||
target_level: &PuzzleDraftLevelRecord,
|
target_level: &PuzzleDraftLevelRecord,
|
||||||
|
picture_reference: Option<&str>,
|
||||||
) -> Vec<PuzzleDraftLevelRecord> {
|
) -> Vec<PuzzleDraftLevelRecord> {
|
||||||
let mut levels = draft.levels.clone();
|
let mut levels = draft.levels.clone();
|
||||||
if let Some(index) = levels
|
if let Some(index) = levels
|
||||||
@@ -3092,6 +3098,11 @@ fn build_puzzle_levels_with_primary_name(
|
|||||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||||
{
|
{
|
||||||
levels[index].level_name = target_level.level_name.clone();
|
levels[index].level_name = target_level.level_name.clone();
|
||||||
|
if let Some(picture_reference) =
|
||||||
|
picture_reference.map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
levels[index].picture_reference = Some(picture_reference.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
levels
|
levels
|
||||||
}
|
}
|
||||||
@@ -3161,7 +3172,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
}
|
}
|
||||||
let generated_level_name = target_level.level_name.clone();
|
let generated_level_name = target_level.level_name.clone();
|
||||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||||
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
|
||||||
)?);
|
)?);
|
||||||
let candidates_json = serde_json::to_string(
|
let candidates_json = serde_json::to_string(
|
||||||
&candidates
|
&candidates
|
||||||
@@ -3208,6 +3219,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
),
|
),
|
||||||
target_level.level_id.as_str(),
|
target_level.level_id.as_str(),
|
||||||
candidates.into_records(),
|
candidates.into_records(),
|
||||||
|
reference_image_src,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
Ok((session, true))
|
Ok((session, true))
|
||||||
@@ -3311,7 +3323,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
|||||||
}
|
}
|
||||||
let generated_level_name = target_level.level_name.clone();
|
let generated_level_name = target_level.level_name.clone();
|
||||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||||
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
|
||||||
)?);
|
)?);
|
||||||
let persisted_upload = persist_puzzle_generated_asset(
|
let persisted_upload = persist_puzzle_generated_asset(
|
||||||
state,
|
state,
|
||||||
@@ -3374,6 +3386,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
|||||||
),
|
),
|
||||||
target_level.level_id.as_str(),
|
target_level.level_id.as_str(),
|
||||||
vec![candidate.clone()],
|
vec![candidate.clone()],
|
||||||
|
reference_image_src,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
Ok((session, true))
|
Ok((session, true))
|
||||||
@@ -3413,6 +3426,7 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
|||||||
mut session: PuzzleAgentSessionRecord,
|
mut session: PuzzleAgentSessionRecord,
|
||||||
target_level_id: &str,
|
target_level_id: &str,
|
||||||
candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
|
candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
|
||||||
|
picture_reference: Option<&str>,
|
||||||
updated_at_micros: i64,
|
updated_at_micros: i64,
|
||||||
) -> PuzzleAgentSessionRecord {
|
) -> PuzzleAgentSessionRecord {
|
||||||
let Some(draft) = session.draft.as_mut() else {
|
let Some(draft) = session.draft.as_mut() else {
|
||||||
@@ -3443,6 +3457,12 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
|||||||
level.selected_candidate_id = Some(selected.candidate_id.clone());
|
level.selected_candidate_id = Some(selected.candidate_id.clone());
|
||||||
level.cover_image_src = Some(selected.image_src.clone());
|
level.cover_image_src = Some(selected.image_src.clone());
|
||||||
level.cover_asset_id = Some(selected.asset_id.clone());
|
level.cover_asset_id = Some(selected.asset_id.clone());
|
||||||
|
if let Some(picture_reference) = picture_reference
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
level.picture_reference = Some(picture_reference.to_string());
|
||||||
|
}
|
||||||
level.generation_status = "ready".to_string();
|
level.generation_status = "ready".to_string();
|
||||||
if target_index == 0 {
|
if target_index == 0 {
|
||||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||||
@@ -3892,9 +3912,13 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) ->
|
|||||||
|
|
||||||
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
||||||
let message = error.to_string();
|
let message = error.to_string();
|
||||||
|
// 中文注释:历史运行态或旧 SpacetimeDB 错误快照可能仍带 APIMart 图片网关文案;当前 GPT-image-2 已统一迁移到 VectorEngine,返回给前端前先归一,避免误导排障。
|
||||||
|
let is_legacy_apimart_image_error =
|
||||||
|
message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART");
|
||||||
let provider = if message.contains("VectorEngine")
|
let provider = if message.contains("VectorEngine")
|
||||||
|| message.contains("vector-engine")
|
|| message.contains("vector-engine")
|
||||||
|| message.contains("VECTOR_ENGINE")
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|
|| is_legacy_apimart_image_error
|
||||||
{
|
{
|
||||||
VECTOR_ENGINE_PROVIDER
|
VECTOR_ENGINE_PROVIDER
|
||||||
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
||||||
@@ -3905,6 +3929,8 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
|||||||
let status = if provider == VECTOR_ENGINE_PROVIDER
|
let status = if provider == VECTOR_ENGINE_PROVIDER
|
||||||
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
||||||
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
||||||
|
|| message.contains("APIMART_API_KEY")
|
||||||
|
|| message.contains("APIMART_BASE_URL")
|
||||||
|| message.contains("未配置"))
|
|| message.contains("未配置"))
|
||||||
{
|
{
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
@@ -3920,6 +3946,7 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
|||||||
|| message.contains("VectorEngine")
|
|| message.contains("VectorEngine")
|
||||||
|| message.contains("vector-engine")
|
|| message.contains("vector-engine")
|
||||||
|| message.contains("VECTOR_ENGINE")
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|
|| is_legacy_apimart_image_error
|
||||||
|| message.contains("参考图")
|
|| message.contains("参考图")
|
||||||
|| message.contains("图片")
|
|| message.contains("图片")
|
||||||
|| message.contains("OSS")
|
|| message.contains("OSS")
|
||||||
@@ -3949,13 +3976,28 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
|||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let user_message = normalize_legacy_puzzle_image_error_message(message.as_str());
|
||||||
|
|
||||||
AppError::from_status(status).with_details(json!({
|
AppError::from_status(status).with_details(json!({
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"message": message,
|
"message": user_message,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_legacy_puzzle_image_error_message(message: &str) -> String {
|
||||||
|
message
|
||||||
|
.replace(
|
||||||
|
"APIMart 图片生成密钥未配置",
|
||||||
|
"VectorEngine 图片生成密钥未配置",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"APIMart 图片生成地址未配置",
|
||||||
|
"VectorEngine 图片生成地址未配置",
|
||||||
|
)
|
||||||
|
.replace("APIMART_API_KEY", "VECTOR_ENGINE_API_KEY")
|
||||||
|
.replace("APIMART_BASE_URL", "VECTOR_ENGINE_BASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
fn puzzle_error_response(
|
fn puzzle_error_response(
|
||||||
request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
@@ -4021,10 +4063,15 @@ async fn generate_puzzle_image_candidates(
|
|||||||
candidate_count: u32,
|
candidate_count: u32,
|
||||||
candidate_start_index: usize,
|
candidate_start_index: usize,
|
||||||
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
||||||
|
let total_started_at = Instant::now();
|
||||||
let count = candidate_count.clamp(1, 1);
|
let count = candidate_count.clamp(1, 1);
|
||||||
let resolved_model = resolve_puzzle_image_model(image_model);
|
let resolved_model = resolve_puzzle_image_model(image_model);
|
||||||
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
|
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
|
||||||
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
||||||
|
let has_reference_image = reference_image_src
|
||||||
|
.map(str::trim)
|
||||||
|
.map(|value| !value.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
provider = resolved_model.provider_name(),
|
provider = resolved_model.provider_name(),
|
||||||
image_model = resolved_model.request_model_name(),
|
image_model = resolved_model.request_model_name(),
|
||||||
@@ -4032,24 +4079,45 @@ async fn generate_puzzle_image_candidates(
|
|||||||
level_name,
|
level_name,
|
||||||
prompt_chars = prompt.chars().count(),
|
prompt_chars = prompt.chars().count(),
|
||||||
actual_prompt_chars = actual_prompt.chars().count(),
|
actual_prompt_chars = actual_prompt.chars().count(),
|
||||||
has_reference_image = reference_image_src
|
has_reference_image,
|
||||||
.map(str::trim)
|
|
||||||
.map(|value| !value.is_empty())
|
|
||||||
.unwrap_or(false),
|
|
||||||
"拼图图片生成请求已准备"
|
"拼图图片生成请求已准备"
|
||||||
);
|
);
|
||||||
|
let reference_image_started_at = Instant::now();
|
||||||
let reference_image = match reference_image_src
|
let reference_image = match reference_image_src
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
Some(source) => {
|
Some(source) => {
|
||||||
Some(resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?)
|
let resolved =
|
||||||
|
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
reference_mime = %resolved.mime_type,
|
||||||
|
reference_bytes = resolved.bytes_len,
|
||||||
|
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图参考图解析完成"
|
||||||
|
);
|
||||||
|
Some(resolved)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
if !has_reference_image {
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图参考图解析跳过"
|
||||||
|
);
|
||||||
|
}
|
||||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||||
|
let vector_engine_started_at = Instant::now();
|
||||||
let generated = create_puzzle_vector_engine_image_generation(
|
let generated = create_puzzle_vector_engine_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
@@ -4058,10 +4126,21 @@ async fn generate_puzzle_image_candidates(
|
|||||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
count,
|
count,
|
||||||
reference_image.as_deref(),
|
reference_image
|
||||||
|
.as_ref()
|
||||||
|
.map(|image| image.data_url.as_str()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
generated_image_count = generated.images.len(),
|
||||||
|
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图 VectorEngine 生图与下载完成"
|
||||||
|
);
|
||||||
let mut items = Vec::with_capacity(generated.images.len());
|
let mut items = Vec::with_capacity(generated.images.len());
|
||||||
|
|
||||||
for (index, image) in generated.images.into_iter().enumerate() {
|
for (index, image) in generated.images.into_iter().enumerate() {
|
||||||
@@ -4070,6 +4149,7 @@ async fn generate_puzzle_image_candidates(
|
|||||||
candidate_start_index + index + 1
|
candidate_start_index + index + 1
|
||||||
);
|
);
|
||||||
let downloaded_image = image.clone();
|
let downloaded_image = image.clone();
|
||||||
|
let persist_started_at = Instant::now();
|
||||||
let asset = persist_puzzle_generated_asset(
|
let asset = persist_puzzle_generated_asset(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -4082,6 +4162,17 @@ async fn generate_puzzle_image_candidates(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_id = %candidate_id,
|
||||||
|
image_bytes = downloaded_image.bytes.len(),
|
||||||
|
image_mime = %downloaded_image.mime_type,
|
||||||
|
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图生成图片已写入 OSS 与资产索引"
|
||||||
|
);
|
||||||
items.push(GeneratedPuzzleImageCandidate {
|
items.push(GeneratedPuzzleImageCandidate {
|
||||||
record: PuzzleGeneratedImageCandidateRecord {
|
record: PuzzleGeneratedImageCandidateRecord {
|
||||||
candidate_id,
|
candidate_id,
|
||||||
@@ -4097,6 +4188,16 @@ async fn generate_puzzle_image_candidates(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_count = items.len(),
|
||||||
|
has_reference_image,
|
||||||
|
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图图片候选生成完成"
|
||||||
|
);
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4144,6 +4245,30 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
|
||||||
|
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||||
|
"APIMart 图片生成密钥未配置".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
let body = response.into_body();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body bytes should read");
|
||||||
|
let payload: Value =
|
||||||
|
serde_json::from_slice(&bytes).expect("error response should be valid json");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["details"]["provider"],
|
||||||
|
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["details"]["message"],
|
||||||
|
Value::String("VectorEngine 图片生成密钥未配置".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||||
let levels_json = serde_json::to_string(&vec![json!({
|
let levels_json = serde_json::to_string(&vec![json!({
|
||||||
@@ -4293,6 +4418,116 @@ mod tests {
|
|||||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||||
|
let item = PuzzleAnchorItemRecord {
|
||||||
|
key: "visualSubject".to_string(),
|
||||||
|
label: "画面".to_string(),
|
||||||
|
value: "雨夜猫街".to_string(),
|
||||||
|
status: "confirmed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
PuzzleAnchorPackRecord {
|
||||||
|
theme_promise: item.clone(),
|
||||||
|
visual_subject: item.clone(),
|
||||||
|
visual_mood: item.clone(),
|
||||||
|
composition_hooks: item.clone(),
|
||||||
|
tags_and_forbidden: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
|
||||||
|
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||||
|
PuzzleResultDraftRecord {
|
||||||
|
work_title: "雨夜猫街".to_string(),
|
||||||
|
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
level_name: "猫画面".to_string(),
|
||||||
|
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
theme_tags: vec![],
|
||||||
|
forbidden_directives: vec![],
|
||||||
|
creator_intent: None,
|
||||||
|
anchor_pack,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "idle".to_string(),
|
||||||
|
levels: vec![PuzzleDraftLevelRecord {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "猫画面".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "idle".to_string(),
|
||||||
|
}],
|
||||||
|
form_draft: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let mut target_level = draft.levels[0].clone();
|
||||||
|
target_level.level_name = "雨夜猫街".to_string();
|
||||||
|
|
||||||
|
let levels = build_puzzle_levels_with_primary_update(
|
||||||
|
&draft,
|
||||||
|
&target_level,
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(levels[0].level_name, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].picture_reference.as_deref(),
|
||||||
|
Some("data:image/png;base64,abcd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
|
||||||
|
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||||
|
let session = PuzzleAgentSessionRecord {
|
||||||
|
session_id: "puzzle-session-1".to_string(),
|
||||||
|
seed_text: "雨夜猫街".to_string(),
|
||||||
|
current_turn: 1,
|
||||||
|
progress_percent: 0,
|
||||||
|
stage: "draft_ready".to_string(),
|
||||||
|
anchor_pack: anchor_pack.clone(),
|
||||||
|
draft: Some(test_puzzle_draft_record()),
|
||||||
|
messages: Vec::new(),
|
||||||
|
last_assistant_reply: None,
|
||||||
|
published_profile_id: None,
|
||||||
|
suggested_actions: Vec::new(),
|
||||||
|
result_preview: None,
|
||||||
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id: "puzzle-session-1-candidate-1".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
|
||||||
|
asset_id: "puzzle-cover-1".to_string(),
|
||||||
|
prompt: "雨夜猫街".to_string(),
|
||||||
|
actual_prompt: Some("雨夜猫街".to_string()),
|
||||||
|
source_type: "generated:gpt-image-2".to_string(),
|
||||||
|
selected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
"puzzle-level-1",
|
||||||
|
vec![candidate],
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(
|
||||||
|
draft.levels[0].picture_reference.as_deref(),
|
||||||
|
Some("data:image/png;base64,abcd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||||
let invalid_operation =
|
let invalid_operation =
|
||||||
@@ -4347,6 +4582,12 @@ struct PuzzleGeneratedImages {
|
|||||||
images: Vec<PuzzleDownloadedImage>,
|
images: Vec<PuzzleDownloadedImage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PuzzleResolvedReferenceImage {
|
||||||
|
data_url: String,
|
||||||
|
mime_type: String,
|
||||||
|
bytes_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
struct GeneratedPuzzleImageCandidate {
|
struct GeneratedPuzzleImageCandidate {
|
||||||
record: PuzzleGeneratedImageCandidateRecord,
|
record: PuzzleGeneratedImageCandidateRecord,
|
||||||
downloaded_image: PuzzleDownloadedImage,
|
downloaded_image: PuzzleDownloadedImage,
|
||||||
@@ -4490,8 +4731,14 @@ async fn create_puzzle_vector_engine_image_generation(
|
|||||||
candidate_count,
|
candidate_count,
|
||||||
reference_image,
|
reference_image,
|
||||||
);
|
);
|
||||||
|
let request_url = puzzle_vector_engine_images_generation_url(settings);
|
||||||
|
let has_reference_image = reference_image
|
||||||
|
.map(str::trim)
|
||||||
|
.map(|value| !value.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let request_started_at = Instant::now();
|
||||||
let response = http_client
|
let response = http_client
|
||||||
.post(puzzle_vector_engine_images_generation_url(settings))
|
.post(request_url.as_str())
|
||||||
.header(
|
.header(
|
||||||
reqwest::header::AUTHORIZATION,
|
reqwest::header::AUTHORIZATION,
|
||||||
format!("Bearer {}", settings.api_key),
|
format!("Bearer {}", settings.api_key),
|
||||||
@@ -4507,6 +4754,18 @@ async fn create_puzzle_vector_engine_image_generation(
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64;
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = image_model.request_model_name(),
|
||||||
|
endpoint = %request_url,
|
||||||
|
status = status.as_u16(),
|
||||||
|
prompt_chars = prompt.chars().count(),
|
||||||
|
size,
|
||||||
|
has_reference_image,
|
||||||
|
elapsed_ms = upstream_elapsed_ms,
|
||||||
|
"拼图 VectorEngine 图片生成 HTTP 返回"
|
||||||
|
);
|
||||||
let response_text = response.text().await.map_err(|error| {
|
let response_text = response.text().await.map_err(|error| {
|
||||||
map_puzzle_vector_engine_request_error(format!(
|
map_puzzle_vector_engine_request_error(format!(
|
||||||
"读取拼图 VectorEngine 图片生成响应失败:{error}"
|
"读取拼图 VectorEngine 图片生成响应失败:{error}"
|
||||||
@@ -4526,13 +4785,22 @@ async fn create_puzzle_vector_engine_image_generation(
|
|||||||
)?;
|
)?;
|
||||||
let image_urls = extract_puzzle_image_urls(&payload);
|
let image_urls = extract_puzzle_image_urls(&payload);
|
||||||
if !image_urls.is_empty() {
|
if !image_urls.is_empty() {
|
||||||
return download_puzzle_images_from_urls(
|
let download_started_at = Instant::now();
|
||||||
|
let images = download_puzzle_images_from_urls(
|
||||||
http_client,
|
http_client,
|
||||||
format!("vector-engine-{}", current_utc_micros()),
|
format!("vector-engine-{}", current_utc_micros()),
|
||||||
image_urls,
|
image_urls,
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
image_model = image_model.request_model_name(),
|
||||||
|
image_count = images.images.len(),
|
||||||
|
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图 VectorEngine 图片下载完成"
|
||||||
|
);
|
||||||
|
return Ok(images);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(
|
Err(
|
||||||
@@ -4612,7 +4880,7 @@ async fn resolve_puzzle_reference_image_as_data_url(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
http_client: &reqwest::Client,
|
http_client: &reqwest::Client,
|
||||||
source: &str,
|
source: &str,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||||
let trimmed = source.trim();
|
let trimmed = source.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -4625,11 +4893,17 @@ async fn resolve_puzzle_reference_image_as_data_url(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||||
return Ok(format!(
|
let bytes_len = parsed.bytes.len();
|
||||||
|
let data_url = format!(
|
||||||
"data:{};base64,{}",
|
"data:{};base64,{}",
|
||||||
parsed.mime_type,
|
parsed.mime_type,
|
||||||
BASE64_STANDARD.encode(parsed.bytes)
|
BASE64_STANDARD.encode(&parsed.bytes)
|
||||||
));
|
);
|
||||||
|
return Ok(PuzzleResolvedReferenceImage {
|
||||||
|
data_url,
|
||||||
|
mime_type: parsed.mime_type,
|
||||||
|
bytes_len,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if !trimmed.starts_with('/') {
|
if !trimmed.starts_with('/') {
|
||||||
@@ -4699,11 +4973,13 @@ async fn resolve_puzzle_reference_image_as_data_url(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(format!(
|
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
|
||||||
"data:{};base64,{}",
|
let bytes_len = body.len();
|
||||||
content_type,
|
Ok(PuzzleResolvedReferenceImage {
|
||||||
BASE64_STANDARD.encode(body)
|
data_url: format!("data:{};base64,{}", mime_type, BASE64_STANDARD.encode(body)),
|
||||||
))
|
mime_type,
|
||||||
|
bytes_len,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_puzzle_remote_image(
|
async fn download_puzzle_remote_image(
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ impl VolcengineSpeechConfig {
|
|||||||
}
|
}
|
||||||
VolcengineSpeechAuthMode::LegacyApp => {
|
VolcengineSpeechAuthMode::LegacyApp => {
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"X-Api-App-Id",
|
"X-Api-App-Key",
|
||||||
header_value(self.app_id.as_deref().unwrap_or(""))?,
|
header_value(self.app_id.as_deref().unwrap_or(""))?,
|
||||||
);
|
);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
|
|||||||
@@ -416,6 +416,43 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
|
||||||
|
const onCreateFromForm = vi.fn();
|
||||||
|
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||||
|
stubReferenceImageUpload(uploadedDataUrl);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PuzzleAgentWorkspace
|
||||||
|
session={null}
|
||||||
|
onBack={() => {}}
|
||||||
|
onSubmitMessage={() => {}}
|
||||||
|
onExecuteAction={() => {}}
|
||||||
|
onCreateFromForm={onCreateFromForm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
|
||||||
|
target: {
|
||||||
|
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
||||||
|
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||||
|
|
||||||
|
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||||
|
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||||
|
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||||
|
referenceImageSrc: uploadedDataUrl,
|
||||||
|
imageModel: 'gpt-image-2',
|
||||||
|
aiRedraw: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
||||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||||
stubReferenceImageUpload(uploadedDataUrl);
|
stubReferenceImageUpload(uploadedDataUrl);
|
||||||
|
|||||||
@@ -589,6 +589,45 @@ describe('PuzzleResultView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uses the saved level picture reference when regenerating a level image', () => {
|
||||||
|
const onExecuteAction = vi.fn();
|
||||||
|
const session = createSession({
|
||||||
|
draft: {
|
||||||
|
...createSession().draft!,
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
...createSession().draft!.levels![0]!,
|
||||||
|
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PuzzleResultView
|
||||||
|
session={session}
|
||||||
|
onBack={() => {}}
|
||||||
|
onExecuteAction={onExecuteAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||||
|
fireEvent.click(
|
||||||
|
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
|
||||||
|
'button',
|
||||||
|
{ name: '确定' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'generate_puzzle_images',
|
||||||
|
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('passes the selected image model when regenerating a level image', () => {
|
test('passes the selected image model when regenerating a level image', () => {
|
||||||
const onExecuteAction = vi.fn();
|
const onExecuteAction = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -637,6 +637,8 @@ function PuzzleLevelDetailDialog({
|
|||||||
);
|
);
|
||||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||||
const hasFormalImage = Boolean(formalImageSrc);
|
const hasFormalImage = Boolean(formalImageSrc);
|
||||||
|
const effectiveReferenceImageSrc =
|
||||||
|
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
|
||||||
const isGenerationProgressVisible = isGenerationProgressActive;
|
const isGenerationProgressVisible = isGenerationProgressActive;
|
||||||
const generationSecondsLeft = isBusy
|
const generationSecondsLeft = isBusy
|
||||||
? Math.max(generationCountdown, 1)
|
? Math.max(generationCountdown, 1)
|
||||||
@@ -722,7 +724,7 @@ function PuzzleLevelDetailDialog({
|
|||||||
onGenerate(
|
onGenerate(
|
||||||
level.levelId,
|
level.levelId,
|
||||||
level.pictureDescription.trim() || undefined,
|
level.pictureDescription.trim() || undefined,
|
||||||
referenceImageSrc || undefined,
|
effectiveReferenceImageSrc || undefined,
|
||||||
imageModel,
|
imageModel,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -829,11 +831,11 @@ function PuzzleLevelDetailDialog({
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
|
title={effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-4 w-4" />
|
<ImagePlus className="h-4 w-4" />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{referenceImageSrc ? '更换参考图' : '添加参考图'}
|
{effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -861,11 +863,11 @@ function PuzzleLevelDetailDialog({
|
|||||||
aria-label="图面参考"
|
aria-label="图面参考"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{referenceImageSrc ? (
|
{effectiveReferenceImageSrc ? (
|
||||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||||
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
|
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
|
||||||
<ResolvedAssetImage
|
<ResolvedAssetImage
|
||||||
src={referenceImageSrc}
|
src={effectiveReferenceImageSrc}
|
||||||
alt="拼图参考图"
|
alt="拼图参考图"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -882,6 +884,7 @@ function PuzzleLevelDetailDialog({
|
|||||||
setReferenceImageSrc('');
|
setReferenceImageSrc('');
|
||||||
setReferenceImageLabel('');
|
setReferenceImageLabel('');
|
||||||
setReferenceImageError(null);
|
setReferenceImageError(null);
|
||||||
|
onLevelChange({ ...level, pictureReference: null });
|
||||||
}}
|
}}
|
||||||
className="platform-icon-button h-9 w-9"
|
className="platform-icon-button h-9 w-9"
|
||||||
aria-label="移除参考图"
|
aria-label="移除参考图"
|
||||||
|
|||||||
@@ -310,6 +310,16 @@ const originalMatchMedia = window.matchMedia;
|
|||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
|
||||||
|
function dispatchPointerEvent(
|
||||||
|
target: HTMLElement,
|
||||||
|
type: string,
|
||||||
|
options: { pointerId: number; clientY: number },
|
||||||
|
) {
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.assign(event, options);
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
const puzzlePublicEntry = {
|
const puzzlePublicEntry = {
|
||||||
sourceType: 'puzzle',
|
sourceType: 'puzzle',
|
||||||
workId: 'puzzle-work-public-1',
|
workId: 'puzzle-work-public-1',
|
||||||
@@ -1298,6 +1308,132 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
|
|||||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onSelectNextRecommendEntry = vi.fn();
|
||||||
|
const onSelectPreviousRecommendEntry = vi.fn();
|
||||||
|
const firstEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-feed-1',
|
||||||
|
profileId: 'puzzle-profile-feed-1',
|
||||||
|
ownerUserId: 'user-feed-1',
|
||||||
|
publicWorkCode: 'PZ-FEED1',
|
||||||
|
worldName: '当前拼图',
|
||||||
|
coverImageSrc: 'current-cover.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
const secondEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-feed-2',
|
||||||
|
profileId: 'puzzle-profile-feed-2',
|
||||||
|
ownerUserId: 'user-feed-2',
|
||||||
|
publicWorkCode: 'PZ-FEED2',
|
||||||
|
worldName: '下一拼图',
|
||||||
|
coverImageSrc: 'next-cover.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
const thirdEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-feed-3',
|
||||||
|
profileId: 'puzzle-profile-feed-3',
|
||||||
|
ownerUserId: 'user-feed-3',
|
||||||
|
publicWorkCode: 'PZ-FEED3',
|
||||||
|
worldName: '上一拼图',
|
||||||
|
coverImageSrc: 'previous-cover.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthUiContext.Provider
|
||||||
|
value={{
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
publicUserCode: '100001',
|
||||||
|
username: 'tester',
|
||||||
|
displayName: '测试玩家',
|
||||||
|
avatarUrl: null,
|
||||||
|
phoneNumberMasked: null,
|
||||||
|
loginMethod: 'password',
|
||||||
|
bindingStatus: 'active',
|
||||||
|
wechatBound: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
canAccessProtectedData: true,
|
||||||
|
openLoginModal: vi.fn(),
|
||||||
|
requireAuth: (action) => action(),
|
||||||
|
openSettingsModal: vi.fn(),
|
||||||
|
openAccountModal: vi.fn(),
|
||||||
|
setCurrentUser: vi.fn(),
|
||||||
|
logout: vi.fn(async () => undefined),
|
||||||
|
musicVolume: 0.42,
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
|
platformTheme: 'light',
|
||||||
|
setPlatformTheme: vi.fn(),
|
||||||
|
isHydratingSettings: false,
|
||||||
|
isPersistingSettings: false,
|
||||||
|
settingsError: null,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RpgEntryHomeView
|
||||||
|
activeTab="home"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
hasSavedGame={false}
|
||||||
|
savedSnapshot={null}
|
||||||
|
saveEntries={[]}
|
||||||
|
saveError={null}
|
||||||
|
featuredEntries={[]}
|
||||||
|
latestEntries={[firstEntry, secondEntry, thirdEntry]}
|
||||||
|
myEntries={[]}
|
||||||
|
historyEntries={[]}
|
||||||
|
profileDashboard={null}
|
||||||
|
isLoadingPlatform={false}
|
||||||
|
isLoadingDashboard={false}
|
||||||
|
isResumingSaveWorldKey={null}
|
||||||
|
platformError={null}
|
||||||
|
dashboardError={null}
|
||||||
|
onContinueGame={vi.fn()}
|
||||||
|
onResumeSave={vi.fn()}
|
||||||
|
onOpenCreateWorld={vi.fn()}
|
||||||
|
onOpenCreateTypePicker={vi.fn()}
|
||||||
|
onOpenGalleryDetail={vi.fn()}
|
||||||
|
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
|
||||||
|
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
|
||||||
|
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
|
||||||
|
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
|
||||||
|
onOpenLibraryDetail={vi.fn()}
|
||||||
|
onSearchPublicCode={vi.fn()}
|
||||||
|
/>
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('.platform-recommend-runtime-preview'),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('.platform-recommend-swipe-card'),
|
||||||
|
).toHaveLength(3);
|
||||||
|
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||||
|
act(() => {
|
||||||
|
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
|
||||||
|
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const rail = document.querySelector(
|
||||||
|
'.platform-recommend-swipe-rail',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
expect(rail?.className).toContain('platform-recommend-swipe-rail');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 210 });
|
||||||
|
vi.advanceTimersByTime(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
test('logged out active recommend bottom tab selects next work without login', async () => {
|
test('logged out active recommend bottom tab selects next work without login', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSelectNextRecommendEntry = vi.fn();
|
const onSelectNextRecommendEntry = vi.fn();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
|
type CSSProperties,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -171,6 +172,8 @@ const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
|||||||
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
||||||
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||||
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||||
|
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||||
|
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||||
|
|
||||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||||
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
|
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
|
||||||
@@ -727,18 +730,100 @@ function CreationLibraryCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecommendRuntimeMeta({
|
function RecommendRuntimePreviewCard({
|
||||||
|
entry,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
entry: PlatformPublicGalleryCard;
|
||||||
|
position: 'previous' | 'next';
|
||||||
|
}) {
|
||||||
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
|
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||||
|
const typeLabel = describePublicGalleryCardKind(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="platform-recommend-runtime-preview"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-preview-position={position}
|
||||||
|
>
|
||||||
|
{coverImage ? (
|
||||||
|
<ResolvedAssetBackdrop
|
||||||
|
src={coverImage}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.08),rgba(0,0,0,0.42))]" />
|
||||||
|
<div className="platform-recommend-runtime-preview__body">
|
||||||
|
<span className="platform-public-work-card__kind">{typeLabel}</span>
|
||||||
|
<span className="platform-recommend-runtime-preview__title">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendSwipeCard({
|
||||||
entry,
|
entry,
|
||||||
authorAvatarUrl,
|
authorAvatarUrl,
|
||||||
onSelectNext,
|
isActive,
|
||||||
onSelectPrevious,
|
visual,
|
||||||
|
onDragPointerDown,
|
||||||
|
onDragPointerMove,
|
||||||
|
onDragPointerUp,
|
||||||
|
onDragPointerCancel,
|
||||||
}: {
|
}: {
|
||||||
entry: PlatformPublicGalleryCard;
|
entry: PlatformPublicGalleryCard;
|
||||||
authorAvatarUrl?: string | null;
|
authorAvatarUrl?: string | null;
|
||||||
onSelectNext?: () => void;
|
isActive: boolean;
|
||||||
onSelectPrevious?: () => void;
|
visual: ReactNode;
|
||||||
|
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`platform-recommend-swipe-card ${isActive ? 'platform-recommend-swipe-card--active' : 'platform-recommend-swipe-card--preview'}`}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<div className="platform-recommend-swipe-card__visual">{visual}</div>
|
||||||
|
<div className="platform-recommend-swipe-card__meta">
|
||||||
|
<RecommendRuntimeMeta
|
||||||
|
entry={entry}
|
||||||
|
authorAvatarUrl={authorAvatarUrl}
|
||||||
|
isActive={isActive}
|
||||||
|
onDragPointerDown={onDragPointerDown}
|
||||||
|
onDragPointerMove={onDragPointerMove}
|
||||||
|
onDragPointerUp={onDragPointerUp}
|
||||||
|
onDragPointerCancel={onDragPointerCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendRuntimeMeta({
|
||||||
|
entry,
|
||||||
|
authorAvatarUrl,
|
||||||
|
onDragPointerDown,
|
||||||
|
onDragPointerMove,
|
||||||
|
onDragPointerUp,
|
||||||
|
onDragPointerCancel,
|
||||||
|
isActive = true,
|
||||||
|
}: {
|
||||||
|
entry: PlatformPublicGalleryCard;
|
||||||
|
authorAvatarUrl?: string | null;
|
||||||
|
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||||
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const swipeStartYRef = useRef<number | null>(null);
|
|
||||||
const playCount = getPlatformWorldPlayCount(entry);
|
const playCount = getPlatformWorldPlayCount(entry);
|
||||||
const remixCount = getPlatformWorldRemixCount(entry);
|
const remixCount = getPlatformWorldRemixCount(entry);
|
||||||
const likeCount = getPlatformWorldLikeCount(entry);
|
const likeCount = getPlatformWorldLikeCount(entry);
|
||||||
@@ -751,37 +836,23 @@ function RecommendRuntimeMeta({
|
|||||||
{ label: '点赞', value: likeCount, icon: Heart },
|
{ label: '点赞', value: likeCount, icon: Heart },
|
||||||
{ label: '改造', value: remixCount, icon: MessageCircle },
|
{ label: '改造', value: remixCount, icon: MessageCircle },
|
||||||
];
|
];
|
||||||
const handlePointerEnd = (clientY: number) => {
|
|
||||||
const startY = swipeStartYRef.current;
|
|
||||||
swipeStartYRef.current = null;
|
|
||||||
if (startY === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaY = clientY - startY;
|
|
||||||
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deltaY < 0) {
|
|
||||||
onSelectNext?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectPrevious?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="platform-recommend-work-meta"
|
className={`platform-recommend-work-meta ${
|
||||||
|
isActive
|
||||||
|
? 'platform-recommend-work-meta--active'
|
||||||
|
: 'platform-recommend-work-meta--preview'
|
||||||
|
}`}
|
||||||
aria-label={`${entry.worldName} 作品信息`}
|
aria-label={`${entry.worldName} 作品信息`}
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
swipeStartYRef.current = event.clientY;
|
onDragPointerDown?.(event);
|
||||||
}}
|
|
||||||
onPointerUp={(event) => handlePointerEnd(event.clientY)}
|
|
||||||
onPointerCancel={() => {
|
|
||||||
swipeStartYRef.current = null;
|
|
||||||
}}
|
}}
|
||||||
|
onPointerMove={onDragPointerMove}
|
||||||
|
onPointerUp={onDragPointerUp}
|
||||||
|
onPointerCancel={onDragPointerCancel}
|
||||||
>
|
>
|
||||||
<div className="platform-recommend-work-meta__stats">
|
<div className="platform-recommend-work-meta__stats">
|
||||||
{statItems.map(({ label, value, icon: Icon }) => (
|
{statItems.map(({ label, value, icon: Icon }) => (
|
||||||
@@ -3769,6 +3840,171 @@ export function RpgEntryHomeView({
|
|||||||
) ??
|
) ??
|
||||||
recommendedFeedEntries[0] ??
|
recommendedFeedEntries[0] ??
|
||||||
null;
|
null;
|
||||||
|
const activeRecommendIndex = activeRecommendEntry
|
||||||
|
? recommendedFeedEntries.findIndex(
|
||||||
|
(entry) =>
|
||||||
|
buildPublicGalleryCardKey(entry) ===
|
||||||
|
buildPublicGalleryCardKey(activeRecommendEntry),
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
const previousRecommendEntry =
|
||||||
|
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
|
||||||
|
? recommendedFeedEntries[
|
||||||
|
(activeRecommendIndex - 1 + recommendedFeedEntries.length) %
|
||||||
|
recommendedFeedEntries.length
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
const nextRecommendEntry =
|
||||||
|
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
|
||||||
|
? recommendedFeedEntries[
|
||||||
|
(activeRecommendIndex + 1) % recommendedFeedEntries.length
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||||||
|
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||||||
|
useState<1 | -1 | null>(null);
|
||||||
|
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const recommendDragStartRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
startY: number;
|
||||||
|
dragging: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const commitRecommendDrag = useCallback(
|
||||||
|
(direction: 1 | -1) => {
|
||||||
|
if (recommendDragCommitDirection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecommendDragCommitDirection(direction);
|
||||||
|
const panelHeight =
|
||||||
|
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||||||
|
const commitDistance =
|
||||||
|
panelHeight > 0 ? panelHeight : window.innerHeight;
|
||||||
|
setRecommendDragOffsetY(
|
||||||
|
direction === 1 ? -commitDistance : commitDistance,
|
||||||
|
);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (direction === 1) {
|
||||||
|
onSelectNextRecommendEntry?.();
|
||||||
|
} else {
|
||||||
|
onSelectPreviousRecommendEntry?.();
|
||||||
|
}
|
||||||
|
setRecommendDragOffsetY(0);
|
||||||
|
setRecommendDragCommitDirection(null);
|
||||||
|
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onSelectNextRecommendEntry,
|
||||||
|
onSelectPreviousRecommendEntry,
|
||||||
|
recommendDragCommitDirection,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const beginRecommendDrag = useCallback(
|
||||||
|
(event: PointerEvent<HTMLElement>) => {
|
||||||
|
if (
|
||||||
|
recommendDragCommitDirection ||
|
||||||
|
!isAuthenticated ||
|
||||||
|
!activeRecommendEntry ||
|
||||||
|
recommendedFeedEntries.length <= 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recommendDragStartRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startY: event.clientY,
|
||||||
|
dragging: false,
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeRecommendEntry,
|
||||||
|
isAuthenticated,
|
||||||
|
recommendDragCommitDirection,
|
||||||
|
recommendedFeedEntries.length,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const moveRecommendDrag = useCallback((event: PointerEvent<HTMLElement>) => {
|
||||||
|
const drag = recommendDragStartRef.current;
|
||||||
|
if (!drag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaY = event.clientY - drag.startY;
|
||||||
|
drag.dragging =
|
||||||
|
drag.dragging || Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
|
||||||
|
if (!drag.dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const cardHeight =
|
||||||
|
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||||||
|
const dragLimit =
|
||||||
|
cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
|
||||||
|
setRecommendDragOffsetY(
|
||||||
|
Math.max(
|
||||||
|
-dragLimit,
|
||||||
|
Math.min(dragLimit, deltaY),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const endRecommendDrag = useCallback(
|
||||||
|
(event: PointerEvent<HTMLElement>) => {
|
||||||
|
const drag = recommendDragStartRef.current;
|
||||||
|
if (!drag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.currentTarget.releasePointerCapture?.(drag.pointerId);
|
||||||
|
recommendDragStartRef.current = null;
|
||||||
|
const deltaY = event.clientY - drag.startY;
|
||||||
|
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
|
||||||
|
setRecommendDragOffsetY(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitRecommendDrag(deltaY < 0 ? 1 : -1);
|
||||||
|
},
|
||||||
|
[commitRecommendDrag],
|
||||||
|
);
|
||||||
|
const cancelRecommendDrag = useCallback(
|
||||||
|
(event: PointerEvent<HTMLElement>) => {
|
||||||
|
const drag = recommendDragStartRef.current;
|
||||||
|
if (drag) {
|
||||||
|
event.currentTarget.releasePointerCapture?.(drag.pointerId);
|
||||||
|
}
|
||||||
|
recommendDragStartRef.current = null;
|
||||||
|
setRecommendDragOffsetY(0);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const recommendRailStyle = {
|
||||||
|
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
|
||||||
|
} satisfies CSSProperties;
|
||||||
|
const recommendRailClassName = recommendDragCommitDirection
|
||||||
|
? 'platform-recommend-swipe-rail--committing'
|
||||||
|
: recommendDragOffsetY === 0
|
||||||
|
? 'platform-recommend-swipe-rail--settled'
|
||||||
|
: 'platform-recommend-swipe-rail--dragging';
|
||||||
|
const selectNextRecommendEntry = useCallback(() => {
|
||||||
|
if (
|
||||||
|
isAuthenticated &&
|
||||||
|
activeRecommendEntry &&
|
||||||
|
recommendedFeedEntries.length > 1
|
||||||
|
) {
|
||||||
|
commitRecommendDrag(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectNextRecommendEntry?.();
|
||||||
|
}, [
|
||||||
|
activeRecommendEntry,
|
||||||
|
commitRecommendDrag,
|
||||||
|
isAuthenticated,
|
||||||
|
onSelectNextRecommendEntry,
|
||||||
|
recommendedFeedEntries.length,
|
||||||
|
]);
|
||||||
const openActiveRecommendEntry = useCallback(() => {
|
const openActiveRecommendEntry = useCallback(() => {
|
||||||
if (!activeRecommendEntry) {
|
if (!activeRecommendEntry) {
|
||||||
return;
|
return;
|
||||||
@@ -3786,12 +4022,6 @@ export function RpgEntryHomeView({
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
openRecommendGalleryDetail,
|
openRecommendGalleryDetail,
|
||||||
]);
|
]);
|
||||||
const selectNextRecommendEntry = useCallback(() => {
|
|
||||||
onSelectNextRecommendEntry?.();
|
|
||||||
}, [onSelectNextRecommendEntry]);
|
|
||||||
const selectPreviousRecommendEntry = useCallback(() => {
|
|
||||||
onSelectPreviousRecommendEntry?.();
|
|
||||||
}, [onSelectPreviousRecommendEntry]);
|
|
||||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||||
const openLeadPublicEntry = () => {
|
const openLeadPublicEntry = () => {
|
||||||
if (leadPublicEntry) {
|
if (leadPublicEntry) {
|
||||||
@@ -3863,18 +4093,22 @@ export function RpgEntryHomeView({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="platform-recommend-runtime-panel">
|
{isLoadingPlatform ? (
|
||||||
{isLoadingPlatform ? (
|
<section className="platform-recommend-runtime-panel">
|
||||||
<div className="platform-recommend-runtime-state">
|
<div className="platform-recommend-runtime-state">
|
||||||
正在读取公开作品...
|
正在读取公开作品...
|
||||||
</div>
|
</div>
|
||||||
) : !isAuthenticated && activeRecommendEntry ? (
|
</section>
|
||||||
|
) : !isAuthenticated && activeRecommendEntry ? (
|
||||||
|
<section className="platform-recommend-runtime-panel">
|
||||||
<RecommendCoverOnlyCard
|
<RecommendCoverOnlyCard
|
||||||
entry={activeRecommendEntry}
|
entry={activeRecommendEntry}
|
||||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||||
onClick={openActiveRecommendEntry}
|
onClick={openActiveRecommendEntry}
|
||||||
/>
|
/>
|
||||||
) : recommendRuntimeError ? (
|
</section>
|
||||||
|
) : recommendRuntimeError ? (
|
||||||
|
<section className="platform-recommend-runtime-panel">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -3886,25 +4120,81 @@ export function RpgEntryHomeView({
|
|||||||
>
|
>
|
||||||
{recommendRuntimeError}
|
{recommendRuntimeError}
|
||||||
</button>
|
</button>
|
||||||
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
|
</section>
|
||||||
|
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
|
||||||
|
<section className="platform-recommend-runtime-panel">
|
||||||
<div className="platform-recommend-runtime-state">加载中...</div>
|
<div className="platform-recommend-runtime-state">加载中...</div>
|
||||||
) : (
|
</section>
|
||||||
<div className="platform-recommend-runtime-viewport">
|
) : activeRecommendEntry ? (
|
||||||
{recommendRuntimeContent}
|
<div
|
||||||
</div>
|
ref={recommendCardStageRef}
|
||||||
)}
|
className="platform-recommend-swipe-stage"
|
||||||
</section>
|
>
|
||||||
|
<div
|
||||||
|
className={`platform-recommend-swipe-rail ${recommendRailClassName}`}
|
||||||
|
style={recommendRailStyle}
|
||||||
|
>
|
||||||
|
{previousRecommendEntry ? (
|
||||||
|
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--previous">
|
||||||
|
<RecommendSwipeCard
|
||||||
|
entry={previousRecommendEntry}
|
||||||
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||||||
|
previousRecommendEntry,
|
||||||
|
)}
|
||||||
|
isActive={false}
|
||||||
|
visual={
|
||||||
|
<RecommendRuntimePreviewCard
|
||||||
|
entry={previousRecommendEntry}
|
||||||
|
position="previous"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeRecommendEntry && isAuthenticated ? (
|
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
|
||||||
<RecommendRuntimeMeta
|
<RecommendSwipeCard
|
||||||
entry={activeRecommendEntry}
|
entry={activeRecommendEntry}
|
||||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||||||
onSelectNext={selectNextRecommendEntry}
|
activeRecommendEntry,
|
||||||
onSelectPrevious={selectPreviousRecommendEntry}
|
)}
|
||||||
/>
|
isActive
|
||||||
) : !isLoadingPlatform ? (
|
visual={
|
||||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
<div className="platform-recommend-runtime-viewport">
|
||||||
) : null}
|
{recommendRuntimeContent}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onDragPointerDown={beginRecommendDrag}
|
||||||
|
onDragPointerMove={moveRecommendDrag}
|
||||||
|
onDragPointerUp={endRecommendDrag}
|
||||||
|
onDragPointerCancel={cancelRecommendDrag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextRecommendEntry ? (
|
||||||
|
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
|
||||||
|
<RecommendSwipeCard
|
||||||
|
entry={nextRecommendEntry}
|
||||||
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||||||
|
nextRecommendEntry,
|
||||||
|
)}
|
||||||
|
isActive={false}
|
||||||
|
visual={
|
||||||
|
<RecommendRuntimePreviewCard
|
||||||
|
entry={nextRecommendEntry}
|
||||||
|
position="next"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="platform-recommend-runtime-panel">
|
||||||
|
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
106
src/index.css
106
src/index.css
@@ -2391,11 +2391,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.platform-mobile-recommend-stage {
|
.platform-mobile-recommend-stage {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.28rem;
|
gap: 0.28rem;
|
||||||
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2421,6 +2423,108 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--platform-recommend-runtime-fill);
|
background: var(--platform-recommend-runtime-fill);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-stage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-rail {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-rail--settled,
|
||||||
|
.platform-recommend-swipe-rail--committing {
|
||||||
|
transition: transform 180ms cubic-bezier(0.2, 0.78, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-rail--dragging {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-page {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-page--previous {
|
||||||
|
transform: translate3d(0, -100%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-page--current {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-page--next {
|
||||||
|
transform: translate3d(0, 100%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-runtime-preview {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--platform-recommend-runtime-fill);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-card {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-card__visual {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--platform-recommend-runtime-border);
|
||||||
|
border-radius: 1.65rem;
|
||||||
|
background: var(--platform-recommend-runtime-fill);
|
||||||
|
box-shadow: var(--platform-recommend-runtime-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-card__meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-runtime-preview__body {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-runtime-preview__title {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
font-size: clamp(1.45rem, 6.4vw, 2.15rem);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-cover-only {
|
.platform-recommend-cover-only {
|
||||||
@@ -2495,7 +2599,7 @@ body {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--platform-text-strong);
|
color: var(--platform-text-strong);
|
||||||
touch-action: pan-y;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user