feat: split DA and API layers by domain, add parallel route docs

This commit is contained in:
2026-08-10 01:56:09 +08:00
parent 68c5e8ee04
commit 0394da6771
38 changed files with 1156 additions and 437 deletions
+5 -5
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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()}]);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+130
View File
@@ -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` 页归 R3R2 只做 `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。
+26
View File
@@ -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` 聚合入口导入。
- 不得跨路线修改其他分支的文件所有权。
+93
View File
@@ -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 + TypeScriptNestJS 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 测试,保持手测清单即可。
+124
View File
@@ -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 + TypeScriptNestJS 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 引导登录。
+132
View File
@@ -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 + TypeScriptNestJS 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 筛选、越权场景提示、支付未配置提示。
+116
View File
@@ -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 + TypeScriptNestJS 11 + Prisma/PostgreSQL + Redis/BullMQ
腾讯云 COSwordcloud 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。
-90
View File
@@ -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 })
}
+7
View File
@@ -0,0 +1,7 @@
/**
* R2 收货地址接口归属文件。
* 后端 /api/addresses CRUD 就绪后,在这里补充:
* fetchAddresses / createAddress / updateAddress / deleteAddress / setDefaultAddress
* 返回类型优先直接对应 src/types/index.ts 的 AddressItem 契约。
*/
export {}
+45
View File
@@ -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()
}
+7
View File
@@ -0,0 +1,7 @@
/**
* R2 设计清单接口归属文件。
* 后端 /api/design-list CRUD 就绪后,在这里补充:
* fetchDesignList / createDesign / updateDesign / deleteDesign
* 设计数据(贴纸、掩膜、分类信息)以 JSON 方式随 items/designData 提交。
*/
export {}
+11
View File
@@ -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'
+7
View File
@@ -0,0 +1,7 @@
/**
* R3 订单接口归属文件。
* 后端 /api/orders 就绪后,在这里补充:
* createOrder / fetchOrders / fetchOrderDetail / payOrder
* 金额一律以服务端重算结果为准,前端只提交商品/设计数据与地址快照。
*/
export {}
+16
View File
@@ -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 })
}
+7
View File
@@ -0,0 +1,7 @@
/**
* R4 上传接口归属文件。
* 后端 COS/wordcloud 适配器就绪后,在这里补充:
* getUploadCredentials / uploadImage / generateWordCloud / getWordCloudJob / sketchImage
* 词云任务使用“创建任务 -> 轮询状态 -> 取结果链接”的异步模型。
*/
export {}
+24
View File
@@ -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')
}
-328
View File
@@ -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)
}
+42
View File
@@ -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)
}
+57
View File
@@ -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)
}
+47
View File
@@ -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'
+35
View File
@@ -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}`
}
+55
View File
@@ -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
}
+33
View File
@@ -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
}
+123
View File
@@ -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
}