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:
Vendored
-8
@@ -133,14 +133,6 @@
|
|||||||
</image>
|
</image>
|
||||||
</template>
|
</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">
|
<template name="tmpl_0_8">
|
||||||
<block>{{i.v}}</block>
|
<block>{{i.v}}</block>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -82,6 +82,14 @@ interface DesignDataV1 {
|
|||||||
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`,
|
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`,
|
||||||
WCD 打包器同时兜底读旧字段。
|
WCD 打包器同时兜底读旧字段。
|
||||||
|
|
||||||
|
> **实现注记(2026-09-12,DIY 修正,非版本升级)**:`stickers[].width/height` 的语义为
|
||||||
|
> **画布显示像素**(坐标空间 = `category.mask` 的宽高),`x/y` 使用画布坐标空间
|
||||||
|
> (原点 = mask 左上角;**允许超出画布边界**——2026-09-12 需求确认:贴纸可拖出画布
|
||||||
|
> 自由摆放,渲染端裁切显示,WCD 打包按原值还原,不做钳制)。
|
||||||
|
> 渲染、碰撞检测、WCD 打包三方按同一语义消费,打包时不再做像素换算。
|
||||||
|
> 历史数据中按「图片原始像素」写入的值(width > mask 宽高)由读取端按 mask 等比归一化兼容,
|
||||||
|
> 不做存量迁移。`addSticker` 时即归一化到画布空间(建议初始占 mask 短边 60%)。
|
||||||
|
|
||||||
## 3. 冻结约束(R2 需落地)
|
## 3. 冻结约束(R2 需落地)
|
||||||
|
|
||||||
| # | 约束 | 说明 |
|
| # | 约束 | 说明 |
|
||||||
|
|||||||
@@ -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. 重新进入 DIY(source=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 工作内容**:**修复点在 checkout(R3 红线文件),不属于 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/决策#4(R2 允许暂存本地图、
|
||||||
|
R4 派单前持久化回写)直接矛盾,且真机落库数据即为 wxfile://。已按冻结决策修正措辞
|
||||||
|
(非字段/语义变更,不升版本)。
|
||||||
|
|
||||||
|
## 修复顺序与依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
问题2(草稿持久化)──┬──> 问题4(编辑页空白,自动修复)
|
||||||
|
└──> 问题3(进入即 designing,独立但同文件顺手做)
|
||||||
|
问题1(碰撞归一化 + 坐标钳制)—— 独立,含旧数据读端兼容
|
||||||
|
问题5(TouchSlider + CSS filter 预览)—— 独立;edits 消费点需要 DIY 渲染端配合
|
||||||
|
问题6 —— 不修(R3 待办,已记录)
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议批次**:① 问题 2+3+4(同一文件、一次回归)✅ 已实施(2026-09-12,commit eea6c3f,
|
||||||
|
`build:weapp` 通过,tsc 无新增错误)→ ② 问题 1(含兼容验证)✅ 已实施(2026-09-12,
|
||||||
|
commit 3b224cf:归一化 + 坐标钳制 + 读端兼容 + transform-origin;
|
||||||
|
**坐标钳制后经需求确认撤销**,commit 见后续,贴纸恢复可拖出画布)→
|
||||||
|
③ 问题 5(改动面最大,单独一批)✅ 已实施(2026-09-12,commit 2e3676a:
|
||||||
|
TouchSlider 组件化 + Image/CSS filter 预览 + edits 参数化保存 + DIY 渲染端
|
||||||
|
消费 edits;checkout 预览消费 edits 仍记 R3 待办)。
|
||||||
|
|
||||||
|
**统一验证清单**:加贴纸不重叠不误报 / 拖不出去 / 中途退出重进布局还在 /
|
||||||
|
进入工作台清单变「设计中」/ 编辑页任何时候有图 / 滑块可拖 / 编辑保存后 DIY 效果保持 /
|
||||||
|
`nest build` + `build:weapp` + 契约注记同步。
|
||||||
@@ -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,前端提交 e557dbc;WCD 红线已按上文落实)
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `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 | 契约文档收口 + 双端构建 | 验收标准逐条打勾 |
|
||||||
@@ -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-12,commits 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-12,route 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 页仍读本地 store,R3 接入时一并切 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 下发(契约允许字段新增)。
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
| [R3 订单 + 支付占位](route-r3-order-pay.md) | `feat/r3-order-pay` | 服务端下单、金额重算、订单状态机、支付未配置占位 |
|
| [R3 订单 + 支付占位](route-r3-order-pay.md) | `feat/r3-order-pay` | 服务端下单、金额重算、订单状态机、支付未配置占位 |
|
||||||
| [R4 上传 + 词云 + 线稿](route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` | COS 上传、wordcloud 契约冻结、sketch、任务轮询 |
|
| [R4 上传 + 词云 + 线稿](route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` | COS 上传、wordcloud 契约冻结、sketch、任务轮询 |
|
||||||
|
|
||||||
|
**已收口路线的交接说明**(后续线路接入前必读):
|
||||||
|
|
||||||
|
- [R2 交接收口说明](R2/README.md)——R3/R4/R1 接入服务端化地址与设计清单前的事实、红线与待办。
|
||||||
|
|
||||||
## 合并顺序与依赖
|
## 合并顺序与依赖
|
||||||
|
|
||||||
- R1 是交易链路的前置,优先合入。
|
- R1 是交易链路的前置,优先合入。
|
||||||
|
|||||||
+29
-10
@@ -2,7 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
|
|||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import './index.scss'
|
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 type { AddressItem } from '../../types'
|
||||||
import { useThemeContext } from '../../context/ThemeContext'
|
import { useThemeContext } from '../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||||
@@ -26,7 +27,17 @@ export default function AddressPage() {
|
|||||||
const [detail, setDetail] = useState('')
|
const [detail, setDetail] = useState('')
|
||||||
const [isDefault, setIsDefault] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
@@ -62,8 +73,9 @@ export default function AddressPage() {
|
|||||||
content: '删除后将无法恢复该地址',
|
content: '删除后将无法恢复该地址',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
deleteAddress(id)
|
deleteAddressApi(id)
|
||||||
load()
|
.catch(() => deleteAddress(id)) // 失败降级本地删除
|
||||||
|
.finally(load)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -80,15 +92,22 @@ export default function AddressPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = { name, phone, region, detail, isDefault }
|
const payload = { name, phone, region, detail, isDefault }
|
||||||
if (editing) {
|
const save = editing
|
||||||
updateAddress(editing.id, payload)
|
? updateAddressApi(editing.id, payload)
|
||||||
} else {
|
: createAddress(payload)
|
||||||
addAddress(payload)
|
save
|
||||||
}
|
.then(() => Taro.showToast({ title: '保存成功', icon: 'success' }))
|
||||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
.catch(() => {
|
||||||
|
// 服务端不可达时降级本地保存,联网后进入页面会刷新
|
||||||
|
if (editing) updateAddress(editing.id, payload)
|
||||||
|
else addAddress(payload)
|
||||||
|
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
load()
|
load()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRegionChange = (e: any) => {
|
const onRegionChange = (e: any) => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { View, Text, ScrollView, Image } from '@tarojs/components'
|
|||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import './index.scss'
|
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 { PRODUCT_ICON_MAP } from '../../utils/productConfig'
|
||||||
import { assetUrl } from '../../utils/asset'
|
import { assetUrl } from '../../utils/asset'
|
||||||
import { useThemeContext } from '../../context/ThemeContext'
|
import { useThemeContext } from '../../context/ThemeContext'
|
||||||
@@ -17,6 +18,7 @@ const STATUS_TABS = [
|
|||||||
{ code: 'toDesign', label: '待设计' },
|
{ code: 'toDesign', label: '待设计' },
|
||||||
{ code: 'undesigned', label: '未设计' },
|
{ code: 'undesigned', label: '未设计' },
|
||||||
{ code: 'designing', label: '设计中' },
|
{ code: 'designing', label: '设计中' },
|
||||||
|
{ code: 'processing', label: '生产中' },
|
||||||
{ code: 'ordered', 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 }> = {
|
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
|
||||||
undesigned: { label: '未设计', cls: 'badge-pink' },
|
undesigned: { label: '未设计', cls: 'badge-pink' },
|
||||||
designing: { label: '设计中', cls: 'badge-blue' },
|
designing: { label: '设计中', cls: 'badge-blue' },
|
||||||
|
processing: { label: '生产中', cls: 'badge-warning' },
|
||||||
ordered: { label: '已下单', cls: 'badge-green' }
|
ordered: { label: '已下单', cls: 'badge-green' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +41,15 @@ export default function DesignListPage() {
|
|||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
|
||||||
|
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
|
||||||
setList(getDesignList())
|
setList(getDesignList())
|
||||||
|
fetchDesignList()
|
||||||
|
.then(data => {
|
||||||
|
setList(data)
|
||||||
|
setDesignList(data)
|
||||||
|
})
|
||||||
|
.catch(() => setList(getDesignList()))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const init = useCallback(() => {
|
const init = useCallback(() => {
|
||||||
@@ -100,10 +111,12 @@ export default function DesignListPage() {
|
|||||||
content: `确定删除选中的 ${selected.size} 个条目吗?`,
|
content: `确定删除选中的 ${selected.size} 个条目吗?`,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
removeDesigns(Array.from(selected))
|
const ids = Array.from(selected)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setManaging(false)
|
setManaging(false)
|
||||||
load()
|
deleteDesigns(ids)
|
||||||
|
.catch(() => removeDesigns(ids)) // 失败降级本地删除
|
||||||
|
.finally(load)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -164,7 +177,7 @@ export default function DesignListPage() {
|
|||||||
{/* 列表 */}
|
{/* 列表 */}
|
||||||
<ScrollView className='design-list' scrollY>
|
<ScrollView className='design-list' scrollY>
|
||||||
{filtered.map(item => {
|
{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 || '')
|
const rawIcon = (item.productIcon || '').startsWith('/icon/') || /^https?:/i.test(item.productIcon || '')
|
||||||
? item.productIcon
|
? item.productIcon
|
||||||
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
|
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
|
||||||
|
|||||||
@@ -103,6 +103,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
|
||||||
|
transform-origin: top left;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
border: 2rpx solid transparent;
|
border: 2rpx solid transparent;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
@@ -391,6 +393,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
transform-origin: top left;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+185
-43
@@ -1,10 +1,10 @@
|
|||||||
import { View, Text, Image } from '@tarojs/components'
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getProductById } from '../../utils/productConfig'
|
import { getProductById } from '../../utils/productConfig'
|
||||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
|
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 { useThemeContext } from '../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||||
@@ -27,6 +27,41 @@ export default function DIYPage() {
|
|||||||
const [previewMode, setPreviewMode] = useState(false)
|
const [previewMode, setPreviewMode] = useState(false)
|
||||||
const [hasOverlap, setHasOverlap] = 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(() => {
|
useEffect(() => {
|
||||||
const router = Taro.getCurrentInstance().router
|
const router = Taro.getCurrentInstance().router
|
||||||
const params = router ? router.params : undefined
|
const params = router ? router.params : undefined
|
||||||
@@ -37,20 +72,9 @@ export default function DIYPage() {
|
|||||||
const product = getProductById(productId)
|
const product = getProductById(productId)
|
||||||
if (product) {
|
if (product) {
|
||||||
setCategory(product)
|
setCategory(product)
|
||||||
setQuantity(Number(params ? params.quantity : undefined) || 1)
|
const count = Number(params ? params.quantity : undefined) || 1
|
||||||
const newDesign: DesignItem = {
|
setQuantity(count)
|
||||||
id: 'DSG' + Date.now(),
|
createDesignEntry(product, count)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,22 +88,42 @@ export default function DIYPage() {
|
|||||||
setQuantity(design.count)
|
setQuantity(design.count)
|
||||||
const product = getProductById(design.productId)
|
const product = getProductById(design.productId)
|
||||||
if (product) setCategory(product)
|
if (product) setCategory(product)
|
||||||
if (design.designData ? design.designData.stickers : undefined) {
|
const dd = design.designData
|
||||||
setStickers(design.designData.stickers)
|
const rawStickers = dd ? dd.stickers : undefined
|
||||||
} else if (design.designData ? design.designData.imageSrc : 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 = {
|
const s: StickerItem = {
|
||||||
id: 'legacy_' + Date.now(),
|
id: 'legacy_' + Date.now(),
|
||||||
src: design.designData.imageSrc,
|
src: dd.imageSrc,
|
||||||
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
|
x: dd.imagePos ? dd.imagePos.x || 0 : 0,
|
||||||
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
|
y: dd.imagePos ? dd.imagePos.y || 0 : 0,
|
||||||
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
|
scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
isOverlapping: false
|
isOverlapping: false
|
||||||
}
|
}
|
||||||
setStickers([s])
|
setStickers([s])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing)
|
||||||
|
if (design.status === 'undesigned') {
|
||||||
|
updateDesign(dId, { status: 'designing' })
|
||||||
|
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -89,23 +133,71 @@ export default function DIYPage() {
|
|||||||
const found = getProductById(productId)
|
const found = getProductById(productId)
|
||||||
if (found) {
|
if (found) {
|
||||||
setCategory(found)
|
setCategory(found)
|
||||||
const newDesign: DesignItem = {
|
createDesignEntry(found, 1)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ---------- 草稿持久化(修正工作流 问题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 checkOverlap = (list: StickerItem[]) => {
|
||||||
const newList = list.map(s => ({ ...s, isOverlapping: false }))
|
const newList = list.map(s => ({ ...s, isOverlapping: false }))
|
||||||
@@ -144,14 +236,17 @@ export default function DIYPage() {
|
|||||||
Taro.getImageInfo({
|
Taro.getImageInfo({
|
||||||
src,
|
src,
|
||||||
success: (info) => {
|
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 = {
|
const newSticker: StickerItem = {
|
||||||
id: 'stk_' + Date.now(),
|
id: 'stk_' + Date.now(),
|
||||||
src,
|
src,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
width: info.width,
|
width: Math.round(info.width * k),
|
||||||
height: info.height,
|
height: Math.round(info.height * k),
|
||||||
isOverlapping: false
|
isOverlapping: false
|
||||||
}
|
}
|
||||||
const next = [...stickers, newSticker]
|
const next = [...stickers, newSticker]
|
||||||
@@ -160,15 +255,17 @@ export default function DIYPage() {
|
|||||||
setActiveStickerId(newSticker.id)
|
setActiveStickerId(newSticker.id)
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
// fallback 尺寸
|
// fallback 尺寸(同样按画布空间归一化)
|
||||||
|
const maskMin = Math.min(category.mask.width, category.mask.height)
|
||||||
|
const k = (maskMin * 0.6) / 200
|
||||||
const newSticker: StickerItem = {
|
const newSticker: StickerItem = {
|
||||||
id: 'stk_' + Date.now(),
|
id: 'stk_' + Date.now(),
|
||||||
src,
|
src,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
width: 200,
|
width: Math.round(200 * k),
|
||||||
height: 200,
|
height: Math.round(200 * k),
|
||||||
isOverlapping: false
|
isOverlapping: false
|
||||||
}
|
}
|
||||||
const next = [...stickers, newSticker]
|
const next = [...stickers, newSticker]
|
||||||
@@ -200,6 +297,7 @@ export default function DIYPage() {
|
|||||||
const handleStickerTouchMove = (e: any) => {
|
const handleStickerTouchMove = (e: any) => {
|
||||||
if (!isDragging || !activeStickerId) return
|
if (!isDragging || !activeStickerId) return
|
||||||
const touch = e.touches[0]
|
const touch = e.touches[0]
|
||||||
|
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
|
||||||
const next = stickers.map(s => {
|
const next = stickers.map(s => {
|
||||||
if (s.id !== activeStickerId) return s
|
if (s.id !== activeStickerId) return s
|
||||||
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
|
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
|
||||||
@@ -212,6 +310,7 @@ export default function DIYPage() {
|
|||||||
|
|
||||||
const handleEditSticker = () => {
|
const handleEditSticker = () => {
|
||||||
if (!activeStickerId || !designId) return
|
if (!activeStickerId || !designId) return
|
||||||
|
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
|
||||||
Taro.navigateTo({
|
Taro.navigateTo({
|
||||||
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
|
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
|
||||||
})
|
})
|
||||||
@@ -226,6 +325,18 @@ export default function DIYPage() {
|
|||||||
setStickers(checked)
|
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 = () => {
|
const goBack = () => {
|
||||||
Taro.navigateBack()
|
Taro.navigateBack()
|
||||||
}
|
}
|
||||||
@@ -247,10 +358,39 @@ export default function DIYPage() {
|
|||||||
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
|
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (designId) {
|
if (designId && category) {
|
||||||
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
|
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
|
||||||
const data = await persistDesignMedia({ stickers, category, version: 1 })
|
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)
|
setPreviewMode(false)
|
||||||
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
|
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})`,
|
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||||
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
||||||
zIndex: activeStickerId === s.id ? 10 : 5,
|
zIndex: activeStickerId === s.id ? 10 : 5,
|
||||||
|
filter: stickerFilter(s),
|
||||||
width: s.width || 200,
|
width: s.width || 200,
|
||||||
height: s.height || 200
|
height: s.height || 200
|
||||||
}}
|
}}
|
||||||
@@ -382,6 +523,7 @@ export default function DIYPage() {
|
|||||||
src={s.src}
|
src={s.src}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||||
|
filter: stickerFilter(s),
|
||||||
width: s.width || 200,
|
width: s.width || 200,
|
||||||
height: s.height || 200
|
height: s.height || 200
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -81,6 +81,16 @@
|
|||||||
box-shadow: var(--shadow-card);
|
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 {
|
.edit-tools {
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
@@ -226,3 +236,30 @@
|
|||||||
.mt-20 {
|
.mt-20 {
|
||||||
margin-top: 24rpx;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+129
-229
@@ -1,10 +1,9 @@
|
|||||||
import { View, Text, Canvas, Image } from '@tarojs/components'
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getProductById } from '../../../utils/productConfig'
|
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
|
||||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
|
||||||
import { sketchImage } from '../../../utils/api'
|
|
||||||
import { useThemeContext } from '../../../context/ThemeContext'
|
import { useThemeContext } from '../../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../../hooks/useSafeArea'
|
import { useSafeArea } from '../../../hooks/useSafeArea'
|
||||||
import { useStatusBar } from '../../../hooks/useStatusBar'
|
import { useStatusBar } from '../../../hooks/useStatusBar'
|
||||||
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
|
|||||||
import BottomActionBar from '../../../components/BottomActionBar'
|
import BottomActionBar from '../../../components/BottomActionBar'
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
|
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
|
||||||
|
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
|
||||||
|
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
|
||||||
|
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4:edits 不进 WCD
|
||||||
|
document.json,仅 manifest.meta,src 保持原样对 R4 无影响)。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
||||||
|
|
||||||
/** 拖动手势滑块组件 */
|
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect) */
|
||||||
interface SliderProps {
|
interface SliderProps {
|
||||||
|
id: string
|
||||||
value: number
|
value: number
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
step?: number
|
|
||||||
onChange: (val: number) => void
|
onChange: (val: number) => void
|
||||||
showValue?: boolean
|
|
||||||
format?: (v: number) => string
|
format?: (v: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
|
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
|
||||||
const trackRef = useRef<any>(null)
|
const rectRef = useRef<{ left: number; width: number } | null>(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
|
||||||
|
|
||||||
const computedFromClientX = (clientX: number) => {
|
const measure = (cb?: () => void) => {
|
||||||
if (!trackRef.current) return value
|
Taro.createSelectorQuery()
|
||||||
const query = Taro.createSelectorQuery().in(trackRef.current)
|
.select(`#${id}`)
|
||||||
// 注意:这里需要取 track 元素的位置信息
|
.boundingClientRect((rect: any) => {
|
||||||
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
|
if (rect && rect.width > 0) rectRef.current = { left: rect.left, width: rect.width }
|
||||||
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
if (cb) cb()
|
||||||
let raw = min + (max - min) * ratio
|
})
|
||||||
if (step > 0) {
|
.exec()
|
||||||
raw = Math.round(raw / step) * step
|
|
||||||
}
|
}
|
||||||
return clamp(raw, min, max)
|
|
||||||
|
const valueFromX = (clientX: number) => {
|
||||||
|
const rect = rectRef.current
|
||||||
|
if (!rect || !rect.width) return value
|
||||||
|
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
||||||
|
return clamp(min + (max - min) * ratio, min, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchStart = (e: any) => {
|
const handleTouchStart = (e: any) => {
|
||||||
setDragging(true)
|
|
||||||
const touch = e.touches && e.touches[0]
|
const touch = e.touches && e.touches[0]
|
||||||
const x = touch ? touch.clientX : 0
|
if (!touch) return
|
||||||
onChange(computedFromClientX(x))
|
measure(() => onChange(valueFromX(touch.clientX)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchMove = (e: any) => {
|
const handleTouchMove = (e: any) => {
|
||||||
if (!dragging) return
|
|
||||||
const touch = e.touches && e.touches[0]
|
const touch = e.touches && e.touches[0]
|
||||||
const x = touch ? touch.clientX : 0
|
if (!touch) return
|
||||||
onChange(computedFromClientX(x))
|
if (!rectRef.current) {
|
||||||
|
measure(() => onChange(valueFromX(touch.clientX)))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
onChange(valueFromX(touch.clientX))
|
||||||
const handleTouchEnd = () => {
|
|
||||||
setDragging(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = `${((value - min) / (max - min)) * 100}%`
|
const pct = `${((value - min) / (max - min)) * 100}%`
|
||||||
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
|
|||||||
return (
|
return (
|
||||||
<View className='slider-wrap'>
|
<View className='slider-wrap'>
|
||||||
<View
|
<View
|
||||||
|
id={id}
|
||||||
className='slider-track'
|
className='slider-track'
|
||||||
ref={trackRef}
|
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
|
||||||
>
|
>
|
||||||
<View className='slider-fill' style={{ width: pct }} />
|
<View className='slider-fill' style={{ width: pct }} />
|
||||||
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
|
<View className='slider-thumb' style={{ left: pct }} />
|
||||||
</View>
|
</View>
|
||||||
<Text className='slider-value'>{format ? format(value) : value}</Text>
|
<Text className='slider-value'>{format ? format(value) : value}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -90,50 +93,15 @@ export default function StickerEditPage() {
|
|||||||
const [designId, setDesignId] = useState('')
|
const [designId, setDesignId] = useState('')
|
||||||
const [stickerId, setStickerId] = useState('')
|
const [stickerId, setStickerId] = useState('')
|
||||||
const [sticker, setSticker] = useState<StickerItem | null>(null)
|
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 [brightness, setBrightness] = useState(0)
|
||||||
const [hue, setHue] = useState(0)
|
const [hue, setHue] = useState(0)
|
||||||
const [contrast, setContrast] = useState(0)
|
const [contrast, setContrast] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const router = Taro.getCurrentInstance().router
|
const router = Taro.getCurrentInstance().router
|
||||||
@@ -151,96 +119,44 @@ export default function StickerEditPage() {
|
|||||||
setBrightness((st.edits && st.edits.brightness) || 0)
|
setBrightness((st.edits && st.edits.brightness) || 0)
|
||||||
setHue((st.edits && st.edits.hue) || 0)
|
setHue((st.edits && st.edits.hue) || 0)
|
||||||
setContrast((st.edits && st.edits.contrast) || 0)
|
setContrast((st.edits && st.edits.contrast) || 0)
|
||||||
initCanvas(st)
|
|
||||||
}
|
|
||||||
}, [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 {
|
} else {
|
||||||
ctx.filter = 'none'
|
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
|
||||||
|
setNotFound(true)
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
ctx.drawImage(img, x, y, w, h)
|
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
|
||||||
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])
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!sticker || !designId) return
|
if (!sticker || !designId) return
|
||||||
setLoading(true)
|
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 dList = getDesignList()
|
||||||
const dIdx = dList.findIndex(d => d.id === designId)
|
const dIdx = dList.findIndex(d => d.id === designId)
|
||||||
if (dIdx === -1) { setLoading(false); return }
|
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
|
||||||
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
|
const d = dList[dIdx]
|
||||||
|
const stickers = (d.designData && d.designData.stickers) || []
|
||||||
const sIdx = stickers.findIndex(s => s.id === stickerId)
|
const sIdx = stickers.findIndex(s => s.id === stickerId)
|
||||||
if (sIdx === -1) { setLoading(false); return }
|
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
|
||||||
stickers[sIdx] = {
|
stickers[sIdx] = {
|
||||||
...stickers[sIdx],
|
...stickers[sIdx],
|
||||||
src: newSrc,
|
...(pendingSrc ? { src: pendingSrc } : {}),
|
||||||
edits: { brightness, hue, contrast }
|
edits: { brightness, hue, contrast }
|
||||||
}
|
}
|
||||||
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
|
// 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.showToast({ title: '保存成功', icon: 'success' })
|
||||||
Taro.navigateBack()
|
Taro.navigateBack()
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
setLoading(false)
|
|
||||||
Taro.showToast({ title: '保存失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 裁剪:调用微信cropImage */
|
/** 裁剪:调用微信cropImage */
|
||||||
@@ -258,46 +174,61 @@ export default function StickerEditPage() {
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
const newSticker = { ...sticker, src: res.tempFilePath }
|
const newSticker = { ...sticker, src: res.tempFilePath }
|
||||||
setSticker(newSticker)
|
setSticker(newSticker)
|
||||||
// 重绘
|
setPendingSrc(res.tempFilePath)
|
||||||
setTimeout(() => redraw(), 200)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
|
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
|
||||||
const handleSketch = async () => {
|
const handleSketch = async () => {
|
||||||
if (!sticker) return
|
if (!sticker) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await sketchImage(sticker.src)
|
const res = await sketchImage(sticker.src)
|
||||||
if (res?.imageUrl) {
|
if (res?.imageUrl) {
|
||||||
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
|
setSticker({ ...sticker, src: res.imageUrl })
|
||||||
setSticker(newSticker)
|
setPendingSrc(res.imageUrl)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setTimeout(() => redraw(), 200)
|
|
||||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||||
} else {
|
} else {
|
||||||
throw new Error('no imageUrl')
|
throw new Error('no imageUrl')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 如果接口不可用/失败,降级为前端灰度+边缘检测
|
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
|
||||||
applyFrontendSketch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 前端线稿降级方案:灰度+反相高对比 */
|
|
||||||
const applyFrontendSketch = () => {
|
|
||||||
if (!sticker || !ctxRef.current) { setLoading(false); return }
|
|
||||||
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
|
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
|
||||||
setBrightness(20)
|
setBrightness(20)
|
||||||
setContrast(80)
|
setContrast(80)
|
||||||
setHue(0)
|
setHue(0)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bPct = `${((brightness + 100) / 200) * 100}%`
|
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
|
||||||
const hPct = `${((hue + 180) / 360) * 100}%`
|
? 'none'
|
||||||
const cPct = `${((contrast + 100) / 200) * 100}%`
|
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<View className={`theme-${resolvedTheme}`}>
|
<View className={`theme-${resolvedTheme}`}>
|
||||||
@@ -311,13 +242,13 @@ export default function StickerEditPage() {
|
|||||||
<Text className='edit-save' onTap={handleSave}>保存</Text>
|
<Text className='edit-save' onTap={handleSave}>保存</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Canvas 预览 */}
|
{/* 预览:Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter) */}
|
||||||
<View className='edit-canvas-wrap'>
|
<View className='edit-canvas-wrap'>
|
||||||
<Canvas
|
<Image
|
||||||
id='editCanvas'
|
className='edit-preview-img'
|
||||||
type='2d'
|
src={sticker ? sticker.src : ''}
|
||||||
className='edit-canvas'
|
mode='aspectFit'
|
||||||
style={{ width: '100%', height: '480rpx' }}
|
style={{ filter: previewFilter }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -336,73 +267,42 @@ export default function StickerEditPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 亮度 */}
|
{/* 亮度 */}
|
||||||
<View className='edit-row slider-row'
|
<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()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='edit-label'>亮度</Text>
|
<Text className='edit-label'>亮度</Text>
|
||||||
<View className='slider-wrap'>
|
<TouchSlider
|
||||||
<View className='slider-track'>
|
id='slider-brightness'
|
||||||
<View className='slider-fill' style={{ width: bPct }} />
|
value={brightness}
|
||||||
<View className='slider-thumb' style={{ left: bPct }} />
|
min={-100}
|
||||||
</View>
|
max={100}
|
||||||
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
|
onChange={setBrightness}
|
||||||
</View>
|
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 色相 */}
|
{/* 色相 */}
|
||||||
<View className='edit-row slider-row'
|
<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()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='edit-label'>色相</Text>
|
<Text className='edit-label'>色相</Text>
|
||||||
<View className='slider-wrap'>
|
<TouchSlider
|
||||||
<View className='slider-track'>
|
id='slider-hue'
|
||||||
<View className='slider-fill' style={{ width: hPct }} />
|
value={hue}
|
||||||
<View className='slider-thumb' style={{ left: hPct }} />
|
min={-180}
|
||||||
</View>
|
max={180}
|
||||||
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
|
onChange={setHue}
|
||||||
</View>
|
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 对比度 */}
|
{/* 对比度 */}
|
||||||
<View className='edit-row slider-row'
|
<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()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='edit-label'>对比度</Text>
|
<Text className='edit-label'>对比度</Text>
|
||||||
<View className='slider-wrap'>
|
<TouchSlider
|
||||||
<View className='slider-track'>
|
id='slider-contrast'
|
||||||
<View className='slider-fill' style={{ width: cPct }} />
|
value={contrast}
|
||||||
<View className='slider-thumb' style={{ left: cPct }} />
|
min={-100}
|
||||||
</View>
|
max={100}
|
||||||
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
|
onChange={setContrast}
|
||||||
</View>
|
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import Taro from '@tarojs/taro'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getProductById } from '../../utils/productConfig'
|
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 { useThemeContext } from '../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||||
@@ -60,10 +61,25 @@ export default function ProductPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confirmAdd = () => {
|
const confirmAdd = () => {
|
||||||
addDesign(product, quantity)
|
// 写服务端(需登录,创建即 DRAFT),成功后回写本地缓存;失败降级本地
|
||||||
setShowModal(false)
|
createDesign({
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
unitPrice: product.price,
|
||||||
|
count: quantity
|
||||||
|
})
|
||||||
|
.then(item => {
|
||||||
|
setDesignList([item, ...getDesignList()])
|
||||||
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
|
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)
|
setTimeout(() => Taro.navigateBack(), 1200)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToDIY = () => {
|
const goToDIY = () => {
|
||||||
|
|||||||
+21
-6
@@ -77,8 +77,15 @@ export interface StickerItem {
|
|||||||
export interface DesignDataV1 {
|
export interface DesignDataV1 {
|
||||||
/** 结构版本;旧数据缺省视为 v1 */
|
/** 结构版本;旧数据缺省视为 v1 */
|
||||||
version?: 1
|
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 打包的画布底 */
|
/** 底图(持久 URL),WCD 打包的画布底 */
|
||||||
background?: {
|
background?: {
|
||||||
src: string
|
src: string
|
||||||
@@ -142,17 +149,23 @@ export interface WordCloudDispatchResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设计清单条目 */
|
/** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5) */
|
||||||
export interface DesignItem {
|
export interface DesignItem {
|
||||||
id: string
|
id: string
|
||||||
productId: string
|
productId: string
|
||||||
productName: string
|
productName: string
|
||||||
productIcon: string
|
/**
|
||||||
|
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
|
||||||
|
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
|
||||||
|
*/
|
||||||
|
productIcon?: string
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
count: number
|
count: number
|
||||||
status: 'undesigned' | 'designing' | 'ordered'
|
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
|
||||||
|
status: 'undesigned' | 'designing' | 'processing' | 'ordered'
|
||||||
designData?: DesignDataV1
|
designData?: DesignDataV1
|
||||||
orderId?: string
|
orderId?: string
|
||||||
|
/** 服务端 createdAt(ISO),展示时取日期部分 */
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +181,7 @@ export interface OrderItem {
|
|||||||
sku: string
|
sku: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收货地址 */
|
/** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts) */
|
||||||
export interface AddressItem {
|
export interface AddressItem {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -176,6 +189,8 @@ export interface AddressItem {
|
|||||||
region: string[] // [province, city, district]
|
region: string[] // [province, city, district]
|
||||||
detail: string // 门牌号/详细地址
|
detail: string // 门牌号/详细地址
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
|
/** 服务端创建时间(ISO 8601),本地缓存数据可能没有 */
|
||||||
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑器中的图片状态(向后兼容) */
|
/** 编辑器中的图片状态(向后兼容) */
|
||||||
|
|||||||
@@ -1,7 +1,93 @@
|
|||||||
/**
|
/**
|
||||||
* R2 收货地址接口归属文件。
|
* R2 收货地址接口(api-contract-v1 §4)。
|
||||||
* 后端 /api/addresses CRUD 就绪后,在这里补充:
|
*
|
||||||
* fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress
|
* 唯一负责 region: [province, city, district] ↔ 后端 province/city/district
|
||||||
* 返回类型优先直接对应 src/types/index.ts 的 AddressItem 契约。
|
* 双向转换的文件;页面与 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 forbidNonWhitelisted:undefined 字段会被 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
@@ -1,7 +1,119 @@
|
|||||||
/**
|
/**
|
||||||
* R2 设计清单接口归属文件。
|
* R2 设计清单接口(api-contract-v1 §5)。
|
||||||
* 后端 /api/design-list CRUD 就绪后,在这里补充:
|
*
|
||||||
* fetchDesignList / createDesign / updateDesign / deleteDesign
|
* 映射约定(阶段0 决策,见 docs/r2-workflow.md):
|
||||||
* 设计数据(贴纸、掩膜、分类信息)以 JSON 方式随 items/designData 提交。
|
* - 一条前端 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(→SUBMITTED,DIY 保存设计时) */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 收货地址本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
|
||||||
|
* 真实 CRUD 已走 utils/api/address.ts;本层职责:
|
||||||
|
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
|
||||||
|
* - API 失败/断网 → 页面读缓存正常浏览;
|
||||||
|
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
|
||||||
|
*/
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import type { AddressItem } from '../../types'
|
import type { AddressItem } from '../../types'
|
||||||
import { key } from './keys'
|
import { key } from './keys'
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 设计清单本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
|
||||||
|
* 真实 CRUD 已走 utils/api/design.ts;本层职责:
|
||||||
|
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
|
||||||
|
* - API 失败/断网 → 页面读缓存正常浏览;
|
||||||
|
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
|
||||||
|
*/
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import type { DesignItem } from '../../types'
|
import type { DesignItem } from '../../types'
|
||||||
import { assetUrl } from '../asset'
|
import { assetUrl } from '../asset'
|
||||||
|
|||||||
Reference in New Issue
Block a user