Files
worldmodel/plans/CrowdRoom/04_web_app_plan.md
T
gaojie 1ea74b46da
Sync to site1 / sync (push) Has been cancelled
chore: update CrowdRoom categories from worldmodel to CrowdRoom
2026-05-21 02:20:22 +08:00

893 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "CrowdRoom · Web 端设计(v0.2"
date: 2026-05-20
draft: false
tags: ["CrowdRoom", "众包", "3D 重建", "机器人", "导航", "隐私"]
categories: ["CrowdRoom"]
---
# CrowdRoom · Web 端设计(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-6(§1.1 路由表追加 `R-16 /me/embeds` 用户管理 iframe 嵌入配额与 Referer 白名单页)+ G-7(§1.1 路由表追加 `R-17 /admin/reports` 内部审核工作台,role=admin only)。源决策见 [`10_governance.md`](10_governance.md) §3.1reviewer 工具)与 §4 P-W-6iframe 限流落地)。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图、[`01_data_schema.md`](01_data_schema.md) 的 9 张表与 `layer_manifest.json` Schema、[`02_api_contract.md`](02_api_contract.md) §2 的 19 个端点(v0.2,原 16 + 3 个新增 E-17/18/19)与 §8.2 的 6 条 Web 硬契约(Web-Y1 ~ Web-Y6)、以及 [`03_ios_app_plan.md`](03_ios_app_plan.md) §2.2 Flow B 的 Universal Link 入口,落地为一份**可直接交付给 Web 工程团队**的设计。
>
> **Web 端的使命**:把"扫房 → 上传"产出的 `canonical.glb + layer_manifest.json` 在浏览器里渲染成可玩、可分层、可换材质、可换家具、可 Remix、可分享的 3D 房间作品;这是 CrowdRoom 直接回应用户原始需求"**类 ArcGIS 分层 + 显示/隐藏 + 换材质 + 换其他家具**"的入口。
>
> 本章不重复隐私治理总章(由子任务 5 收口);本章亦不涉及 iOS 端 RoomPlan 采集、转码 Worker 实现(已在 03/02 中定义)。
---
## 1. Web 站点信息架构
### 1.1 路由表(Next.js App Router
CrowdRoom Web 选用 [Next.js 14 App Router](https://nextjs.org/docs/app),因为 SSR + OG 卡片 + SEO + Edge Runtime 都是消费级社区的硬需求。路由规划如下(≥10 条):
| # | 路由 | 渲染 | 主要数据 | 说明 |
|---|------|------|---------|------|
| R-01 | `/` | SSR + ISR (60s) | `E-06 GET rooms` 公开 feed 前 20 条 | 首页瀑布流,落地页 |
| R-02 | `/r/[room_id]` | SSROG meta 必须服务端拼) | `E-07 GET rooms + current_version` | 房间详情 3D 浏览器 |
| R-03 | `/r/[room_id]/edit?fork=1` | CSR only(编辑器太重) | `E-09` 父 manifest + 父 glb + 空 overlay | **Remix 编辑器**(核心) |
| R-04 | `/remix/[remix_id]` | SSR | `remixes` 行 + overlay JSON | 已发布的 remix 详情;引用父几何 |
| R-05 | `/remix/[remix_id]/edit` | CSR | 自己创建的 remix 才可进 | Remix 二次编辑(owner-only |
| R-06 | `/u/[username]` | SSR + ISR (120s) | `users` + 该用户的 rooms / remixes | 用户主页 |
| R-07 | `/search?q=...&tag=...&grade=...` | SSR | `E-08 search_rooms` RPC | 搜索结果页 |
| R-08 | `/assets?kind=material&class=wood` | SSR + ISR (300s) | `assets` 表过滤 | 公共资产库浏览(独立可逛) |
| R-09 | `/login` / `/signup` | CSRSupabase Auth | `auth.users` | 登录/注册;OAuth 回调 `/auth/callback` |
| R-10 | `/me` / `/me/rooms` / `/me/drafts` / `/me/quota` | CSR(需登录) | `E-15 quota`、个人 rooms | 个人控制台 |
| R-11 | `/notifications` | CSR + Realtime channel `user:{id}` | 评论 / 点赞 / Remix / 转码完成事件流 | 通知中心 |
| R-12 | `/embed/r/[room_id]` | CSR(极简 chrome | 同 R-02 | iframe 嵌入版(无导航条、无评论) |
| R-13 | `/about` / `/legal` / `/privacy` / `/terms` | SSG | 静态 MDX | 法务与说明,由子任务 5 写正文 |
| R-14 | `/api/og/r/[room_id]` | Edge FunctionVercel OG | manifest + viewState | **OG 卡片动态生成**,详见 §9 |
| R-15 | `/sitemap.xml` / `/robots.txt` | SSG | 公开 rooms 列表分页 | SEO 入口 |
| R-16 | `/me/embeds` | CSR(需登录) | `embed_settings` 表 + Referer 限流统计([`10_governance.md`](10_governance.md) §4 P-W-6 | **v0.2 / G-6**:用户管理「自家房间被 iframe 嵌入」的配额、Referer 白名单与黑名单;显示日访问量、可一键关闭嵌入或封禁某 Referer |
| R-17 | `/admin/reports` | CSR**role=admin** 才可进,否则 403 | `reports` 工单队列 + NSFW score + 敏感词命中 + 内容预览 | **v0.2 / G-7**:内部审核工作台(reviewer 用),对应 [`10_governance.md`](10_governance.md) §3.1 「兼职 Reviewer × 1」工具需求;MVP 用 Supabase Studio + 本路由组合 |
> 共 **17 条路由组**v0.2,原 15 + v0.2 / G-6 新增 R-16 + v0.2 / G-7 新增 R-17),覆盖 5 大场景:浏览(R-01/02/04/07/08)、创作(R-03/05/10)、社交(R-06/11)、基础设施(R-09/12/13/14/15)、**v0.2 治理 / 用户控制(R-16/17**。
> 🔄 **v0.2 — 回写自 G-6**R-16 `/me/embeds` 是 [`10_governance.md`](10_governance.md) §4 P-W-6「iframe 嵌入频次/速率限制」决策的用户侧落地点。页面内容:
>
> - **嵌入开关**(默认开 / 单房间粒度可关,关闭后 R-12 `/embed/r/{id}` 返回 403 + 业务码 `EMBED_FORBIDDEN`,详见 [`02_api_contract.md`](02_api_contract.md) §7
> - **Referer 列表**:所有曾经成功嵌入过自家房间的外部域名 + 日访问量 + 累计访问量;按访问量降序,最多展示前 100 条
> - **白名单登记**:用户可主动登记某个 Referer 域名进入「注册 Referer」档(30 req/min/Referer),未登记的走默认档(5 req/min/Referer),对应 P-W-6 表格的两档
> - **黑名单封禁**:点击某条 Referer → 「封禁此来源」→ 写入 `embed_blocked_referers` 表(service_role 维护)→ 该 Referer 立即收到 `EMBED_FORBIDDEN`
> - **配额展示**:当前账户档位(free 1 万 / creator 10 万 / pro 100 万 req/月)+ 已用 / 剩余 / 重置时间
> - **不计入 Storage 流量**:页面顶部固定文案「iframe 嵌入流量由平台兜底,不消耗你的 Storage 配额」(与 [`10_governance.md`](10_governance.md) §4 P-W-6「CDN 流量归属」条款对齐)
>
> 该路由在 §1.2 跳转图中归属 `/me/*` 子树,鉴权同 R-10。
>
> 🔄 **v0.2 — 回写自 G-7**R-17 `/admin/reports` 是 [`10_governance.md`](10_governance.md) §3.1「兼职 Reviewer × 1,每日 2 小时(约工单 30–50 条 / 日)」的工具承载页。页面内容:
>
> - **鉴权门控**:进入页面前 `middleware.ts` 读 `auth.users.app_metadata->>role` 判断是否 `'admin'`;非 admin 直接返回 403(不是 401,避免暴露路由存在)
> - **工单队列**:表格列 `report_id / target_type / target_id / reason / 自动信号(NSFW score、敏感词命中)/ 举报数累计 / SLA 剩余时间 / 状态`
> - **内容预览**:点击某行 → 右侧抽屉打开目标内容(房间 3D 预览 / 评论 / 用户档案)+ 既往违规记录
> - **判定按钮**`Approve(误报恢复)/ Reject(违规下架,选择处罚等级 L1-L4 或 WL 白名单越级)/ Defer(转 owner-team 终审)`,对应 [`10_governance.md`](10_governance.md) §6 处罚阶梯
> - **批量操作**:选中多行 → 批量 Approve / Reject(限同一 target_type
> - **审计落库**:每个判定写 `moderation_actions` 表(含 reviewer_id / 操作 / 时间 / 理由),用于 §8 申诉流程二次复核
> - **MVP 数据源**:直接 PostgREST 查 `reports` 表 + `comments / rooms / remixes` 关联;P1 起接 Hive Moderation 第三方审核分数
>
> 该路由**不**在 `/sitemap.xml` 也**不**在 `/robots.txt` 允许列表(默认 noindex,避免 SEO 误抓)。
### 1.2 核心页面跳转图(用户旅程)
```mermaid
graph LR
Home[Home /]
Search[Search /search]
Detail[RoomDetail /r/room_id]
RemixEdit[RemixEditor /r/room_id/edit fork=1]
RemixDetail[RemixDetail /remix/remix_id]
Assets[AssetLibrary /assets]
UserHome[UserHome /u/username]
Login[Login /login]
Me[Profile /me]
Embed[Embed /embed/r/room_id]
Home --> Detail
Home --> Search
Search --> Detail
Detail --> RemixEdit
Detail --> RemixDetail
Detail --> UserHome
Detail --> Embed
RemixEdit --> RemixDetail
RemixDetail --> RemixEdit
UserHome --> Detail
Home --> Assets
RemixEdit --> Assets
Login --> Me
Me --> Detail
```
> Remix 编辑器(`RemixEdit`)是整个 Web 的"重心页面"——所有"换材质/换家具/分层切换"的交互都在这里发生,对应用户原始需求。
---
## 2. 技术栈选型表
| 模块 | 推荐 | 备选 | 一行理由 |
|------|------|------|---------|
| Web 框架 | **Next.js 14 App Router + TypeScript** | Remix / SvelteKit | SSR + OG meta + Edge Function 一栈搞定;社区生态最厚 |
| 3D 渲染 | **Three.js r160+ · React-Three-Fiber v8 · `@react-three/drei`** | Babylon.js / PlayCanvas | R3F 让"图层切换/换家具"用 React 组件思维直接表达;与 Next.js SSR 兼容(动态 import + `ssr:false` |
| 模型加载 | **drei `useGLTF` + `KTX2Loader` + `MeshoptDecoder`** | three.js 原生 `GLTFLoader` | drei 内置缓存与 Suspense 集成;KTX2/Meshopt 都是 [`02_api_contract.md`](02_api_contract.md) §3.2 转码管线产物 |
| 状态管理 | **Zustand 4.x**(图层/相机/选中态/草稿) | Jotai / Redux Toolkit | Zustand 单 store + `subscribeWithSelector` 对 R3F 性能友好;不引入 Provider 树 |
| 服务端数据 | **`@supabase/ssr`**Server Component + Route Handler | `@supabase/auth-helpers-nextjs`(已废弃) | App Router 官方推荐;cookie 鉴权链路安全 |
| 浏览器数据 | **`@supabase/supabase-js` v2 + `@tanstack/react-query` v5** | SWR | React Query 的乐观更新 + 缓存失效控制对评论/点赞场景最合适 |
| 样式 | **Tailwind CSS v3 + shadcn/uiRadix UI 二次封装)** | CSS Modules / Stitches | shadcn 的 Dialog/DropdownMenu/Slider 直接拿来即用,A11y 已经做掉 |
| 图标 | **Lucide React** | Heroicons / Tabler | 与 shadcn 默认同款;树摇彻底 |
| 表单 | **React Hook Form + Zod** | Formik | Zod schema 可同时复用到 Edge Function 的入参校验 |
| 国际化 | **next-intl**(中英双语,`zh-CN` / `en-US` | next-i18next | App Router 友好;按路由段 `/[locale]/...` 切分 |
| 分析 | **PostHog Cloud**(自托管事件 + Session Replay 关闭以保护隐私) | Plausible | 不用 GA(合规风险 + 国内访问差);PostHog 提供 funnel 与 feature flag |
| 部署 | **Vercel**Edge Network + Image Optimization | Cloudflare Pages + Workers | Vercel 与 Next.js 14 集成最深;CN 访问后期可加 Cloudflare 镜像 |
| 错误监控 | **Sentry Browser SDK** | Datadog RUM | 与 Supabase 后端 / iOS 端共用一个 Sentry 项目,跨端联查 |
| 包管理 | **pnpm 8** + Turborepo(单仓多包) | npm / yarn | 与 iOS Worker 共享 schemas/ 包;pnpm 节省磁盘 |
| 测试 | **Vitest + Playwright** | Jest + Cypress | Vitest 与 Vite/Next 14 同栈;Playwright 跑 3D 截图 diff |
---
## 3. 3D 渲染架构
### 3.1 渲染层链路(从 URL 到画面)
> ✅ **契约 Web-Y1**:渲染入口**必须**先拉 `layer_manifest.json`,按 4 层固定 ID`walls / floor / furniture / materials`)切换可见性;**禁止**自己解析 .glb 节点树推断结构——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y1。
```mermaid
graph LR
URL[URL r room_id] --> Route[App Router]
Route --> Fetch1[CDN GET layer_manifest.json]
Route --> Fetch2[CDN GET canonical.glb]
Fetch1 --> Validate[Zod schema check 失败抛 MANIFEST_INVALID]
Validate --> BuildMap[构建 Map layerId nodeIds]
Fetch2 --> Cache[useGLTF 缓存]
BuildMap --> Scene[R3F Canvas Scene]
Cache --> Scene
Scene --> Groups[4 个 group layerRef]
Groups --> WallGroup[group walls]
Groups --> FloorGroup[group floor]
Groups --> FurnGroup[group furniture]
Groups --> MatSlots[material slots 注入到上述三层 mesh]
State[Zustand layerStore] --> Bind[group.visible 双向绑定]
Bind --> WallGroup
Bind --> FloorGroup
Bind --> FurnGroup
```
### 3.2 R3F 场景图组织原则
| 决策 | 拍板 | 理由 |
|------|------|------|
| 每层一个 `<group>` 而非用 mesh.visible 逐个 | **是** | 切层 = 1 次 React state 变更触发 1 次 group.visible 赋值;逐 mesh 切要 N 次,浪费 |
| 节点名约定 | **沿用 manifest 中 `mesh_node_ids[]`Worker 端已统一 `wall_* / floor_* / furn_*` 前缀** | Web 端通过 `scene.getObjectByName(nodeId)` O(1) 拿引用 |
| `<Suspense>` 边界 | **Canvas 内一层、AssetPicker 缩略图一层** | 渲染主场景与挑材质的网络等待互不阻塞 |
| 选中态高亮 | **额外注入 `<Outline>` (drei) post-processing,不修改 mesh material** | 防止"选中后退出忘了恢复"的副作用 |
| 物理 / 灯光 | **MVP 用 `<Environment preset="apartment">`,无物理引擎** | 真实光照成本不划算;apartment 预设对家居场景视觉够用 |
### 3.3 性能预算
| 设备档 | 帧率目标 | `.glb` 大小 | 三角形 | Draw Call | 纹理上限 |
|--------|---------|-------------|--------|-----------|---------|
| 桌面端(Chrome/Edge/Firefox | **≥ 60 fps** | ≤ 5 MB | ≤ 200 k | ≤ 30 | ≤ 16 张 1024² |
| 移动端 Safari iOS 16+ | **≥ 30 fps** | ≤ 2 MB | ≤ 80 k | ≤ 15 | ≤ 8 张 512² |
| 旧桌面(Intel 集显) | ≥ 30 fps | 同移动端预算 | 同上 | 同上 | 同上 |
**预算违反时的兜底**(在 `<Canvas>` 外部检测 `gpu.tier`,参考 `@react-three/drei``useDetectGPU`):
- Tier 1(低端)→ 自动启用 `dpr={[1, 1]}` + 关闭阴影 + Texture 自动降到 512²
- Tier 0(无 WebGL2)→ 退化到 `model-viewer` 静态预览组件(详情页给"3D 视图不可用"提示)
### 3.4 LOD 策略
**MVP 不做 LOD**(理由:单房间几何已经在 Worker 端走 Draco/Meshopt 压缩到 15 MB,移动端不掉帧;额外切多套 LOD 会让转码 Worker 跑得更慢)。
**何时引入**
- 单房间 `canonical.glb > 8 MB`(超过 creator 配额上限)
- 单页面同屏需展示 ≥ 2 个房间(如对比页 / 楼层拼接 P2)
- 移动端 90 分位首屏 TTI > 4 s
引入时方案:Worker 端额外产 `canonical_lod1.glb`30% 面数)与 `canonical_lod2.glb`10% 面数),manifest 增加 `lod_uris[]` 字段,Web 端按相机距离切换。
### 3.5 相机控制
| 场景 | 控制器 | 行为 |
|------|--------|------|
| 进入房间 | **OrbitControls + 自动 fit-to-bbox**(从 manifest `room_metrics` 反推 OBB | 1.2 s ease-out 缓动到房间斜上方 45° |
| 点击图层节点 | OrbitControls.target 飞到该 mesh OBB 中心 | 0.6 s 缓动 + 自动调整距离使物体撑满 60% 视口 |
| 全屏 / 嵌入 (`/embed`) | 同上,但移除右键面板 | 嵌入版给最简 UI |
| WebXRVR / AR | **MVP 不做**,预留 `<XR>` 组件挂载点 | drei `@react-three/xr` 已就绪 |
---
## 4. 类 ArcGIS 分层 UI 设计 🌟
> **本节直接回应用户原始需求"空间信息,类似 ArcGIS 的分层地图信息一样,选择显示、隐藏"。** 这是 Web 端最具辨识度的体验,必须做精。
### 4.1 图层面板(LayerPanel)整体布局
房间详情页的右侧抽屉(Desktop ≥ 1280 时常驻 320 px;移动端折叠为底部 sheet)展示**4 层固定结构**,与 [`01_data_schema.md`](01_data_schema.md) §5.1 的"4 层固定"约束严格对齐:
```
┌─────────────────────────────────┐
│ Layers [ ⊕ ] │ ← 顶部 "保存为视图" 按钮
├─────────────────────────────────┤
│ 👁 🔒 [████████░░] Walls ▾ │ ← 可见性 / 锁定 / 不透明度 / 展开
│ └─ wall_0 [缩略] 👁 │
│ └─ wall_1 [缩略] 👁 │
│ └─ wall_2 [缩略] 👁 │
├─────────────────────────────────┤
│ 👁 🔒 [████████░░] Floor ▸ │
├─────────────────────────────────┤
│ 👁 🔒 [██████░░░░] Furniture ▾ │
│ └─ 🛏 Bed [缩略] 👁 │ ← 家具层显示语义图标
│ └─ 🛋 Sofa [缩略] 👁 │
│ └─ 📺 TV [缩略] 👁 │
├─────────────────────────────────┤
│ 🎨 Materials ▾ │ ← 材质层独立形态(非几何)
│ └─ mat_wall_paint [色块] │
│ └─ mat_floor_wood [贴图] │
│ └─ mat_bed_fabric [色块] │
└─────────────────────────────────┘
```
### 4.2 每层提供的操作
| 操作 | 图标 | 行为 | 状态键(Zustand |
|------|------|------|------------------|
| **可见性 toggle** | 👁 / 🚫 | 整层 `group.visible` 切换 → 直接对应"显示/隐藏"用户需求 | `layers[kind].visible: bool` |
| **锁定** | 🔒 / 🔓 | 编辑模式下防止误操作;锁定后该层节点不响应点击/拖拽 | `layers[kind].locked: bool` |
| **不透明度滑块** | 0100 | 整层 `material.transparent = true; .opacity = v/100` | `layers[kind].opacity: 0..1` |
| **展开 / 折叠** | ▾ / ▸ | 展开后列出该层 mesh 节点(家具显示语义标签 + 缩略图) | UI 局部 state |
| **单节点 toggle** | 👁 | 仅隐藏某一个 mesh(家具层尤其常用:藏掉电视看墙) | `layers.furniture.hiddenItems: Set<itemId>` |
| **保存为视图** | ⊕ | 把当前 4 层可见性 + 相机状态打包成 "Named View",可分享/收藏 | 写入 `viewState`(见 §9 |
### 4.3 交互细节
| 触发 | 反馈 |
|------|------|
| 鼠标 hover 节点行 | 3D 场景中对应 mesh 加 `<Outline>` 高亮(淡黄色)+ 浮层显示尺寸/语义 |
| 点击节点行 | 相机飞到该 mesh + 在 3D 场景中长亮(橙色)+ 右侧弹出"材质/家具替换"面板(§5/§6) |
| 双击层标题 | 仅显示该层(其它 3 层临时隐藏),再双击恢复 |
| 拖拽不透明度滑块 | 实时(每帧)应用;松手时写入 store 触发自动保存 |
| 右键节点行 | 上下文菜单:复制 nodeId / 在新标签打开材质资产 / 报错(送 Sentry breadcrumb |
### 4.4 "图层组合"功能(Named Views
致敬 ArcGIS 的"图层组"概念,CrowdRoom 把"4 层可见性 + 相机 + 已应用的 overlay"打包成一个可分享、可命名的视图:
| 字段 | 例 |
|------|-----|
| `name` | "白模视角"、"只看家具"、"完成版" |
| `layer_toggles` | `{walls:true, floor:true, furniture:false, materials:true}` |
| `camera` | `{position:[2.4,1.6,3.0], look_at:[0,0.8,0], fov_deg:55}` |
| `overlay_id?` | 关联到某个 remix(可选) |
实现走 [`02_api_contract.md`](02_api_contract.md) E-10 `/functions/v1/view-state`token 在 URL 上:`/r/{room_id}?vs={token}`
> ✅ **契约 Web-Y4(原契约)**:解码 `vs=` 时**必须**对未知字段宽容(前向兼容),schema 升级时旧 token 不应失效。
>
> ✅ **契约 Web-Y4(本子任务新增声明)**viewState token 必须**确定性解码**gzip+base64url,无服务器随机种子),以便 `/api/og/r/[room_id]?vs=...` 的 headless Chromium 在 SSR 时**像素级复现**同一画面用于 OG 卡片;详见 §9。
### 4.5 LayerPanel React 组件骨架
```tsx
// components/layer-panel/LayerPanel.tsx
"use client";
import { useLayerStore } from "@/stores/layer-store";
import { Eye, EyeOff, Lock, Unlock, ChevronDown, ChevronRight } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import type { LayerKind } from "@/types/manifest";
const LAYERS: LayerKind[] = ["walls", "floor", "furniture", "materials"];
export function LayerPanel() {
const layers = useLayerStore((s) => s.layers);
const toggle = useLayerStore((s) => s.toggleLayerVisible);
const lock = useLayerStore((s) => s.toggleLayerLocked);
const setOpacity = useLayerStore((s) => s.setLayerOpacity);
const expand = useLayerStore((s) => s.toggleExpanded);
return (
<aside className="w-80 border-l h-full overflow-y-auto bg-background">
<header className="p-3 flex items-center justify-between border-b">
<h2 className="text-sm font-semibold">Layers</h2>
<SaveViewButton />
</header>
<ul>
{LAYERS.map((kind) => {
const L = layers[kind];
const Expand = L.expanded ? ChevronDown : ChevronRight;
return (
<li key={kind} className="border-b">
<div className="flex items-center gap-2 p-2">
<button onClick={() => toggle(kind)} aria-label={`Toggle ${kind} visibility`}>
{L.visible ? <Eye size={16} /> : <EyeOff size={16} className="opacity-40" />}
</button>
<button onClick={() => lock(kind)} aria-label={`Lock ${kind}`}>
{L.locked ? <Lock size={16} /> : <Unlock size={16} className="opacity-40" />}
</button>
<Slider
className="flex-1"
value={[L.opacity * 100]}
onValueChange={([v]) => setOpacity(kind, v / 100)}
min={0} max={100} step={1}
aria-label={`${kind} opacity`}
/>
<button onClick={() => expand(kind)} aria-label={`Expand ${kind}`}>
<Expand size={16} />
</button>
</div>
{L.expanded && <LayerNodes kind={kind} nodes={L.nodes} />}
</li>
);
})}
</ul>
</aside>
);
}
```
`LayerNodes` 子组件渲染每个 mesh 的小行(含语义图标 + 缩略图 + 单节点 👁),代码同构。整个面板约 60 行 TSX 加上 `Slider` / `SaveViewButton` 共 ~120 行——shadcn/ui 已经把 A11y 做好,键盘 Tab/Space 即可操作所有按钮(呼应 §10)。
---
## 5. 材质替换 UX 🌟
> **本节直接回应用户原始需求"更换材质"。** 材质替换不改几何,是最轻量的 Remix 形态,必须做得"所见即所得"。
>
> ✅ **契约 Web-Y2**Remix 必须**浏览器内实时合成**(父 glb + 父 manifest + overlay),不请求服务端预合成。材质替换天然只改 `material.map`/`material.color`,完全可在 Web 端用 Three.js 一次性替换 PBR 槽位即可——这是 Web-Y2 落地的最佳证据。
### 5.1 进入材质替换的入口
| 入口 | 行为 |
|------|------|
| 在 3D 场景中点击墙/地/家具 mesh | 右侧抽屉切到 **"Material" tab**,展示该 mesh 当前 PBR 槽位(base_color / normal / roughness / metallic / AO |
| 在图层面板 Materials 层点击某个 `slot_id` | 同上 |
| 命令面板(`Cmd+K`)输入 "material" | 列出所有 slot,键盘选择 |
### 5.2 材质槽位面板(MaterialSlotPanel
```
┌─────────────────────────────────────┐
│ Material · floor_0 ▾ │
├─────────────────────────────────────┤
│ Current: │
│ [base_color preview] Oak Natural │
│ [normal preview] │
│ roughness ▓▓▓▓▓▓░░░░ 0.62 │
│ metallic ░░░░░░░░░░ 0.00 │
├─────────────────────────────────────┤
│ Replace with: │
│ [Wood] [Tile] [Fabric] [Metal] │
│ [Paint] [Wallpaper] [Favorites] │
│ ┌────┬────┬────┬────┐ │
│ │ 🪵 │ 🪵 │ 🪵 │ 🪵 │ ← 资产网格 │
│ └────┴────┴────┴────┘ │
│ [Load more] │
└─────────────────────────────────────┘
```
### 5.3 实时预览(不重载 .glb
替换流程**全部在浏览器内**完成,无网络往返(除资产纹理 GET):
```ts
// 伪代码:从 assets 表挑选新材质后
const newAsset = await fetchAsset(assetId); // GET /rest/v1/assets?id=eq.{id}
const tex = await ktx2Loader.loadAsync(newAsset.pbr.base_color_tex);
const targetMesh = scene.getObjectByName(slot.target_mesh_id) as Mesh;
const mat = targetMesh.material as MeshStandardMaterial;
mat.map = tex;
mat.roughness = newAsset.pbr.roughness;
mat.metallic = newAsset.pbr.metallic;
mat.needsUpdate = true;
// 写入 overlay 草稿(防抖 3 s 自动保存,见 §7)
overlayDraft.push({
op: "replace_material",
target_slot_id: slot.slot_id,
asset_id: assetId,
pbr_override: { ...newAsset.pbr },
});
```
### 5.4 资产库面板(AssetPickerMaterial
| 元素 | 设计 |
|------|------|
| 分类 Tab | 木材 / 瓷砖 / 布料 / 金属 / 油漆 / 壁纸 / 收藏夹(按 `assets.tags` 过滤;与 [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.kind='material'` 对齐) |
| 网格视图 | 默认 4 列,每格 96×96,悬浮显示名称 + 来源 + 协议(CC0/CC-BY |
| 收藏夹 | 浏览器 `localStorage``favorite_asset_ids[]`;登录后写入 `users.favorites` 表(P2 加表) |
| 搜索 | 在分类内全文 `name + tags`;走 PostgREST `assets?or=(name.ilike.*q*,tags.cs.{q})` |
| 拖拽 | 支持把缩略图直接拖到 3D 场景中目标 mesh 上(HTML5 drag + raycaster 命中检测) |
| 协议筛选 | 顶部固定开关"仅显示 CC0"——MVP 默认开启,规避版权 |
### 5.5 保存为 Remixmaterial_overrides
材质替换写入 `remix_overlay.json``ops[]` 中([`02_api_contract.md`](02_api_contract.md) §4.2 已定义 `op: replace_material`):
```json
{
"op": "replace_material",
"target_slot_id": "mat_floor_wood",
"asset_id": "a8e92...",
"pbr_override": {
"base_color_tex": "assets/materials/oak_dark/base.webp",
"normal_tex": "assets/materials/oak_dark/normal.webp",
"roughness": 0.55,
"metallic": 0.0
}
}
```
**校验**Edge Function 在 `remix-publish` 时跑):
- `target_slot_id` 必须在父 manifest `materials.slots[]` 中存在且 `replaceable=true`
- `asset_id` 必须存在于 `assets` 表(`E-11` 失败时抛 `ASSET_NOT_FOUND`
- 失败 → 客户端回滚最近一次操作并 Toast 提示
### 5.6 单墙改色快捷操作(`set_wall_color`
不想拖整套贴图、只想试色时,提供"色环 picker"快捷入口:
- 点击墙 mesh → MaterialSlotPanel 顶部多一个 "Quick Color" 区
- 用 react-colorful 选色 → 直接 `material.color.set(hex)`
- 写入 overlay 的 `op: set_wall_color`[`02_api_contract.md`](02_api_contract.md) §4.3 已定义)
---
## 6. 家具替换 UX 🌟
> **本节直接回应用户原始需求"更换其他家具"。** 家具替换比材质替换复杂——要换几何 + 要对齐位置/朝向,对齐方案直接利用 [`01_data_schema.md`](01_data_schema.md) §5.2 中家具层强制保留的 `obb` 与 `anchor_point` 字段。
>
> ✅ **契约 Web-Y2 落地**:家具替换同样在浏览器内合成——隐藏父几何的 furniture 节点 + GLTFLoader 加载新 asset .glb,无需服务端介入。
### 6.1 进入家具替换的入口
| 入口 | 行为 |
|------|------|
| 在 3D 场景中点击家具 mesh | 右侧抽屉切到 **"Furniture" tab** + 自动按家具的 `semantic_class` 过滤资产库 |
| 在图层面板 Furniture 层点击某个 `item_id` | 同上 |
| 在资产库 `/assets?kind=furniture` 浏览时点 "Try in a room" | 进入 Remix 编辑器并默认锁定该 asset 为下一次点击的替换目标 |
### 6.2 替换面板(FurnitureSwapPanel
```
┌─────────────────────────────────────┐
│ Replace · bed_001 (semantic: bed) │
├─────────────────────────────────────┤
│ Original: │
│ OBB extent 2.00 × 0.60 × 1.50 │
│ anchor_point [-0.5, 0.0, -1.4] │
├─────────────────────────────────────┤
│ Suggested ("bed" assets): │
│ ┌────┬────┬────┬────┐ │
│ │ 🛏 │ 🛏 │ 🛏 │ 🛏 │ │
│ └────┴────┴────┴────┘ │
│ ☑ Snap to anchor (auto-align) │
├─────────────────────────────────────┤
│ Fine-tune (after select): │
│ X ▓░░░ +0.00 m │
│ Y ░░░░ +0.00 m │
│ Z ░░░░ +0.00 m │
│ Rot Y ⟳ 0° │
│ Scale ▓▓░░ 1.00x │
└─────────────────────────────────────┘
```
### 6.3 自动对齐(OBB-based
新家具的 anchor 与原家具 OBB 对齐遵循以下规则:
| 步骤 | 公式 / 行为 |
|------|------------|
| 1. 拉取新 asset 的 `anchor_point`(资产侧元数据,运营录入) | `assets.pbr.anchor_point` 或默认底面中心 `[0, -extent_y/2, 0]` |
| 2. 计算变换矩阵 `T` | `T = translate(original.anchor_point) · quat(original.obb.quat) · translate(-new_asset.anchor_point)` |
| 3. 应用到新 asset 的 Group | `assetGroup.matrix.copy(T); assetGroup.matrixAutoUpdate = false;` |
| 4. 若资产 OBB extent 与原 extent 比超过 1.5× | 给警告 toast "新家具明显大于原家具,可能溢出墙面" |
| 5. 隐藏原 furniture 节点 | `scene.getObjectByName(item.mesh_node_ids[0]).visible = false` |
> **关键设计动机回顾**[`01_data_schema.md`](01_data_schema.md) §5.2 让 `furnitureItem` 强制包含 `obb` 与 `anchor_point` 两个字段,正是为了让 Web 端能"零网络往返"实现自动对齐——这里是该设计的直接消费方。
### 6.4 手动微调
自动对齐之后,用户仍可在 X/Y/Z 平移 + Y 轴旋转 + 等比缩放四个自由度上微调(不开放任意 6DoF 自由度,避免家具"飘起来"或"贴墙穿模"):
| 自由度 | 范围 | UI |
|--------|------|------|
| 平移 X/Y/Z | ±0.5 m | 三个滑块 |
| 旋转 Y | 0360° | 圆形旋钮 |
| 缩放 | 0.7×–1.3× | 滑块;等比,禁止非等比避免视觉怪异 |
3D 场景中同时显示 Three.js `<TransformControls>`gizmo),与滑块双向绑定。
### 6.5 隐藏原家具 vs 删除原家具
> ✅ 关键决策:**永远是"隐藏 + 叠加新 asset",不删除原几何**。
理由:
- **Remix 可回退**:用户取消替换 → 让原 furniture 节点 `visible=true` 即可,无需重新下载 `canonical.glb`
- **存储零成本**overlay 只是 5 行 JSON,不复制几何
- **审计可追**:父房间作者能在自己的 dashboard 看到"我的房间被 N 个 remix 替换了 bed 这件家具"
- **不破坏 OBB 校验**:保留原节点意味着 manifest 始终自洽,未来若加"对比模式"(原版 vs Remix 并排)零改造
写入 overlay
```json
{
"op": "replace_furniture",
"target_item_id": "bed_001",
"asset_id": "a91c2...",
"asset_glb_uri": "assets/furniture/modern_bed_oak.glb",
"transform": {
"translate": [0.02, 0, -0.05],
"rotate_quat": [0.999, 0, 0.044, 0],
"scale": [1, 1, 1]
},
"snap_to_anchor": true
}
```
### 6.6 整体隐藏(`hide_layer`
如果用户想"看清房屋骨架",可在图层面板 furniture 层点 👁,写入 `op: hide_layer, layer_kind: furniture` 即可。这是"显示/隐藏"用户需求在批量场景下的快捷形态。
---
## 7. Remix 完整流程(Web-Y2 落地)
### 7.1 端到端序列图
> ✅ **契约 Web-Y2**:Remix 编辑器在浏览器内实时合成 = 父 .glb + 父 manifest + 本地 overlay 草稿,三者都不经过服务端预合成。
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant Web as Web Client
participant CDN as CDN
participant Edge as Edge Function
participant DB as Postgres
participant Shot as Headless Screenshot Worker
U->>Web: 在 /r/room_id 点 Remix
Web->>Edge: POST functions v1 remix-create parent_version_id title 空 overlay
Edge->>DB: insert remixes overlay 空 author_id auth.uid
Edge-->>Web: remix_id overlay_path
Web->>U: 跳转 /r/room_id/edit fork 1 携带 remix_id
Web->>CDN: GET canonical.glb 父版本
Web->>CDN: GET layer_manifest.json 父版本
CDN-->>Web: 两份文件 useGLTF 缓存命中即复用
loop 编辑会话
U->>Web: 切层 换材质 换家具 调相机
Web->>Web: 修改 overlayDraft Zustand
Web->>Web: 即时应用到 Three.js 场景
Note over Web: 防抖 3 秒
Web->>Edge: PATCH functions v1 remix-update overlay
Edge->>DB: update remixes overlay updated_at
Edge-->>Web: 200 ok last_saved
end
U->>Web: 点击 Publish
Web->>Edge: POST functions v1 remix-publish remix_id viewState
Edge->>DB: update remixes is_public true
Edge->>Shot: enqueue thumbnail job remix_id viewState
Shot->>CDN: GET canonical.glb 父
Shot->>Shot: headless Chromium 渲染 重放 viewState
Shot->>CDN: PUT thumbnail.webp
Edge-->>Web: published thumbnail_path
Web->>U: 跳转 /remix/remix_id 展示发布版
```
### 7.2 自动保存与冲突解决
| 场景 | 策略 |
|------|------|
| 单用户单设备编辑 | 防抖 3 s + 失焦时立即保存 + 关闭页签前 `beforeunload` 拦截 + 浏览器 `localStorage` 双重备份 |
| 同一用户多设备 | 后开的标签拿到更新的 `updated_at` 时,给"该 Remix 已在另一处被编辑"提示,让用户选"覆盖本地" / "丢弃本地" |
| 离线编辑 | overlay 草稿写 IndexedDB(用 `idb` 库),重新联网时尝试 PATCH;若返回 `REMIX_PARENT_DELETED` → 走下面的兜底 |
| 父房间被原作者硬删 | 抓 `REMIX_PARENT_DELETED`[`02_api_contract.md`](02_api_contract.md) §7.1 已定义)→ 弹窗:"父房间已被作者删除。你的修改可保存为独立副本(自动 fork 上一个已知 ready 的父快照)";提供"保存为独立副本"与"丢弃"两个按钮 |
| 父版本未 ready | 抓 `REMIX_PARENT_NOT_READY` → 跳回 `/r/{parent_room_id}` 并显示转码进度 |
| Overlay schema 升级 | overlay 顶部带 `schema_version`;旧客户端遇到新版字段时,对未知 `op` 跳过并 warning(前向兼容) |
### 7.3 操作历史与撤销
| 元素 | 设计 |
|------|------|
| 撤销栈 | Zustand `temporal` middleware;最多 50 步 |
| 快捷键 | `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 重做 |
| 历史面板 | 顶部"History"抽屉,列出 op 类型 + 时间戳;点任一行回到该状态 |
| 草稿版本 | 每次自动保存视为一个"快照";用户可在历史面板 fork 出某个早期快照为新 remix |
### 7.4 错误码与 UI 映射
| 业务码(来自 [`02_api_contract.md`](02_api_contract.md) §7) | 用户文案 | 行为 |
|---|---|---|
| `REMIX_PARENT_DELETED` | "原房间已被删除,是否保存为独立副本?" | 双按钮选择 |
| `REMIX_PARENT_NOT_READY` | "原房间正在处理中,请稍后再来 Remix" | 跳父详情显示进度 |
| `OVERLAY_INVALID` | "本次操作未通过校验:{detail}" | 回滚最后 1 op + 上报 Sentry |
| `ASSET_NOT_FOUND` | "该资产已下架,请选择其他材质/家具" | 资产卡片置灰 + 移出收藏 |
| `QUOTA_EXCEEDED` | "本月 Remix 配额已用完,下月 1 号重置" | 跳 `/me/quota` |
| `RATE_LIMITED` | "操作太快了,请稍后再试" | 30 s 倒计时 |
---
## 8. 浏览 / 搜索 / 发现 UX
### 8.1 首页瀑布流(`/`
致敬 Pinterest,但偏 3D 场景的"展柜感"
| 元素 | 设计 |
|------|------|
| 列数 | 桌面 4 列、平板 3 列、移动 2 列;用 CSS `column-count` + `break-inside: avoid` |
| 卡片宽高比 | 16:9(与缩略图 1280×720 对齐) |
| 卡片元素 | ① 缩略图(hover 时切到 `preview.mp4` 自动播放 5 s)② 标题 ③ 作者头像 + handle ④ ❤ 数 ⑤ 🔄 Remix 数 ⑥ 标签 chips(最多 3 个) |
| 列表分页 | 无限滚动 + `IntersectionObserver`;每页 20React Query infinite query |
| 排序切换 | 顶部 "Latest / Trending / Most Remixed / Following"Trending 走 `like_count / age^1.5` 衰减公式 |
| 筛选 | 顶部 Pill:户型 / 风格 / 城市 / 质量分(与 iOS 端 `quality_grade` 联动) |
| 空状态 | "还没有公开作品" + 引导上传按钮(仅登录用户可见) |
### 8.2 房间详情页(`/r/[room_id]`)布局
```
┌──────────────────────────────────────────────────────────┐
│ ← Home / Rooms / "我的客厅" ❤ 23 🔄 5 ⋯ │ ← 顶栏 + 操作
├──────────────────────────────────────────────────────────┤
│ │ Layers │
│ ┌──────────────────┐ │ 👁 Walls │
│ │ │ │ 👁 Floor │
│ │ 3D Canvas │ │ 👁 Furniture │
│ │ │ │ 🎨 Materials │
│ └──────────────────┘ │ │
│ │ [Save view] │
│ by @alice · 2 days ago · 阳台、北欧风 │ │
├──────────────────────────────────────────────────────────┤
│ Description ... │
│ Tags: 北欧 · 客厅 · 18m² │
├──────────────────────────────────────────────────────────┤
│ Comments (12) │
│ └─ @bob: 好看! │
│ └─ @carol: 那个沙发是哪里买的? │
└──────────────────────────────────────────────────────────┘
```
### 8.3 顶栏操作
| 按钮 | 行为 | 走的 API |
|------|------|--------|
| ❤ 点赞 | toggle + 数字本地 +1/-1 + 防抖 600 ms | `E-12 like-toggle`Web-Y3 |
| 🔄 Remix | 走 §7.1 序列图第 1 步 | `E-11 remix-create` |
| 📤 分享 | 弹分享面板(§8.4) | `E-10 view-state` |
| ⚠ 举报 | 举报弹层(reason 选项) | `E-14 report` |
| ⋯ 更多 | 嵌入代码 / 在新标签打开 / 复制 nodeId(开发者用) | 客户端 |
> ✅ **契约 Web-Y3**:点赞**必须**走 `/functions/v1/like-toggle`(幂等 + 防刷 5/s),禁止直接 INSERT/DELETE `likes` 表——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y3。本节顶栏 ❤ 按钮的实现严格走 E-12,且乐观更新 UI(先 +1,请求失败回滚)。
### 8.4 分享面板(SharePanel
| 元素 | 行为 |
|------|------|
| 复制链接 | `/r/{room_id}?vs={current_viewState_token}`,含当前图层组合 |
| 微博 / Twitter | 直链跳官方分享 endpoint,预填标题 + 链接 |
| 微信扫码 | 用 `qrcode` 库前端生成 PNG 二维码,提示"用微信扫一扫"(解决 iOS Safari → 微信深链难题) |
| 嵌入代码 | `<iframe src="https://crowdroom.app/embed/r/{room_id}?vs={token}" width="640" height="360" frameborder="0"></iframe>` 一键复制 |
| 下载缩略图 | 直接 GET `thumbnail@2x.webp`CDN 公开 URL |
### 8.5 评论区
| 元素 | 设计 |
|------|------|
| 列表 | 平铺时间序(最新在上),每条含头像/handle/正文/时间/回复按钮 |
| 回复 | 一级回复(`comments.reply_to`),不做多级嵌套(控制 UI 复杂度) |
| 提交 | 走 PostgREST `POST /rest/v1/comments`RLS 校验作者 = `auth.uid()`);乐观更新 |
| 长度限制 | 1000 字符上限,剩余字数实时提示;超出按钮置灰 |
| 删除 | 评论作者或房间 owner 可删(与 [`01_data_schema.md`](01_data_schema.md) §3.7 RLS 一致) |
> ✅ **契约 Web-Y6**[`02_api_contract.md`](02_api_contract.md) §8.2 已定义):评论提交后**乐观更新** UI,失败时回滚——`RLS_DENIED` 表示用户已被该房间作者拉黑,提示"暂无评论权限"。
### 8.6 资产库独立浏览页(`/assets`
为不想 Remix、只想"逛素材"的用户提供一个独立入口:
| 元素 | 设计 |
|------|------|
| 顶部 Tab | Furniture / Material |
| 侧栏筛选 | semantic_class(家具)/ tags(材质)/ licenseCC0 only 默认) |
| 卡片 | 缩略图 + 名称 + 协议 + 来源 + "Try in a room"(跳 Remix |
| 详情弹窗 | 3D 单品预览(`<model-viewer>` 即可,不上 R3F+ 元数据 |
---
## 9. SSR / SEO / OG 卡片(Web-Y4 落地)
> ✅ **契约 Web-Y4(强化版)**viewState `?vs=` token 必须**确定性解码**且**可被 SSR OG 截图服务复现**——即 `/api/og/r/[room_id]?vs={token}` 用 headless Chromium 渲染时,产出的 OG 图与用户当前浏览器画面像素级一致。这是 Web-Y4 在本子任务里的最终形态:前向兼容(原契约)+ SSR 可复现(本子任务新增)两件事一起绑死。
### 9.1 SSR meta 标签结构
每个 `/r/[room_id]` SSR 时输出:
```html
<meta property="og:title" content="我的客厅 by @alice · CrowdRoom" />
<meta property="og:description" content="北欧风 · 18.4 m² · 6 件家具 · 23 ❤" />
<meta property="og:image" content="https://crowdroom.app/api/og/r/{room_id}?vs={token}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://crowdroom.app/r/{room_id}?vs={token}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://crowdroom.app/api/og/r/{room_id}?vs={token}" />
<link rel="canonical" href="https://crowdroom.app/r/{room_id}" />
```
### 9.2 OG 卡片动态生成 `/api/og/r/[room_id]`
| 阶段 | 行为 |
|------|------|
| 解析 query | 解码 `?vs={token}`gzip+base64url)得到 `{layer_toggles, camera, overlay_id?}` |
| 拉取数据 | CDN GET 父 `canonical.glb` + 父 `layer_manifest.json` + 可选 overlay |
| 渲染 | Vercel Edge Function 内 headless Chromium`@vercel/og` + `three.js` worker)以 1200×630 渲染一帧 |
| 缓存 | `Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800`;缓存键 = `room_id + version_no + viewState_hash` |
| 失败兜底 | 渲染超时 → 返回 `thumbnail@2x.webp` 静态缩略图(仍是合格 OG 图) |
> **为什么把 OG 生成放在 Edge 而不是转码 Worker**:转码 Worker 一次只跑一份 `canonical thumbnail`,无法为每个 viewState 变体单独生成;Edge 按需 + 长缓存最划算。MVP 可暂时只生成"默认视图"的 OG 图(即忽略 `?vs=` 时直接返回 `thumbnail@2x.webp`),把动态 OG 列入 P1。
### 9.3 viewState token 编码契约(确定性)
| 字段 | 序列化规则 |
|------|--------|
| 排列顺序 | 固定 alphabetical`camera` < `layer_toggles` < `overlay_id`),保证哈希稳定 |
| 浮点精度 | 相机位置/look_at 保留 3 位小数;fov 保留 1 位 |
| 编码 | `gzip``base64url`(无 padding |
| 版本 | 头部 1 字节 magic `0x01` 标识 schema_version |
| 解码宽容 | 未知字段忽略 + warningWeb-Y4 原契约) |
> 该编码契约同步给 `view-state` Edge Function[`02_api_contract.md`](02_api_contract.md) E-10)——服务端与客户端使用**同一份** TypeScript 库(`packages/viewstate-codec`Turborepo 共享包),保证编码一致。
### 9.4 robots.txt 与 sitemap
| 资源 | 规则 |
|------|------|
| `robots.txt` | 允许爬 `/``/r/*``/u/*``/assets``/about`;禁爬 `/r/*/edit``/me/*``/notifications` |
| `sitemap.xml` | 每天定时(pg_cron)生成;含所有 `visibility='public'` 的 rooms 与 remixes |
| Hreflang | `<link rel="alternate" hreflang="zh-CN"...>``hreflang="en-US"` 配对 |
| 结构化数据 | 房间详情页输出 schema.org `3DModel` JSON-LD(提升 Google rich result |
### 9.5 CDN 与 JWT 严格分离
> ✅ **契约 Web-Y5**:缩略图与 `.glb`/`.json` 一律走 CDN 公开 URL`/storage/v1/object/public/...`),**不要**发起带 JWT 的请求——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y5。
实现要点:
- Supabase JS Client 拉公开资源时使用 `getPublicUrl(path)`**禁止**走 `download(path)`(后者会带 JWT 命中私有 bucket 路径)
- 公开 CDN URL 写入 manifest 的 `glb_uri` 时已是相对路径,前端拼前缀 `process.env.NEXT_PUBLIC_CDN_BASE` 即可
- Sentry breadcrumb 监控所有对 `/storage/v1/` 的请求,若 header 带 `Authorization` 直接抛 dev 警告
---
## 10. 性能与可访问性
### 10.1 性能优化
| 项 | 措施 |
|----|------|
| Code splitting | Three.js + R3F 在 `/r/*` 路由动态 `import('@react-three/fiber')`;首页 bundle ≤ 180 KB gzip |
| 图片 | Next/Image + AVIF/WebP 自动协商;缩略图主图 priority + lazy 同屏 |
| 字体 | next/font 本地内联,subset 仅含中英常用字符 |
| 关键资源 preload | `<link rel="preload" as="fetch" href="...layer_manifest.json">`(详情页 SSR 时输出) |
| 路由预取 | `<Link prefetch>` 默认开(卡片 hover 预拉 manifest |
| Service Worker | MVP 不上 PWAP1 可加(offline 缓存 manifest + 资产) |
| Web Vitals 目标 | 首页 LCP < 2.5 s / 详情页 LCP < 3 s(不含 3D 渲染)/ INP < 200 ms |
| 监控 | Vercel Analytics + PostHog 自定义事件 `3d_first_frame_ms` |
### 10.2 可访问性(A11y
| 项 | 措施 |
|----|------|
| 键盘导航 | 所有按钮 / 滑块 / 列表 Tab 可达;Esc 取消当前选中 mesh / 关闭面板 |
| 3D Canvas 不可达 fallback | 提供"图层 + 房间元数据"的纯 HTML 视图(`<details>` 列出 manifest 内容),ARIA `role="img" aria-label="3D 房间预览"` |
| 颜色对比 | WCAG AA 级;图层面板的"已隐藏"状态用 0.4 opacity + 图标双重提示,不只靠颜色 |
| 屏幕阅读 | LayerPanel 每行有 `aria-label="Walls layer, visible, opacity 80%"` |
| 动效 | 尊重 `prefers-reduced-motion`,关闭相机飞行缓动 |
| 国际化 | `zh-CN` / `en-US` 双语,路由前缀 `/zh` / `/en`(默认 `/zh` |
### 10.3 浏览器兼容矩阵
| 浏览器 | 最低版本 | 3D 体验 | 备注 |
|--------|---------|---------|------|
| Chrome / Edge | 110+ | 全功能 | 主目标 |
| Firefox | 110+ | 全功能 | KTX2 需要启用 WebGL2(默认开) |
| Safari (macOS) | 16+ | 全功能 | 关掉阴影避免卡顿 |
| Safari (iOS) | 16+ | 30fps 限速 | 同 §3.3 移动端预算 |
| 微信内嵌 X5 | 部分 | iOS 走 WKWebView 同 SafariAndroid X5 fallback 到 `<model-viewer>` | 详情页 banner 提示"用浏览器打开体验更佳" |
---
## 11. MVP 范围与不做项
### 11.1 MVP8 周内交付,与 [`00_overview.md`](00_overview.md) §7.1 对齐)
| 模块 | 交付物 |
|------|--------|
| 路由骨架 | R-01 / R-02 / R-03 / R-04 / R-06 / R-07 / R-09 / R-10 / R-13 / R-15 共 10 条 |
| 3D 渲染 | manifest 4 层 group + OrbitControls + fit-to-bbox + Outline 高亮 |
| 图层面板 | §4 全部交付(4 层 + 显示/隐藏 + 锁定 + 不透明度 + 单节点 toggle + Save View |
| 材质替换 | §5 全部(资产库筛选 + 实时预览 + 写入 overlay + 单墙改色) |
| 家具替换 | §6 全部(同语义筛选 + OBB 自动对齐 + 手动微调 + 隐藏原节点) |
| Remix 编辑 | §7.1 全流程 + §7.2 单设备自动保存 + §7.4 6 条错误码兜底 |
| 浏览 / 搜索 | §8.1 / §8.2 / §8.3 / §8.5(评论、点赞)+ §8.4 复制链接与嵌入代码 |
| 登录 | Supabase Auth:邮箱 + Sign in with Apple + Sign in with Google |
| 个人主页 | `/u/[handle]` + `/me`(含配额展示 §E-15 |
| SEO 基本盘 | SSR + 静态 OG(用 `thumbnail@2x.webp`+ sitemap + robots |
| i18n | 中文/英文双语切换 |
| 监控 | Sentry + PostHog event funnel |
### 11.2 明确不做(MVP 外)
-**实时多人协同编辑**Remix 走 fork,与 iOS Flow B 决策一致)
-**VR / AR 模式**drei `@react-three/xr` 留口子,P2 再上)
-**AI 自动配色 / 风格推荐**(成本与争议都大)
-**电商导流 / 家具购买链接**(合规审查负担,P2 才考虑)
-**付费贴图市场 / 创作者分成**(社区先长内容再谈商业化)
-**动态 OG 卡片**(按 viewState 像素级复现的 OG 列入 P1,MVP 用静态缩略图)
-**PWA / 离线模式**
-**3D 编辑器加新几何**(如新增装饰墙、新增门窗)——MVP 仅允许"换"与"隐藏",不允许"加几何"
-**多人评论实时推送**(评论提交后用 React Query 失效缓存重拉,不接 Realtime channel
-**历史快照 fork**(§7.3 提到的"从某早期快照 fork 新 remix"列入 P1
---
## 12. 风险与开放问题
| # | 风险 / 开放问题 | 当前判断 | 待后续讨论 |
|---|---------------|---------|-----------|
| **R-Web-1** | **移动端 Safari 上 Three.js 性能下限**iPhone 13 Mini / SE 等设备在大场景(>3 MB)可能跌到 20 fps | §3.3 已定移动预算 ≤ 2 MB;若 95 分位仍掉帧,则 Tier 1 自动降到 `<model-viewer>` 静态预览 | 是否需要后端转码 Worker 额外产 `canonical_mobile.glb`(更激进压缩)? |
| **R-Web-2** | **大场景首屏 TTI**>20 MB `.glb`(实际有用户扫整套别墅)会让 LCP > 6 s,OG 截图 Edge 函数也会超时 | 上传配额已限 single .glb ≤ 15 MB[`02_api_contract.md`](02_api_contract.md) §6 creator 档),但 100 MB 房间已在用户调研中出现 | 是否要在转码阶段强制拒收超 50 MB 的 .glb?或者 P1 引入 LOD |
| **R-Web-3** | **自动对齐 OBB 朝向不一致**RoomPlan 导出的 `obb.quat` 偶尔与新 asset 的 `anchor_point` 朝向相差 90°(如沙发朝向墙的方向) | §6.3 给出"超 1.5×"的告警,但**朝向**没有自动校正 | 是否在资产侧元数据中加 `facing_direction`front/back/left/right),用启发式对齐? |
| **R-Web-4** | **离线 Remix 草稿与父版本删除的冲突**:用户离线 1 周编辑,期间父房间被作者硬删 | §7.2 抓 `REMIX_PARENT_DELETED` → 提示保存独立副本;但"独立副本"是否承诺保留父几何快照?涉及版权与法务 | 子任务 5 隐私治理章节需明确:父房间被作者删除后,其几何快照能否被未发表的 remix 继承 |
| **R-Web-5** | **公共资产库的版权审查负担**:MVP 只用 CC0,但用户社区可能要求引入 CC-BY 甚至付费素材 | 暂只接 CC0;前端硬过滤所有非 CC0 的 asset | 何时上"创作者上传素材"通道?需要审核流水,子任务 5 共同规划 |
| **R-Web-6** | **Vercel Edge Function 不支持 WebGL**headless Chromium 在 Edge 上的 GPU 不可用) | §9.2 的"动态 OG"实际需放在容器(Fly.io)而不是 Vercel Edge | 是否引入专门的"screenshot worker"容器?还是 MVP 完全跳过动态 OG? |
---
## 13. 给子任务 5(隐私治理)的契约要点
Web 端在分享面板(§8.4)、OG 卡片(§9.2)、Remix 失败兜底(§7.2 父被删后的独立副本)、资产库版权(R-Web-5)处与隐私治理章节有强耦合。子任务 5 撰写隐私治理总章时**必须保证**以下要点:
| # | 治理章节必须保证 | 与本章对应 |
|---|------------------|-----------|
| **P-W-1** | 明确"viewState 分享链接"能否泄露隐藏图层的内容——即第三方拿到 `?vs=...` 后能否反向取消隐藏看到原始几何 | §4.4 + §9.3viewState 只记录"可见性"开关,**不持有几何**,因此天然不泄露隐藏几何;治理章应文字明确这一点 |
| **P-W-2** | 明确 OG 卡片中"作者头像 + 房间标题"是否对外公开——`unlisted` 房间是否允许 OG 图被搜索引擎抓取 | §9.1 + §9.4:`unlisted` 房间应在 robots.txt 阻止 OG endpoint,仅持链访问 |
| **P-W-3** | 明确 Remix 父房间被作者硬删后,已发表的 remix 是否可继续承载父几何快照(涉及"作者撤回权 vs Remixer 既得权"冲突) | §7.2 + R-Web-4 |
| **P-W-4** | 明确公共资产库的协议白名单——是否在 P1 开放 CC-BY?是否要求二次创作 attribution | §5.4 + R-Web-5 |
| **P-W-5** | 明确举报通道(E-14)在 Web 端的可达性——是否要求所有公开页面都有"举报"按钮 | §8.3 ⚠ 举报按钮 |
| **P-W-6** | 明确"嵌入代码"iframe)的频次/速率限制——iframe 嵌入到广告联盟时是否计入原房间作者的流量计费 | §8.4 嵌入代码 |
| **P-W-7** | 明确视图分享 `?vs=` 是否可被 Realtime 服务用作"用户偏好"画像(合规风险) | §9.3 viewState 编码契约 |
---
## 14. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **15 条路由 / 10 项技术栈** | Next.js 14 App Router + R3F + Zustand + Supabase SSR + Tailwind/shadcn,部署 Vercel |
| **3D 渲染契约** | manifest → 4 层 group → Zustand 双向绑定;桌面 60 fps / 移动 30 fpsMVP 不做 LOD |
| **类 ArcGIS 图层面板** | 4 层固定 ID + 👁/🔒/不透明度/单节点 toggle + Named Views 分享;直接回应用户原始需求"显示/隐藏" |
| **材质替换 UX** | 浏览器内 `material.map` 替换 + 资产库 CC0 默认 + overlay 5 行 JSON;直接回应"换材质" |
| **家具替换 UX** | OBB 自动对齐 + 4 自由度微调 + 隐藏原节点不删除;直接回应"换其他家具" |
| **Remix 浏览器实时合成** | 父 .glb + 父 manifest + 本地 overlay 三件套,零服务端预合成;防抖 3 s 自动保存 + 6 条错误码兜底 |
| **OG 卡片可复现 Web-Y4** | viewState gzip+base64url 确定性编码;Edge 截图按需缓存 24 h |
| **6 条契约落地** | Web-Y1 §3 / Web-Y2 §5 §6 §7 / Web-Y3 §8.3 / Web-Y4 §4.4 §9 / Web-Y5 §9.5 / Web-Y6 §8.5 |
| **7 条治理移交** | viewState 隐私 / unlisted OG / Remix 父删除 / 资产协议白名单 / 举报可达 / 嵌入流量计费 / vs token 画像 |
读完本章你应能:
- ✅ 给 Web 工程师一份 8 周内可交付的功能清单与路由蓝图
- ✅ 评审"换材质 / 换家具 / 图层切换"三大核心交互的端到端可行性
- ✅ 知道 Web-Y1~Y6 每条契约具体落在哪一节
- ✅ 接手子任务 5 时知道隐私治理章节要回答 Web 侧的 7 个问题
---
**章节版本**v0.1 · 草案
**关键收获**Web 端是 CrowdRoom 直接面向"灵感党 + Remixer"的体验门面——用 Next.js + R3F 把 [`01_data_schema.md`](01_data_schema.md) 的 `layer_manifest.json` 和 [`02_api_contract.md`](02_api_contract.md) §4 的 `remix_overlay.json` 翻译成"类 ArcGIS 分层 + 拖拽换材质换家具"的消费级体验;所有 Remix 合成都发生在浏览器,服务端只承担鉴权、存储、防刷与可选 OG 截图。