Merge feature R4 (upload + wordcloud + WCD dispatch) into master
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概览
|
||||
|
||||
智绘微刻(Smart-Engraving)微信小程序:词云生成 + 个性化激光雕刻定制。基于 **Taro 3.6.31 + React 18 + TypeScript**,SCSS 变量驱动主题,构建产物运行在微信小程序(webpack5 编译到 `dist/`)。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖
|
||||
npm run dev:weapp # 微信小程序开发模式(watch,配合微信开发者工具)
|
||||
npm run build:weapp # 生产构建;也是提交前 / 验收的标准验证命令
|
||||
npm run dev:h5 # H5 预览调试(H5 下事件用 onClick,见下文)
|
||||
node --test tools/homeTopMask.test.mjs # 运行 node:test 单测(目前唯一测试,无需 jest)
|
||||
```
|
||||
|
||||
没有配置 lint/test 脚本。提交前验证 = `npm run build:weapp` 通过(见 `docs/parallel-development-guide.md` §6)。
|
||||
|
||||
## 数据访问层(核心架构,务必先读)
|
||||
|
||||
页面**不得**直接读写 Storage 或直接发请求。所有数据操作取道两个聚合入口,二者都只 re-export、不堆业务:
|
||||
|
||||
- `../../utils/store`(DA 层,本地 Storage,按域拆文件):页面从 `src/utils/store/index.ts` 导入 `getDesignList()`、`addAddress()`、`designToOrder()` 等。
|
||||
- `../../utils/api`(HTTP 层,按域拆文件):页面从 `src/utils/api/index.ts` 导入 `login()`、`fetchProducts()` 等。
|
||||
|
||||
每个域文件(`store/keys|user|design|address|order|theme.ts`、`api/auth|user|product|address|design|order|upload.ts`)按领域职责划分,新增逻辑加到对应域文件里(不要新建 `xxx2.ts`),新文件需在聚合入口显式 re-export。后端替换 = 把 `store/<domain>.ts` 的函数换成 `api/<domain>.ts` 的 HTTP 调用,页面层零改动。
|
||||
|
||||
**openid 作用域隔离**(`store/keys.ts`):storage key 形如 `${scope}_${openid}`,通过 `key(scope)`(当前用户)与 `keyFor(scope, openid)`(指定用户)生成,实现多用户数据隔离。
|
||||
|
||||
类型统一放 `src/types/index.ts` 或 `src/types/<domain>.ts`,不要散落在页面里。
|
||||
|
||||
## 协作约定(R0–R4 并行路线)
|
||||
|
||||
仓库按四人并行开发组织,主干为 `master`,禁止直接推主干,合入走 PR。每人一条端到端路线(前端 + 后端、同名分支):
|
||||
|
||||
| 路线 | 分支 | 页面归属 | 数据域 |
|
||||
|---|---|---|---|
|
||||
| R1 商品目录 | `feat/r1-catalog` | `index`、`shop`、`shop/detail`、`product` | `api/product` |
|
||||
| R2 地址+设计清单 | `feat/r2-address-design` | `address`、`designList` | `store/design`、`store/address`、`api/address`、`api/design` |
|
||||
| R3 订单+支付占位 | `feat/r3-order-pay` | `checkout`、`orders`、`orderDetail` | `store/order`、`api/order` |
|
||||
| R4 上传+词云+线稿 | `feat/r4-upload-wordcloud` | `wordcloud`、`diy/stickerEdit` | `api/upload` |
|
||||
|
||||
`store/index.ts` 与 `api/index.ts` 由 R0(本分支 `feat/r0-dev-foundation`)维护,**只加不删、不堆业务**。完整规则见 `docs/team-collaboration-guide.md` 与 `docs/parallel-development-guide.md`(含合并顺序:R1 → R2/R4 → R3)。
|
||||
|
||||
## 主题系统
|
||||
|
||||
`ThemeMode`:`light` / `dark` / `auto`(`store/theme.ts`)。`ThemeContext`(`src/context/ThemeContext.tsx`)产出最终生效的 `resolvedTheme: 'light' | 'dark'`。
|
||||
|
||||
- 页面根节点 `className={theme-${resolvedTheme}}`;所有颜色/字号来自 `app.scss` 里的 CSS 变量(浅色 `:root` + `.theme-dark`),**禁止在页面里硬编码颜色/字号/组件形态**,token 语义见 `DESIGN.md`,落地规则见 `设计变更文档.md`。
|
||||
- 每个页面都挂 `<ThemedPageMeta />`(`PageMeta + NavigationBar`),按 `resolvedTheme` 强制覆盖顶部/状态栏颜色;`useStatusBar(resolvedTheme)` 在页面显示时再同步一次。`app.config.ts` 里的 `darkmode: true` 只作为系统主题探测通道(`onThemeChange`/`getAppBaseInfo`),窗口颜色不依赖它。
|
||||
- 自定义 tabBar 无法继承 React Context,通过 `Taro.eventCenter` 订阅 `themeChange` 事件 (`THEME_CHANGE_EVENT`) 同步主题。
|
||||
- 图标一律用 `src/icon/` 下的 PNG/SVG,**禁止用 emoji 当图标**。
|
||||
|
||||
## 事件与生命周期(重要兼容坑)
|
||||
|
||||
- **一律用 `onTap`,不要用 `onClick`**。微信基础库 3.17.0 + Taro React 下动态更新 handler 会触发 `TaroElement.removeEventListener(undefined)`,报 `Cannot read properties of undefined (reading '_num')`。仅在 H5 构建里可用 `onClick`。
|
||||
- 页面显示刷新用 `useDidShow`(`@tarojs/taro`),不要手动覆盖 `page.onShow`(热更新会重复叠加导致异常)。
|
||||
|
||||
## 请求与登录
|
||||
|
||||
- `src/utils/request.ts`:`BASE_URL = https://wxbackend.tokenleaping.com`;后端统一响应 `{ code, message, data }`,`code === 0` 成功;token 存 `smart_access_token`,走 `Authorization: Bearer`。HTTP 401 会自动触发一次会话续登(`app.tsx` 注册的 `refreshSession`),失败则清 token 并调 `onUnauthorized`。
|
||||
- `session.ts` 的 `refreshSession`:`wx.login` 换新 code → `/api/auth/login` 续签 → `/api/users/me` 校验 openid 与本地账号一致才放行(防串号)。`mock_*` 开头的 openid 是历史残留,`isMockOpenid` 会识别并拒绝。
|
||||
- 登录态校验有缓存(`src/utils/authState.ts`),`LoginGuard` 监听 `authStateChanged`/`tabBarChange`/`themeChange` 事件自动刷新。
|
||||
|
||||
## 静态资源与 OSS
|
||||
|
||||
- 产品大图已迁到阿里云 OSS(bucket `wordcloudwechat`,杭州),包体积需保持在微信 2MB 限制内(当前 `dist/` 约 1MB)。
|
||||
- 基址在根目录 `.env` 的 `OSS_BASE_URL`(git 忽略),由 `config/index.js` 注入为 `__OSS_BASE_URL__` 常量。
|
||||
- 所有资源路径经 `assetUrl()`(`src/utils/asset.ts`)处理:`/img/*` 大图拼 OSS 远程地址,`/icon/*` 小图标与 `src/icon`、`theme.json` 通过 `config/index.js` 的 copy patterns 打包进 `dist/`。
|
||||
- 商品/品类配置集中在 `src/utils/productConfig.ts`(`PRODUCTS` 数组 + icon 映射 + `tone` 主题色),遮罩尺寸说明见 `docs/mask-config-guide.md`。
|
||||
|
||||
## 页面结构说明
|
||||
|
||||
路由与 tabBar 见 `src/app.config.ts`(全局 `navigationStyle: custom`,5 个 tab 的自定义 tabBar)。几个非平凡的页面:
|
||||
|
||||
- `pages/shop/index`:单页双容器——2 列商品网格 + 固定 overlay 沉浸式详情(滚动视差、产品 `tone` 主题色联动),点击卡片在当前页内过渡,不跳转;底部 CTA 跳 `/pages/product/index?id=xxx` 衔接购买链路。
|
||||
- `pages/diy/index`:DIY 工作台(贴纸拖拽/缩放/碰撞检测),后续 `diy/stickerEdit` 为贴纸编辑画板。
|
||||
- 共享组件在 `src/components/`(`ThemedPageMeta`、`ScrollTopMask`、`TopBarGradient`、`BottomActionBar`、`LoginGuard`、`LoginModal` 等)。
|
||||
|
||||
## 已知遗留问题
|
||||
|
||||
README「已知遗留问题」表记载(改动相关页面时注意):
|
||||
|
||||
- checkout 页在设计中新增地址后无法及时同步(design 数据写入与读取生命周期不一致)。
|
||||
- `diy/stickerEdit` 页面无法运行:Canvas 2D `createImage` 在部分基础库版本返回 `undefined`,且线稿 AI 接口未接入。
|
||||
@@ -30,8 +30,8 @@ smart-engraving-miniapp/
|
||||
│ │ └── index.ts # TypeScript 类型定义(ProductCategory、DesignItem、OrderItem 等)
|
||||
│ ├── utils/
|
||||
│ │ ├── productConfig.ts # 产品品类配置(5 个品类 + iconImg 映射)
|
||||
│ │ ├── store.ts # Data Access 层(Storage 读写,openid 隔离)
|
||||
│ │ ├── api.ts / request.ts # 后端 HTTP 封装(wx.request + token)
|
||||
│ │ ├── store/ # Data Access 层(按域拆分,openid 隔离)
|
||||
│ │ ├── api/ # 后端 HTTP 封装(按域拆分,wx.request + token)
|
||||
│ │ ├── authState.ts # 登录态校验缓存(isAuthVerified / invalidateAuthCache)
|
||||
│ │ └── themeBackground.ts # 页面窗口背景同步(backgroundColorTop/Bottom)
|
||||
│ ├── context/
|
||||
@@ -139,16 +139,16 @@ smart-engraving-miniapp/
|
||||
|
||||
### 前端 DA 层设计
|
||||
|
||||
所有数据操作通过 `src/utils/store.ts` 抽象:
|
||||
所有数据操作通过 `src/utils/store/` 抽象(按域拆分,详见 `docs/parallel-development-guide.md`):
|
||||
|
||||
```
|
||||
pages/
|
||||
└─ 调用 store.getDesignList() / addDesign() / designToOrder() 等业务接口
|
||||
└─ store.ts 内封装 Taro.getStorageSync / setStorageSync
|
||||
└─ store/<domain>.ts 内封装 Taro.getStorageSync / setStorageSync
|
||||
└─ key = ${scope}_${openid} 实现多用户数据隔离
|
||||
```
|
||||
|
||||
**后端替换方案**:上线时只需重写 `store.ts` 中的函数为 `wx.request` HTTP 调用,页面层零改动。
|
||||
**后端替换方案**:按域在 `store/<domain>.ts` 与 `api/<domain>.ts` 中替换为 HTTP 调用,页面层零改动。
|
||||
|
||||
### Icon 系统(emoji 已完全移除)
|
||||
|
||||
|
||||
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
@@ -1 +1 @@
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{653:function(e,n,t){var a=t(7842),i=t(5544),c=t(118),o=t(758),r=t.n(o),s=t(6540),u=t(8594),f=t(9703),g=t(6606),h=t(8199),l=t(4848),v=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}];function p(e){var n=v.findIndex(function(n){return e.endsWith(n.pagePath)});return v[n>=0?n:0].pagePath}function b(){var e=r().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return p(n)}function m(){var e=(0,s.useState)((0,u.eW)((0,u.O4)())),n=(0,i.A)(e,2),t=n[0],a=n[1],o=(0,s.useState)(b),h=(0,i.A)(o,2),m=h[0],d=h[1];(0,s.useEffect)(function(){var e=function(e){return a(e)};return r().eventCenter.on(f.Wo,e),function(){r().eventCenter.off(f.Wo,e)}},[]),(0,s.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,u.Rq)(e.theme),"auto"===(0,u.O4)()&&a(e.theme))},n=r();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,s.useEffect)(function(){var e=function(e){var n=r().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";d(p(e||t))};return r().eventCenter.on("tabBarChange",e),function(){r().eventCenter.off("tabBarChange",e)}},[]),(0,s.useEffect)(function(){(0,g.G)(t)},[t]);var x=function(e){d(p(e)),r().switchTab({url:e}),r().eventCenter.trigger("tabBarChange",e)};return(0,l.jsxs)(c.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,l.jsx)(c.Ss,{className:"tab-bar-gradient"}),(0,l.jsx)(c.Ss,{className:"custom-tab-bar theme-".concat(t),children:v.map(function(e){var n=e.pagePath===m;return(0,l.jsxs)(c.Ss,{className:"tab-item ".concat(n?"active":""),onTap:function(){return x(e.pagePath)},children:[n&&(0,l.jsx)(c.Ss,{className:"tab-indicator"}),(0,l.jsx)(c._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,l.jsx)(c.EY,{className:"tab-label",children:e.label})]},e.pagePath)})})]})}Component((0,a.createComponentConfig)(m,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(653)});e.O()}]);
|
||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{653:function(e,n,t){var a=t(7842),i=t(5544),c=t(118),o=t(758),r=t.n(o),s=t(6540),u=t(1457),f=t(9703),g=t(6606),h=t(8199),l=t(4848),v=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}];function p(e){var n=v.findIndex(function(n){return e.endsWith(n.pagePath)});return v[n>=0?n:0].pagePath}function b(){var e=r().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return p(n)}function m(){var e=(0,s.useState)((0,u.eW)((0,u.O4)())),n=(0,i.A)(e,2),t=n[0],a=n[1],o=(0,s.useState)(b),h=(0,i.A)(o,2),m=h[0],d=h[1];(0,s.useEffect)(function(){var e=function(e){return a(e)};return r().eventCenter.on(f.Wo,e),function(){r().eventCenter.off(f.Wo,e)}},[]),(0,s.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,u.Rq)(e.theme),"auto"===(0,u.O4)()&&a(e.theme))},n=r();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,s.useEffect)(function(){var e=function(e){var n=r().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";d(p(e||t))};return r().eventCenter.on("tabBarChange",e),function(){r().eventCenter.off("tabBarChange",e)}},[]),(0,s.useEffect)(function(){(0,g.G)(t)},[t]);var x=function(e){d(p(e)),r().switchTab({url:e}),r().eventCenter.trigger("tabBarChange",e)};return(0,l.jsxs)(c.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,l.jsx)(c.Ss,{className:"tab-bar-gradient"}),(0,l.jsx)(c.Ss,{className:"custom-tab-bar theme-".concat(t),children:v.map(function(e){var n=e.pagePath===m;return(0,l.jsxs)(c.Ss,{className:"tab-item ".concat(n?"active":""),onTap:function(){return x(e.pagePath)},children:[n&&(0,l.jsx)(c.Ss,{className:"tab-indicator"}),(0,l.jsx)(c._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,l.jsx)(c.EY,{className:"tab-label",children:e.label})]},e.pagePath)})})]})}Component((0,a.createComponentConfig)(m,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(653)});e.O()}]);
|
||||
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
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
@@ -0,0 +1,120 @@
|
||||
# 设计数据 → WCD 生产任务契约 v1
|
||||
|
||||
> 跨路线契约:**R2(数据生产者)与本路线 R4(WCD 打包消费者)共同遵守**。
|
||||
> 冻结目的:R4 下单后要把设计清单以 `.wcd` 包投递到词云平台形成生产任务
|
||||
> (见 `docs/routes/route-r4-upload-wordcloud.md` §3)。R4 能打包,取决于 R2
|
||||
> 保存的 `designData` 携带哪些字段。本文档把这些字段**先定死**。
|
||||
>
|
||||
> 版本规则:字段只能新增 optional 字段;改名/改语义/改类型必须升版本并同步本文件。
|
||||
|
||||
## 1. 为什么需要本契约
|
||||
|
||||
词云平台用 `.wcd`(Zip:`manifest.json` + `document.json` + `assets/`)还原整套画布。
|
||||
还原需要三样东西全部可用:
|
||||
|
||||
1. **画布尺寸 / 背景** ← 来自 `category.mask`。
|
||||
2. **贴纸布局**(位置、大小、旋转、层级)← 来自 `stickers[]`。
|
||||
3. **贴纸图片字节** ← 来自每个贴纸的**持久 URL**(COS)。
|
||||
|
||||
第 3 点是关键约束:如果贴纸图是 `wxfile://`/`tmp` 临时路径,后端打包时下载不到字节,
|
||||
WCD 生产任务直接失败。因此本契约把"图片必须持久化"从 R2 的已知限制升级为**冻结约束**。
|
||||
|
||||
## 2. 冻结的 `designData` 结构(v1)
|
||||
|
||||
与现有前端 `DesignItem.designData` 对齐,保留 `imageSrc/imagePos` 兼容旧版,**新增**如下字段:
|
||||
|
||||
```ts
|
||||
/** 设计数据 v1 —— 支持 R4 WCD 生产任务打包 */
|
||||
interface DesignDataV1 {
|
||||
/** 结构版本,缺省按 v1 处理;旧数据可由后端/前端按版本迁移 */
|
||||
version?: 1
|
||||
|
||||
/** 商品品类(已有)。mask 是画布尺寸来源,保存清单时必须保留 */
|
||||
category?: {
|
||||
id: string
|
||||
mask: { shape: 'rect' | 'circle'; width: number; height: number; borderRadius?: number }
|
||||
tone?: [number, number, number]
|
||||
}
|
||||
|
||||
/** 底图(已有 imageSrc 语义的规范化):src 必须是持久 URL */
|
||||
background?: {
|
||||
src: string // COS 持久 URL;禁止 wxfile:// / tmp 临时路径
|
||||
color?: string // 画布底色(缺省由 mask/主题决定)
|
||||
pos?: { x: number; y: number; scale: number } // 兼容旧 imagePos
|
||||
}
|
||||
|
||||
/**
|
||||
* 词云信息(R4 在词云生成后写入;R2 保存/更新清单时原样保留这组字段,
|
||||
* 后端 items JSON 白名单放行)。
|
||||
*/
|
||||
wordcloud?: {
|
||||
jobId?: string // 生成该词云的 wxmp_backend 侧任务 id
|
||||
imageUrl: string // 词云结果图持久 URL(COS)
|
||||
names: string[] // 名单快照(≤200),生产/重建用,可为空
|
||||
}
|
||||
|
||||
/** 贴纸列表(已有)。每个贴纸的 src 升级为持久 URL 约束 */
|
||||
stickers?: Array<{
|
||||
id: string
|
||||
src: string // ★ 必须为 COS 持久 URL(https),禁止 wxfile:// / tmp / 空
|
||||
x: number
|
||||
y: number
|
||||
scale: number
|
||||
width: number
|
||||
height: number
|
||||
rotation?: number // 已冻结(2026-08-12 决策#2):本期 DIY 持久化;无旋转则省略
|
||||
zIndex?: number // 已冻结(2026-08-12 决策#2):图层顺序;WCD 按 zIndex 排列元素
|
||||
isOverlapping?: boolean
|
||||
edits?: {
|
||||
brightness?: number // -100~100
|
||||
hue?: number // -180~180
|
||||
contrast?: number // -100~100
|
||||
sketchSrc?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
关系说明:
|
||||
|
||||
- `DesignItem.designData`(前端类型文件 `src/types/index.ts`)与后端 `design-list.items[].designData`
|
||||
(服务端 JSON)为**同一份**结构;前端类型以本契约为准,后端只做白名单校验不改业务 JSON。
|
||||
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`,
|
||||
WCD 打包器同时兜底读旧字段。
|
||||
|
||||
## 3. 冻结约束(R2 需落地)
|
||||
|
||||
| # | 约束 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | **贴纸图持久化(R4 负责)** | 决策#4:**贴纸持久化由 R4 完成**。R2 允许保存/更新清单时贴纸 `src` 暂为本地路径(`wxfile://`/`tmp`);**R4 在下单/派单前**把本地图上传为 COS 持久 URL 并回写 `designData.stickers[].src`。WCD 打包只消费持久 URL。R2 需保证该字段可被 R4 回写 |
|
||||
| 2 | **词云字段保留** | `designData.wordcloud` 由 R4 写入后,R2 的 PATCH/列表接口不得丢弃该分组 |
|
||||
| 3 | **mask 必须存在** | `designData.category.mask` 是画布尺寸来源;缺 mask 的旧数据 WCD 打包按产品默认尺寸兜底 |
|
||||
| 4 | **布局字段持久化** | 决策#2:`rotation`/`zIndex` 本期由 DIY 随贴纸持久化(当前 `StickerItem` 缺 rotation,需补) |
|
||||
| 5 | **白名单放行** | 后端 `design-list` 的 items JSON 白名单放行 `version/background/wordcloud/rotation/zIndex` 字段;单条 ≤1MB 上限以新结构复核(图片在远端 URL,JSON 本体仍应很小) |
|
||||
| 6 | **状态映射不变** | `ordered` 继续由 `orderId != null` 派生,本契约不改变 R2 状态机 |
|
||||
|
||||
## 4. 边界(本期不做)
|
||||
|
||||
- 贴纸 `edits`(亮度/色相/对比度/`sketchSrc` 线稿)无法由词云平台 `.wcd`
|
||||
`document.json` 表达,本期不随 WCD 携带;仅记录在 `manifest.meta`。若后续生产需要
|
||||
线稿还原,需单独升 WCD 契约(wordcloud 侧支持后再谈)。
|
||||
- 字体不随包携带(wordcloud 第一阶段不做字体打包)。
|
||||
- `.wcd` 只做"订单 → wordcloud"单向投递;暂不做"词云平台 → 小程序"的反向导出。
|
||||
|
||||
## 5. 落地位置
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| 前端类型 | `wechat_wc/src/types/index.ts` 的 `DesignItem.designData` 按本契约补齐字段 |
|
||||
| 前端页面 | `diy`(保存 designData)、`checkout`(下单前确认 designData 完整) |
|
||||
| 后端白名单 | `wxmp_backend/src/design-list/*` 的 items 校验放行新字段 |
|
||||
| 契约发行 | `wxmp_backend/docs/api-contract-v1.md` §5 引用本文档 |
|
||||
|
||||
## 6. 决策记录(2026-08-12 冻结)
|
||||
|
||||
| 评审点 | 决策 |
|
||||
|---|---|
|
||||
| 1. `background`/`wordcloud` 命名 | **接受**,按 §2 结构冻结 |
|
||||
| 2. `rotation`/`zIndex` 是否本期持久化 | **本期持久化**,DIY 随贴纸保存(见约束 #4) |
|
||||
| 3. 名单快照 `wordcloud.names` 是否必须 | **小程序可不必须**:`names` 保持 optional,小程序可不带;R4 生成词云后按实际写入,生产侧不强依赖 |
|
||||
| 4. 贴纸图持久化由谁触发 | **R4 负责**:下单/派单前把本地贴纸图上传为 COS 持久 URL 并回写 `src`(见约束 #1) |
|
||||
@@ -0,0 +1,133 @@
|
||||
# 并行开发指南(R0)
|
||||
|
||||
团队的仓库、分工、Git 规范与开发节奏以
|
||||
[团队协作总文档](team-collaboration-guide.md) 为准;本页专注代码修改约定。
|
||||
|
||||
本文档是四名成员并行开发时的代码修改约定。目标是:每个人在自己路线的文件里改动,
|
||||
不与其他人发生文件级冲突;共享文件只由 R0 聚合入口维护,平时不再堆业务代码。
|
||||
|
||||
## 1. 目录约定
|
||||
|
||||
页面不得直接读写 Storage 或跨过聚合入口调用底层模块,统一从下面两个入口导入:
|
||||
|
||||
```ts
|
||||
import { getAddressList, addAddress } from '../../utils/store'
|
||||
import { fetchProducts, createOrder } from '../../utils/api'
|
||||
```
|
||||
|
||||
`src/utils/store/`:DA 层,按域拆分:
|
||||
|
||||
| 文件 | 归属 | 内容 |
|
||||
|---|---|---|
|
||||
| `store/keys.ts` | R0,尽量不改 | openid 作用域 key 与 mock 判断 |
|
||||
| `store/user.ts` | R0/R3 | 用户信息、账号切换、注册表 |
|
||||
| `store/design.ts` | R2 | 设计清单本地数据 |
|
||||
| `store/address.ts` | R2 | 收货地址本地数据 |
|
||||
| `store/order.ts` | R3 | 订单本地数据与 designToOrder |
|
||||
| `store/theme.ts` | R0 | 主题模式读写 |
|
||||
| `store/index.ts` | R0 | 聚合 re-export,只加不删 |
|
||||
|
||||
`src/utils/api/`:HTTP 接口层,按域拆分:
|
||||
|
||||
| 文件 | 归属 | 内容 |
|
||||
|---|---|---|
|
||||
| `api/auth.ts` | R0 | 登录、登出、token |
|
||||
| `api/user.ts` | R0 | 用户资料 |
|
||||
| `api/product.ts` | R1 | 商品、分类 |
|
||||
| `api/address.ts` | R2 | 收货地址(待补) |
|
||||
| `api/design.ts` | R2 | 设计清单(待补) |
|
||||
| `api/order.ts` | R3 | 订单、支付(待补) |
|
||||
| `api/upload.ts` | R4 | 上传、词云、线稿(待补) |
|
||||
| `api/index.ts` | R0 | 聚合 re-export |
|
||||
|
||||
类型统一放在 `src/types/index.ts`,或按域新增 `src/types/<domain>.ts`,
|
||||
不要把页面私有类型散落在各个页面里。
|
||||
|
||||
## 2. 路线归属
|
||||
|
||||
每条路线的完整开发内容、设计 DO/DON'T 与验收标准见 `docs/routes/`:
|
||||
|
||||
| 文档 | 分支 |
|
||||
|---|---|
|
||||
| [R1 商品目录动态化](routes/route-r1-product-catalog.md) | `feat/r1-catalog` |
|
||||
| [R2 地址 + 设计清单](routes/route-r2-address-design.md) | `feat/r2-address-design` |
|
||||
| [R3 订单 + 支付占位](routes/route-r3-order-pay.md) | `feat/r3-order-pay` |
|
||||
| [R4 上传 + 词云 + 线稿](routes/route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` |
|
||||
|
||||
| 路线 | 页面 | 后端 | 词云项目 |
|
||||
|---|---|---|---|
|
||||
| R1 商品目录 | `index`、`shop`、`product` | products/categories、schema 扩展、seed | 不用 |
|
||||
| R2 地址 + 设计清单 | `address`、`designList` | addresses CRUD、design-list CRUD、状态映射 | 不用 |
|
||||
| R3 订单 + 支付占位 | `checkout`、`orders`、`orderDetail`、付款按钮 | orders 重算/事务、payments 占位 | 不用 |
|
||||
| R4 上传 + 词云 + 线稿 | `wordcloud`、`diy/stickerEdit` | upload、词云适配器、sketch、队列 | 复用,需冻结最小契约 |
|
||||
|
||||
文件所有权约定:
|
||||
|
||||
- `checkout` 页归 R3,R2 只做 `address` 和 `designList` 页。
|
||||
- `profile` 页是只读统计消费者,等 R2/R3 合入后再统一收尾。
|
||||
- `wordcloud` 项目只有 R4 会碰,其余路线不依赖它。
|
||||
|
||||
## 3. 新增接口怎么改
|
||||
|
||||
1. 后端先把 Swagger/DTO 定下来,确认字段与状态枚举。
|
||||
2. 在对应的 `api/<domain>.ts` 里写函数,返回类型对齐 `src/types`。
|
||||
3. 不需要改页面里的导入路径:聚合入口已 re-export 所有域文件,
|
||||
新函数会自动从 `../../utils/api` 导出。
|
||||
4. 页面只调用 `api` 层的函数,不在页面里直接写 `http.get`。
|
||||
|
||||
## 4. 新增本地业务怎么改
|
||||
|
||||
1. 判断属于哪个域,写进 `store/<domain>.ts`。
|
||||
2. 若该域已有文件,直接在文件内追加函数;不要新建 `xxx2.ts`。
|
||||
3. 新域需在 `store/index.ts` 增加一行显式 re-export。
|
||||
4. 页面通过 `../../utils/store` 导入,不直接 import 内部模块路径。
|
||||
|
||||
## 5. Git 协作
|
||||
|
||||
每路线在 `wechat_wc` 与 `wxmp_backend` 使用同名分支:
|
||||
|
||||
| 路线 | 分支名 |
|
||||
|---|---|
|
||||
| R1 | `feat/r1-catalog` |
|
||||
| R2 | `feat/r2-address-design` |
|
||||
| R3 | `feat/r3-order-pay` |
|
||||
| R4 | `feat/r4-upload-wordcloud` |
|
||||
|
||||
日常循环:
|
||||
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git checkout -b feat/r1-catalog
|
||||
|
||||
# 提交并推送自己的分支
|
||||
git add -A
|
||||
git commit -m "feat(catalog): xxx"
|
||||
git push -u origin feat/r1-catalog
|
||||
|
||||
# 主干有更新时,变基到最新
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
|
||||
# 合入:只有合入负责人执行
|
||||
git checkout main && git pull
|
||||
git merge --no-ff feat/r1-catalog
|
||||
git push origin main
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 主干 `main` 保持可运行,禁止直接 push,合入走 Pull Request。
|
||||
- 后端与前端同名分支是一对,评审时成对看,后端 Swagger 先定契约。
|
||||
- 不跨路线互相拉分支;主干更新只通过 `rebase origin/main` 获取。
|
||||
- 合入顺序:R1 先合(R3 服务端金额重算依赖商品表),R2/R4 随后,R3 最后。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
每次提交前至少保证:
|
||||
|
||||
```bash
|
||||
npm run build:weapp
|
||||
```
|
||||
|
||||
R1 合入前额外跑后端接口 smoke;R2/R3 合入前跑对应页面在微信开发者工具里的手测;
|
||||
R4 合入前跑一次词云契约 smoke。
|
||||
@@ -0,0 +1,130 @@
|
||||
# R4 交付报告(feat/r4-upload-wordcloud)
|
||||
|
||||
> 日期:2026-08-13 | 范围:上传 / 词云生成 / 线稿 / **下单后 WCD 生产任务投递**
|
||||
> 涉及仓库:`wechat_wc`(小程序前端)、`wxmp_backend`(NestJS 后端)、`wordcloud`(FastAPI 词云平台)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与架构
|
||||
|
||||
把「用户上传底图 + 名单 → 词云生成 → DIY 贴纸 → 下单」链路中的上传、词云、线稿全部接到真实后端,
|
||||
并**在下单后把订单对应的整套设计以 `.wcd` 包投递到词云平台形成生产任务**。
|
||||
|
||||
```
|
||||
小程序 designData(贴纸持久化为 COS URL)
|
||||
→ POST /api/orders(designListId 关联,R3)
|
||||
→ POST /api/orders/:id/dispatch(幂等)
|
||||
→ WordCloudService.buildWcdPackage(designData) → .wcd(Zip: manifest.json+document.json+assets/)
|
||||
→ POST {WORDCLOUD_API_URL}/api/jobs wcd_file(MODE=WCD)
|
||||
→ 词云平台还原设计 + 合成生产 PNG → 后端轮询同步 CustomizationTask/WordCloudJob → 产物转存 COS
|
||||
```
|
||||
|
||||
小程序只与 `wxmp_backend` 通信;`WORDCLOUD_API_URL` 未配置时返回结构化 `not_configured`,不做假成功。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前端交付(wechat_wc / feat/r4-upload-wordcloud)
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `src/types/index.ts` | `DesignDataV1`(`version/background/wordcloud`)、`StickerItem` 补 `rotation/zIndex`;新增 `WordCloudJob/WordCloudStatus/CosCredentials/SketchResult/WordCloudDispatchStatus/WordCloudDispatchResult` |
|
||||
| `src/utils/api/upload.ts` | `getUploadCredentials`、`uploadToCos`(预签名 PUT 直传)、`sketchImage`、`createWordCloudJob`、`getWordCloudJob`、`getWordCloudResult`、`persistDesignMedia`(贴纸持久化,决策#4)、`dispatchOrder`;multipart 统一带 Bearer token、解析 `{code,message,data}` 信封 |
|
||||
| `src/pages/wordcloud/index.tsx` | 去掉 `setInterval`+`Math.random` 假进度 → 真实「上传底图→建任务→轮询→取结果→保存相册」;新增生成失败/未配置提示块 |
|
||||
| `src/pages/diy/stickerEdit/index.tsx` | 删 `your-api-domain.com` 占位 → 走 `sketchImage`(`POST /api/sketch`),失败仍降级前端灰度 |
|
||||
| `src/pages/diy/index.tsx` | `handleComplete` 保存前调用 `persistDesignMedia`:本地贴纸/底图 → COS 持久 URL 写回 `designData` |
|
||||
| 文档 | `docs/routes/route-r4-upload-wordcloud.md`、`docs/design-data-contract-v1.md`、`CLAUDE.md` |
|
||||
|
||||
验证:`npm run build:weapp` 通过。
|
||||
|
||||
## 3. 后端交付(wxmp_backend / feat/r4-upload-wordcloud)
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `config` | `wordcloud: { apiUrl, timeoutMs }` 配置组 + Joi `WORDCLOUD_API_URL/WORDCLOUD_TIMEOUT_MS`(可选) |
|
||||
| `cos` | `CosService`:预签名 PUT URL 签发、服务端 `putObject`、公网 URL;未配置 → `configured=false` |
|
||||
| `upload` | `GET /api/upload/credentials?key=` 返回预签名 URL 并记录 `Upload` 归属(当前用户) |
|
||||
| `wordcloud` | `POST /api/wordcloud/generate`(multipart image+names → xlsx → 投递 `/api/jobs`)、`GET /api/wordcloud/jobs/:id`(归属校验+实时代理轮询)、`GET /api/wordcloud/jobs/:id/result` |
|
||||
| `wordcloud` 派单 | `buildWcdPackage`(designData→.wcd,下载素材字节、sha256)、`dispatchToWordcloud`(**幂等**、未配置→`not_configured`)、`POST /api/orders/:id/dispatch` |
|
||||
| `wordcloud` 同步 | `onModuleInit` 后台 30s 轮询进行中任务 → 成功时结果图转存 COS、同步 `CustomizationTask→SUCCESS/resultUrl` |
|
||||
| `sketch` | `POST /api/sketch`(multipart image,类型/大小校验 ≤10MB)→ 转存 COS 返回 URL |
|
||||
| `orders` | `OrdersService.create` 保存 `designList` 关联(R3 契约字段) |
|
||||
|
||||
**Prisma 迁移**(3 个):
|
||||
|
||||
| 迁移 | 内容 |
|
||||
|---|---|
|
||||
| `add_word_cloud_job` | `WordCloudJob` 模型 + `WordCloudJobStatus` 枚举 |
|
||||
| `add_remote_job_id` | `WordCloudJob.remoteJobId`(wordcloud 外部 job_id) |
|
||||
| `r4_order_design_and_dispatch` | `Order.designListId`+`DesignList.orders`;`CustomizationTask.orderId/wordcloudJobId` |
|
||||
|
||||
验证:`nest build` 通过、`prisma validate` 通过、迁移已应用。
|
||||
|
||||
## 4. wordcloud 平台交付(wordcloud / feat/r4-wcd-job,仅工作区)
|
||||
|
||||
`POST /api/jobs` 新增**可选 `wcd_file`**(`MODE=WCD`):
|
||||
- 校验 `format=wordcloud-canvas`、`version=1`;素材按 SHA-256 去重注册(复用 `/api/design-templates/import` 逻辑);`document.json` 落库为生产设计。
|
||||
- 后台线程用 Pillow 把 CanvasDocument 合成**扁平生产 PNG**(背景 + 按 zIndex 叠贴纸)→ `set_artifacts({png})` → `queued/running/success`,产物经 `GET /api/jobs/{id}/files/png` 可下载。
|
||||
- 对既有 `.xlsx` 名单模式完全向后兼容(纯新增可选字段)。
|
||||
|
||||
验证:`py_compile` 通过;合成算法用独立脚本验证(贴纸正确合成到对应坐标)。**未提交**——该仓库 `app.py` 有他人大量在途改动且无远程,改动留在 `feat/r4-wcd-job` 工作区,由 wordcloud 维护者合入其分支后提交。
|
||||
|
||||
## 5. 配置(后端 `.env`)
|
||||
|
||||
```dotenv
|
||||
# ── 词云平台 ──
|
||||
WORDCLOUD_API_URL= # 词云服务地址;留空 → 派单返回 not_configured
|
||||
WORDCLOUD_TIMEOUT_MS=30000
|
||||
# ── COS ──
|
||||
COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
COS_BUCKET=wordcloudwechat
|
||||
COS_REGION=ap-guangzhou
|
||||
```
|
||||
|
||||
## 6. 验证情况
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `wechat_wc npm run build:weapp` | ✅ 通过(多次) |
|
||||
| `wxmp_backend nest build` | ✅ 通过(EXIT=0,0 error) |
|
||||
| `wxmp_backend prisma validate` + 迁移应用 | ✅ 通过 |
|
||||
| `wordcloud app.py py_compile` + 合成算法单测 | ✅ 通过(算法级) |
|
||||
| `wordcloud pytest` | ⚠️ 系统 python 缺 `pandas/pytest`(项目 venv 未装),**完整测试需团队环境** |
|
||||
|
||||
诚实说明:COS 与 wordcloud 的本地 env 均为空,**运行期未做真实端到端联调**(见 §7 注意事项)。
|
||||
|
||||
## 7. 注意事项(上线前必读)
|
||||
|
||||
1. **微信后台域名**:小程序 `downloadFile`/`uploadFile` 合法域名需加入 COS 桶域名(`https://wordcloudwechat.cos.ap-guangzhou.myqcloud.com`)与后端域名(`https://wxbackend.tokenleaping.com`);否则真机上传/下载失败。
|
||||
2. **COS**:`wordcloudwechat` 桶需公共读;`COS_*` 密钥只存后端 env。直传用预签名 URL,小程序不持永久密钥。
|
||||
3. **wordcloud 部署**:`WORDCLOUD_API_URL` 指向真实部署实例;联调前锁定一个版本(它正在大量变更)。`wcd_file` 实现需 wordcloud 侧合入其分支(当前在工作区)。
|
||||
4. **贴纸持久化依赖**:`buildWcdPackage` 只消费持久 URL;若 designData 里的贴纸仍是本地/临时路径会被跳过。前端保存时已调用 `persistDesignMedia`,但**必须在 COS 已配置**下才生效。
|
||||
5. **任务同步**:当前用服务内 30s 后台轮询推进状态(未依赖 Redis/BullMQ queue);生产量大时可换 queue processor(占位仍在 `src/queue/`)。
|
||||
6. **安全**:所有 wordcloud 任务查询带 userId 归属校验;未配置时结构化返回,不做假成功;名单 ≤200、底图 ≤10MB、任务超时清理。
|
||||
7. **测试环境**:跑 wordcloud 测试需安装 `pandas`/`pytest`。
|
||||
|
||||
## 8. 对接需求
|
||||
|
||||
| 对接方 | 需求 |
|
||||
|---|---|
|
||||
| **R2(设计清单)** | `designData` 按 `docs/design-data-contract-v1.md` 冻结:放行 `version/background/wordcloud/rotation/zIndex`;`category.mask` 必须保留;贴纸图 `src` 由 R4 持久化(R2 允许保存时暂为本地路径)。4 项决策已冻结(见契约 §6) |
|
||||
| **R3(订单)** | 创建订单时传 `designListId`(`OrdersService.create` 已支持存储);订单进入 `PROCESSING` 时可自动触发 `POST /api/orders/:id/dispatch`;支付未配置期用该端点做联调/运营触发 |
|
||||
| **wordcloud 团队** | 合入 `feat/r4-wcd-job` 工作区的 `create_job` wcd_file 改动;对外部署;契约 v1.1 已冻结(`wxmp_backend/docs/wordcloud-contract.md`) |
|
||||
| **运维** | 配置 `COS_*`、`WORDCLOUD_API_URL`;微信后台加域名;跑一次迁移 `prisma migrate deploy` |
|
||||
| **前端(R4 自身)** | 词云页/线稿页联调需后端接口真实可用;下单后派单状态展示挂在 R3 的订单详情页 |
|
||||
|
||||
## 9. 分支与提交
|
||||
|
||||
| 仓库 | 分支 | 提交 |
|
||||
|---|---|---|
|
||||
| wechat_wc | `feat/r4-upload-wordcloud` | docs(契约/路线/CLAUDE.md) → 前端类型/api/词云页/线稿 → 贴纸持久化+dispatch |
|
||||
| wxmp_backend | `feat/r4-upload-wordcloud` | docs(契约) → config → WordCloudJob+适配器 → COS → WCD 派单 → sketch/Upload/同步 → jobs/:id/result |
|
||||
| wordcloud | `feat/r4-wcd-job` | 工作区改动(未提交,无远程) |
|
||||
|
||||
远端 `master`/`main` 全程复查:无新提交需合并,分支已随开发保持同步。
|
||||
|
||||
## 10. 剩余 / 交接
|
||||
|
||||
- wordcloud 侧 `wcd_file` 合入其分支 + 部署锁定版本 → 端到端联调(下单 → WCD 生产 PNG)。
|
||||
- R3 完成后接入"订单进 PROCESSING 自动派单"与订单详情派单状态展示。
|
||||
- 生产化:换 Redis/BullMQ 队列做任务同步;加任务超时清理、单账号频率限制。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 四条并行开发路线
|
||||
|
||||
四份文档分别对应四个开发分支,覆盖前端 `wechat_wc`、后端 `wxmp_backend` 与词云项目
|
||||
`wordcloud` 的分工、开发内容、设计 DO/DON'T 和验收标准。以本目录文档为准,开发时
|
||||
不要随意扩大分支边界。
|
||||
|
||||
| 文档 | 分支 | 内容 |
|
||||
|---|---|---|
|
||||
| [R1 商品目录动态化](route-r1-product-catalog.md) | `feat/r1-catalog` | 商品/分类 API、seed、首页/商品/详情页切换 |
|
||||
| [R2 地址 + 设计清单](route-r2-address-design.md) | `feat/r2-address-design` | 地址 CRUD、默认地址事务、设计 JSON 与状态映射 |
|
||||
| [R3 订单 + 支付占位](route-r3-order-pay.md) | `feat/r3-order-pay` | 服务端下单、金额重算、订单状态机、支付未配置占位 |
|
||||
| [R4 上传 + 词云 + 线稿](route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` | COS 上传、wordcloud 契约冻结、sketch、任务轮询 |
|
||||
|
||||
## 合并顺序与依赖
|
||||
|
||||
- R1 是交易链路的前置,优先合入。
|
||||
- R2 依赖已有登录体系,可与 R1 并行。
|
||||
- R3 依赖 R1 的商品表和 R2 的地址/设计清单接口,最后合入。
|
||||
- R4 独立并行,但依赖 wordcloud 最小契约冻结。
|
||||
|
||||
## 通用红线
|
||||
|
||||
- 前后端同名分支成对评审,后端 Swagger 先定契约,前端再实现。
|
||||
- 每个分支合入主干前必须通过各自仓库的构建验证。
|
||||
- 页面统一从 `../../utils/store` 与 `../../utils/api` 聚合入口导入。
|
||||
- 不得跨路线修改其他分支的文件所有权。
|
||||
@@ -0,0 +1,93 @@
|
||||
# R1 商品目录动态化(feat/r1-catalog)
|
||||
|
||||
**Goal:** 让首页、商品列表、沉浸式详情、商品详情四个页面改从后端商品/分类 API 取数,
|
||||
前端不再直接依赖静态 `productConfig.ts` 作为线上数据源。
|
||||
|
||||
**Architecture:** 前端新增按域的 `api/product.ts` 与页面级加载状态;后端扩展 Product 表字段、
|
||||
补齐 products/categories 查询能力、写入 5 个真实品类 seed;前后端通过 `docs/api-contract-v1.md`
|
||||
冻结响应结构;静态 `productConfig.ts` 降级为离线兜底与图标映射,R1 合并验证前不删除。
|
||||
|
||||
**Tech Stack:** Taro 3.6 + React 18 + TypeScript;NestJS 11 + Prisma/PostgreSQL。
|
||||
|
||||
## 1. 本分支要开发的内容
|
||||
|
||||
### 前端 `wechat_wc`
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/types/index.ts` 或 `src/types/product.ts` | ProductCategory 与后端字段对齐,新增 `subtitle/tone/story/scene/tags/specs/mask/originalPrice/leadTime/status` |
|
||||
| `src/utils/api/product.ts` | `fetchProducts(params)`、`fetchProduct(id)`、`fetchCategories()`,返回类型显式声明 |
|
||||
| `src/hooks/useProducts.ts`(新增) | 商品列表/详情加载、loading/error/retry、可选 60s 内存缓存 |
|
||||
| `src/pages/index/index.tsx` | 品类网格与搜索改走 API;成品轮播仍可静态,但价格/图片建议取商品数据 |
|
||||
| `src/pages/shop/index.tsx` | 商品网格改走 API,补加载骨架、空态、失败重试 |
|
||||
| `src/pages/shop/detail/index.tsx` | 按 `id` 拉详情,补 loading/error,保留沉浸式滚动动画 |
|
||||
| `src/pages/product/index.tsx` | 按 `id` 拉详情,`addDesign` 继续复用 `store/design.ts` |
|
||||
| `src/utils/productConfig.ts` | 保留为 seed 参考与离线兜底,不参与线上主流程 |
|
||||
|
||||
### 后端 `wxmp_backend`
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `prisma/schema.prisma` | Product 增加 `subtitle/leadTime/originalPrice/tone/tags/specs/mask/story/scene/iconImg`,其中 `tone Int[]`、`specs Json`、`mask Json`、`tags String[]` |
|
||||
| `prisma/seed.ts` | 写入 5 个真实品类(笔记本小/大、杯垫、笔盒、书灯),图片用 OSS URL,mask 与前端现有一致 |
|
||||
| `products` | `GET /api/products`:分页、`categoryId`、`keyword`、`status=ON_SALE`;`GET /api/products/:id`:404 处理 |
|
||||
| `categories` | `GET /api/categories`:稳定排序返回,暂做扁平结构 |
|
||||
| `docs/api-contract-v1.md` | 商品/分类接口、分页结构、Decimal 数字格式、错误码 |
|
||||
|
||||
### 词云项目 `wordcloud`
|
||||
|
||||
不参与本分支。
|
||||
|
||||
## 2. 设计注意事项
|
||||
|
||||
### DO
|
||||
|
||||
- DO 前端只消费后端返回的 `price`,下单链路以后也不允许前端自算金额。
|
||||
- DO 在 API 层把 Decimal 字符串规范成本地 `number`,页面组件里不做字符串拼接运算。
|
||||
- DO 所有列表/详情页补齐 loading、空态、失败重试,失败时给出可操作文案。
|
||||
- DO 图片加载失败使用 `product.iconImg` 或 `四角星.svg` 兜底,单张图失败不阻塞页面。
|
||||
- DO `mask`、`specs`、`tone` 等富字段作为后端 JSON 返回,前端类型用判别联合描述。
|
||||
- DO 后端 seed 和前端 `productConfig.ts` 使用同一套稳定的业务 ID(如 `notebook-small`),方便前后端联调。
|
||||
- DO 在 R1 分支内同步更新前端 `types` 与 Swagger 文档,一个 PR 成对评审。
|
||||
- DO 商品列表接口先给分页参数,前端第一版可以每次取全部,但接口结构要能扩展。
|
||||
- DO 公共接口统一 `auth:false`,不要携带 token。
|
||||
- DO 保持项目事件规范:所有点击使用 `onTap`,不使用 `onClick`。
|
||||
|
||||
### DON'T
|
||||
|
||||
- DON'T 直接删除 `productConfig.ts`,R1 合并前它仍是离线兜底和图标映射来源。
|
||||
- DON'T 在页面里直接写 `http.get('/api/products')`,必须收口到 `api/product.ts`。
|
||||
- DON'T 在前端硬编码新的图片 CDN 地址,继续走 `assetUrl` 与 `OSS_BASE_URL`。
|
||||
- DON'T 让前端根据 `originalPrice` 自行计算促销逻辑,促销后续由后端字段统一表达。
|
||||
- DON'T 依赖数据库插入顺序,列表必须有 `sort/createdAt` 稳定排序。
|
||||
- DON'T 在 seed 中用随机 cuid 造成不同环境商品 ID 漂移。
|
||||
- DON'T 在 R1 里顺手改主题、TabBar、DIY 等无关文件。
|
||||
- DON'T 把 `description` 塞进 JSON 大字段混用,普通段落继续用字符串字段。
|
||||
|
||||
## 3. 补充内容(用户未列但建议纳入)
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 前后端本地联调:首页/商品列表/沉浸式详情/商品详情四个页面数据来自 API,静态配置失效时页面有兜底而不会白屏。
|
||||
- 后端 `npm run build`、前端 `npm run build:weapp` 通过。
|
||||
- Swagger 中商品列表/详情/分类文档完整;前端类型与 Swagger 字段一一对应。
|
||||
- seed 后数据库包含 5 个在售品类,图片 URL 可访问。
|
||||
|
||||
### 合并与依赖
|
||||
|
||||
- 分支名:前端与后端均为 `feat/r1-catalog`。
|
||||
- 合并顺序:R1 是整个交易链路的前提,优先合入;R3 的服务端金额重算依赖本分支的商品表。
|
||||
- 合入前跑一次后端接口 smoke:`GET /api/categories`、`GET /api/products`、`GET /api/products/:id`。
|
||||
|
||||
### 风险
|
||||
|
||||
- OSS 图片域名若未配到微信后台 `downloadFile` 合法域名,商品图会在真机白图,联调时先确认。
|
||||
- `tone`、`mask` 等富字段若后端 JSON 序列化方式不统一,前端类型会悄悄失效,合入前要跑真实返回样例。
|
||||
- 首页成品轮播目前混用静态文案与商品数据,R1 建议只把“热门品类”切 API,轮播数据源单独决策。
|
||||
|
||||
### 测试要求
|
||||
|
||||
- 后端:至少补 products/categories 的 e2e(正常列表、空列表、404、keyword 过滤)。
|
||||
- 前端:三个页面手测加载态、断网重试、空数据、关键词搜索无结果、图片 404 兜底。
|
||||
- 不需要在 R1 引入自动化 UI 测试,保持手测清单即可。
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# R2 收货地址 + 设计清单(feat/r2-address-design)
|
||||
|
||||
**Goal:** 把收货地址和设计清单从本地 Storage 切换到后端 CRUD,完成字段契约、接口权限、
|
||||
默认地址事务、设计数据 JSON 与状态映射,让 R3 的订单闭环可以直接消费这两组能力。
|
||||
|
||||
**Architecture:** 前端 `api/address.ts`、`api/design.ts` 负责 HTTP 契约,本地
|
||||
`store/address.ts`、`store/design.ts` 在联调期保留为兜底;后端补齐 addresses 与 design-list
|
||||
的完整 CRUD、事务与本人数据校验;`checkout` 页归 R3 所有,R2 只定义接口不修改该页面。
|
||||
|
||||
**Tech Stack:** Taro 3.6 + React 18 + TypeScript;NestJS 11 + Prisma/PostgreSQL。
|
||||
|
||||
## 1. 本分支要开发的内容
|
||||
|
||||
### 前端 `wechat_wc`
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/utils/api/address.ts` | `fetchAddresses/createAddress/updateAddress/deleteAddress/setDefaultAddress`,负责 `region[]` 与 `province/city/district` 双向转换 |
|
||||
| `src/utils/api/design.ts` | `fetchDesignList/createDesign/updateDesign/deleteDesigns` |
|
||||
| `src/pages/address/index.tsx` | 列表、新增、编辑、删除、设为默认全部走 API;保留表单校验与区域 Picker |
|
||||
| `src/pages/designList/index.tsx` | 列表、状态筛选、批量删除走 API;保留 LoginGuard |
|
||||
| `src/types/index.ts` | AddressItem/DesignItem 与后端结构对齐,新增服务端 id、状态码、时间字段 |
|
||||
| `src/utils/store/address.ts`、`design.ts` | 降级为“本地缓存/离线兜底”,API 调用成功后同步刷新 |
|
||||
|
||||
### 后端 `wxmp_backend`
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `addresses` | `GET/POST /api/addresses`、`PATCH /api/addresses/:id`、`PATCH /api/addresses/:id/default`、`DELETE /api/addresses/:id`;全部带 userId 归属校验 |
|
||||
| `addresses` 事务 | 设置默认时事务内先清旧默认再设新默认;删除默认地址后自动指定最新一条为默认 |
|
||||
| `design-list` | `GET/POST /api/design-list`、`PATCH /api/design-list/:id`、`DELETE /api/design-list/:id`、批量删除 |
|
||||
| `design-list` 校验 | `items` JSON 结构与大小校验、状态机转换、本人专属查询 |
|
||||
| `docs/api-contract-v1.md` | 地址字段映射、设计清单状态枚举、错误码 |
|
||||
|
||||
### 词云项目 `wordcloud`
|
||||
|
||||
不参与本分支,但 `designData` 中的本地贴纸图片路径问题会影响后续 R4,见风险一节。
|
||||
|
||||
## 2. 状态映射(必须先定死)
|
||||
|
||||
后端 `DesignListStatus` 目前是 `DRAFT/SUBMITTED/PROCESSING/DONE`,前端是
|
||||
`undesigned/designing/ordered`。R2 不要在两边各造一套值,建议按显示语义映射:
|
||||
|
||||
| 前端显示 | 后端状态 | 前端状态码 |
|
||||
|---|---|---|
|
||||
| 待设计 | DRAFT | `undesigned` |
|
||||
| 设计中 | SUBMITTED | `designing` |
|
||||
| 生产中 | PROCESSING | `processing` |
|
||||
| 已下单 | DONE | `ordered` |
|
||||
|
||||
“已下单”不要用独立状态表达,改为 `orderId != null` 派生;前端只显示,不提交这个状态。
|
||||
状态码收口到 `docs/api-contract-v1.md`,后端校验状态迁移,前端不传自由字符串。
|
||||
|
||||
## 3. 字段契约
|
||||
|
||||
### 地址
|
||||
|
||||
后端 `province/city/district/detail` 对应前端 `region: [province, city, district] + detail`。
|
||||
转换只放在 `api/address.ts`,页面和 store 不得散落第二次转换。
|
||||
|
||||
### 设计数据
|
||||
|
||||
`DesignList.items` 是服务端 JSON,前端提交结构建议与当前 `DesignItem.designData` 对齐:
|
||||
|
||||
```ts
|
||||
interface DesignData {
|
||||
stickers?: StickerItem[]
|
||||
category?: ProductCategory
|
||||
imageSrc?: string
|
||||
imagePos?: { x: number; y: number; scale: number }
|
||||
}
|
||||
```
|
||||
|
||||
服务端只做白名单字段校验,不修改业务 JSON 内容;单条设计数据建议限制在 1MB 以内。
|
||||
|
||||
## 4. 设计注意事项
|
||||
|
||||
### DO
|
||||
|
||||
- DO 地址的增删改查和默认切换全部要求登录态,401 时引导去个人中心登录。
|
||||
- DO 所有按 id 操作的服务端路由同时带 `userId` 条件,404 与 403 区分清楚。
|
||||
- DO 默认地址切换与删除补偿放在同一个 Prisma transaction 里。
|
||||
- DO 前端继续沿用户已熟悉的表单校验,但最终校验提示以后端返回为准。
|
||||
- DO 设计清单状态通过后端枚举返回,前端状态筛选基于返回的 `status` 计算。
|
||||
- DO 批量删除设计条目提供单个接口或循环调用时保证部分失败可恢复;推荐后端一次批量删除。
|
||||
- DO 本地 store 只在 API 失败时做降级读取,成功写回后以服务端数据为准。
|
||||
|
||||
### DON'T
|
||||
|
||||
- DON'T 把前端 `region` 数组原样 POST 给后端,后端契约是四个独立地址字段。
|
||||
- DON'T 让前端直接写 `isDefault` 的两条规则,默认地址唯一性由后端事务保证。
|
||||
- DON'T 在路由/service 层漏掉归属校验;这是本分支最容易出的越权洞。
|
||||
- DON'T 新增前端自造状态 `ordered` 之外的字符串,状态值必须在契约文档里可枚举。
|
||||
- DON'T 在本分支修改 `checkout/index.tsx`,地址选择器与下单集成留给 R3。
|
||||
- DON'T 把 `wxfile://` 临时贴纸路径当作可持久化 URL 直接入库,R4 之前它只是本地预览。
|
||||
- DON'T 用设计清单字段承载订单状态,`orderId` 派生展示即可。
|
||||
|
||||
## 5. 补充内容
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 地址页与设计清单页在登录态下增删改查、默认切换、批量删除全部走真实后端并刷新。
|
||||
- 后端所有变更接口带本人校验,越权访问返回 403,不存在资源返回 404。
|
||||
- 默认地址永远唯一;删除默认地址后列表自动产生新的默认地址。
|
||||
- 前端构建与后端构建通过;Swagger 涵盖地址与设计清单全部接口。
|
||||
- 状态映射表在 `docs/api-contract-v1.md` 中落地,前端页面不再出现魔法字符串状态。
|
||||
|
||||
### 合并与依赖
|
||||
|
||||
- 分支名:前端与后端均为 `feat/r2-address-design`。
|
||||
- R2 依赖已有登录体系(已完成),不依赖 R1;可与 R1 并行开发。
|
||||
- checkout 对地址 API 的调用在 R3 接入,R2 只需保证接口稳定即可。
|
||||
|
||||
### 风险
|
||||
|
||||
- 设计数据中的贴纸是本地临时路径,一旦真机重启或清理缓存,旧设计预览会裂图;在 R4 上传能力完成前,文档明确这是已知限制。
|
||||
- 旧版本本地 `smart_design_list_<openid>` 数据与后端数据可能并存,首次切换登录时避免双写冲突;可做一次性“导入本地清单”或直接忽略旧数据。
|
||||
- 批量删除接口若后端未实现,前端循环删除会遇到部分失败,联调时先约定失败语义。
|
||||
|
||||
### 测试要求
|
||||
|
||||
- 后端:地址 CRUD、默认地址唯一性事务、越权访问、设计清单状态迁移、超大 JSON 拒绝。
|
||||
- 前端:地址新增/编辑/删除/默认切换、设计清单筛选/批量删除、断网降级提示、401 引导登录。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# R3 订单闭环 + 支付接口占位(feat/r3-order-pay)
|
||||
|
||||
**Goal:** 打通“设计清单 → 结算确认 → 创建订单 → 订单列表/详情 → 状态流转”的服务端闭环,
|
||||
微信支付只留接口与配置位,不填真实密钥;支付不可用时流程能明确回到“待付款/支付未配置”。
|
||||
|
||||
**Architecture:** 前端 `api/order.ts` 承接下单与查询,`checkout/orders/orderDetail` 三个页面
|
||||
改为服务端数据源;后端订单创建在事务内重算金额、快照地址、生成幂等的订单号并创建 PENDING
|
||||
支付记录;`payments` 在密钥未配置时返回显式占位响应,不做假成功。
|
||||
|
||||
**Tech Stack:** Taro 3.6 + React 18 + TypeScript;NestJS 11 + Prisma/PostgreSQL + Redis/BullMQ(可选)。
|
||||
|
||||
## 1. 本分支要开发的内容
|
||||
|
||||
### 前端 `wechat_wc`
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/utils/api/order.ts` | `createOrder/fetchOrders/fetchOrderDetail/payOrder`;请求只带商品项、地址 id、designListId,不带金额 |
|
||||
| `src/types/index.ts` | 服务端 Order 类型:`orderNo/status/totalAmount/items/addressSnapshot/createdAt/paidAt` |
|
||||
| `src/pages/checkout/index.tsx` | 从服务端读设计清单,选择地址(用 R2 的地址接口),提交下单;展示服务端总价;支付未配置时提示后回订单列表 |
|
||||
| `src/pages/orders/index.tsx` | 订单列表按状态 Tab 拉取;付款/确认收货等按钮调用对应接口或显示未配置 |
|
||||
| `src/pages/orderDetail/index.tsx` | 按订单 id 拉详情;地址快照、商品、状态条、物流占位 |
|
||||
| `src/utils/store/order.ts` | 标记 deprecated,仅保留为本地兜底,不再被 checkout 主流程调用 |
|
||||
| `src/pages/profile/index.tsx` | 状态统计改为基于服务端订单列表(R2/R3 合入后统一收尾) |
|
||||
|
||||
### 后端 `wxmp_backend`
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `orders` | `POST /api/orders`:服务端按 Product/design-list 重算金额、事务创建订单+items+Payment、生成唯一 orderNo、快照地址 |
|
||||
| `orders` 查询 | `GET /api/orders`(状态筛选/分页)、`GET /api/orders/:id`(仅本人)、`PATCH /api/orders/:id/confirm`、`POST /api/orders/:id/cancel`(按需) |
|
||||
| `payments` | `POST /api/payments/:orderId/pay` 与 `POST /api/payments/notify` 保留签名;密钥为空时返回 `{ configured:false, message:'支付未配置' }` |
|
||||
| `wechat` | `createUnifiedOrder/verifyPayNotify` 保持占位,不填入假商户参数 |
|
||||
| `queue` | 可选:订单创建后入队定制任务,处理器先只更新 `CustomizationTask` 状态 |
|
||||
| `docs/api-contract-v1.md` | 订单创建请求/响应、状态机、金额字段精度、错误码、幂等键 |
|
||||
|
||||
### 词云项目 `wordcloud`
|
||||
|
||||
不参与本分支。
|
||||
|
||||
## 2. 订单状态机
|
||||
|
||||
服务端 `OrderStatus` 与前端展示映射:
|
||||
|
||||
| 后端 | 前端 Tab | 说明 |
|
||||
|---|---|---|
|
||||
| PENDING | 待付款 | 允许取消;支付未配置时长期停留 |
|
||||
| PAID | 待发货 | 由支付回调推进,占位期不出现 |
|
||||
| PROCESSING | 待发货 | 定制生产中 |
|
||||
| SHIPPED | 待收货 | 需要发货/物流数据,本分支可展示占位 |
|
||||
| COMPLETED | 已完成 | 用户确认收货推进 |
|
||||
| CANCELLED | 已取消 | 待付款状态用户取消 |
|
||||
|
||||
前端只读状态数组,不做状态转移判断;转移一律走后端接口或支付回调。
|
||||
|
||||
## 3. 接口设计要点
|
||||
|
||||
创建订单请求:
|
||||
|
||||
```ts
|
||||
{
|
||||
designListId: string,
|
||||
addressId: string,
|
||||
items: [{ productId, quantity }]
|
||||
}
|
||||
```
|
||||
|
||||
服务端响应:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string,
|
||||
orderNo: string,
|
||||
status: "PENDING",
|
||||
totalAmount: number,
|
||||
items: [],
|
||||
addressSnapshot: {},
|
||||
createdAt: string
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 设计注意事项
|
||||
|
||||
### DO
|
||||
|
||||
- DO 订单金额、单价、总价全部由服务端计算,前端提交只给 `productId + quantity`。
|
||||
- DO 创建订单使用 `prisma.$transaction`,订单、明细、支付记录任一步失败整体回滚。
|
||||
- DO 给创建订单接口支持幂等键(客户端 `requestId` 或 `designListId` 防重复下单),双击提交只产生一单。
|
||||
- DO 订单号 `orderNo` 唯一,生成规则包含日期与随机位并在冲突时重试。
|
||||
- DO 收货地址在创建订单时直接快照到 `addressSnapshot`,订单创建后不再跟随地址变更。
|
||||
- DO 支付占位返回结构化 `configured:false`,前端据此显示“暂不支持支付”而不是错误弹窗。
|
||||
- DO 后端从配置读取微信支付密钥,密钥缺失时支付接口直接返回占位,不使用默认值或硬编码。
|
||||
- DO 订单列表/详情都校验当前用户 openid,越权访问返回 403。
|
||||
- DO 金额相关字段用 Decimal 精确类型持久化,序列化时统一成字符串或 number,避免浮点误差。
|
||||
|
||||
### DON'T
|
||||
|
||||
- DON'T 信任前端传的 `totalAmount`/`price`,即使前端为了展示计算过也一律忽略。
|
||||
- DON'T 让客户端参数直接决定 `status`,状态只能由服务端接口和支付回调推进。
|
||||
- DON'T 在代码里填占位商户号、密钥、证书路径;密钥缺失是合法运行状态而非 bug。
|
||||
- DON'T 模拟支付成功,包括开发环境;未配置就是未配置,避免上线前“假流程”掩盖问题。
|
||||
- DON'T 支付回调先解锁订单再验签;本分支只留入口,不实现任何未验签的落库逻辑。
|
||||
- DON'T 在本分支删除 `store/order.ts`,把它标记 deprecated 即可,本地兜底退出要留到全链路验证后。
|
||||
- DON'T 修改 R2 的地址/设计清单接口签名,R3 只消费。
|
||||
|
||||
## 5. 补充内容
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 指定一个已登录用户,从设计清单进入结算页,选地址后创建订单;服务端返回的 `totalAmount` 与商品单价×数量一致。
|
||||
- 双击提交只生成一单,订单号唯一。
|
||||
- 订单列表/详情展示服务端数据,越权用户拿不到他人订单。
|
||||
- 支付按钮在密钥未配置时返回 `configured:false` 并给出明确文案,不调用微信、不写成功记录。
|
||||
- 后端 `npm run build`、前端 `npm run build:weapp` 通过。
|
||||
|
||||
### 合并与依赖
|
||||
|
||||
- 分支名:前端与后端均为 `feat/r3-order-pay`。
|
||||
- 依赖 R1 的商品表和 R2 的地址/设计清单接口,合并顺序放在 R1、R2 之后。
|
||||
- 支付实现作为一个独立后续里程碑,不阻塞本分支交付。
|
||||
|
||||
### 风险
|
||||
|
||||
- 微信小程序对订单支付有场景与类目要求,目前未备案时不要尝试真支付,容易触发审核风险。
|
||||
- 物流信息当前为假数据,SHIPPED/COMPLETED 的物流时间轴要标注占位。
|
||||
- 若 R2 未按约定提供地址接口,checkout 集成会被卡住;R3 开发时先按 `api-contract-v1.md` 编写,联调阶段再对齐实现。
|
||||
|
||||
### 测试要求
|
||||
|
||||
- 后端:订单创建事务、金额重算、重复提交幂等、越权查询、状态流转、支付未配置占位响应。
|
||||
- 前端:结算页创建订单、双击防抖、订单空态、状态 Tab 筛选、越权场景提示、支付未配置提示。
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# R4 上传 + 词云生成 + 线稿 + 下单后 WCD 生产任务(feat/r4-upload-wordcloud)
|
||||
|
||||
**Goal:** 打通用户图片上传、词云任务生成、DIY 贴纸线稿处理、异步任务状态轮询,
|
||||
并在**下单后把订单对应的设计以 WCD 格式投递到词云平台**形成生产任务;
|
||||
这是四条路线中唯一复用 `wordcloud` 项目的路线,必须通过冻结接口契约的方式隔离它的大量变更。
|
||||
|
||||
**Architecture:** 小程序只与 `wxmp_backend` 通信;wxmp_backend 实现 COS 上传凭证、词云适配器、
|
||||
sketch 接口、任务记录/轮询,以及**由设计数据构造 `.wcd` 包并投递给词云平台**的派单服务;
|
||||
`wordcloud` FastAPI 服务作为外部引擎,只暴露并冻结最小契约。
|
||||
小程序页面对词云使用轮询进度,不使用 SSE/EventSource。
|
||||
|
||||
**Tech Stack:** Taro 3.6 + React 18 + TypeScript;NestJS 11 + Prisma/PostgreSQL + Redis/BullMQ;
|
||||
腾讯云 COS;wordcloud FastAPI + EfficientWordCloud。
|
||||
|
||||
## 1. 本分支要开发的内容
|
||||
|
||||
### 前端 `wechat_wc`
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/utils/api/upload.ts` | `getUploadCredentials/uploadToCos/sketchImage/createWordCloudJob/getWordCloudJob/getWordCloudResult` |
|
||||
| `src/pages/wordcloud/index.tsx` | 真实上传底图、提交名字清单、创建任务、轮询进度、展示结果图并保存相册 |
|
||||
| `src/pages/diy/stickerEdit/index.tsx` | 把占位域名 `https://your-api-domain.com/api/sketch` 换成 `BASE_URL/api/sketch`;保留前端灰度降级 |
|
||||
| `src/types/index.ts` 或 `src/types/upload.ts` | `WordCloudJob/WordCloudStatus/SketchResult/CosCredentials` |
|
||||
| `src/utils/request.ts` 或新增 `upload.ts` | 上传文件统一带 token 与业务 header |
|
||||
| `src/types/index.ts`(designData) | 按 `docs/design-data-contract-v1.md` 冻结结构补充字段(详见下文 R2 依赖) |
|
||||
|
||||
> 下单后的 WCD 投递完全发生在后端,**前端不新增页面与接口**;前端只需保证
|
||||
> `designData` 按冻结契约携带持久图片 URL、布局与名单快照。
|
||||
|
||||
### 后端 `wxmp_backend`
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| `upload` | `GET /api/upload/credentials?key=...` 返回 STS 临时凭证或 COS 预签名 URL;`Upload` 表记录 |
|
||||
| `wordcloud`(新增) | `POST /api/wordcloud/generate`、`GET /api/wordcloud/jobs/:id`、`GET /api/wordcloud/jobs/:id/result` |
|
||||
| `wordcloud` 适配器 | 把小程序传入的底图+名字列表转成 wordcloud 契约请求;任务状态入库,用户归属校验 |
|
||||
| **`wordcloud` 派单服务(新增)** | `buildWcdPackage(designData)` 构造 `.wcd` → `dispatchToWordcloud(orderId)` 投递 → 落 `CustomizationTask` → 轮询产物并转存 COS(见 §3) |
|
||||
| `sketch`(新增) | `POST /api/sketch` 接收图片,返回处理后图片 URL;内部可先做基础处理或接外部 AI |
|
||||
| `queue` | BullMQ 消费者把 wordcloud/sketch/生产派单任务状态同步到 `CustomizationTask`/`WordCloudJob` |
|
||||
| `docs/wordcloud-contract.md` | 冻结 wordcloud 接口,含**可选的 WCD 任务输入**,含版本号 |
|
||||
| `config` | `.env` 增加 `WORDCLOUD_API_URL`(词云平台地址),见 §3.4 |
|
||||
| `prisma` | `CustomizationTask` 增加 `orderId`/`wordcloudJobId`(migration) |
|
||||
|
||||
### 词云项目 `wordcloud`
|
||||
|
||||
- 需要冻结的接口:`POST /api/jobs`(现有:`name_list(.xlsx)` 必填;**新增可选 `wcd_file`**)、
|
||||
`GET /api/jobs/{id}`、`GET /api/jobs/{id}/result`、`GET /api/jobs/{id}/files/{kind}`。
|
||||
- 其余 canvas、assets、projects、templates 等接口继续按它自己的节奏演进,小程序后端不依赖。
|
||||
- 若契约不变,wordcloud 内部重构无需通知 R4;契约变更时先升版本号,R4 单独出适配器更新。
|
||||
|
||||
## 2. 词云任务模型
|
||||
|
||||
小程序侧采用异步任务模型:
|
||||
|
||||
```ts
|
||||
interface WordCloudJob {
|
||||
id: string
|
||||
status: 'queued' | 'running' | 'success' | 'failed'
|
||||
progress: number
|
||||
imageUrl?: string
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. 前端上传底图,拿到 COS URL 或临时文件。
|
||||
2. `POST /api/wordcloud/generate` 提交底图 + 名字文本,后端创建任务并返回 `jobId`。
|
||||
3. 前端每 1-2 秒 `GET /api/wordcloud/jobs/:id` 轮询,`success` 后取 `imageUrl`。
|
||||
4. 结果图建议由 wxmp_backend 转存 COS 后返回微信可下载域名链接。
|
||||
|
||||
## 3. 下单后生产任务:WCD 投递(本分支新增功能)
|
||||
|
||||
> 场景:用户在 DIY 过程中生成的词云图、贴纸布局最终落在一条设计清单(`design-list`)里。
|
||||
> 下单之后,订单对应的整套设计必须能**原样恢复到词云平台**,形成可追溯、可复用的
|
||||
> **生产任务**(用于后续加工/激光雕刻)。传输格式采用词云平台已定义的 `.wcd`
|
||||
> 画布导入导出包(Zip:`manifest.json` + `document.json` + `assets/`)。
|
||||
|
||||
### 3.1 端到端链路
|
||||
|
||||
```
|
||||
设计清单 designData(R2 按 design-data-contract-v1.md 冻结结构保存)
|
||||
→ R3 POST /api/orders 创建订单
|
||||
→ 订单进入生产(PENDING → PROCESSING;支付未配置期可走幂等触发,见 3.3)
|
||||
→ queue 入队 customization 生产任务
|
||||
→ WordCloudService.dispatchToWordcloud(orderId)
|
||||
1. 读订单关联 design-list 的 designData
|
||||
2. 贴纸图片持久化(R4 职责,契约束 #1):本地 `wxfile://`/`tmp` 图 → 上传 COS → 回写 `stickers[].src` 为持久 URL
|
||||
3. buildWcdPackage(designData) → 内存构造 .wcd(布局 → document.json,图片字节 → assets/)
|
||||
4. POST {WORDCLOUD_API_URL}/api/jobs multipart wcd_file + params
|
||||
5. 落 CustomizationTask{ orderId, wordcloudJobId, status },开轮询
|
||||
6. 轮询 GET /api/jobs/{id}/result → 产物(PNG/SVG)转存 COS
|
||||
→ 更新 CustomizationTask.status / resultUrl
|
||||
```
|
||||
|
||||
小程序前端永不接触 wordcloud;WCD 完全由 wxmp_backend 构造与投递。
|
||||
|
||||
### 3.2 WCD 包结构(wxmp_backend 构造,wordcloud 侧已能消费)
|
||||
|
||||
后端按 `designData` 构造以下 Zip:
|
||||
|
||||
```
|
||||
{orderNo}.wcd
|
||||
├── manifest.json // format: "wordcloud-canvas", version: 1
|
||||
│ // + canvas{width,height,background} + assets[ id/name/type/mimeType/sha256/size ]
|
||||
├── document.json // CanvasDocument:width/height/background/layers/elements
|
||||
└── assets/<assetId>.<ext> // 每个 sticker/底图的图片字节(从持久 URL 下载)
|
||||
```
|
||||
|
||||
映射规则(以词云平台 `/api/design-templates/import` 现有实现为准):
|
||||
|
||||
- `document.canvas` ← 商品 `category.mask` 尺寸 + 背景色。
|
||||
- `elements[]` ← 底图(`background.src`)作为一个 element;每个贴纸
|
||||
`{ type:'sticker', assetId: asset-N, x, y, width, height, rotation?, opacity? }`,
|
||||
`assetId` 用包内临时 ID。
|
||||
- `assets/` ← 每个贴纸图片 + 底图的字节,文件名以包内 `assetId` 开头
|
||||
(wordcloud 解包根据 `assets/<assetId>*` 匹配)。
|
||||
- 名单快照(`designData.wordcloud.names`)写入 `manifest` 的自定义 `meta` 字段,
|
||||
仅作记录,不影响导入还原。
|
||||
|
||||
### 3.3 触发与幂等
|
||||
|
||||
- 推荐:订单进入 `PROCESSING` 时(支付回调确认后)入队 `customization` 任务,
|
||||
由处理器调用派单服务。
|
||||
- 支付未配置期:提供 `POST /api/orders/:id/dispatch`(幂等)作为联调/运营触发手段,
|
||||
同一订单只投递一次(以 `CustomizationTask.orderId` 唯一或状态机约束)。
|
||||
|
||||
### 3.4 环境变量(wxmp_backend)
|
||||
|
||||
```dotenv
|
||||
# ── 词云平台(WCD 生产任务)────────────────────────────
|
||||
# 词云服务(FastAPI)地址;未配置时下单后的 WCD 派单返回“未配置”,不做假成功
|
||||
WORDCLOUD_API_URL=
|
||||
# 请求 wordcloud 超时(毫秒);可选
|
||||
WORDCLOUD_TIMEOUT_MS=30000
|
||||
```
|
||||
|
||||
`config/configuration.ts` 增加 `wordcloud: { apiUrl, timeoutMs }`;
|
||||
`config/validation.schema.ts` 增加 `WORDCLOUD_API_URL: Joi.string().allow('').default('')`
|
||||
(允许为空,与"支付密钥未配置是合法状态"一致)。
|
||||
|
||||
### 3.5 词云平台需新增的能力
|
||||
|
||||
- `POST /api/jobs` 支持可选 `wcd_file`(multipart `.wcd`):存在时跳过 `.xlsx` 名单
|
||||
模式,进入"还原设计 → 生成生产任务"模式;状态机与产物管线复用现有 jobs。
|
||||
- wordcloud 平台改动点(供其 R4 实现参考,契约先行冻结):
|
||||
1. `name_list` 与 `wcd_file` 二选一(`wcd_file` 对已有调用向后兼容,纯新增)。
|
||||
2. 解包 `.wcd` 校验 `format/version`,`assets` 按 SHA-256 去重(复用现有导入逻辑)。
|
||||
3. `document.json` 归一化后落 `design_documents`,`sticker.assetId` 重映射为真实素材 ID。
|
||||
4. 创建生产 job,产物沿用 `GET /api/jobs/{id}/result` + `/files/{kind}`。
|
||||
|
||||
### 3.6 设计注意事项
|
||||
|
||||
#### DO
|
||||
|
||||
- DO 由 wxmp_backend 构造并投递 WCD,wordcloud 地址、密钥、内部参数对小程序完全不可见。
|
||||
- DO `wordcloudJobId → orderId/userId` 关联入库,轮询与结果查询带归属校验。
|
||||
- DO `WORDCLOUD_API_URL` 未配置时返回结构化“未配置”错误(状态字面量 `not_configured`,
|
||||
见 `api-contract-v1.md` §8),前端给出可理解提示。
|
||||
- DO 派单幂等:同一订单重复触发只产生一次投递。
|
||||
- DO 产物由 wxmp_backend 下载转存 COS 后返回公网 URL,不向小程序暴露 wordcloud 内部地址。
|
||||
- DO 名单/布局等设计快照写入 `designData`(冻结契约),保证下单后可重建 WCD。
|
||||
- DO WCD 内部的贴纸图片必须来自持久 URL(COS),打包前先下载字节。
|
||||
- DO 包大小与单账号投递频率限制(wordcloud 为 CPU 密集任务)。
|
||||
|
||||
#### DON'T
|
||||
|
||||
- DON'T 在小程序前端硬编码 wordcloud 基础地址或直接调用它。
|
||||
- DON'T 让用户通过猜 `jobId` 读取他人任务结果,所有查询都带 userId 条件。
|
||||
- DON'T 在密钥/`WORDCLOUD_API_URL` 未配置时静默跳过或假装成功。
|
||||
- DON'T 用假的 `setInterval` 进度伪装下单后的任务结果;同样不模拟生产派单成功。
|
||||
- DON'T 把 `.wcd` 文件当最终产物直接返回小程序,它只是 wordcloud 的交换容器。
|
||||
- DON'T 接受本地临时路径(`wxfile://`/`tmp`)作为 `designData` 中的贴纸图,R4 打包拿不到字节。
|
||||
|
||||
### 3.7 验收标准
|
||||
|
||||
- 订单进入生产后,wordcloud 平台出现对应 job:`queued → running → success`,
|
||||
产物 PNG 可下载并被 wxmp_backend 转存 COS。
|
||||
- 同一订单重复触发只投递一次。
|
||||
- `WORDCLOUD_API_URL` 未配置(留空)时,派单返回结构化“未配置”,不落假成功记录。
|
||||
- 由 `designData` 构造的 `.wcd` 能在 wordcloud `/api/design-templates/import`
|
||||
同套逻辑下原样还原(素材去重、布局一致)。
|
||||
- 后端 `npm run build`、前端 `npm run build:weapp` 通过;Swagger 与契约文档一致。
|
||||
|
||||
### 3.8 风险
|
||||
|
||||
- wordcloud 侧 `POST /api/jobs` 现强制 `name_list`;支持 `wcd_file` 二选一需要
|
||||
wordcloud 排期改动。契约先冻结,wordcloud 实现可后置,wxmp_backend 按契约先写适配层。
|
||||
- `designData` 中贴纸图片若仍为本地/临时 URL,WCD 打包会失败——**依赖 R2 冻结持久 URL 规则**。
|
||||
- 支付未接入期没有自然触发点,需要一个幂等 `dispatch` 接口用于联调。
|
||||
- `CustomizationTask` 缺 `orderId`/`wordcloudJobId` 字段,需一次 migration。
|
||||
- 贴纸 `edits`(亮度/色相/对比度/线稿)暂无法由 WCD `document.json` 表达,本期先不随包携带,
|
||||
在 `manifest.meta` 记录并在契约中标注边界。
|
||||
|
||||
## 4. 对 R2 的依赖(接口先冻结)
|
||||
|
||||
R4 的 WCD 打包依赖 R2 设计清单里保存的 `designData` 结构。**接口需在本分支开工前冻结**,
|
||||
相关契约见新文档 `docs/design-data-contract-v1.md`(跨路线共享,R2 为数据生产者,R4 为消费者)。
|
||||
|
||||
需 R2 配合冻结的要点:
|
||||
|
||||
| 项 | 要求 |
|
||||
|---|---|
|
||||
| `designData` 结构 | 按 `docs/design-data-contract-v1.md` 补 `version/background/wordcloud` 字段;`category.mask` 必须保存 |
|
||||
| 贴纸图片持久化 | **R4 负责**:下单/派单前把贴纸本地路径上传为 COS 持久 URL 并回写 `src`;R2 允许保存时 `src` 暂为本地路径 |
|
||||
| 名单快照 | `designData.wordcloud.names` **为 optional,小程序可不带**;R4 生成词云后按实际写入,R2 保存/更新清单时保留该组字段,后端白名单放行 |
|
||||
| 布局完整性 | 贴纸 `rotation`/`zIndex` 本期持久化(现有 `StickerItem` 缺 rotation,需补) |
|
||||
| `design-list` 白名单 | 后端 items JSON 校验放行上述新字段,单条 ≤1MB 上限按新结构复核 |
|
||||
|
||||
决策记录与细则:见 `docs/design-data-contract-v1.md` §6。
|
||||
|
||||
## 5. 补充内容
|
||||
|
||||
### 合并与依赖
|
||||
|
||||
- 分支名:前端与后端均为 `feat/r4-upload-wordcloud`。
|
||||
- 与 R1-R3 并行,不依赖商品/订单链路;后端上传基础设施可为后续 R2 的贴纸图片持久化提供能力。
|
||||
- 下单后 WCD 派单依赖 R2 冻结的 designData 结构与 R3 的订单状态机。
|
||||
- wordcloud 契约若未冻结,R4 先完成“契约文档 + 后端适配层”,联调阶段再补真实 job。
|
||||
|
||||
### 测试要求
|
||||
|
||||
- 后端:上传凭证、大小/类型校验、wordcloud 适配器正常/失败/超时、**WCD 打包与派单、任务归属权限**、sketch 接口。
|
||||
- 前端:词云上传、轮询进度、fail 重试、结果保存相册;线稿接口失败降级;**下单后派单状态展示**;无 COS/无 WORDCLOUD 配置提示。
|
||||
- 集成:用固定 wordcloud 部署版本跑一次真实生成端到端 smoke + 一次下单派单 smoke。
|
||||
@@ -0,0 +1,190 @@
|
||||
# 智绘微刻小程序团队协作总文档
|
||||
|
||||
本文档是四人团队的协作单一入口,定义仓库、分工、Git 规范、契约管理和推荐开发节奏。
|
||||
四个路线的具体开发内容见 `docs/routes/`,后端接口契约见
|
||||
`wxmp_backend/docs/api-contract-v1.md` 与 `wxmp_backend/docs/wordcloud-contract.md`。
|
||||
|
||||
## 1. 仓库与主干
|
||||
|
||||
| 仓库 | 角色 | 主干分支 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `wechat_wc` | 小程序前端(Taro) | `master` | 已连接 Gitee,页面与前端协作文档都在这里 |
|
||||
| `wxmp_backend` | 小程序后端(NestJS) | `main` | 已连接 GitHub,接口契约与迁移在这边 |
|
||||
| `wordcloud` | 词云生成外部服务(FastAPI) | 独立演进 | 只通过冻结契约复用,不参与前后端分支同步 |
|
||||
|
||||
> 注意:早期文档里写“合并到 main”时,一律按本表执行:前端合 `master`,后端合 `main`。
|
||||
|
||||
## 2. 分工模型
|
||||
|
||||
团队由 4 人组成,每人独立拥有一条端到端路线,前端和后端改动都归本人:
|
||||
|
||||
| 成员 | 路线 | 分支 | 仓库 |
|
||||
|---|---|---|---|
|
||||
| 成员 1 | R1 商品目录 | `feat/r1-catalog` | `wechat_wc` + `wxmp_backend` |
|
||||
| 成员 2 | R2 地址 + 设计清单 | `feat/r2-address-design` | `wechat_wc` + `wxmp_backend` |
|
||||
| 成员 3 | R3 订单 + 支付占位 | `feat/r3-order-pay` | `wechat_wc` + `wxmp_backend` |
|
||||
| 成员 4 | R4 上传 + 词云 + 线稿 | `feat/r4-upload-wordcloud` | `wechat_wc` + `wxmp_backend` |
|
||||
|
||||
四人互不等待:各自在两条仓库做自己的切片,不拆前端/后端小组,也不跨路线改别人的文件。
|
||||
路线之间只通过契约、主干和合并顺序衔接。
|
||||
|
||||
### 2.1 四条路线
|
||||
|
||||
| 路线 | 分支 | 目标 | wordcloud |
|
||||
|---|---|---|---|
|
||||
| R1 商品目录 | `feat/r1-catalog` | 商品/分类 API + 前端四个页面切 API | 不用 |
|
||||
| R2 地址 + 设计清单 | `feat/r2-address-design` | 地址 CRUD、清单 CRUD、状态映射 | 不用 |
|
||||
| R3 订单 + 支付占位 | `feat/r3-order-pay` | 服务端下单、订单状态机、支付未配置占位 | 不用 |
|
||||
| R4 上传 + 词云 + 线稿 | `feat/r4-upload-wordcloud` | COS 上传、词云任务、sketch、队列 | 唯一使用 |
|
||||
|
||||
### 2.2 文件所有权
|
||||
|
||||
前端公共文件只由 R0 维护:
|
||||
|
||||
| 文件 | 归属 |
|
||||
|---|---|
|
||||
| `src/utils/store/index.ts`、`src/utils/api/index.ts` | R0 聚合入口,只加 re-export,不堆业务 |
|
||||
| `src/utils/store/design.ts`、`address.ts` | R2 |
|
||||
| `src/utils/store/order.ts` | R3 |
|
||||
| `src/utils/api/product.ts` | R1 |
|
||||
| `src/utils/api/address.ts`、`design.ts` | R2 |
|
||||
| `src/utils/api/order.ts` | R3 |
|
||||
| `src/utils/api/upload.ts` | R4 |
|
||||
|
||||
页面所有权:
|
||||
|
||||
| 页面 | 归属 |
|
||||
|---|---|
|
||||
| `index`、`shop`、`shop/detail`、`product` | R1 |
|
||||
| `address`、`designList` | R2 |
|
||||
| `checkout`、`orders`、`orderDetail` | R3 |
|
||||
| `wordcloud`、`diy/stickerEdit` | R4 |
|
||||
| `profile` | 只读统计消费者,R2/R3 合入后统一收尾 |
|
||||
|
||||
后端按 NestJS 模块自然划分,不跨路线修改其他模块。
|
||||
|
||||
## 3. Git 规范
|
||||
|
||||
### 3.1 分支
|
||||
|
||||
- 每条路线在前端与后端仓库使用同名分支:`feat/r1-catalog`、`feat/r2-address-design`、`feat/r3-order-pay`、`feat/r4-upload-wordcloud`。
|
||||
- 主干只允许合入,不允许直接开发;面向发布的小改动可以走 `fix/...` 短分支。
|
||||
- 分支从各自仓库主干创建:前端 `git checkout master`,后端 `git checkout main`。
|
||||
|
||||
```bash
|
||||
# 前端示例
|
||||
git checkout master && git pull
|
||||
git checkout -b feat/r1-catalog
|
||||
|
||||
# 后端示例
|
||||
git checkout main && git pull
|
||||
git checkout -b feat/r1-catalog
|
||||
```
|
||||
|
||||
### 3.2 提交
|
||||
|
||||
- 使用 Conventional Commits:`feat(scope): message`、`fix(scope): message`、`docs(scope): message`、`refactor(scope): message`。
|
||||
- scope 建议:`catalog`、`address`、`design`、`order`、`pay`、`upload`、`wordcloud`、`store`、`api`、`contract`。
|
||||
- 一个提交只做一件事;文件级原子提交,不把无关改动混进同一个 commit。
|
||||
- 不提交 `.env`、密钥、证书、临时日志;已跟踪的 `dist/` 产物按各自仓库现有约定处理。
|
||||
- 遇到他人未提交的工作区改动,不重置、不覆盖,需要时先沟通。
|
||||
|
||||
### 3.3 评审与合并
|
||||
|
||||
- 禁止直接推主干,统一走 Gitee/GitHub Pull Request。
|
||||
- 同一路线的前端 PR 与后端 PR 由同一成员维护,其他成员评审;后端先定契约,前端按契约实现。
|
||||
- 合并前先 rebase 主干,保持提交历史线性:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git rebase origin/master # 前端
|
||||
git rebase origin/main # 后端
|
||||
```
|
||||
|
||||
- 合入使用 `--no-ff`,保留路线合并痕迹。
|
||||
- 合入顺序:R1 最先(R3 依赖商品表),R2/R4 随后,R3 最后。
|
||||
- 合入动作由路线负责人执行,其余成员只看评审,不代推主干。
|
||||
|
||||
### 3.4 冲突处理
|
||||
|
||||
- R0 聚合入口冲突由维护者协调,其他人只提交自己域内文件。
|
||||
- 共享页面(如 `checkout`)按所有权表归属,冲突时由该页负责人解决。
|
||||
- 出现跨路线耦合需求时,先在契约文档升版本,再开新任务,不临时改别人文件。
|
||||
|
||||
## 4. 契约管理
|
||||
|
||||
| 文档 | 位置 | 作用 |
|
||||
|---|---|---|
|
||||
| API 契约 v1 | `wxmp_backend/docs/api-contract-v1.md` | 前后端接口的唯一依据 |
|
||||
| wordcloud 契约 v1 | `wxmp_backend/docs/wordcloud-contract.md` | 隔离 wordcloud 大量变更 |
|
||||
| 路线文档 | `wechat_wc/docs/routes/` | 每条路线要开发什么、DO/DON'T、验收 |
|
||||
|
||||
规则:
|
||||
|
||||
- 接口先冻结,前端再开发;同一版本内禁止改字段名/类型/语义。
|
||||
- 字段只能新增 optional 字段,否则升版本并同步更新前端 `types`。
|
||||
- wordcloud 契约变更由 R4 单独升级适配器,不阻塞其他路线。
|
||||
|
||||
## 5. 推荐开发节奏
|
||||
|
||||
### 5.1 单个路线的生命周期
|
||||
|
||||
1. 契约对齐:后端写 DTO/Swagger + 更新契约文档,前端同步类型。
|
||||
2. 独立实现:本人同时处理前端页面/数据层与后端 service/事务/权限。
|
||||
3. 本地联调:用 Swagger + 微信开发者工具走通主流程。
|
||||
4. 验收:构建、接口 smoke、手测清单逐项打勾。
|
||||
5. 合并:rebase 主干,前端与后端 PR 一起提交,通知其他成员 `git pull`。
|
||||
|
||||
### 5.2 建议的两周迭代
|
||||
|
||||
| 阶段 | 建议安排 |
|
||||
|---|---|
|
||||
| 第 1-3 天 | R0 收尾;R1-R4 四轨独立开工;R3/R4 先冻结各自契约 |
|
||||
| 第 4-8 天 | R1 合入;R2 持续开发;R3 基于 R1 契约开工;R4 开发上传与适配层 |
|
||||
| 第 9-12 天 | R2 合入;R3 联调;R4 与 wordcloud 联调 |
|
||||
| 第 13-14 天 | R3/R4 验收与合入;整理遗留问题和下一迭代计划 |
|
||||
|
||||
路线的粗略工时(一人独立完成):
|
||||
|
||||
| 路线 | 预计 |
|
||||
|---|---|
|
||||
| R1 | 3-5 天 |
|
||||
| R2 | 3-4 天 |
|
||||
| R3 | 5-7 天 |
|
||||
| R4 | 5-7 天(含外部联调) |
|
||||
|
||||
### 5.3 每日节奏
|
||||
|
||||
- 早晨 15 分钟同步:昨天的进度、blocker、今天的契约/联调事项。
|
||||
- 每人每天开工前 `git pull` + rebase 主干;长期分支不隔夜不管。
|
||||
- 小步提交,一天至少推到远端一次,避免本地大爆炸式改动。
|
||||
- PR 当天评审,评审人 24h 内反馈;遇到阻塞先降级方案再继续。
|
||||
|
||||
## 6. 验收红线(Definition of Done)
|
||||
|
||||
每路线合入前必须满足:
|
||||
|
||||
- 前端 `npm run build:weapp` 通过;后端 `npm run build` 通过。
|
||||
- Swagger 与 `api-contract-v1.md` 一致,前端类型与契约一致。
|
||||
- 涉及金额:金额只能服务端重算,客户端金额永不生效。
|
||||
- 涉及私有数据:所有按 id 查询都校验本人,越权返回 403。
|
||||
- 涉及支付:密钥未配置时返回结构化“未配置”,不做假成功。
|
||||
- 涉及词云:小程序不直连 wordcloud,任务结果有用户归属。
|
||||
- 路线文档中的 DO/DON'T 与验收清单逐项确认。
|
||||
|
||||
## 7. 高风险警示
|
||||
|
||||
- 不要在仓库里提交微信支付密钥、COS 密钥、JWT_SECRET。
|
||||
- 不要把 `wxfile://` 临时路径当作持久化图片地址入库。
|
||||
- 不要模拟词云进度或模拟支付成功。
|
||||
- 不要随意扩大路线边界;超出范围的需求先更新契约或新建任务。
|
||||
|
||||
## 8. 文档索引
|
||||
|
||||
- [并行开发指南](parallel-development-guide.md)
|
||||
- [R1 商品目录动态化](routes/route-r1-product-catalog.md)
|
||||
- [R2 收货地址 + 设计清单](routes/route-r2-address-design.md)
|
||||
- [R3 订单闭环 + 支付占位](routes/route-r3-order-pay.md)
|
||||
- [R4 上传 + 词云 + 线稿](routes/route-r4-upload-wordcloud.md)
|
||||
- `wxmp_backend/docs/api-contract-v1.md`
|
||||
- `wxmp_backend/docs/wordcloud-contract.md`
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../utils/productConfig'
|
||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
|
||||
import { persistDesignMedia } from '../../utils/api'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
@@ -241,18 +242,15 @@ export default function DIYPage() {
|
||||
return base
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
const handleComplete = async () => {
|
||||
if (hasOverlap) {
|
||||
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (designId) {
|
||||
updateDesign(designId, {
|
||||
designData: {
|
||||
stickers,
|
||||
category
|
||||
}
|
||||
})
|
||||
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
|
||||
const data = await persistDesignMedia({ stickers, category, version: 1 })
|
||||
updateDesign(designId, { designData: data })
|
||||
}
|
||||
setPreviewMode(false)
|
||||
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../../utils/productConfig'
|
||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
||||
import { sketchImage } from '../../../utils/api'
|
||||
import { useThemeContext } from '../../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../../hooks/useStatusBar'
|
||||
@@ -263,36 +264,25 @@ export default function StickerEditPage() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 线稿:预留后端AI接口 */
|
||||
const API_BASE = 'https://your-api-domain.com' // ← 填入你的服务器地址
|
||||
|
||||
const handleSketch = () => {
|
||||
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
|
||||
const handleSketch = async () => {
|
||||
if (!sticker) return
|
||||
setLoading(true)
|
||||
Taro.uploadFile({
|
||||
url: `${API_BASE}/api/sketch`,
|
||||
filePath: sticker.src,
|
||||
name: 'image',
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.url) {
|
||||
const newSticker = { ...sticker, src: data.url, edits: { ...sticker.edits, sketchSrc: data.url } }
|
||||
setSticker(newSticker)
|
||||
setTimeout(() => redraw(), 200)
|
||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error('no url')
|
||||
}
|
||||
} catch {
|
||||
// 如果接口不可用,降级为前端灰度+边缘检测
|
||||
applyFrontendSketch()
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
applyFrontendSketch()
|
||||
try {
|
||||
const res = await sketchImage(sticker.src)
|
||||
if (res?.imageUrl) {
|
||||
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
|
||||
setSticker(newSticker)
|
||||
setLoading(false)
|
||||
setTimeout(() => redraw(), 200)
|
||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error('no imageUrl')
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 如果接口不可用/失败,降级为前端灰度+边缘检测
|
||||
applyFrontendSketch()
|
||||
}
|
||||
}
|
||||
|
||||
/** 前端线稿降级方案:灰度+反相高对比 */
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
import ThemedPageMeta from '../../components/ThemedPageMeta'
|
||||
import ScrollTopMask from '../../components/ScrollTopMask'
|
||||
import { assetUrl } from '../../utils/asset'
|
||||
import { createWordCloudJob, getWordCloudJob } from '../../utils/api'
|
||||
|
||||
export default function WordCloudPage() {
|
||||
const { theme, resolvedTheme } = useThemeContext()
|
||||
@@ -19,6 +20,7 @@ export default function WordCloudPage() {
|
||||
const [generatedImage, setGeneratedImage] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const steps = [
|
||||
{ num: 1, label: '上传底图' },
|
||||
@@ -33,24 +35,53 @@ export default function WordCloudPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleGenerate = () => {
|
||||
const handleGenerate = async () => {
|
||||
if (!namesText.trim()) {
|
||||
Taro.showToast({ title: '请先输入名字', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setStep(3); setIsGenerating(true); setProgress(0)
|
||||
const timer = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(timer); setIsGenerating(false)
|
||||
setGeneratedImage(baseImage)
|
||||
return 100
|
||||
}
|
||||
return prev + Math.random() * 15
|
||||
})
|
||||
}, 1000)
|
||||
if (!baseImage) {
|
||||
Taro.showToast({ title: '请先上传底图', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setStep(3); setIsGenerating(true); setProgress(0); setError('')
|
||||
try {
|
||||
// 底图以 multipart 直接交给后端创建词云任务(不再本地模拟)
|
||||
const { jobId } = await createWordCloudJob(baseImage, namesText.trim())
|
||||
await pollWordCloudJob(jobId)
|
||||
} catch (e) {
|
||||
setIsGenerating(false)
|
||||
setError(e instanceof Error ? e.message : '生成失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询词云任务:success 取结果图,failed 展示错误;单次网络抖动自动重试 */
|
||||
const pollWordCloudJob = (jobId: string) => new Promise<void>((resolve, reject) => {
|
||||
let retries = 0
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const job = await getWordCloudJob(jobId)
|
||||
setProgress(Math.min(job.progress, 100))
|
||||
if (job.status === 'success') {
|
||||
clearInterval(timer); setIsGenerating(false)
|
||||
setGeneratedImage(job.imageUrl || baseImage)
|
||||
resolve()
|
||||
} else if (job.status === 'failed') {
|
||||
clearInterval(timer); setIsGenerating(false)
|
||||
setError(job.error || '词云生成失败')
|
||||
reject(new Error(job.error || '词云生成失败'))
|
||||
}
|
||||
} catch {
|
||||
// 连续 5 次请求失败才终止,避免瞬时网络问题打断任务
|
||||
retries += 1
|
||||
if (retries > 5) {
|
||||
clearInterval(timer); setIsGenerating(false)
|
||||
reject(new Error('任务状态获取失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
const handleExportImage = () => {
|
||||
if (!generatedImage) return
|
||||
Taro.saveImageToPhotosAlbum({
|
||||
@@ -148,10 +179,21 @@ export default function WordCloudPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 步骤3: 生成中/预览 */}
|
||||
{/* 步骤3: 生成中/结果/失败 */}
|
||||
{step === 3 && (
|
||||
<View className='step-content'>
|
||||
{isGenerating ? (
|
||||
{error ? (
|
||||
<View className='generating-panel'>
|
||||
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||
<Text className='generating-title'>生成失败</Text>
|
||||
<Text className='generating-subtitle'>{error}</Text>
|
||||
<View className='action-btns'>
|
||||
<View className='btn-primary' onTap={() => { setError(''); setStep(2) }}>
|
||||
<Text>返回修改名单</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : isGenerating ? (
|
||||
<View className='generating-panel'>
|
||||
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||
<Text className='generating-title'>正在生成词云...</Text>
|
||||
|
||||
+77
-8
@@ -56,6 +56,10 @@ export interface StickerItem {
|
||||
width: number
|
||||
height: number
|
||||
isOverlapping: boolean
|
||||
/** 旋转角(度);本期 DIY 持久化,WCD 打包会携带(见 design-data-contract-v1.md 决策#2) */
|
||||
rotation?: number
|
||||
/** 图层顺序,WCD 按 zIndex 排列元素 */
|
||||
zIndex?: number
|
||||
/** 编辑状态持久化 */
|
||||
edits?: {
|
||||
brightness?: number // -100 ~ 100
|
||||
@@ -66,6 +70,78 @@ export interface StickerItem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计数据 v1:支撑 R4 下单后 WCD 生产任务打包。
|
||||
* 契约见 docs/design-data-contract-v1.md;贴纸 src 持久化由 R4 负责。
|
||||
*/
|
||||
export interface DesignDataV1 {
|
||||
/** 结构版本;旧数据缺省视为 v1 */
|
||||
version?: 1
|
||||
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
|
||||
category?: ProductCategory
|
||||
/** 底图(持久 URL),WCD 打包的画布底 */
|
||||
background?: {
|
||||
src: string
|
||||
color?: string
|
||||
pos?: { x: number; y: number; scale: number }
|
||||
}
|
||||
/** 词云信息(R4 词云生成后写入,R2 保存/更新清单时原样保留) */
|
||||
wordcloud?: {
|
||||
jobId?: string
|
||||
imageUrl: string
|
||||
names: string[]
|
||||
}
|
||||
/** 贴纸(src 需为 COS 持久 URL,见 design-data-contract-v1.md 约束#1) */
|
||||
stickers?: StickerItem[]
|
||||
/** 兼容旧版字段 */
|
||||
imageSrc?: string
|
||||
imagePos?: { x: number; y: number; scale: number }
|
||||
}
|
||||
|
||||
/** 词云任务状态(与 wordcloud 契约、后端透传一致) */
|
||||
export type WordCloudStatus = 'queued' | 'running' | 'success' | 'failed'
|
||||
|
||||
/** 词云任务(前端轮询模型:route-r4 §2 / api-contract §8) */
|
||||
export interface WordCloudJob {
|
||||
id: string
|
||||
status: WordCloudStatus
|
||||
progress: number
|
||||
imageUrl?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** COS 直传凭证(STS 临时凭证或预签名 URL;小程序不得持有永久密钥) */
|
||||
export interface CosCredentials {
|
||||
key: string
|
||||
bucket?: string
|
||||
region?: string
|
||||
/** STS 临时密钥(服务端下发,非主账户密钥) */
|
||||
credentials?: { secretId?: string; secretKey?: string; token?: string } | null
|
||||
/** 预签名直传 URL(与 credentials 二选一) */
|
||||
presignedUrl?: string
|
||||
}
|
||||
|
||||
/** 线稿处理结果(POST /api/sketch) */
|
||||
export interface SketchResult {
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
/** 下单后 WCD 派单状态:not_configured = WORDCLOUD_API_URL 未配置(见 api-contract §8) */
|
||||
export type WordCloudDispatchStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'not_configured'
|
||||
|
||||
/** 下单后 WCD 派单结果(POST /api/orders/:id/dispatch) */
|
||||
export interface WordCloudDispatchResult {
|
||||
orderId: string
|
||||
status: WordCloudDispatchStatus
|
||||
wordcloudJobId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 设计清单条目 */
|
||||
export interface DesignItem {
|
||||
id: string
|
||||
@@ -75,14 +151,7 @@ export interface DesignItem {
|
||||
unitPrice: number
|
||||
count: number
|
||||
status: 'undesigned' | 'designing' | 'ordered'
|
||||
designData?: {
|
||||
/** 兼容旧版字段 */
|
||||
imageSrc?: string
|
||||
imagePos?: { x: number; y: number; scale: number }
|
||||
/** 新版贴纸列表 */
|
||||
stickers?: StickerItem[]
|
||||
category?: ProductCategory
|
||||
}
|
||||
designData?: DesignDataV1
|
||||
orderId?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import http, { setToken, clearToken, getToken } from './request'
|
||||
|
||||
export { getToken }
|
||||
|
||||
/**
|
||||
* 后端 API 方法集合
|
||||
* 对应 wxmp_backend 的路由(见后端 Swagger /docs)
|
||||
* 说明:登录会调用后端 /api/auth/login,成功后把 accessToken 存入本地,
|
||||
* 供后续请求自动携带。购物车/订单等仍可用本地 store.ts 作为离线兜底。
|
||||
*/
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
isNewUser: boolean // 首次登录(未设资料)为 true,前端需引导补填头像/姓名
|
||||
nickname: string | null
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录:把 wx.login() 的 code 交给后端,后端用 code2Session 换 openid,
|
||||
* 自动注册/续登并签发 accessToken(个人主体无需手机号)。
|
||||
* 返回 isNewUser 供前端判断是否需补全资料。
|
||||
*/
|
||||
export async function login(code: string): Promise<LoginResult> {
|
||||
const data = await http.post<LoginResult>('/api/auth/login', { code }, { auth: false })
|
||||
if (data?.accessToken) {
|
||||
setToken(data.accessToken)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/** 更新当前用户资料(昵称/头像),新用户在补填后提交到后端持久化 */
|
||||
export async function updateProfile(profile: { nickname?: string; avatar?: string }): Promise<UserProfile> {
|
||||
return http.patch<UserProfile>('/api/users/me', profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次注册:用 registerTicket + wx.getPhoneNumber 的 code 验证手机号,完成后自动登录
|
||||
*/
|
||||
export async function registerWithPhone(
|
||||
registerTicket: string,
|
||||
phoneCode: string,
|
||||
profile?: { nickname?: string; avatar?: string },
|
||||
): Promise<{ accessToken: string }> {
|
||||
const data = await http.post<{ accessToken: string }>(
|
||||
'/api/auth/register',
|
||||
{ registerTicket, phoneCode, ...profile },
|
||||
{ auth: false },
|
||||
)
|
||||
setToken(data.accessToken)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 登出:仅清除本地 token(后端 JWT 无状态,无需撤销) */
|
||||
export function logout(): void {
|
||||
clearToken()
|
||||
}
|
||||
|
||||
/** 后端用户信息(对应 User 模型;openpid 为主标识,openid 可空) */
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
openpid?: string | null
|
||||
openid?: string | null
|
||||
unionid?: string | null
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
phone?: string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息 */
|
||||
export async function getMe(): Promise<UserProfile> {
|
||||
return http.get<UserProfile>('/api/users/me')
|
||||
}
|
||||
|
||||
/** 商品列表 */
|
||||
export async function fetchProducts() {
|
||||
return http.get('/api/products', { auth: false })
|
||||
}
|
||||
|
||||
/** 商品详情 */
|
||||
export async function fetchProduct(id: string) {
|
||||
return http.get(`/api/products/${id}`, { auth: false })
|
||||
}
|
||||
|
||||
/** 分类列表 */
|
||||
export async function fetchCategories() {
|
||||
return http.get('/api/categories', { auth: false })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* R2 收货地址接口归属文件。
|
||||
* 后端 /api/addresses CRUD 就绪后,在这里补充:
|
||||
* fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress
|
||||
* 返回类型优先直接对应 src/types/index.ts 的 AddressItem 契约。
|
||||
*/
|
||||
export {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import http, { clearToken, getToken, setToken } from '../request'
|
||||
|
||||
export { getToken }
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
isNewUser: boolean // 首次登录(未设资料)为 true,前端需引导补填头像/姓名
|
||||
nickname: string | null
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录:把 wx.login() 的 code 交给后端,后端用 code2Session 换 openid,
|
||||
* 自动注册/续登并签发 accessToken(个人主体无需手机号)。
|
||||
* 返回 isNewUser 供前端判断是否需补全资料。
|
||||
*/
|
||||
export async function login(code: string): Promise<LoginResult> {
|
||||
const data = await http.post<LoginResult>('/api/auth/login', { code }, { auth: false })
|
||||
if (data?.accessToken) {
|
||||
setToken(data.accessToken)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次注册:用 registerTicket + wx.getPhoneNumber 的 code 验证手机号,完成后自动登录
|
||||
*/
|
||||
export async function registerWithPhone(
|
||||
registerTicket: string,
|
||||
phoneCode: string,
|
||||
profile?: { nickname?: string; avatar?: string },
|
||||
): Promise<{ accessToken: string }> {
|
||||
const data = await http.post<{ accessToken: string }>(
|
||||
'/api/auth/register',
|
||||
{ registerTicket, phoneCode, ...profile },
|
||||
{ auth: false },
|
||||
)
|
||||
setToken(data.accessToken)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 登出:仅清除本地 token(后端 JWT 无状态,无需撤销) */
|
||||
export function logout(): void {
|
||||
clearToken()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* R2 设计清单接口归属文件。
|
||||
* 后端 /api/design-list CRUD 就绪后,在这里补充:
|
||||
* fetchDesignList / createDesign / updateDesign / deleteDesign
|
||||
* 设计数据(贴纸、掩膜、分类信息)以 JSON 方式随 items/designData 提交。
|
||||
*/
|
||||
export {}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API 层聚合入口:页面统一从 '../../utils/api' 导入。
|
||||
* 新接口写在 api/<domain>.ts,本文件只负责 re-export,不要往单文件堆代码。
|
||||
*/
|
||||
export * from './auth'
|
||||
export * from './user'
|
||||
export * from './product'
|
||||
export * from './address'
|
||||
export * from './design'
|
||||
export * from './order'
|
||||
export * from './upload'
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* R3 订单接口归属文件。
|
||||
* 后端 /api/orders 就绪后,在这里补充:
|
||||
* createOrder / fetchOrders / fetchOrderDetail / payOrder
|
||||
* 金额一律以服务端重算结果为准,前端只提交商品/设计数据与地址快照。
|
||||
*/
|
||||
export {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import http from '../request'
|
||||
|
||||
/** 商品列表 */
|
||||
export async function fetchProducts() {
|
||||
return http.get('/api/products', { auth: false })
|
||||
}
|
||||
|
||||
/** 商品详情 */
|
||||
export async function fetchProduct(id: string) {
|
||||
return http.get(`/api/products/${id}`, { auth: false })
|
||||
}
|
||||
|
||||
/** 分类列表 */
|
||||
export async function fetchCategories() {
|
||||
return http.get('/api/categories', { auth: false })
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import http, { BASE_URL, getToken } from '../request'
|
||||
import type {
|
||||
CosCredentials,
|
||||
DesignDataV1,
|
||||
SketchResult,
|
||||
StickerItem,
|
||||
WordCloudDispatchResult,
|
||||
WordCloudJob,
|
||||
WordCloudStatus,
|
||||
} from '../../types'
|
||||
|
||||
/**
|
||||
* R4 上传 / 词云 / 线稿接口。
|
||||
* 契约:前端与后端见 docs 里 api-contract-v1.md §8;后端与 wordcloud 见 wordcloud-contract.md。
|
||||
* multipart 上传经 Taro.uploadFile 统一带 Bearer token、解析后端 { code, message, data } 信封;
|
||||
* 与 request.ts 的 JSON 封装保持一致的成功/失败语义。
|
||||
*/
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
/** multipart 上传封装:带 token、解析统一信封;业务失败抛 Error(message) */
|
||||
async function uploadMultipart<T>(
|
||||
path: string,
|
||||
filePath: string,
|
||||
name: string,
|
||||
formData?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const res = await Taro.uploadFile({
|
||||
url: `${BASE_URL}${path}`,
|
||||
filePath,
|
||||
name,
|
||||
formData,
|
||||
header: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
|
||||
let body: Envelope<T>
|
||||
try {
|
||||
body = JSON.parse(res.data)
|
||||
} catch {
|
||||
throw new Error('网络异常,请稍后重试')
|
||||
}
|
||||
if (body && typeof body.code === 'number' && body.code !== 0) {
|
||||
throw new Error(body.message || '请求失败')
|
||||
}
|
||||
return (body && typeof body.code === 'number' ? body.data : body) as T
|
||||
}
|
||||
|
||||
/** 获取 COS 直传凭证(STS 临时凭证或预签名 URL;小程序不持有永久密钥) */
|
||||
export function getUploadCredentials(key: string): Promise<CosCredentials> {
|
||||
return http.get<CosCredentials>(`/api/upload/credentials?key=${encodeURIComponent(key)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 直传 COS:服务端下发预签名 URL 时用 PUT 提交文件字节;否则返回凭证由调用方决定。
|
||||
* 注:COS 直传的最终语义以联调阶段服务端 upload 模块实现为准(当前后端为占位)。
|
||||
*/
|
||||
export async function uploadToCos(localPath: string, credentials: CosCredentials): Promise<string> {
|
||||
if (!credentials.presignedUrl) {
|
||||
throw new Error('获取 COS 直传地址失败')
|
||||
}
|
||||
const fileSystem = Taro.getFileSystemManager()
|
||||
const data = fileSystem.readFileSync(localPath)
|
||||
await Taro.request({
|
||||
url: credentials.presignedUrl,
|
||||
method: 'PUT',
|
||||
data,
|
||||
header: { 'Content-Type': 'application/octet-stream' },
|
||||
})
|
||||
// 直传成功后返回去掉签名参数的对象地址
|
||||
return credentials.presignedUrl.split('?')[0]
|
||||
}
|
||||
|
||||
/** 线稿:图片 → 处理后图片 URL(失败由调用方降级本地灰度) */
|
||||
export function sketchImage(localPath: string): Promise<SketchResult> {
|
||||
return uploadMultipart<SketchResult>('/api/sketch', localPath, 'image')
|
||||
}
|
||||
|
||||
/** 创建词云任务:底图 + 名单 → jobId(异步模型,随后轮询) */
|
||||
export function createWordCloudJob(
|
||||
imagePath: string,
|
||||
names: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<{ jobId: string }> {
|
||||
return uploadMultipart<{ jobId: string }>(
|
||||
'/api/wordcloud/generate',
|
||||
imagePath,
|
||||
'image',
|
||||
{ names, ...(params ? { params: JSON.stringify(params) } : {}) },
|
||||
)
|
||||
}
|
||||
|
||||
/** 轮询词云任务状态;success 后 WordCloudJob.imageUrl 为结果图 COS 链接 */
|
||||
export function getWordCloudJob(jobId: string): Promise<WordCloudJob> {
|
||||
return http.get<WordCloudJob>(`/api/wordcloud/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/** 取词云任务结果(后端将 wordcloud 产物转存 COS 后返回) */
|
||||
export function getWordCloudResult(jobId: string): Promise<{ imageUrl: string; svgUrl?: string }> {
|
||||
return http.get<{ imageUrl: string; svgUrl?: string }>(`/api/wordcloud/jobs/${jobId}/result`)
|
||||
}
|
||||
|
||||
/** 是否为设备本地临时路径(后端/wordcloud 无法消费,需先转 COS 持久 URL) */
|
||||
function isLocalPath(url?: string): boolean {
|
||||
return !!url && /^(wxfile:|http:\/\/tmp\/|tmp\/)/i.test(url)
|
||||
}
|
||||
|
||||
/** 单张本地图 → COS 持久 URL;COS 未配置等失败时保留原路径,不阻断保存 */
|
||||
async function persistLocalImage(localPath: string): Promise<string> {
|
||||
try {
|
||||
const key = `uploads/sticker/${Date.now()}_${Math.floor(Math.random() * 1e6)}.jpg`
|
||||
const cred = await getUploadCredentials(key)
|
||||
return await uploadToCos(localPath, cred)
|
||||
} catch {
|
||||
return localPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计媒体持久化(契约 design-data-contract-v1.md 决策#4:贴纸持久化由 R4 完成)。
|
||||
* 保存/下单前调用,把 designData 里的本地贴纸/底图上传为 COS 持久 URL 写回 src,
|
||||
* 保证 WCD 打包时后端能下载到素材字节。
|
||||
*/
|
||||
export async function persistDesignMedia(designData: DesignDataV1): Promise<DesignDataV1> {
|
||||
if (!designData) return designData
|
||||
const next: DesignDataV1 = { ...designData }
|
||||
if (designData.background && isLocalPath(designData.background.src)) {
|
||||
next.background = {
|
||||
...designData.background,
|
||||
src: await persistLocalImage(designData.background.src),
|
||||
}
|
||||
}
|
||||
if (designData.stickers && designData.stickers.length) {
|
||||
const list: StickerItem[] = []
|
||||
for (const s of designData.stickers) {
|
||||
list.push(isLocalPath(s.src) ? { ...s, src: await persistLocalImage(s.src) } : s)
|
||||
}
|
||||
next.stickers = list
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 下单后 WCD 派单(幂等触发;WORDCLOUD 未配置返回 not_configured,见 api-contract §8) */
|
||||
export function dispatchOrder(orderId: string): Promise<WordCloudDispatchResult> {
|
||||
return http.post<WordCloudDispatchResult>(`/api/orders/${orderId}/dispatch`)
|
||||
}
|
||||
|
||||
export type { WordCloudStatus }
|
||||
@@ -0,0 +1,24 @@
|
||||
import http from '../request'
|
||||
|
||||
/** 后端用户信息(对应 User 模型;openpid 为主标识,openid 可空) */
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
openpid?: string | null
|
||||
openid?: string | null
|
||||
unionid?: string | null
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
phone?: string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/** 更新当前用户资料(昵称/头像),新用户在补填后提交到后端持久化 */
|
||||
export async function updateProfile(profile: { nickname?: string; avatar?: string }): Promise<UserProfile> {
|
||||
return http.patch<UserProfile>('/api/users/me', profile)
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息 */
|
||||
export async function getMe(): Promise<UserProfile> {
|
||||
return http.get<UserProfile>('/api/users/me')
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { DesignItem, OrderItem, AddressItem, StickerItem } from '../types'
|
||||
import { PRODUCT_ICON_MAP } from './productConfig'
|
||||
import { assetUrl } from './asset'
|
||||
|
||||
import { invalidateAuthCache } from './authState'
|
||||
// 重新导出类型,供各页面从 store 直接引用(修正原「声明但不导出」的编译错误)
|
||||
export type { DesignItem, OrderItem, AddressItem, StickerItem }
|
||||
|
||||
const THEME_KEY = 'smart_theme'
|
||||
const USER_KEY = 'smart_user_info'
|
||||
const ACTIVE_USER_KEY = 'smart_active_openid'
|
||||
|
||||
/** 判断 openid 是否为历史版 mock 残留(形如 mock_xxx) */
|
||||
export function isMockOpenid(openid?: string | null): boolean {
|
||||
return !!openid && (openid.startsWith('mock_') || openid.startsWith('mock-'))
|
||||
}
|
||||
|
||||
// ---------- 当前用户 openid 管理 ----------
|
||||
|
||||
function getActiveOpenid(): string {
|
||||
const user = getUserInfoRaw()
|
||||
const openid = user?.openid || Taro.getStorageSync(ACTIVE_USER_KEY) || '_guest_'
|
||||
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
|
||||
return openid
|
||||
}
|
||||
|
||||
function key(scope: string): string {
|
||||
const prefix = getActiveOpenid()
|
||||
return `${scope}_${prefix}`
|
||||
}
|
||||
|
||||
// ---------- 用户数据 ----------
|
||||
|
||||
const USER_REGISTRY_KEY = 'smart_user_registry'
|
||||
const ADMIN_PASSWORD = 'zhihui2024'
|
||||
|
||||
// ---------- 用户注册表 ----------
|
||||
|
||||
function getUserRegistry(): string[] {
|
||||
try { return Taro.getStorageSync(USER_REGISTRY_KEY) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
function saveToRegistry(openid: string) {
|
||||
const list = getUserRegistry()
|
||||
if (!list.includes(openid)) {
|
||||
list.push(openid)
|
||||
Taro.setStorageSync(USER_REGISTRY_KEY, list)
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromRegistry(openid: string) {
|
||||
const list = getUserRegistry().filter(id => id !== openid)
|
||||
Taro.setStorageSync(USER_REGISTRY_KEY, list)
|
||||
}
|
||||
|
||||
// ---------- 用户数据 ----------
|
||||
|
||||
export function getUserInfoRaw(): any {
|
||||
try { return Taro.getStorageSync(USER_KEY) } catch { return null }
|
||||
}
|
||||
|
||||
export function setUserInfoRaw(info: any) {
|
||||
Taro.setStorageSync(USER_KEY, info)
|
||||
if (info?.openid) {
|
||||
Taro.setStorageSync(ACTIVE_USER_KEY, info.openid)
|
||||
// 为每个用户备份独立副本,方便切换账号时读取
|
||||
Taro.setStorageSync(`user_info_${info.openid}`, info)
|
||||
saveToRegistry(info.openid)
|
||||
}
|
||||
invalidateAuthCache()
|
||||
Taro.eventCenter?.trigger('authStateChanged', { openid: info?.openid || '' })
|
||||
}
|
||||
|
||||
export function getUserInfoByOpenid(openid: string): any | null {
|
||||
try {
|
||||
const backup = Taro.getStorageSync(`user_info_${openid}`)
|
||||
if (backup) return backup
|
||||
} catch {}
|
||||
const current = getUserInfoRaw()
|
||||
if (current?.openid === openid) return current
|
||||
return null
|
||||
}
|
||||
|
||||
export function clearUserInfo() {
|
||||
// 仅退出登录,不删除用户数据
|
||||
Taro.removeStorageSync(USER_KEY)
|
||||
Taro.removeStorageSync(ACTIVE_USER_KEY)
|
||||
invalidateAuthCache()
|
||||
Taro.eventCenter?.trigger('authStateChanged', { openid: '' })
|
||||
}
|
||||
|
||||
export function setUserInfo(info: any) {
|
||||
setUserInfoRaw(info)
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return getUserInfoRaw()
|
||||
}
|
||||
|
||||
// ---------- 用户数据库管理 ----------
|
||||
|
||||
export function listAllUsers() {
|
||||
const registry = getUserRegistry()
|
||||
return registry.map(openid => {
|
||||
const info = getUserInfoByOpenid(openid)
|
||||
const dList = getDesignListFor(openid)
|
||||
const oList = getOrderListFor(openid)
|
||||
return {
|
||||
openid,
|
||||
nickName: info?.nickName || '未知用户',
|
||||
avatarUrl: info?.avatarUrl || '',
|
||||
loginAt: info?.loginAt || 0,
|
||||
designCount: dList.length,
|
||||
orderCount: oList.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createUser(nickName: string, avatarUrl?: string) {
|
||||
const openid = 'mock_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
|
||||
const info = { openid, nickName: nickName || '微信用户', avatarUrl: avatarUrl || '', loginAt: Date.now() }
|
||||
setUserInfoRaw(info)
|
||||
return info
|
||||
}
|
||||
|
||||
export function switchUser(openid: string) {
|
||||
const info = getUserInfoByOpenid(openid)
|
||||
if (!info) return false
|
||||
Taro.setStorageSync(USER_KEY, info)
|
||||
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
|
||||
return true
|
||||
}
|
||||
|
||||
export function deleteUser(openid: string) {
|
||||
// 删除该用户的所有数据
|
||||
Taro.removeStorageSync(`user_info_${openid}`)
|
||||
Taro.removeStorageSync(`design_list_${openid}`)
|
||||
Taro.removeStorageSync(`order_list_${openid}`)
|
||||
Taro.removeStorageSync(`address_list_${openid}`)
|
||||
removeFromRegistry(openid)
|
||||
// 如果删的是当前登录用户,清掉登录态
|
||||
const current = getUserInfoRaw()
|
||||
if (current?.openid === openid) {
|
||||
clearUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyAdminPassword(password: string): boolean {
|
||||
return password === ADMIN_PASSWORD
|
||||
}
|
||||
|
||||
// ---------- 跨用户读取辅助函数 ----------
|
||||
|
||||
function keyFor(scope: string, openid: string) {
|
||||
return `${scope}_${openid}`
|
||||
}
|
||||
|
||||
function getDesignListFor(openid: string): DesignItem[] {
|
||||
try { return Taro.getStorageSync(keyFor('design_list', openid)) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
function getOrderListFor(openid: string): OrderItem[] {
|
||||
try { return Taro.getStorageSync(keyFor('order_list', openid)) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
// ---------- 设计清单 ----------
|
||||
|
||||
export function getDesignList(): DesignItem[] {
|
||||
try { return Taro.getStorageSync(key('design_list')) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setDesignList(list: DesignItem[]) {
|
||||
Taro.setStorageSync(key('design_list'), list)
|
||||
}
|
||||
|
||||
export function addDesign(product: any, count: number): DesignItem {
|
||||
const list = getDesignList()
|
||||
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.svg')
|
||||
const item: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productIcon: iconImg,
|
||||
unitPrice: product.price,
|
||||
count,
|
||||
status: 'undesigned',
|
||||
createdAt: new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
setDesignList([...list, item])
|
||||
return item
|
||||
}
|
||||
|
||||
export function updateDesign(id: string, patch: Partial<DesignItem>) {
|
||||
const list = getDesignList()
|
||||
const idx = list.findIndex(d => d.id === id)
|
||||
if (idx === -1) return
|
||||
list[idx] = { ...list[idx], ...patch }
|
||||
setDesignList(list)
|
||||
}
|
||||
|
||||
/** 批量删除设计条目 */
|
||||
export function removeDesigns(ids: string[]) {
|
||||
const list = getDesignList().filter(d => !ids.includes(d.id))
|
||||
setDesignList(list)
|
||||
}
|
||||
|
||||
/** 根据ID删除单条设计 */
|
||||
export function removeDesign(id: string) {
|
||||
const list = getDesignList().filter(d => d.id !== id)
|
||||
setDesignList(list)
|
||||
}
|
||||
|
||||
// ---------- 订单 ----------
|
||||
|
||||
export function getOrderList(): OrderItem[] {
|
||||
try { return Taro.getStorageSync(key('order_list')) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setOrderList(list: OrderItem[]) {
|
||||
Taro.setStorageSync(key('order_list'), list)
|
||||
}
|
||||
|
||||
export function designToOrder(designId: string): OrderItem | null {
|
||||
const dList = getDesignList()
|
||||
const oList = getOrderList()
|
||||
const design = dList.find(d => d.id === designId)
|
||||
if (!design) return null
|
||||
|
||||
const rawIcon = design.productIcon?.startsWith('/icon/') || /^https?:/i.test(design.productIcon || '')
|
||||
? design.productIcon
|
||||
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.svg'
|
||||
|
||||
const iconImg = assetUrl(rawIcon)
|
||||
|
||||
const order: OrderItem = {
|
||||
id: 'ORD' + Date.now().toString().slice(-9),
|
||||
productName: design.productName,
|
||||
productIcon: iconImg,
|
||||
statusCode: 'pending',
|
||||
date: new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-'),
|
||||
price: '¥' + (design.unitPrice * design.count).toFixed(2),
|
||||
count: design.count,
|
||||
sku: `${design.productName} × ${design.count}`
|
||||
}
|
||||
|
||||
const dIdx = dList.findIndex(d => d.id === designId)
|
||||
if (dIdx !== -1) {
|
||||
dList[dIdx].status = 'ordered'
|
||||
dList[dIdx].orderId = order.id
|
||||
}
|
||||
|
||||
setDesignList(dList)
|
||||
setOrderList([order, ...oList])
|
||||
return order
|
||||
}
|
||||
|
||||
// ---------- 主题 ----------
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'auto'
|
||||
|
||||
export function getTheme(): ThemeMode {
|
||||
try { return Taro.getStorageSync(THEME_KEY) || 'auto' } catch { return 'auto' }
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemeMode) {
|
||||
Taro.setStorageSync(THEME_KEY, theme)
|
||||
}
|
||||
|
||||
// 跟随系统模式下,系统主题由 onThemeChange/getAppBaseInfo 写入缓存,供 resolveTheme 使用
|
||||
let detectedSystemTheme: 'light' | 'dark' | null = null
|
||||
|
||||
export function setDetectedSystemTheme(t: 'light' | 'dark'): void {
|
||||
detectedSystemTheme = t
|
||||
}
|
||||
|
||||
/** 根据自动模式解析实际生效主题 */
|
||||
export function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
|
||||
if (mode === 'auto') {
|
||||
// darkmode 开启后 getAppBaseInfo().theme 能拿到系统主题;缓存仅用于启动时加速
|
||||
if (detectedSystemTheme) return detectedSystemTheme
|
||||
try {
|
||||
const info = Taro.getAppBaseInfo()
|
||||
if (info.theme === 'dark' || info.theme === 'light') return info.theme
|
||||
} catch { /* ignore */ }
|
||||
return 'light'
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// ---------- 收货地址 ----------
|
||||
|
||||
export function getAddressList(): AddressItem[] {
|
||||
try { return Taro.getStorageSync(key('address_list')) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setAddressList(list: AddressItem[]) {
|
||||
Taro.setStorageSync(key('address_list'), list)
|
||||
}
|
||||
|
||||
export function addAddress(addr: Omit<AddressItem, 'id'>): AddressItem {
|
||||
const list = getAddressList()
|
||||
const item: AddressItem = { ...addr, id: 'ADR' + Date.now() }
|
||||
// 若设为默认,取消其他默认
|
||||
if (item.isDefault) {
|
||||
list.forEach(a => { a.isDefault = false })
|
||||
}
|
||||
setAddressList([item, ...list])
|
||||
return item
|
||||
}
|
||||
|
||||
export function updateAddress(id: string, patch: Partial<AddressItem>) {
|
||||
const list = getAddressList()
|
||||
const idx = list.findIndex(a => a.id === id)
|
||||
if (idx === -1) return
|
||||
if (patch.isDefault) list.forEach(a => { a.isDefault = false })
|
||||
list[idx] = { ...list[idx], ...patch }
|
||||
setAddressList(list)
|
||||
}
|
||||
|
||||
export function deleteAddress(id: string) {
|
||||
const list = getAddressList().filter(a => a.id !== id)
|
||||
setAddressList(list)
|
||||
}
|
||||
|
||||
export function getDefaultAddress(): AddressItem | undefined {
|
||||
return getAddressList().find(a => a.isDefault)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { AddressItem } from '../../types'
|
||||
import { key } from './keys'
|
||||
|
||||
// ---------- 收货地址 ----------
|
||||
|
||||
export function getAddressList(): AddressItem[] {
|
||||
try { return Taro.getStorageSync(key('address_list')) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setAddressList(list: AddressItem[]) {
|
||||
Taro.setStorageSync(key('address_list'), list)
|
||||
}
|
||||
|
||||
export function addAddress(addr: Omit<AddressItem, 'id'>): AddressItem {
|
||||
const list = getAddressList()
|
||||
const item: AddressItem = { ...addr, id: 'ADR' + Date.now() }
|
||||
// 若设为默认,取消其他默认
|
||||
if (item.isDefault) {
|
||||
list.forEach(a => { a.isDefault = false })
|
||||
}
|
||||
setAddressList([item, ...list])
|
||||
return item
|
||||
}
|
||||
|
||||
export function updateAddress(id: string, patch: Partial<AddressItem>) {
|
||||
const list = getAddressList()
|
||||
const idx = list.findIndex(a => a.id === id)
|
||||
if (idx === -1) return
|
||||
if (patch.isDefault) list.forEach(a => { a.isDefault = false })
|
||||
list[idx] = { ...list[idx], ...patch }
|
||||
setAddressList(list)
|
||||
}
|
||||
|
||||
export function deleteAddress(id: string) {
|
||||
const list = getAddressList().filter(a => a.id !== id)
|
||||
setAddressList(list)
|
||||
}
|
||||
|
||||
export function getDefaultAddress(): AddressItem | undefined {
|
||||
return getAddressList().find(a => a.isDefault)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { DesignItem } from '../../types'
|
||||
import { assetUrl } from '../asset'
|
||||
import { PRODUCT_ICON_MAP } from '../productConfig'
|
||||
import { key, keyFor } from './keys'
|
||||
|
||||
/** 跨用户读取指定 openid 的设计清单(账号管理用) */
|
||||
export function getDesignListFor(openid: string): DesignItem[] {
|
||||
try { return Taro.getStorageSync(keyFor('design_list', openid)) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
// ---------- 设计清单 ----------
|
||||
|
||||
export function getDesignList(): DesignItem[] {
|
||||
try { return Taro.getStorageSync(key('design_list')) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setDesignList(list: DesignItem[]) {
|
||||
Taro.setStorageSync(key('design_list'), list)
|
||||
}
|
||||
|
||||
export function addDesign(product: any, count: number): DesignItem {
|
||||
const list = getDesignList()
|
||||
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.svg')
|
||||
const item: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productIcon: iconImg,
|
||||
unitPrice: product.price,
|
||||
count,
|
||||
status: 'undesigned',
|
||||
createdAt: new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
setDesignList([...list, item])
|
||||
return item
|
||||
}
|
||||
|
||||
export function updateDesign(id: string, patch: Partial<DesignItem>) {
|
||||
const list = getDesignList()
|
||||
const idx = list.findIndex(d => d.id === id)
|
||||
if (idx === -1) return
|
||||
list[idx] = { ...list[idx], ...patch }
|
||||
setDesignList(list)
|
||||
}
|
||||
|
||||
/** 批量删除设计条目 */
|
||||
export function removeDesigns(ids: string[]) {
|
||||
const list = getDesignList().filter(d => !ids.includes(d.id))
|
||||
setDesignList(list)
|
||||
}
|
||||
|
||||
/** 根据ID删除单条设计 */
|
||||
export function removeDesign(id: string) {
|
||||
const list = getDesignList().filter(d => d.id !== id)
|
||||
setDesignList(list)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DA 层聚合入口:页面统一从 '../../utils/store' 导入。
|
||||
* 新增领域逻辑请写在 store/<domain>.ts,本文件只负责 re-export。
|
||||
*/
|
||||
export { isMockOpenid } from './keys'
|
||||
export {
|
||||
getUserInfoRaw,
|
||||
setUserInfoRaw,
|
||||
getUserInfoByOpenid,
|
||||
clearUserInfo,
|
||||
setUserInfo,
|
||||
getUserInfo,
|
||||
listAllUsers,
|
||||
createUser,
|
||||
switchUser,
|
||||
deleteUser,
|
||||
verifyAdminPassword
|
||||
} from './user'
|
||||
export {
|
||||
getDesignList,
|
||||
setDesignList,
|
||||
addDesign,
|
||||
updateDesign,
|
||||
removeDesigns,
|
||||
removeDesign
|
||||
} from './design'
|
||||
export {
|
||||
getOrderList,
|
||||
setOrderList,
|
||||
designToOrder
|
||||
} from './order'
|
||||
export {
|
||||
getAddressList,
|
||||
setAddressList,
|
||||
addAddress,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
getDefaultAddress
|
||||
} from './address'
|
||||
export {
|
||||
getTheme,
|
||||
setTheme,
|
||||
setDetectedSystemTheme,
|
||||
resolveTheme
|
||||
} from './theme'
|
||||
export type { ThemeMode } from './theme'
|
||||
export type { DesignItem, OrderItem, AddressItem, StickerItem } from '../../types'
|
||||
@@ -0,0 +1,35 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
/** 当前登录用户信息在 Storage 中的 key */
|
||||
export const USER_KEY = 'smart_user_info'
|
||||
/** 当前激活 openid 在 Storage 中的 key */
|
||||
export const ACTIVE_USER_KEY = 'smart_active_openid'
|
||||
|
||||
/** 判断 openid 是否为历史版 mock 残留(形如 mock_xxx) */
|
||||
export function isMockOpenid(openid?: string | null): boolean {
|
||||
return !!openid && (openid.startsWith('mock_') || openid.startsWith('mock-'))
|
||||
}
|
||||
|
||||
/** 取当前用户 openid;没有登录态时回退 _guest_ 并缓存 */
|
||||
function getActiveOpenid(): string {
|
||||
let user: any = null
|
||||
try {
|
||||
user = Taro.getStorageSync(USER_KEY)
|
||||
} catch {
|
||||
user = null
|
||||
}
|
||||
const openid = user?.openid || Taro.getStorageSync(ACTIVE_USER_KEY) || '_guest_'
|
||||
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
|
||||
return openid
|
||||
}
|
||||
|
||||
/** 按当前用户生成作用域 storage key */
|
||||
export function key(scope: string): string {
|
||||
const prefix = getActiveOpenid()
|
||||
return `${scope}_${prefix}`
|
||||
}
|
||||
|
||||
/** 按指定 openid 生成作用域 storage key */
|
||||
export function keyFor(scope: string, openid: string): string {
|
||||
return `${scope}_${openid}`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { OrderItem } from '../../types'
|
||||
import { assetUrl } from '../asset'
|
||||
import { PRODUCT_ICON_MAP } from '../productConfig'
|
||||
import { getDesignList, setDesignList } from './design'
|
||||
import { key, keyFor } from './keys'
|
||||
|
||||
/** 跨用户读取指定 openid 的订单列表(账号管理用) */
|
||||
export function getOrderListFor(openid: string): OrderItem[] {
|
||||
try { return Taro.getStorageSync(keyFor('order_list', openid)) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
// ---------- 订单 ----------
|
||||
|
||||
export function getOrderList(): OrderItem[] {
|
||||
try { return Taro.getStorageSync(key('order_list')) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setOrderList(list: OrderItem[]) {
|
||||
Taro.setStorageSync(key('order_list'), list)
|
||||
}
|
||||
|
||||
export function designToOrder(designId: string): OrderItem | null {
|
||||
const dList = getDesignList()
|
||||
const oList = getOrderList()
|
||||
const design = dList.find(d => d.id === designId)
|
||||
if (!design) return null
|
||||
|
||||
const rawIcon = design.productIcon?.startsWith('/icon/') || /^https?:/i.test(design.productIcon || '')
|
||||
? design.productIcon
|
||||
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.svg'
|
||||
|
||||
const iconImg = assetUrl(rawIcon)
|
||||
|
||||
const order: OrderItem = {
|
||||
id: 'ORD' + Date.now().toString().slice(-9),
|
||||
productName: design.productName,
|
||||
productIcon: iconImg,
|
||||
statusCode: 'pending',
|
||||
date: new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-'),
|
||||
price: '¥' + (design.unitPrice * design.count).toFixed(2),
|
||||
count: design.count,
|
||||
sku: `${design.productName} × ${design.count}`
|
||||
}
|
||||
|
||||
const dIdx = dList.findIndex(d => d.id === designId)
|
||||
if (dIdx !== -1) {
|
||||
dList[dIdx].status = 'ordered'
|
||||
dList[dIdx].orderId = order.id
|
||||
}
|
||||
|
||||
setDesignList(dList)
|
||||
setOrderList([order, ...oList])
|
||||
return order
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
const THEME_KEY = 'smart_theme'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'auto'
|
||||
|
||||
export function getTheme(): ThemeMode {
|
||||
try { return Taro.getStorageSync(THEME_KEY) || 'auto' } catch { return 'auto' }
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemeMode) {
|
||||
Taro.setStorageSync(THEME_KEY, theme)
|
||||
}
|
||||
|
||||
// 跟随系统模式下,系统主题由 onThemeChange/getAppBaseInfo 写入缓存,供 resolveTheme 使用
|
||||
let detectedSystemTheme: 'light' | 'dark' | null = null
|
||||
|
||||
export function setDetectedSystemTheme(t: 'light' | 'dark'): void {
|
||||
detectedSystemTheme = t
|
||||
}
|
||||
|
||||
/** 根据自动模式解析实际生效主题 */
|
||||
export function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
|
||||
if (mode === 'auto') {
|
||||
if (detectedSystemTheme) return detectedSystemTheme
|
||||
try {
|
||||
const info = Taro.getAppBaseInfo()
|
||||
if (info.theme === 'dark' || info.theme === 'light') return info.theme
|
||||
} catch { /* ignore */ }
|
||||
return 'light'
|
||||
}
|
||||
return mode
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { invalidateAuthCache } from '../authState'
|
||||
import { getDesignListFor } from './design'
|
||||
import { ACTIVE_USER_KEY, USER_KEY } from './keys'
|
||||
import { getOrderListFor } from './order'
|
||||
|
||||
const USER_REGISTRY_KEY = 'smart_user_registry'
|
||||
const ADMIN_PASSWORD = 'zhihui2024'
|
||||
|
||||
// ---------- 用户注册表 ----------
|
||||
|
||||
function getUserRegistry(): string[] {
|
||||
try { return Taro.getStorageSync(USER_REGISTRY_KEY) || [] } catch { return [] }
|
||||
}
|
||||
|
||||
function saveToRegistry(openid: string) {
|
||||
const list = getUserRegistry()
|
||||
if (!list.includes(openid)) {
|
||||
list.push(openid)
|
||||
Taro.setStorageSync(USER_REGISTRY_KEY, list)
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromRegistry(openid: string) {
|
||||
const list = getUserRegistry().filter(id => id !== openid)
|
||||
Taro.setStorageSync(USER_REGISTRY_KEY, list)
|
||||
}
|
||||
|
||||
// ---------- 用户数据 ----------
|
||||
|
||||
export function getUserInfoRaw(): any {
|
||||
try { return Taro.getStorageSync(USER_KEY) } catch { return null }
|
||||
}
|
||||
|
||||
export function setUserInfoRaw(info: any) {
|
||||
Taro.setStorageSync(USER_KEY, info)
|
||||
if (info?.openid) {
|
||||
Taro.setStorageSync(ACTIVE_USER_KEY, info.openid)
|
||||
// 为每个用户备份独立副本,方便切换账号时读取
|
||||
Taro.setStorageSync(`user_info_${info.openid}`, info)
|
||||
saveToRegistry(info.openid)
|
||||
}
|
||||
invalidateAuthCache()
|
||||
Taro.eventCenter?.trigger('authStateChanged', { openid: info?.openid || '' })
|
||||
}
|
||||
|
||||
export function getUserInfoByOpenid(openid: string): any | null {
|
||||
try {
|
||||
const backup = Taro.getStorageSync(`user_info_${openid}`)
|
||||
if (backup) return backup
|
||||
} catch {}
|
||||
const current = getUserInfoRaw()
|
||||
if (current?.openid === openid) return current
|
||||
return null
|
||||
}
|
||||
|
||||
export function clearUserInfo() {
|
||||
// 仅退出登录,不删除用户数据
|
||||
Taro.removeStorageSync(USER_KEY)
|
||||
Taro.removeStorageSync(ACTIVE_USER_KEY)
|
||||
invalidateAuthCache()
|
||||
Taro.eventCenter?.trigger('authStateChanged', { openid: '' })
|
||||
}
|
||||
|
||||
export function setUserInfo(info: any) {
|
||||
setUserInfoRaw(info)
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return getUserInfoRaw()
|
||||
}
|
||||
|
||||
// ---------- 用户数据库管理 ----------
|
||||
|
||||
export function listAllUsers() {
|
||||
const registry = getUserRegistry()
|
||||
return registry.map(openid => {
|
||||
const info = getUserInfoByOpenid(openid)
|
||||
const dList = getDesignListFor(openid)
|
||||
const oList = getOrderListFor(openid)
|
||||
return {
|
||||
openid,
|
||||
nickName: info?.nickName || '未知用户',
|
||||
avatarUrl: info?.avatarUrl || '',
|
||||
loginAt: info?.loginAt || 0,
|
||||
designCount: dList.length,
|
||||
orderCount: oList.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createUser(nickName: string, avatarUrl?: string) {
|
||||
const openid = 'mock_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
|
||||
const info = { openid, nickName: nickName || '微信用户', avatarUrl: avatarUrl || '', loginAt: Date.now() }
|
||||
setUserInfoRaw(info)
|
||||
return info
|
||||
}
|
||||
|
||||
export function switchUser(openid: string) {
|
||||
const info = getUserInfoByOpenid(openid)
|
||||
if (!info) return false
|
||||
Taro.setStorageSync(USER_KEY, info)
|
||||
Taro.setStorageSync(ACTIVE_USER_KEY, openid)
|
||||
return true
|
||||
}
|
||||
|
||||
export function deleteUser(openid: string) {
|
||||
// 删除该用户的所有数据
|
||||
Taro.removeStorageSync(`user_info_${openid}`)
|
||||
Taro.removeStorageSync(`design_list_${openid}`)
|
||||
Taro.removeStorageSync(`order_list_${openid}`)
|
||||
Taro.removeStorageSync(`address_list_${openid}`)
|
||||
removeFromRegistry(openid)
|
||||
// 如果删的是当前登录用户,清掉登录态
|
||||
const current = getUserInfoRaw()
|
||||
if (current?.openid === openid) {
|
||||
clearUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyAdminPassword(password: string): boolean {
|
||||
return password === ADMIN_PASSWORD
|
||||
}
|
||||
Reference in New Issue
Block a user