feat: split DA and API layers by domain, add parallel route docs
This commit is contained in:
@@ -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,130 @@
|
||||
# 并行开发指南(R0)
|
||||
|
||||
本文档是四名成员并行开发时的代码修改约定。目标是:每个人在自己路线的文件里改动,
|
||||
不与其他人发生文件级冲突;共享文件只由 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,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,116 @@
|
||||
# R4 上传 + 词云生成 + 线稿(feat/r4-upload-wordcloud)
|
||||
|
||||
**Goal:** 打通用户图片上传、词云任务生成、DIY 贴纸线稿处理和异步任务状态轮询;
|
||||
这是四条路线中唯一复用 `wordcloud` 项目的路线,必须通过冻结接口契约的方式隔离它的大量变更。
|
||||
|
||||
**Architecture:** 小程序只与 `wxmp_backend` 通信;wxmp_backend 实现 COS 上传凭证、词云适配器、
|
||||
sketch 接口和任务记录/轮询;`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 |
|
||||
|
||||
### 后端 `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 契约请求;任务状态入库,用户归属校验 |
|
||||
| `sketch`(新增) | `POST /api/sketch` 接收图片,返回处理后图片 URL;内部可先做基础处理或接外部 AI |
|
||||
| `queue` | BullMQ 消费者把 wordcloud/sketch 任务状态同步到 `CustomizationTask`/`WordCloudJob` |
|
||||
| `docs/wordcloud-contract.md` | 冻结 wordcloud 三/四个端点、字段、状态与错误语义,含版本号 |
|
||||
|
||||
### 词云项目 `wordcloud`
|
||||
|
||||
- 需要冻结的接口:`POST /api/jobs`(建议增加直接传名字列表/JSON 的方式)、`GET /api/jobs/{id}`、`GET /api/jobs/{id}/files/png`。
|
||||
- 其余 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. 设计注意事项
|
||||
|
||||
### DO
|
||||
|
||||
- DO 小程序只调用 `wxmp_backend`,`wordcloud` 的地址、密钥、内部参数对小程序完全不可见。
|
||||
- DO 上传走临时凭证或预签名 URL,前端拿不到主账号 SecretKey。
|
||||
- DO 文件名与 `key` 由服务端生成或校验(如 `uploads/{userId}/{uuid}.jpg`),杜绝用户传路径穿越。
|
||||
- DO 限制文件类型与大小(底图建议 png/jpg ≤ 10MB、名单 ≤ 200 个名字),前后端双重校验。
|
||||
- DO 词云任务在数据库建记录并按用户隔离,用户只能查询自己的 job。
|
||||
- DO 轮询采用普通 HTTP GET,不做 SSE;小程序端 EventSource 支持不稳定。
|
||||
- DO 进度字段以 wordcloud 返回为准,前端只负责展示,不再用随机数模拟进度。
|
||||
- DO sketch 请求带登录态和文件大小校验,失败时前端降级本地灰度,并明确提示“已使用本地线稿”。
|
||||
- DO 长期运行的任务设置超时与失败清理,避免 COS 对象和任务记录无限堆积。
|
||||
- DO 在 `docs/wordcloud-contract.md` 记录契约版本,并在后端适配器代码里注释依赖版本。
|
||||
|
||||
### DON'T
|
||||
|
||||
- DON'T 把 wordcloud 项目 fork 进 wxmp_backend,也不要把它的内部 Python/C++ 代码搬进 NestJS。
|
||||
- DON'T 在小程序前端硬编码 wordcloud 基础地址或直接调用它。
|
||||
- DON'T 上传逻辑使用完整云厂商密钥;密钥只存在于服务端环境变量。
|
||||
- DON'T 接受本地临时路径作为最终结果地址,wordcloud 返回的内部 `/api/jobs/...` 必须转成 COS 公网 URL。
|
||||
- DON'T 让用户通过猜 `jobId` 读取他人任务结果,所有查询都带 userId 条件。
|
||||
- DON'T 在密钥未配置时静默跳过上传/词云;返回结构化“未配置”错误,前端给出可理解提示。
|
||||
- DON'T 用假的 `setInterval` 进度假装生成完成,R4 结束前必须移除当前 wordcloud 页的模拟逻辑。
|
||||
|
||||
## 4. 补充内容
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 小程序端上传图片可拿到 COS 可访问 URL,`Upload` 表有记录且归属当前用户。
|
||||
- `POST /api/wordcloud/generate` 可在真实 wordcloud 服务上产出 PNG,前端轮询到结果并保存相册。
|
||||
- `POST /api/sketch` 返回有效图片 URL;接口失败时前端走降级且不白屏。
|
||||
- 词云任务非本人不可访问;上传文件类型/大小校验生效。
|
||||
- `docs/wordcloud-contract.md` 已冻结并带版本号;前端 `wordcloud` 页不再有随机进度。
|
||||
|
||||
### 合并与依赖
|
||||
|
||||
- 分支名:前端与后端均为 `feat/r4-upload-wordcloud`。
|
||||
- 与 R1-R3 并行,不依赖商品/订单链路;后端上传基础设施可为后续 R2 的贴纸图片持久化提供能力。
|
||||
- wordcloud 契约若未冻结,R4 先完成“契约文档 + 后端适配层”,联调阶段再补真实 job。
|
||||
|
||||
### 风险
|
||||
|
||||
- wordcloud 正在大量变更,任务状态/产物路径随时可能变化;这正是契约冻结要解决的问题,联调时先锁定一个部署版本。
|
||||
- COS 与微信 `downloadFile`/`uploadFile` 合法域名必须提前配置,否则真机上传下载会失败。
|
||||
- 词云生成是 CPU 密集型任务,需要限制并发与单账号频率,防止被刷爆资源。
|
||||
- `designData` 中的贴纸图片若在 R4 接入上传,需要 R2 的设计 JSON 结构配合增加 `uploadedUrl` 字段,两分支交接时注意。
|
||||
|
||||
### 测试要求
|
||||
|
||||
- 后端:上传凭证、大小/类型校验、wordcloud 适配器正常/失败/超时、任务归属权限、sketch 接口。
|
||||
- 前端:词云上传、轮询进度、fail 重试、结果保存相册;线稿接口失败降级;无 COS 配置提示。
|
||||
- 集成:用固定 wordcloud 部署版本跑一次真实生成端到端 smoke。
|
||||
|
||||
@@ -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,7 @@
|
||||
/**
|
||||
* R4 上传接口归属文件。
|
||||
* 后端 COS/wordcloud 适配器就绪后,在这里补充:
|
||||
* getUploadCredentials / uploadImage / generateWordCloud / getWordCloudJob / sketchImage
|
||||
* 词云任务使用“创建任务 -> 轮询状态 -> 取结果链接”的异步模型。
|
||||
*/
|
||||
export {}
|
||||
@@ -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