Merge branch 'feat/r2-address-design' into master

R2 收货地址 + 设计清单服务端化:地址/清单完整 CRUD、本地 store 降级缓存层、
DIY 修正批次(草稿持久化/SUBMITTED 触发点/贴纸归一化/edits 参数化保存)、
契约文档同步(api-contract §5 措辞修正、design-data 实现注记)、
跨线路交接文档 docs/routes/R2/

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-12 20:40:48 +08:00
co-authored by Claude
29 changed files with 1286 additions and 342 deletions
-8
View File
@@ -133,14 +133,6 @@
</image>
</template>
<template name="tmpl_0_15">
<canvas canvas-id="{{i.p0}}" disable-scroll="{{xs.b(i.p1,!1)}}" binderror="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongtap="eh" type="{{i.p2}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</canvas>
</template>
<template name="tmpl_0_8">
<block>{{i.v}}</block>
</template>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -82,6 +82,14 @@ interface DesignDataV1 {
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`
WCD 打包器同时兜底读旧字段。
> **实现注记(2026-09-12DIY 修正,非版本升级)**`stickers[].width/height` 的语义为
> **画布显示像素**(坐标空间 = `category.mask` 的宽高),`x/y` 使用画布坐标空间
> (原点 = mask 左上角;**允许超出画布边界**——2026-09-12 需求确认:贴纸可拖出画布
> 自由摆放,渲染端裁切显示,WCD 打包按原值还原,不做钳制)。
> 渲染、碰撞检测、WCD 打包三方按同一语义消费,打包时不再做像素换算。
> 历史数据中按「图片原始像素」写入的值(width > mask 宽高)由读取端按 mask 等比归一化兼容,
> 不做存量迁移。`addSticker` 时即归一化到画布空间(建议初始占 mask 短边 60%)。
## 3. 冻结约束(R2 需落地)
| # | 约束 | 说明 |
+232
View File
@@ -0,0 +1,232 @@
# DIY 工作台修正工作流(R2 收口后问题清单)
> 2026-09-12 真机验收后由需求方提出 6 个问题,本文档逐项记录:现象 → 根因(代码依据)→
> 修复方案 → 契约影响 → 留意点。
> 文件所有权:问题 1-5 全部在 R2 范围内(`pages/diy/*`);问题 6 的修复点在
> `pages/checkout/index.tsx`**R3 红线文件**,需 R3 接手或经批准后最小侵入)。
---
## 问题 1:重叠判定误报——视觉上没重叠却判为重叠
**根因(已定位,实锤)**:碰撞盒与视觉尺寸不一致。三个因素叠加:
1. `addSticker``getImageInfo` 取**图片原始像素**作为 `width/height`(如 640×854),
而画布 `canvas-area` 只有 280~420px——贴纸元素天然比画布大;
2. CSS `.canvas-image``max-width: 100%; max-height: 100%``diy/index.scss:104`),
**渲染时把元素钳制到画布内**,且 Image `mode='aspectFit'` 还会在盒内留白——
但碰撞检测 `checkOverlap` 用的是 `width × scale`(原始像素),**完全没考虑钳制**;
3. 贴纸初始位置固定 (0,0),且坐标不钳制(数据库已出现 `x: -111.93` 的负值,
贴纸可以被拖出画布外)。
结果:两张视觉上分开的贴纸,碰撞盒仍然巨大且互相覆盖 → 误报。
**修复方案**
1. `addSticker` 时**归一化贴纸尺寸**:按 mask 画布等比缩放,使 `max(w, h) = min(mask.w,
mask.h) × 0.6``scale=1` 起步——从此 `width/height` 语义 =「画布显示像素」,
渲染、碰撞、WCD 打包三者自洽;
2. `canvas-image` 保留 max 钳制作为兜底,但归一化后不再触发;
3. ~~拖动时把 `x/y` 钳制在 `[0, mask.w - 显示宽]` / `[0, mask.h - 显示高]` 区间内~~
**已按需求撤销**,2026-09-12:贴纸允许拖出画布外自由摆放,负坐标为合法状态;
误报根因是尺寸语义而非坐标,归一化后已消除)。
**契约影响**`designData.stickers[].width/height` 的**语义**从「原始像素」变为
「画布显示像素」。这不是字段增删,但 WCD 打包按 `width × scale` 还原布局,语义必须
自洽——需在 `design-data-contract-v1.md` 加**实现注记**(非版本升级,与 2MB 注记同性质)。
**留意点**
- 已落库的旧数据(如杯垫那条 640×854)是原始像素语义,新语义渲染会偏大——
需要一次性迁移(按同规则归一化)或在读取端兼容(判断 width > mask 宽则归一化);
推荐后者(读端兼容),避免写数据迁移脚本;
- WCD 打包器(词云平台侧)读到的是新语义数据,联调前要同步告知。
---
## 问题 2:确认完成前设计数据不上库,中途退出即丢失
**现象确认**:属实。当前链路:进入 DIY 即 `createDesign`DRAFT、无 designData),
`designData` 只在「确认完成」时一次性 PATCH。中途退出/切后台/杀进程 → 所有贴纸布局丢失,
只剩一条空 DRAFT 条目。
**是否正常**:是当前实现的设计取舍,但体验上不合理,应当改。
**修复方案(草稿持久化)**
1. `stickers` 状态每次变化(add/delete/move/scale**防抖 800ms** 后写入本地缓存
`updateDesign(designId, { designData: draft })`status 不动);
2. `useEffect` cleanup / `onHide` 时强制 flush 一次防抖;
3. 网络可用时异步 PATCH 服务端(同样不推状态);失败仅落缓存(现有降级语义);
4. 重新进入 DIYsource=designList)时从缓存 designData 恢复 `stickers`——
该恢复逻辑**已存在**`diy/index.tsx` 读取 `design.designData.stickers`),
草稿持久化接上后自动生效。
**契约影响**:无。DRAFT 状态携带 designData 完全合法(白名单/1MB 校验与状态无关);
不触发任何状态迁移。
**留意点**
- 拖动过程中 touchmove 每帧都触发,防抖窗口内的写入必须用最新值覆盖(闭包陷阱);
- 草稿里的贴纸 src 是 `wxfile://` 临时路径——**小程序临时文件可能被系统清理**,
草稿恢复时图片可能 404,渲染需有占位兜底(灰块/删除按钮),不要因加载失败崩页;
- 不要把「草稿自动保存」做成「自动确认」:status 必须保持 DRAFT
`SUBMITTED` 只能由用户显式确认触发。
---
## 问题 3:确认完成前清单条目一直是「未设计」
**根因**:这是阶段 0 决策#6 的字面实现——「DIY **保存设计** → SUBMITTED」。
原本地版行为是**进入 DIY 即 marking 设计中**(旧代码 createDesign 时直接写
`status: 'designing'`),R2 切服务端时按决策#6 改成了 DRAFT 起步,产生了语义回退感。
**修复方案(推荐)**:把 SUBMITTED 的触发点从「确认完成」提前到「**进入设计工作台**」:
- `diy/index.tsx` 的 `createDesignEntry` 创建后立即 PATCH `status: 'designing'`
(或 `createDesign` 成功后追加一次状态调用);从 designList 进入(继续设计)同理;
- 「待设计」筛选 tab`toDesign` = undesigned designing)不受影响;
- 配合问题 2 的草稿持久化后,语义变为「动过工作台 = 设计中」。
**契约影响**:无版本升级。契约只规定状态机方向与映射,触发点是前端实现细节;
但 `r2-workflow.md` 阶段 0 决策#6 的文字需要更新注记,`routes/R2/README.md`
给 R3 的状态触发权说明同步更新(R3 依旧独占 PROCESSING/DONE)。
**留意点**
- 「打开就进设计中」意味着用户误入也会推状态——可接受(原本地版即如此),
但验收文案要说清;
- 状态机单向,DRAFT→SUBMITTED 之后无法回「未设计」,与筛选 tab 的
「待设计」聚合展示核对一遍视觉效果。
---
## 问题 4:确认完成前进入贴纸编辑页,Canvas 空白
**根因(与问题 2 同源)**`stickerEdit` 从**本地缓存**读 `designData.stickers`
`stickerEdit/index.tsx:146-148`)。而问题 2 的现状是确认完成前 designData 根本
不在缓存里 → `sticker` 为 undefined → Canvas 没有绘制目标 → 空白。
「应该任何时候进入都有渲染图」——对,这是缺陷不是设计。
**修复方案**:**做掉问题 2 即自动修复本问题**(草稿实时写缓存 → stickerEdit 永远
能读到最新 stickers)。无独立改动。
**契约影响**:无。
**留意点**
- stickerEdit 找不到 designId/sticker 时目前静默返回(页面停 在空白 Canvas),
加一个空态提示 + 返回按钮,避免"看起来像死了";
- 修好后在真机回归一条链:DIY 加贴纸 → 不确认完成 → 直接点编辑 → 应看到该贴纸。
---
## 问题 5:贴纸编辑页渲染失败——滑块拖不动、线稿无效果、保存无变化
**根因(三个独立缺陷叠加)**
1. **滑块拖不动**:亮度/色相/对比度三个滑块用的是页内自定义 `onTouchMove` +
`Taro.createSelectorQuery().in(e.currentTarget)``stickerEdit/index.tsx:343`)。
Taro React 里 `.in()` 需要组件实例,`e.currentTarget` 是事件对象 → 查询失败/
rect 为 null → `ratio` 计算不出来 → 滑块不动。(文件顶部定义的 `TouchSlider`
组件反而是好的,但根本没被使用——死代码 + 错实现并存。)
2. **转线稿后滑块动了但画面不变**:`applyFrontendSketch` 只 `setState` 数值
(所以滑块位置跳变);画面重绘依赖 `ctx.filter`Canvas 2D),**开发者工具/
部分基础库不支持 ctx.filter** → 重绘了但滤镜无效果。真调后端 `/api/sketch`
成功时才会换 src(当前后端 sketch 为占位 → 总是走降级)。
3. **保存回 DIY 无变化**`handleSave` 用 `canvasToTempFilePath` 导出——Canvas 因
问题 4 空白/或 filter 无效时,导出的还是原图或直接失败 → 贴纸 src 没变或
toast 保存失败。
**修复方案**
1. 三个滑块统一改用已定义的 `TouchSlider` 组件(基于 ref 的
`getBoundingClientRect`,在 H5/小程序两端都可用),删除页内三段坏的
onTouchMove 实现;
2. 预览层弃用 `ctx.filter`:改用 **CSS filter 直接套在 `<Image>` 上**
`style={{ filter: 'brightness() hue-rotate() contrast()' }}`)——小程序 Image
支持 CSS filter,所见即所得且不依赖 Canvas 能力;
3. 「保存」重新设计导出链路:优先请求后端 `/api/sketch`(真实线稿);
纯滤镜场景导出保留 Canvas 方案但增加 `canvasReady` 前置校验,
Canvas 不可用时降级为「只保存 edits 参数、src 不变」——DIY 渲染端用
CSS filter 读 `edits` 实时呈现(效果等价,且这是 WCD 边界内允许的:
edits 本来就不进 document.json,只记 manifest.meta)。
**契约影响**:无版本升级。`edits` 字段契约已冻结且本期不进 WCD document.json
「edits 在小程序端渲染时应用」与契约边界一致。
**留意点**
- `edits` 应用点从「编辑页导出图」改为「DIY 渲染时套 CSS filter」后,
**DIY 画布和 checkout 预览都要消费 edits**,否则编辑效果只存在于编辑页;
checkout 是 R3 文件——本期只改 DIY 端,checkout 的滤镜预览记入 R3 待办;
- 删除 `TouchSlider` 死代码 or 启用它,二选一,不要留两套;
- 真机回归矩阵:调滑块 → 画面变;保存 → DIY 里效果保持;转线稿(后端就绪后)
→ src 被替换为持久 URL。
---
## 问题 6:设计效果确认页(checkout)新建地址后不同步
**根因**`checkout/index.tsx` 只在 **mount 时读一次**本地缓存地址
`useEffect` + `getDefaultAddress/getAddressList`),从地址页 `navigateBack` 返回后
**没有任何 onShow 刷新**。而 R2 侧的地址页在新建成功后**已经正确回写了缓存**
`setAddressList`)——数据就在缓存里,是 checkout 不重新读。
**是否 R2 工作内容****修复点在 checkoutR3 红线文件),不属于 R2**。
R2 侧职责(API 保存 + 缓存回写)已正确完成并经真机验证。
**修复方案(记入 R3 待办)**:
1. checkout 读地址改为「mount + `useDidShow`onShow)双时机」,或
2. 地址页保存成功后 `Taro.eventCenter.emit('addressesChanged')`checkout 监听刷新;
推荐前者(改动更小、无事件时序问题)。
**契约影响**:无。
**留意点**
- 已同步写入 `routes/R2/README.md` 给 R3 的待办;
- 临时绕过方案(不改代码):从确认页退出重进即同步(现况即如此)。
---
## 契约核对总表(api-contract-v1.md + design-data-contract-v1.md
2026-09-12 对两份契约逐项核对后的结论:
| # | api-contract-v1.md | design-data-contract-v1.md |
|---|---|---|
| 1 | 无需改(stickers 为 `unknown[]`,语义由子契约承载);§5 已加实现补充注记指引 | **加实现注记**width/height/x/y 语义 = 画布显示像素,历史数据读端兼容(已完成) |
| 2 | PATCH 部分更新语义后端已实现,1MB 按次校验不受高频影响;无契约文字改动 | 无 |
| 3 | 状态映射表未写死 SUBMITTED 触发点,DRAFT→SUBMITTED 合法;无契约改动(触发点调整记录在 r2-workflow 决策#6 注记) | 无 |
| 4 | 无 | 无 |
| 5 | stickers 透传 edits、§8 sketch 不变;无契约改动 | edits 边界(不进 document.json)不变 |
| 6 | §4 地址接口不变,读时机为客户端行为 | 无 |
**核对中发现并已修正的矛盾**:api-contract §5 引用块原写「贴纸图 src 必须为 COS 持久 URL
(禁止 `wxfile://`/`tmp`)」,与 design-data-contract 约束#1/决策#4R2 允许暂存本地图、
R4 派单前持久化回写)直接矛盾,且真机落库数据即为 wxfile://。已按冻结决策修正措辞
(非字段/语义变更,不升版本)。
## 修复顺序与依赖
```
问题2(草稿持久化)──┬──> 问题4(编辑页空白,自动修复)
└──> 问题3(进入即 designing,独立但同文件顺手做)
问题1(碰撞归一化 + 坐标钳制)—— 独立,含旧数据读端兼容
问题5TouchSlider + CSS filter 预览)—— 独立;edits 消费点需要 DIY 渲染端配合
问题6 —— 不修(R3 待办,已记录)
```
**建议批次**:① 问题 2+3+4(同一文件、一次回归)✅ 已实施(2026-09-12commit eea6c3f
`build:weapp` 通过,tsc 无新增错误)→ ② 问题 1(含兼容验证)✅ 已实施(2026-09-12
commit 3b224cf:归一化 + 坐标钳制 + 读端兼容 + transform-origin
**坐标钳制后经需求确认撤销**,commit 见后续,贴纸恢复可拖出画布)→
③ 问题 5(改动面最大,单独一批)✅ 已实施(2026-09-12commit 2e3676a
TouchSlider 组件化 + Image/CSS filter 预览 + edits 参数化保存 + DIY 渲染端
消费 editscheckout 预览消费 edits 仍记 R3 待办)。
**统一验证清单**:加贴纸不重叠不误报 / 拖不出去 / 中途退出重进布局还在 /
进入工作台清单变「设计中」/ 编辑页任何时候有图 / 滑块可拖 / 编辑保存后 DIY 效果保持 /
`nest build` + `build:weapp` + 契约注记同步。
+260
View File
@@ -0,0 +1,260 @@
# R2 地址 + 设计清单上链路工作流程
> 对应路线文档:`docs/routes/route-r2-address-design.md`
> 契约文档:后端 `wxmp_backend/docs/api-contract-v1.md` §4/§5、前端 `docs/design-data-contract-v1.md`
> 分支:前端 `wechat_wc` / 后端 `wxmp_backend` 均为 `feat/r2-address-design`
---
## 一、这条链路在做什么
把「收货地址」和「设计清单」两组数据从**小程序本地 Storage**openid 隔离的离线数据)
迁到**后端 PostgreSQL**(真实账号数据)。做完之后:
- 换手机、清缓存、卸载重装,地址和设计清单不丢;
- 多端(未来 web/管理后台)能看到同一份数据;
- R3 的下单闭环能直接消费服务端的地址 id 和设计清单 id(下单接口要传 `addressId``designListId`);
- R4 的 WCD 生产打包能从服务端读到完整的 `designData` JSON。
一句话:这是把"玩具数据"换成"账本数据"的一步,R3 整条交易链路都压在它上面。
---
### 阶段 0:契约确认 ✅(2026-09-11 已确认,零契约改动)
以下结论为 R2 实现的唯一依据,实现阶段不再讨论:
1. **清单映射:一条前端 DesignItem = 一条后端 DesignList 记录**`items` 数组固定 1 个元素。
依据:状态枚举是清单级实体字段(逐条映射的前提)、批量删除是 `{ids: string[]}`
`DesignList.orders` 逐条关联、R4 `wordcloud.service.ts` 已按 `items[0]` 消费。
POST /api/design-list 请求体 `{ title, items: [单个条目] }`
2. **designData.category 裁剪为 `{ id, mask, tone? }`**DIY 保存时只取这三个字段提交
(当前 `diy/index.tsx` 提交完整 ProductCategory,需改为裁剪),其余展示字段由客户端
按 productId 从 productConfig 推导。与冻结契约 §2 一致,后端白名单不放宽。
3. **productIcon 不入契约**:服务端不存图标,设计清单页继续用现有
`PRODUCT_ICON_MAP[productId]` 兜底推导(`designList/index.tsx:168` 逻辑已存在)。
4. **title 前端传 productName**:后端 `title` 保持必填不动,创建时填商品名。
5. **GET 两个列表接口不分页**:返回纯数组(地址、清单均为小数据量)。
6. **状态迁移触发点**:创建默认 `DRAFT`product 加入清单);~~DIY 保存设计 → `SUBMITTED`~~
**注记 2026-09-12**SUBMITTED 触发点提前到「进入 DIY 工作台」——新建条目创建后立即
PATCH `designing`,继续设计同样推进;见 `docs/diy-fix-workflow.md` 问题3);
`PROCESSING`/`DONE` 由 R3 订单流程驱动,前端不提交;`ordered``orderId != null` 派生,
前端只读。状态机单向推进,禁止回退。
7. **其他默认**:批量删除用单接口 `POST /api/design-list/batch-delete`
`productId` R2 不做存在性校验(R1 商品表可能未 seed);手机号只做非空字符串校验;
越权 403 / 不存在 404(契约既定)。
### WCD 格式硬性规定(R2 全程适用,依据 `docs/design-data-contract-v1.md` 冻结版 2026-08-12
> R2 是 `designData` 的**生产者**R4 下单后把它打成 `.wcd` 包(Zip`manifest.json` +
> `document.json` + `assets/`)投递给词云平台还原整套画布。还原依赖三样东西全部在
> designData 里:① 画布尺寸 ← `category.mask`;② 贴纸布局 ← `stickers[]`
> x/y/scale/width/height/rotation/zIndex);③ 贴纸图片字节 ← 每个贴纸的**持久 URL**。
> R2 保存的每一份 designData 都要为这三样负责,否则 R4 打包直接失败或画布走样。
**结构规定(后端白名单已按此实现):**
1. designData 顶层只允许 **7 个键**`version` / `category` / `background` / `wordcloud` /
`stickers` / `imageSrc` / `imagePos`(后两个为旧版兼容)。多余顶层键 → 后端 400
forbidNonWhitelisted + service 白名单双重拦截)。
2. `category` 只提交 `{ id, mask, tone? }`(阶段0 决策#2)。`mask` 是 WCD 画布尺寸来源,
**禁止裁掉**——缺 mask 的数据 WCD 只能按产品默认尺寸兜底,画布会走样(契约约束#3)。
3. `stickers[]` 内字段后端不深校验、原样透传。其中 `rotation` / `zIndex` 本期必须随 DIY
保存持久化(契约决策#2 冻结,WCD 按 zIndex 排图层,约束#4;前端类型已具备);
`edits`brightness/hue/contrast/sketchSrc)同样透传,虽然本期不进 WCD document.json。
4. 贴纸 `src` 允许暂存 `wxfile://` / `tmp` 本地路径——契约约束#1 明确允许
(R4 在下单/派单前上传 COS 并回写 src)。**R2 任何环节禁止把它当脏数据清洗掉**。
5. `wordcloud` 分组由 R4 词云生成后写入,R2 任何写入路径**不得丢弃**(约束#2):
后端 PATCH 已做部分更新语义(不传 items 不清 designData);但前端 `updateDesign`
是 items 全量替换——页面必须基于服务端最新 designData 合并改动后再整包提交。
6. 状态机不受本契约影响:`ordered` 仍由 `orderId != null` 派生,前端只读(约束#6)。
**边界(本期不做,契约 §4):** 贴纸 edits/线稿不进 WCD `document.json`(仅记 `manifest.meta`);
字体不随包携带;`.wcd` 只做「订单 → 词云平台」单向投递。
**涉及阶段:** 阶段 3(三个写入点)、阶段 4(缓存回写),注意事项已插入下文对应位置。
## 二、工作流程(按顺序)
### 原阶段 0 任务清单(存档)
1. 通读后端 `docs/api-contract-v1.md` §4(地址)、§5(设计清单)。
2. 确认状态映射表已定死(不再讨论):
| 前端显示 | 前端状态码 | 后端 DesignListStatus |
|---|---|---|
| 待设计 | `undesigned` | `DRAFT` |
| 设计中 | `designing` | `SUBMITTED` |
| 生产中 | `processing` | `PROCESSING` |
| 已下单 | `ordered`(派生) | `DONE` |
`ordered` **不落库**,由 `orderId != null` 派生;前端不提交这个状态。
3. 确认 Prisma `Address` / `DesignList` 模型字段与契约一致,缺字段先出迁移。
### 阶段 1:后端补齐 CRUD(先做,前端等它)✅(2026-09-12,后端提交 0a3c8e8
改动范围:`src/addresses/``src/design-list/`,两个模块均已注册,骨架已存在。
**addresses(当前只有 GET/POST/PATCH :id/default,且 service 全是 TODO):**
1. `PATCH /api/addresses/:id` —— 缺失,新增。
2. `DELETE /api/addresses/:id` —— 缺失,新增;若删的是默认地址,事务内把最新一条设为默认。
3. `POST` / `PATCH :id` / `PATCH :id/default` 的**默认地址唯一性事务**
`prisma.$transaction` 内先 `updateMany` 清掉该用户所有 `isDefault`,再设新默认。
4. `setDefault` 补 userId 归属校验(现在谁都能改任何人的地址,这是越权洞)。
5. 所有按 id 的路由统一:非本人 → 403,不存在 → 404。
**design-list(当前只有 GET/GET :id/POST):**
1. `PATCH /api/design-list/:id` —— 更新 `title`/`items`/状态迁移(状态机后端校验,
只允许 `DRAFT → SUBMITTED → PROCESSING → DONE` 单向推进)。
2. `DELETE /api/design-list/:id` + `POST /api/design-list/batch-delete`body `{ ids: string[] }`
一次事务删,返回实际删除数——不要让前端循环单删)。
3. `items` JSON 白名单校验:放行 `version/background/wordcloud/rotation/zIndex`
**原样透传不修改**`wordcloud` 分组是 R4 写入的,丢了 WCD 打包就失败);
单条 designData ≤ 1MB,超限 400。
4. `findOne` 补归属校验(当前 TODO)。
5. Swagger 补全以上全部接口。
### 阶段 2:前端 API 层(页面不动)✅(2026-09-12,前端提交 8438fea
1. `src/utils/api/address.ts`(现为空占位):实现
`fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress`
**唯一一处**做 `region: [province, city, district]``province/city/district` 双向转换,
页面和 store 不允许出现第二次转换。
2. `src/utils/api/design.ts`:实现
`fetchDesignList / createDesign / updateDesign / deleteDesign(s)`
做后端 `DRAFT/SUBMITTED/...` ↔ 前端 `undesigned/designing/...` 的状态码映射。
3. `src/types/index.ts``AddressItem` 增加服务端字段(服务端 id、`createdAt` 等);
`DesignItem.status``'processing'``DesignItem.orderId` 派生 `ordered` 展示。
### 阶段 3:切换页面(两个页面 + 三个写入点)✅(2026-09-12,前端提交 e557dbcWCD 红线已按上文落实)
| 文件 | 改动 |
|---|---|
| `pages/address/index.tsx` | 增删改查、设默认全部走 API;401 由 request 层统一抛出并引导登录;API 失败降级读本地缓存并提示 |
| `pages/designList/index.tsx` | 列表/筛选/批量删除走 API;状态筛选基于服务端返回的 `status` 计算,消灭魔法字符串 |
| `pages/product/index.tsx`(加入清单) | `addDesign` 写 API(需登录),失败降级本地。此时尚无 designData,创建后保持 DRAFT |
| `pages/diy/index.tsx`(保存 designData | `updateDesign` 走 API`designData.category` 裁剪为 `{id, mask, tone}`(决策#2);贴纸本地图路径允许暂存(R4 派单前才持久化,契约约束 #1)。**WCD 红线**`category.mask` 必须保留;`stickers[].rotation/zIndex` 随保存持久化;items 是全量替换——提交前必须基于服务端最新 designData(含 R4 写入的 `wordcloud` 分组)合并改动后整包提交,不得只传改动片段(见「WCD 格式硬性规定」) |
| `pages/diy/stickerEdit/index.tsx` | 同上,保存贴纸改动走 API;同样受 WCD 红线约束(合并后整包提交,rotation/zIndex 带全) |
### 阶段 4:本地 store 降级改造 ✅(2026-09-12
`utils/store/address.ts``design.ts` 保留,但语义变为"离线兜底缓存"(文件头已注明):
- API 成功 → 把服务端数据写回本地缓存(下次冷启动先展示缓存再刷新,designList/address
两页已实现缓存优先渲染);
**designData 原样存储**:不得清洗 `wxfile://` 等本地贴纸路径、不得裁剪任何字段
WCD 硬性规定 #4/#5);
- API 失败/断网 → 页面读缓存并可正常浏览,写操作提示失败;
- 登录后首次进入:只读服务端,**不自动合并**本地旧数据(联网刷新以服务端为准覆盖缓存,
`smart_design_list_<openid>` 与服务端并存的问题按路线文档风险节处理:忽略或提供
一次性导入,默认忽略)。
### 阶段 5:联调与验收 ✅(2026-09-12 核心收口)
**已验证:**
- 后端 35 项 API 测试全过(CRUD/默认地址事务/403/404/状态机/白名单/1MB/401);
- 前端 API 层 13 项断言全过(复跑于阶段 3/4 之后,确认无回归);
- 真机验收步骤 1-9、11-13 全过(清单链路/地址链路/登录态边界);
- **数据库直查验证 WCD 红线**:`category` 裁剪为 `{id, mask, tone}` 落库、`mask` 完整、
贴纸 `wxfile://` 路径按契约保留(`persistDesignMedia` 因 upload 占位静默降级);
- `build:weapp` / `nest build` 双端构建通过;
- 越权 403/404 由双账号 API 测试覆盖(真机步骤 #14 免测)。
**验收期间发现并修复的问题(非 R2 引入):**
1. 后端缺 `PATCH /api/users/me` 路由(契约 §2 遗漏)→ 已补(后端提交 3fc65e5);
2. 本机缺 `.env``OSS_BASE_URL`)导致首页大图空白 → 环境问题,已记录到
`docs/routes/R2/README.md` 公共注意第 6 条。
**未覆盖(低风险,可后补):** 断网降级真机步骤 #10(代码路径已实现,逻辑简单)。
**交接收口:** 跨线路注意事项见 `docs/routes/R2/README.md`R3 的 checkout 切换与
删除守卫、R4 的整包合并回写、R1 的 productId 校验等)。
按路线文档验收标准逐条过:
- 登录态下地址/清单全流程走真实后端;
- 越权访问返回 403、不存在返回 404(用两个账号互测);
- 默认地址永远唯一;删默认地址后自动产生新默认;
- 断网降级提示、401 引导登录;
- 前端 `npm run build:weapp` 通过、后端 `nest build` 通过;
- Swagger 涵盖全部接口;状态映射落进契约文档。
---
## 三、需要注意的部分(坑位清单)
### 后端
1. **越权是本分支最大风险**。每个按 id 操作的 service 必须带 `where: { id, userId }`
查不到时区分 404/403:先查存在性再查归属,或统一 404(避免枚举他人资源 id 时,
推荐统一 404,路线文档要求两者区分则按 403 处理——按契约文档走)。
2. **默认地址唯一性只能由后端事务保证**。前端传 `isDefault: true` 只是一个"请求"
不是规则;不要在 controller 之外有任何直接 update `isDefault` 的路径。
3. **删除默认地址的补偿**必须和删除在同一个事务里,否则会出现"全员无默认"的中间态。
4. **items JSON 是白名单透传,不是深校验**。后端只验结构和大小,不改内容;
特别是 PATCH 时要做**合并语义**确认:不传 `designData` 不得清掉已有值
(否则 R4 写入的 `wordcloud` 会被一次普通数量修改冲掉)。
5. **状态机校验**:拒绝 `DONE → DRAFT` 这类回退;`ordered` 不接受前端提交。
6. **批量删除**要么后端一个接口,要么明确部分失败语义——不要默认前端循环单删。
### 前端
1. **region 转换只写在 `api/address.ts` 一处**。散落到页面就会出现两套转换,
后续排查字段错位会花双倍时间。
2. **不要改 `checkout/index.tsx`**(红线:checkout 归 R3)。R2 只保证接口稳定,
checkout 里对本地 store 的读取暂不动,R3 接入时一并切换。
3. **不要把 `wxfile://` 临时路径当脏数据清洗掉**。DIY 保存时贴纸 src 暂为本地路径是
契约允许的(R4 在下单/派单前才持久化),后端白名单放行即可。
4. **`DesignItem.status` 增加 `processing` 后**`designList` 页的 `STATUS_STYLE`
筛选 tabs 要同步扩展,漏了会出现"生产中"条目渲染不出徽标。
5. **401 处理已有全局机制**`request.ts` 自动续登 + `onUnauthorized`),
页面里不要自己再写跳登录逻辑,重复处理会出现双弹窗。
6. **旧本地数据不迁移**。首次切换后以服务端为准,本地旧清单直接忽略;
提前和需求方确认这一点(用户可能反馈"我的设计没了"——是预期行为,需要文案兜底)。
### 跨路线影响
| 受影响方 | 影响 | 需要做的 |
|---|---|---|
| **R3 订单** | 下单接口依赖服务端 `addressId` / `designListId` | R2 保证 id 稳定、可查询;R3 接入时 checkout 改传服务端 id |
| **R4 词云/派单** | `designData` 结构和保留字段 | R2 PATCH 不得丢 `wordcloud` / `category.mask` / 贴纸本地路径 |
| **profile / settings / orderDetail 页** | 现在从本地 store 读地址和清单数 | R2 期间 API 成功会回写本地缓存,这些页面**暂不改也能读到底数据**(读的是缓存);R3/R4 后续各自切换 |
| **userDatabase 账号管理** | 靠 `getDesignListFor(openid)` 跨账号读本地清单 | 服务端化后该功能语义变化(读不到别人云端数据),属于已知降级,无需处理 |
---
## 四、影响面(改动文件总览)
**后端 `wxmp_backend`(分支 feat/r2-address-design):**
- `src/addresses/*`:补 PATCH :id、DELETE :id、默认事务、归属校验、DTO、Swagger
- `src/design-list/*`:补 PATCH/DELETE/批量删除、items 白名单与 1MB 校验、状态机、归属校验
- `prisma/`:仅当模型字段与契约不一致时才出迁移(预计不需要)
- `docs/api-contract-v1.md`:落地状态映射表与错误码(如尚未完整落地)
**前端 `wechat_wc`(分支 feat/r2-address-design):**
- `src/utils/api/address.ts``design.ts`:从空占位到完整实现(核心新增)
- `src/types/index.ts`AddressItem/DesignItem 对齐服务端结构
- `src/pages/address/index.tsx``designList/index.tsx`:数据源切 API
- `src/pages/product/index.tsx``diy/index.tsx``diy/stickerEdit/index.tsx`:写入点切 API
- `src/utils/store/address.ts``design.ts`:降级为缓存层,接口签名尽量不变以减少页面改动
**明确不动:** `checkout/index.tsx``orders`/`orderDetail` 页、`utils/store/order.ts`R3 所有)。
---
## 五、里程碑建议
| 步骤 | 产出 | 验证 |
|---|---|---|
| 1 | 后端 addresses 完整 CRUD + 事务 | Swagger 手测 + 双账号越权测试 |
| 2 | 后端 design-list 完整 CRUD + 校验 | 同上 + 超 1MB JSON 拒绝测试 |
| 3 | 前端 API 层 + 类型对齐 | `build:weapp` 通过 |
| 4 | 两个页面 + 三个写入点切换 | 真机全流程 |
| 5 | store 降级缓存 + 断网/401 场景 | 关服务端模拟断网 |
| 6 | 契约文档收口 + 双端构建 | 验收标准逐条打勾 |
+91
View File
@@ -0,0 +1,91 @@
# R2 交接收口说明(给其他线路)
> R2(地址 + 设计清单服务端化)已于 2026-09-12 完成阶段 1-5 核心验收,
> 分支 `feat/r2-address-design`(前后端成对)。本文档记录**其他线路接入时必须知道**的
> 事实与红线,避免重复踩坑。实现细节见 `docs/r2-workflow.md`。
## 已生效的事实(所有线路)
1. **地址/设计清单的真数据源是服务端**PostgreSQL),本地 `utils/store` 已降级为
「离线兜底缓存」:页面 API 成功回写缓存、失败读缓存、联网刷新以服务端为准覆盖,
**旧本地数据不自动合并**(用户反馈"我的设计没了"是预期行为,相关页面需要文案兜底)。
2. **designData 是服务端持久化 JSON**(白名单 7 键:version/category/background/wordcloud/
stickers/imageSrc/imagePos,单条 ≤1MB)。DIY 保存时 `category` 裁剪为 `{id, mask, tone}`
`mask` 已验证完整落库(WCD 画布尺寸来源)。
3. **真机验证**:贴纸 `src` 目前落库为 `wxfile://` 本地路径(`persistDesignMedia` 因后端
upload 模块占位而静默降级保留本地图)——契约允许,但 **R4 的 COS 上传是派单前硬前置**
4. **后端补齐了 `PATCH /api/users/me`**(契约 §2 历史遗漏,service 早有 updateProfile 但
controller 未注册路由,Profile 页保存资料曾 404)。
5. 后端 JSON body 上限 2MB(≤1MB 受理 / 1MB~2MB → 400 / >2MB → 413),
**multipart 上传不走此限制**
6. 环境坑:前端 `.env``OSS_BASE_URL`)不入库。新机器 clone 后必须手动创建,否则
`assetUrl('/img/...')` 退化为找包内本地文件 → **首页大图全空白**(大图 8 月已迁 OSS
包内不再携带)。
7. **DIY 修正批次已全部落地**2026-09-12commits eea6c3f → 190e59a,详见
`docs/diy-fix-workflow.md`),新增事实:
- **清单条目会随时被 PATCH**:DIY 工作台贴纸变化防抖 800ms 静默同步服务端
(含 DRAFT 状态条目,designData 白名单/1MB 校验与状态无关),切后台/跳转前强制
flush。**R4 读改写 designData 时必须整包合并**(红线已在下方给 R4 第 1 条)。
- **SUBMITTED 触发点 = 进入 DIY 工作台**(新建创建后立即 PATCH `designing`
继续设计同样推进),不再是「确认完成」。状态机方向不变(r2-workflow 决策#6 已注记)。
- **贴纸 `edits` 现在会真实落库并被 DIY 渲染端消费**(CSS filter:亮度/色相/对比度);
贴纸 `src` 在纯滤镜编辑下保持不变(裁剪/线稿才替换 src)。
- **`stickers[].width/height` = 画布显示像素**addSticker 按 mask 短边 60% 归一化;
旧数据(原始像素语义)由读取端按 mask 等比归一化兼容,**不做存量迁移**。
- **`x/y` 允许超出画布边界**(2026-09-12 需求确认撤销了坐标钳制):负坐标/超界是
合法状态,渲染端 `overflow: hidden` 裁切显示,WCD 打包按原值还原,不要钳制。
- **页面栈返回不刷新是通病**:DIY 已加 `useDidShow` 从缓存同步贴纸;其他页面若
存在「子页面修改数据 → navigateBack 后不更新」,同样要用 onShow 双时机读取。
8. 契约核对(2026-09-12route DO/DON'T + 双契约):checkout 未改动(本分支 0 diff);
`region[]` 转换只在 `api/address.ts`;前端只提交契约内状态码(designing);
`isDefault` 唯一性由后端事务保证(本地 store 仅离线镜像);设计数据 7 键白名单
/1MB/wordcloud 保留全部遵守。**已知且已被契约接受的偏差**:贴纸 `src` 暂存
`wxfile://`route 文档 DON'T 写于冻结决策之前,以 design-data-contract
约束#1/决策#4 为准,R4 派单前必须上传 COS)。
## 给 R3(订单 + 支付)
1. **checkout 页现在必须切换服务端 id**`POST /api/orders` 要传服务端 `designListId` /
`addressId`。checkout 目前仍读本地 store(R2 红线未动它);因 R2 会把服务端数据回写
缓存,过渡期读缓存能拿到服务端 id,但 R3 接入时应直接切 API。
2. **状态触发权归 R3**`PROCESSING`/`DONE` 由订单流程驱动(前端从不提交);
`ordered``orderId != null` 派生,只读。R2 已实现单向状态机
`DRAFT→SUBMITTED→PROCESSING→DONE`(后端校验,回退 400)。
**注意(2026-09-12 变更)**`SUBMITTED`designing)现在由 R2 前端在「进入 DIY
工作台」时即提交(不再等确认完成)——R3 不要假设 DRAFT 表示"用户还没动过设计",
DRAFT 仅表示条目创建后从未打开过工作台。
3. **删除保护(R3 落地项)**R2 的 DELETE/batch-delete **没有**做"有订单关联的清单
禁止删除"的守卫。R3 建 Order→DesignList 关联时必须同步加 delete-guard 或明确外键行为
(SetNull),否则用户可删掉订单引用的设计快照。
4. orders / orderDetail / profile 页仍读本地 storeR3 接入时一并切 API
(读的是 R2 回写的缓存,过渡期数据可用)。
5. **checkout 地址读取是一次性的**(仅 mount 读缓存,无 onShow 刷新)——用户从
checkout 进入新建地址后返回不会同步,要重进页面才刷新。R3 切 checkout 时改为
mount + onShow 双时机读取(详见 `docs/diy-fix-workflow.md` 问题 6)。
6. **checkout 预览不消费贴纸 `edits`**(亮度/色相/对比度):DIY 渲染端套 CSS filter
呈现编辑效果,checkout 预览接入时需同样消费(R3 待办,见 diy-fix-workflow 问题 5)。
7. 金额重算:按契约用服务端 Product 真实价格 + `designList.items[0]` 快照,
忽略客户端金额;下单带 `requestId` 幂等。
## 给 R4(上传 / 词云 / 派单)
1. **回写 designData 必须整包合并提交**PATCH 的 `items` 是全量替换。R4 写入
`wordcloud` 分组或回写贴纸 COS URL 时,要先取服务端最新 designData、合并后再整包
PATCH——**不能只传改动片段**,否则会冲掉其他字段(与 DIY 保存同一条红线,
`docs/design-data-contract-v1.md` 约束#2)。
2. `persistDesignMedia``utils/api/upload.ts`)已就位且真机验证为静默降级——R4 把
upload 模块做实后,现有调用点无需改动即自动生效。
3. 前端 `DesignDataV1.category` 类型已收窄为 `{id, mask, tone}`(契约 §2 冻结形状),
R4 不要往该字段塞完整 ProductCategory。
4. DIY 贴纸目前不产生 `rotation`(无旋转手势)、`zIndex` 仅渲染层使用——两字段类型已
备好但值可能缺席,WCD 打包按 optional 处理(契约决策#2)。
5. **2026-09-12 起)`width/height` = 画布显示像素**(mask 短边 60% 归一化起步),
`x/y` 为画布坐标空间且**允许超界(含负值,不钳制)**——打包按原值还原布局即可,
不要按「图片原始像素」换算,也不要对坐标做边界裁剪(契约实现注记)。
## 给 R1(商品目录)
1. R2 的 `productId` **不做存在性校验**(当时商品表未 seed 的阶段0 决策#7)。
R1 落地后如需收紧,属于契约语义变更,需同步 `api-contract` 版本。
2. `productIcon` 不入库(决策#3):设计清单页图标按 `PRODUCT_ICON_MAP[productId]` 兜底。
R1 商品表有 `iconImg` 后可改为服务端 optional 下发(契约允许字段新增)。
+4
View File
@@ -11,6 +11,10 @@
| [R3 订单 + 支付占位](route-r3-order-pay.md) | `feat/r3-order-pay` | 服务端下单、金额重算、订单状态机、支付未配置占位 |
| [R4 上传 + 词云 + 线稿](route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` | COS 上传、wordcloud 契约冻结、sketch、任务轮询 |
**已收口路线的交接说明**(后续线路接入前必读):
- [R2 交接收口说明](R2/README.md)——R3/R4/R1 接入服务端化地址与设计清单前的事实、红线与待办。
## 合并顺序与依赖
- R1 是交易链路的前置,优先合入。
+32 -13
View File
@@ -2,7 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { getAddressList, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
import type { AddressItem } from '../../types'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -26,7 +27,17 @@ export default function AddressPage() {
const [detail, setDetail] = useState('')
const [isDefault, setIsDefault] = useState(false)
const load = () => setList(getAddressList())
const load = () => {
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getAddressList())
fetchAddresses()
.then(data => {
setList(data)
setAddressList(data)
})
.catch(() => setList(getAddressList()))
}
useEffect(() => {
load()
@@ -62,8 +73,9 @@ export default function AddressPage() {
content: '删除后将无法恢复该地址',
success: (res) => {
if (res.confirm) {
deleteAddress(id)
load()
deleteAddressApi(id)
.catch(() => deleteAddress(id)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -80,15 +92,22 @@ export default function AddressPage() {
return
}
const payload = { name, phone, region, detail, isDefault }
if (editing) {
updateAddress(editing.id, payload)
} else {
addAddress(payload)
}
Taro.showToast({ title: '保存成功', icon: 'success' })
setShowForm(false)
resetForm()
load()
const save = editing
? updateAddressApi(editing.id, payload)
: createAddress(payload)
save
.then(() => Taro.showToast({ title: '保存成功', icon: 'success' }))
.catch(() => {
// 服务端不可达时降级本地保存,联网后进入页面会刷新
if (editing) updateAddress(editing.id, payload)
else addAddress(payload)
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
})
.finally(() => {
setShowForm(false)
resetForm()
load()
})
}
const onRegionChange = (e: any) => {
+17 -4
View File
@@ -2,7 +2,8 @@ import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback } from 'react'
import './index.scss'
import { getDesignList, removeDesigns, type DesignItem } from '../../utils/store'
import { getDesignList, setDesignList, removeDesigns, type DesignItem } from '../../utils/store'
import { fetchDesignList, deleteDesigns } from '../../utils/api'
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
@@ -17,6 +18,7 @@ const STATUS_TABS = [
{ code: 'toDesign', label: '待设计' },
{ code: 'undesigned', label: '未设计' },
{ code: 'designing', label: '设计中' },
{ code: 'processing', label: '生产中' },
{ code: 'ordered', label: '已下单' }
]
@@ -25,6 +27,7 @@ const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned')
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
undesigned: { label: '未设计', cls: 'badge-pink' },
designing: { label: '设计中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-warning' },
ordered: { label: '已下单', cls: 'badge-green' }
}
@@ -38,7 +41,15 @@ export default function DesignListPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const load = useCallback(() => {
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getDesignList())
fetchDesignList()
.then(data => {
setList(data)
setDesignList(data)
})
.catch(() => setList(getDesignList()))
}, [])
const init = useCallback(() => {
@@ -100,10 +111,12 @@ export default function DesignListPage() {
content: `确定删除选中的 ${selected.size} 个条目吗?`,
success: (res) => {
if (res.confirm) {
removeDesigns(Array.from(selected))
const ids = Array.from(selected)
setSelected(new Set())
setManaging(false)
load()
deleteDesigns(ids)
.catch(() => removeDesigns(ids)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -164,7 +177,7 @@ export default function DesignListPage() {
{/* 列表 */}
<ScrollView className='design-list' scrollY>
{filtered.map(item => {
const style = STATUS_STYLE[item.status]
const style = STATUS_STYLE[item.status] || STATUS_STYLE.undesigned
const rawIcon = (item.productIcon || '').startsWith('/icon/') || /^https?:/i.test(item.productIcon || '')
? item.productIcon
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
+3
View File
@@ -103,6 +103,8 @@
position: absolute;
max-width: 100%;
max-height: 100%;
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
transform-origin: top left;
z-index: 5;
border: 2rpx solid transparent;
border-radius: 8rpx;
@@ -391,6 +393,7 @@
position: absolute;
max-width: 100%;
max-height: 100%;
transform-origin: top left;
z-index: 5;
}
+185 -43
View File
@@ -1,10 +1,10 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api'
import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -27,6 +27,41 @@ export default function DIYPage() {
const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = useState(false)
/** 进入 DIY 即创建清单条目,并推进到「设计中」(问题3 修正:进入工作台即 SUBMITTED),失败降级本地 */
const createDesignEntry = (product: { id: string; name: string; price: number; icon: string }, count: number) => {
createDesign({
productId: product.id,
productName: product.name,
unitPrice: product.price,
count
})
.then(item => {
setDesignList([item, ...getDesignList()]) // 回写缓存,checkout 等页面仍读缓存
setDesignId(item.id)
return updateDesignApi(item.id, { status: 'designing' })
.then(updated => {
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: updated.status } : d))
})
.catch(() => {
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: 'designing' as const } : d))
})
})
.catch(() => {
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...getDesignList(), newDesign])
setDesignId(newDesign.id)
})
}
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
@@ -37,20 +72,9 @@ export default function DIYPage() {
const product = getProductById(productId)
if (product) {
setCategory(product)
setQuantity(Number(params ? params.quantity : undefined) || 1)
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count: Number(params ? params.quantity : undefined) || 1,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
const list = getDesignList()
setDesignList([...list, newDesign])
setDesignId(newDesign.id)
const count = Number(params ? params.quantity : undefined) || 1
setQuantity(count)
createDesignEntry(product, count)
}
return
}
@@ -64,22 +88,42 @@ export default function DIYPage() {
setQuantity(design.count)
const product = getProductById(design.productId)
if (product) setCategory(product)
if (design.designData ? design.designData.stickers : undefined) {
setStickers(design.designData.stickers)
} else if (design.designData ? design.designData.imageSrc : undefined) {
const dd = design.designData
const rawStickers = dd ? dd.stickers : undefined
if (rawStickers) {
// 问题1 留意点:旧数据 width/height 为图片原始像素语义,读取端按 mask 等比归一化
//(契约实现注记,不做存量迁移);新语义数据(≤mask 短边 60%)原样通过
const mask = (dd && dd.category && dd.category.mask) || (product ? product.mask : undefined)
const normalized: StickerItem[] = mask
? rawStickers.map(s => {
if (s.width > mask.width || s.height > mask.height) {
const k = (Math.min(mask.width, mask.height) * 0.6) / Math.max(s.width, s.height)
return { ...s, width: Math.round(s.width * k), height: Math.round(s.height * k) }
}
return s
})
: rawStickers
setStickers(normalized)
} else if (dd ? dd.imageSrc : undefined) {
// 兼容旧版数据
const s: StickerItem = {
id: 'legacy_' + Date.now(),
src: design.designData.imageSrc,
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
src: dd.imageSrc,
x: dd.imagePos ? dd.imagePos.x || 0 : 0,
y: dd.imagePos ? dd.imagePos.y || 0 : 0,
scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
width: 200,
height: 200,
isOverlapping: false
}
setStickers([s])
}
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing
if (design.status === 'undesigned') {
updateDesign(dId, { status: 'designing' })
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
}
}
return
}
@@ -89,23 +133,71 @@ export default function DIYPage() {
const found = getProductById(productId)
if (found) {
setCategory(found)
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: found.id,
productName: found.name,
productIcon: found.icon,
unitPrice: found.price,
count: 1,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
const list = getDesignList()
setDesignList([...list, newDesign])
setDesignId(newDesign.id)
createDesignEntry(found, 1)
}
}
}, [])
// ---------- 草稿持久化(修正工作流 问题2----------
// 贴纸变化防抖 800ms:本地缓存即时同步 + 服务端静默同步;不推状态(SUBMITTED 只由显式动作触发)
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestRef = useRef({ designId: '', quantity: 1, category: null as any, stickers: [] as StickerItem[] })
latestRef.current = { designId, quantity, category, stickers }
const saveDraft = () => {
const { designId: dId, quantity: qty, category: cat, stickers: st } = latestRef.current
if (!dId || !cat) return
// WCD 红线:基于缓存中服务端最新 designData 合并(保留 wordcloud 分组),只覆盖本页编辑字段
const cached = getDesignList().find(d => d.id === dId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: { id: cat.id, mask: cat.mask, ...(cat.tone ? { tone: cat.tone } : {}) },
stickers: st
}
updateDesign(dId, { designData }) // 本地缓存即时同步(问题4:贴纸编辑页从这里读取)
updateDesignApi(dId, {
item: { productId: cat.id, productName: cat.name, unitPrice: cat.price, count: qty, designData }
}).catch(() => {}) // 草稿静默降级:失败仅落缓存
}
const flushDraft = () => {
if (draftTimerRef.current) {
clearTimeout(draftTimerRef.current)
draftTimerRef.current = null
}
saveDraft()
}
// 隐藏页面(切后台/跳转)时强制落盘,防止防抖窗口内的改动丢失
Taro.useDidHide(flushDraft)
// 卸载时同样落盘
useEffect(() => flushDraft, [])
// eslint-disable-next-line react-hooks/exhaustive-deps
const skipDraftRef = useRef(true)
useEffect(() => {
if (skipDraftRef.current) { skipDraftRef.current = false; return }
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
draftTimerRef.current = setTimeout(saveDraft, 800)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stickers])
// 问题5 留意点:页面栈返回(贴纸编辑页保存后 navigateBack)不触发重新挂载,
// 需在 onShow 从缓存同步最新贴纸(src/edits),否则工作台仍渲染旧 state。
// 首次 onShow 跳过——mount 读取端已做过旧数据归一化,避免被未归一化的缓存覆盖。
const skipShowRef = useRef(true)
Taro.useDidShow(() => {
if (skipShowRef.current) { skipShowRef.current = false; return }
const dId = latestRef.current.designId
if (!dId) return
const cached = getDesignList().find(d => d.id === dId)
const list = cached && cached.designData ? cached.designData.stickers : undefined
if (list && list.length) {
setStickers(checkOverlap(list))
}
})
// 矩形碰撞检测
const checkOverlap = (list: StickerItem[]) => {
const newList = list.map(s => ({ ...s, isOverlapping: false }))
@@ -144,14 +236,17 @@ export default function DIYPage() {
Taro.getImageInfo({
src,
success: (info) => {
// 问题1 修正:归一化到画布坐标空间(width/height = 画布显示像素,契约实现注记)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / Math.max(info.width, info.height)
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: info.width,
height: info.height,
width: Math.round(info.width * k),
height: Math.round(info.height * k),
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -160,15 +255,17 @@ export default function DIYPage() {
setActiveStickerId(newSticker.id)
},
fail: () => {
// fallback 尺寸
// fallback 尺寸(同样按画布空间归一化)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / 200
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: 200,
height: 200,
width: Math.round(200 * k),
height: Math.round(200 * k),
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -200,6 +297,7 @@ export default function DIYPage() {
const handleStickerTouchMove = (e: any) => {
if (!isDragging || !activeStickerId) return
const touch = e.touches[0]
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
const next = stickers.map(s => {
if (s.id !== activeStickerId) return s
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
@@ -212,6 +310,7 @@ export default function DIYPage() {
const handleEditSticker = () => {
if (!activeStickerId || !designId) return
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
Taro.navigateTo({
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
})
@@ -226,6 +325,18 @@ export default function DIYPage() {
setStickers(checked)
}
// 问题5 修正:渲染端消费贴纸 edits(亮度/色相/对比度 → CSS filter),
// 与贴纸编辑页同一呈现逻辑;checkout 预览消费 edits 记入 R3 待办
const stickerFilter = (s: StickerItem) => {
const ed = s.edits
if (!ed) return undefined
const b = ed.brightness || 0
const h = ed.hue || 0
const c = ed.contrast || 0
if (b === 0 && h === 0 && c === 0) return undefined
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
}
const goBack = () => {
Taro.navigateBack()
}
@@ -247,10 +358,39 @@ export default function DIYPage() {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId) {
if (designId && category) {
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 })
updateDesign(designId, { designData: data })
// category 裁剪为 {id, mask, tone}(阶段0 决策#2;契约约束#3:mask 必须保留)
const trimmedCategory = {
id: category.id,
mask: category.mask,
...(category.tone ? { tone: category.tone } : {})
}
// WCD 红线:基于服务端已有 designData 合并(保留 R4 写入的 wordcloud 分组等),只覆盖本页编辑的字段
const cached = getDesignList().find(d => d.id === designId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: trimmedCategory,
stickers: data.stickers || stickers
}
// 本地缓存同步(checkout 仍读缓存)
updateDesign(designId, { status: 'designing', designData })
try {
await updateDesignApi(designId, {
status: 'designing',
item: {
productId: category.id,
productName: category.name,
unitPrice: category.price,
count: quantity,
designData
}
})
} catch {
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
}
}
setPreviewMode(false)
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
@@ -297,6 +437,7 @@ export default function DIYPage() {
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
zIndex: activeStickerId === s.id ? 10 : 5,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
@@ -382,6 +523,7 @@ export default function DIYPage() {
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
+37
View File
@@ -81,6 +81,16 @@
box-shadow: var(--shadow-card);
}
/* 预览图(Image + CSS filter,替代 Canvas 预览) */
.edit-preview-img {
width: 100%;
height: 480rpx;
background: var(--bg-input);
border-radius: 24rpx;
border: 1rpx solid var(--border-strong);
box-shadow: var(--shadow-card);
}
/* 工具面板 */
.edit-tools {
padding: 24rpx;
@@ -226,3 +236,30 @@
.mt-20 {
margin-top: 24rpx;
}
/* 空态(贴纸未找到) */
.edit-empty {
margin: 24rpx 24rpx 0;
padding: 60rpx 40rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.edit-empty-title {
font-size: 32rpx;
font-weight: 600;
}
.edit-empty-desc {
font-size: 26rpx;
opacity: 0.65;
text-align: center;
}
.edit-empty .btn-primary {
padding: 16rpx 60rpx;
border-radius: 44rpx;
}
+142 -242
View File
@@ -1,10 +1,9 @@
import { View, Text, Canvas, Image } from '@tarojs/components'
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
import { useState, useEffect, useRef, useCallback } from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { sketchImage } from '../../../utils/api'
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
/* ============================================================
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4edits 不进 WCD
document.json,仅 manifest.metasrc 保持原样对 R4 无影响)。
============================================================ */
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件 */
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect */
interface SliderProps {
id: string
value: number
min: number
max: number
step?: number
onChange: (val: number) => void
showValue?: boolean
format?: (v: number) => string
}
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
const trackRef = useRef<any>(null)
const [dragging, setDragging] = useState(false)
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
const rectRef = useRef<{ left: number; width: number } | null>(null)
const computedFromClientX = (clientX: number) => {
if (!trackRef.current) return value
const query = Taro.createSelectorQuery().in(trackRef.current)
// 注意:这里需要取 track 元素的位置信息
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
const measure = (cb?: () => void) => {
Taro.createSelectorQuery()
.select(`#${id}`)
.boundingClientRect((rect: any) => {
if (rect && rect.width > 0) rectRef.current = { left: rect.left, width: rect.width }
if (cb) cb()
})
.exec()
}
const valueFromX = (clientX: number) => {
const rect = rectRef.current
if (!rect || !rect.width) return value
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
let raw = min + (max - min) * ratio
if (step > 0) {
raw = Math.round(raw / step) * step
}
return clamp(raw, min, max)
return clamp(min + (max - min) * ratio, min, max)
}
const handleTouchStart = (e: any) => {
setDragging(true)
const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
if (!touch) return
measure(() => onChange(valueFromX(touch.clientX)))
}
const handleTouchMove = (e: any) => {
if (!dragging) return
const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
}
const handleTouchEnd = () => {
setDragging(false)
if (!touch) return
if (!rectRef.current) {
measure(() => onChange(valueFromX(touch.clientX)))
return
}
onChange(valueFromX(touch.clientX))
}
const pct = `${((value - min) / (max - min)) * 100}%`
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
return (
<View className='slider-wrap'>
<View
id={id}
className='slider-track'
ref={trackRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
>
<View className='slider-fill' style={{ width: pct }} />
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
<View className='slider-thumb' style={{ left: pct }} />
</View>
<Text className='slider-value'>{format ? format(value) : value}</Text>
</View>
@@ -90,50 +93,15 @@ export default function StickerEditPage() {
const [designId, setDesignId] = useState('')
const [stickerId, setStickerId] = useState('')
const [sticker, setSticker] = useState<StickerItem | null>(null)
const [notFound, setNotFound] = useState(false)
// 裁剪/线稿产生的 src 替换(保存时一并落库;取消则丢弃)
const [pendingSrc, setPendingSrc] = useState<string | null>(null)
// 编辑参数
const [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false)
const [canvasReady, setCanvasReady] = useState(false)
const canvasRef = useRef<any>(null)
const ctxRef = useRef<any>(null)
const canvasNodeRef = useRef<any>(null)
const canvasSizeRef = useRef<{width:number, height:number}>({width:0,height:0})
// 使用 nextTick + 重试初始化 Canvas(小程序 2D)
const initCanvas = useCallback((st: StickerItem) => {
if (canvasReady) return
Taro.nextTick(() => {
const query = Taro.createSelectorQuery()
query.select('#editCanvas')
.fields({ node: true, size: true })
.exec((res: any) => {
const resFirst = res && res[0]
const canvas = resFirst ? resFirst.node : undefined
if (!canvas) {
setTimeout(() => initCanvas(st), 300)
return
}
const ctx = canvas.getContext('2d')
let cw = (res[0] && res[0].width) || 300
let ch = (res[0] && res[0].height) || 400
if (cw <= 0) cw = 300
if (ch <= 0) ch = 400
const dpr = Taro.getSystemInfoSync().pixelRatio || 1
canvas.width = Math.round(cw * dpr)
canvas.height = Math.round(ch * dpr)
ctx.scale(dpr, dpr)
canvasNodeRef.current = canvas
ctxRef.current = ctx
canvasSizeRef.current = { width: cw, height: ch }
setCanvasReady(true)
drawSticker(ctx, cw, ch, st)
})
})
}, [canvasReady])
useEffect(() => {
const router = Taro.getCurrentInstance().router
@@ -151,96 +119,44 @@ export default function StickerEditPage() {
setBrightness((st.edits && st.edits.brightness) || 0)
setHue((st.edits && st.edits.hue) || 0)
setContrast((st.edits && st.edits.contrast) || 0)
initCanvas(st)
} else {
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
setNotFound(true)
}
}, [initCanvas])
/** canvas 上绘制带滤镜的贴纸 */
const drawSticker = (ctx: any, cw: number, ch: number, st: StickerItem, opts?: { brightness: number; hue: number; contrast: number }) => {
if (!ctx || !canvasNodeRef.current) return
const canvas = canvasNodeRef.current
// 防护:cw/ch 为 0 时容易绘制异常
if (!cw || !ch) return
const imgW = st.width || 200
const imgH = st.height || 200
const img = canvas.createImage()
img.onload = () => {
ctx.clearRect(0, 0, cw, ch)
ctx.save()
// 居中缩放显示(限制最大为画板 90%)
const scale = Math.min(cw / imgW, ch / imgH, 4) * 0.9
const w = imgW * scale
const h = imgH * scale
const x = (cw - w) / 2
const y = (ch - h) / 2
// 应用滤镜(小程序 Canvas 2D 支持 filter 属性 2.16.0+
const b = opts ? opts.brightness : brightness
const hVal = opts ? opts.hue : hue
const c = opts ? opts.contrast : contrast
if (b !== 0 || hVal !== 0 || c !== 0) {
ctx.filter = `brightness(${100 + b}%) hue-rotate(${hVal}deg) contrast(${100 + c}%)`
} else {
ctx.filter = 'none'
}
ctx.drawImage(img, x, y, w, h)
ctx.restore()
}
img.onerror = (err: any) => {
console.error('Canvas load image error:', err, st.src)
Taro.showToast({ title: '图片加载失败', icon: 'none' })
}
if (st.src) {
img.src = st.src
}
}
/** 重新绘制(参数变化时) */
const redraw = () => {
if (!sticker || !ctxRef.current || !canvasNodeRef.current) return
const { width, height } = canvasSizeRef.current
drawSticker(ctxRef.current, width, height, sticker, { brightness, hue, contrast })
}
useEffect(() => {
redraw()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [brightness, hue, contrast])
}, [])
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
const handleSave = () => {
if (!sticker || !designId) return
setLoading(true)
// 使用 Canvas 2D 导出
// @ts-ignore
Taro.canvasToTempFilePath({
canvas: canvasNodeRef.current,
quality: 0.92,
success: (res: any) => {
const newSrc = res.tempFilePath
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); return }
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); return }
stickers[sIdx] = {
...stickers[sIdx],
src: newSrc,
edits: { brightness, hue, contrast }
}
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
},
fail: () => {
setLoading(false)
Taro.showToast({ title: '保存失败', icon: 'none' })
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
const d = dList[dIdx]
const stickers = (d.designData && d.designData.stickers) || []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
stickers[sIdx] = {
...stickers[sIdx],
...(pendingSrc ? { src: pendingSrc } : {}),
edits: { brightness, hue, contrast }
}
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
const designData = { ...d.designData, stickers }
updateDesign(designId, { designData }) // 本地缓存同步
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
updateDesignApi(designId, {
item: {
productId: d.productId,
productName: d.productName,
unitPrice: d.unitPrice,
count: d.count,
designData
}
})
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
setLoading(false)
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
}
/** 裁剪:调用微信cropImage */
@@ -258,46 +174,61 @@ export default function StickerEditPage() {
success: (res) => {
const newSticker = { ...sticker, src: res.tempFilePath }
setSticker(newSticker)
// 重绘
setTimeout(() => redraw(), 200)
setPendingSrc(res.tempFilePath)
}
})
}
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
const handleSketch = async () => {
if (!sticker) return
setLoading(true)
try {
const res = await sketchImage(sticker.src)
if (res?.imageUrl) {
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
setSticker(newSticker)
setSticker({ ...sticker, src: res.imageUrl })
setPendingSrc(res.imageUrl)
setLoading(false)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no imageUrl')
}
} catch {
// 如果接口不可用/失败,降级为前端灰度+边缘检测
applyFrontendSketch()
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
}
/** 前端线稿降级方案:灰度+反相高对比 */
const applyFrontendSketch = () => {
if (!sticker || !ctxRef.current) { setLoading(false); return }
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
? 'none'
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
const bPct = `${((brightness + 100) / 200) * 100}%`
const hPct = `${((hue + 180) / 360) * 100}%`
const cPct = `${((contrast + 100) / 200) * 100}%`
if (notFound) {
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<ScrollTopMask title="编辑贴纸" targetSelector=".edit-header" showBack />
<View className='sticker-edit-page'>
<View className='edit-header' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'></Text>
<Text className='edit-save' />
</View>
<View className='edit-empty surface-card mt-20'>
<Text className='edit-empty-title'></Text>
<Text className='edit-empty-desc'></Text>
<View className='btn-primary' onTap={() => Taro.navigateBack()}>
<Text></Text>
</View>
</View>
</View>
</View>
)
}
return (
<View className={`theme-${resolvedTheme}`}>
@@ -311,13 +242,13 @@ export default function StickerEditPage() {
<Text className='edit-save' onTap={handleSave}></Text>
</View>
{/* Canvas 预览 */}
{/* 预览Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter */}
<View className='edit-canvas-wrap'>
<Canvas
id='editCanvas'
type='2d'
className='edit-canvas'
style={{ width: '100%', height: '480rpx' }}
<Image
className='edit-preview-img'
src={sticker ? sticker.src : ''}
mode='aspectFit'
style={{ filter: previewFilter }}
/>
</View>
@@ -336,73 +267,42 @@ export default function StickerEditPage() {
</View>
{/* 亮度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
// 取得滑轨位置
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setBrightness(clamp(v, -100, 100))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: bPct }} />
<View className='slider-thumb' style={{ left: bPct }} />
</View>
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
</View>
<TouchSlider
id='slider-brightness'
value={brightness}
min={-100}
max={100}
onChange={setBrightness}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 色相 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-180 + 360 * ratio) / 1) * 1
setHue(clamp(v, -180, 180))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: hPct }} />
<View className='slider-thumb' style={{ left: hPct }} />
</View>
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
</View>
<TouchSlider
id='slider-hue'
value={hue}
min={-180}
max={180}
onChange={setHue}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 对比度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setContrast(clamp(v, -100, 100))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: cPct }} />
<View className='slider-thumb' style={{ left: cPct }} />
</View>
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
</View>
<TouchSlider
id='slider-contrast'
value={contrast}
min={-100}
max={100}
onChange={setContrast}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
</View>
+21 -5
View File
@@ -3,7 +3,8 @@ import Taro from '@tarojs/taro'
import { useState } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { addDesign } from '../../utils/store'
import { addDesign, getDesignList, setDesignList } from '../../utils/store'
import { createDesign } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -60,10 +61,25 @@ export default function ProductPage() {
}
const confirmAdd = () => {
addDesign(product, quantity)
setShowModal(false)
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1200)
// 写服务端(需登录,创建即 DRAFT),成功后回写本地缓存;失败降级本地
createDesign({
productId: product.id,
productName: product.name,
unitPrice: product.price,
count: quantity
})
.then(item => {
setDesignList([item, ...getDesignList()])
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
})
.catch(() => {
addDesign(product, quantity)
Taro.showToast({ title: `网络不可用,已加入本地清单 x${quantity}`, icon: 'none' })
})
.finally(() => {
setShowModal(false)
setTimeout(() => Taro.navigateBack(), 1200)
})
}
const goToDIY = () => {
+21 -6
View File
@@ -77,8 +77,15 @@ export interface StickerItem {
export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */
version?: 1
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
category?: ProductCategory
/**
* 商品品类(契约 §2 冻结:仅 {id, mask, tone}DIY 保存时裁剪);
* mask 是画布尺寸来源,说明见 docs/mask-config-guide.md
*/
category?: {
id: string
mask: MaskConfig
tone?: [number, number, number]
}
/** 底图(持久 URL),WCD 打包的画布底 */
background?: {
src: string
@@ -142,17 +149,23 @@ export interface WordCloudDispatchResult {
message?: string
}
/** 设计清单条目 */
/** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5 */
export interface DesignItem {
id: string
productId: string
productName: string
productIcon: string
/**
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
*/
productIcon?: string
unitPrice: number
count: number
status: 'undesigned' | 'designing' | 'ordered'
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
status: 'undesigned' | 'designing' | 'processing' | 'ordered'
designData?: DesignDataV1
orderId?: string
/** 服务端 createdAtISO),展示时取日期部分 */
createdAt: string
}
@@ -168,7 +181,7 @@ export interface OrderItem {
sku: string
}
/** 收货地址 */
/** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts */
export interface AddressItem {
id: string
name: string
@@ -176,6 +189,8 @@ export interface AddressItem {
region: string[] // [province, city, district]
detail: string // 门牌号/详细地址
isDefault: boolean
/** 服务端创建时间(ISO 8601),本地缓存数据可能没有 */
createdAt?: string
}
/** 编辑器中的图片状态(向后兼容) */
+91 -5
View File
@@ -1,7 +1,93 @@
/**
* R2 收货地址接口归属文件
* 后端 /api/addresses CRUD 就绪后,在这里补充:
* fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress
* 返回类型优先直接对应 src/types/index.ts 的 AddressItem 契约
* R2 收货地址接口api-contract-v1 §4
*
* 唯一负责 region: [province, city, district] ↔ 后端 province/city/district
* 双向转换的文件;页面与 store 不得出现第二次转换(route-r2 §3)
*/
export {}
import http from '../request'
import type { AddressItem } from '../../types'
/** 后端 Address 结构(契约 §4:四个独立地址字段) */
interface ServerAddress {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
/** ServerAddress → 前端 AddressItem(合并省市区为 region 数组) */
function toAddressItem(rec: ServerAddress): AddressItem {
return {
id: rec.id,
name: rec.name,
phone: rec.phone,
region: [rec.province, rec.city, rec.district],
detail: rec.detail,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
}
}
/** 前端 region 数组 → 后端四个独立字段 */
function toServerFields(addr: {
name?: string
phone?: string
region?: string[]
detail?: string
isDefault?: boolean
}): Record<string, unknown> {
// 后端 ValidationPipe forbidNonWhitelistedundefined 字段会被 JSON 序列化丢弃,
// 这里显式展开保证只传后端声明的字段
const body: Record<string, unknown> = {}
if (addr.name !== undefined) body.name = addr.name
if (addr.phone !== undefined) body.phone = addr.phone
if (addr.region !== undefined) {
const [province = '', city = '', district = ''] = addr.region
body.province = province
body.city = city
body.district = district
}
if (addr.detail !== undefined) body.detail = addr.detail
if (addr.isDefault !== undefined) body.isDefault = addr.isDefault
return body
}
/** 我的收货地址列表(默认地址在前) */
export async function fetchAddresses(): Promise<AddressItem[]> {
const list = await http.get<ServerAddress[]>('/api/addresses')
return (list || []).map(toAddressItem)
}
/** 新增收货地址(首个地址后端自动设为默认) */
export async function createAddress(
addr: Omit<AddressItem, 'id' | 'createdAt'>,
): Promise<AddressItem> {
const rec = await http.post<ServerAddress>('/api/addresses', toServerFields(addr))
return toAddressItem(rec)
}
/** 更新本人地址(只传显式给出的字段;isDefault=true 由后端事务保证唯一默认) */
export async function updateAddress(
id: string,
patch: Partial<Omit<AddressItem, 'id' | 'createdAt'>>,
): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}`, toServerFields(patch))
return toAddressItem(rec)
}
/** 删除本人地址(若删的是默认地址,后端自动补偿最新一条为默认) */
export async function deleteAddress(id: string): Promise<void> {
await http.del(`/api/addresses/${id}`)
}
/** 设为默认地址 */
export async function setDefaultAddress(id: string): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}/default`)
return toAddressItem(rec)
}
+117 -5
View File
@@ -1,7 +1,119 @@
/**
* R2 设计清单接口归属文件
* 后端 /api/design-list CRUD 就绪后,在这里补充:
* fetchDesignList / createDesign / updateDesign / deleteDesign
* 设计数据(贴纸、掩膜、分类信息)以 JSON 方式随 items/designData 提交。
* R2 设计清单接口api-contract-v1 §5
*
* 映射约定(阶段0 决策,见 docs/r2-workflow.md):
* - 一条前端 DesignItem = 一条后端 DesignList 记录,items 固定 1 个元素;
* - title 由 productName 承担(后端必填);
* - productIcon 不入库(后端 forbidNonWhitelisted 会拒绝),页面按 productId 兜底推导;
* - 状态映射:DRAFT↔undesigned、SUBMITTED↔designing、PROCESSING↔processing、DONE↔ordered
* - 前端只允许提交 designing(→SUBMITTED),PROCESSING/DONE 由 R3 驱动。
*/
export {}
import http from '../request'
import type { DesignDataV1, DesignItem } from '../../types'
/** 后端 DesignListStatus → 前端状态码(契约 §5 映射表) */
const SERVER_STATUS_MAP: Record<string, DesignItem['status']> = {
DRAFT: 'undesigned',
SUBMITTED: 'designing',
PROCESSING: 'processing',
DONE: 'ordered',
}
/** 清单条目(后端 items[] 固定 1 个元素,契约 §5) */
export interface DesignListEntryPayload {
productId: string
productName: string
unitPrice: number
count: number
designData?: DesignDataV1
}
/** 后端 DesignList 记录结构 */
interface ServerDesignList {
id: string
userId?: string
title: string
items: (Partial<DesignListEntryPayload> & Record<string, unknown>)[]
status: string
createdAt: string
updatedAt?: string
}
/** ServerDesignList → 前端 DesignItem */
function toDesignItem(rec: ServerDesignList): DesignItem {
const entry = rec.items && rec.items[0]
return {
id: rec.id,
productId: entry?.productId ?? '',
productName: entry?.productName || rec.title,
// productIcon 有意不返回:服务端不存储,页面按 PRODUCT_ICON_MAP[productId] 兜底
unitPrice: entry?.unitPrice ?? 0,
count: entry?.count ?? 1,
status: SERVER_STATUS_MAP[rec.status] ?? 'undesigned',
designData: entry?.designData,
createdAt: (rec.createdAt || '').slice(0, 10),
}
}
/** 我的设计清单(createdAt 倒序) */
export async function fetchDesignList(): Promise<DesignItem[]> {
const list = await http.get<ServerDesignList[]>('/api/design-list')
return (list || []).map(toDesignItem)
}
/** 创建清单(一条设计一条清单;后端初始状态 DRAFT/undesigned */
export async function createDesign(entry: DesignListEntryPayload): Promise<DesignItem> {
const rec = await http.post<ServerDesignList>('/api/design-list', {
title: entry.productName,
items: [
{
productId: entry.productId,
productName: entry.productName,
unitPrice: entry.unitPrice,
count: entry.count,
...(entry.designData !== undefined ? { designData: entry.designData } : {}),
},
],
})
return toDesignItem(rec)
}
export interface UpdateDesignPayload {
/** 新标题(前端一般不传) */
title?: string
/** 状态迁移:前端只允许 designing(→SUBMITTEDDIY 保存设计时) */
status?: 'designing'
/** 条目整体替换(items 是全量覆盖,修改 designData 时必须带全 4 个基本字段) */
item?: DesignListEntryPayload
}
/** 更新本人清单(部分更新:只传显式给出的字段) */
export async function updateDesign(id: string, payload: UpdateDesignPayload): Promise<DesignItem> {
const body: Record<string, unknown> = {}
if (payload.title !== undefined) body.title = payload.title
if (payload.status !== undefined) body.status = 'SUBMITTED'
if (payload.item !== undefined) {
body.items = [
{
productId: payload.item.productId,
productName: payload.item.productName,
unitPrice: payload.item.unitPrice,
count: payload.item.count,
...(payload.item.designData !== undefined ? { designData: payload.item.designData } : {}),
},
]
}
const rec = await http.patch<ServerDesignList>(`/api/design-list/${id}`, body)
return toDesignItem(rec)
}
/** 删除本人清单 */
export async function deleteDesign(id: string): Promise<void> {
await http.del(`/api/design-list/${id}`)
}
/** 批量删除(后端只删本人条目,返回实际删除数;部分 id 无效不报错) */
export async function deleteDesigns(ids: string[]): Promise<number> {
const res = await http.post<{ deleted: number }>('/api/design-list/batch-delete', { ids })
return res?.deleted ?? 0
}
+7
View File
@@ -1,3 +1,10 @@
/**
* 收货地址本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/address.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { AddressItem } from '../../types'
import { key } from './keys'
+7
View File
@@ -1,3 +1,10 @@
/**
* 设计清单本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/design.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { DesignItem } from '../../types'
import { assetUrl } from '../asset'