diff --git a/docs/微信小程序本地开发协议.md b/docs/微信小程序本地开发协议.md new file mode 100644 index 0000000..718037f --- /dev/null +++ b/docs/微信小程序本地开发协议.md @@ -0,0 +1,95 @@ +# 微信小程序本地开发协议 + +> 适用范围:`wechat_wc` 小程序前端及其本机 Docker 联调。 +> 依据:微信开放文档“小程序开发指南”、页面路由与自定义 tabBar 规范。最后核对:2026-09-16。 + +## 1. 目录与构建边界 + +- 业务源码只修改 `src/`、`config/` 和受版本管理的配置文件;`dist/` 是 Taro 编译产物,禁止手工修补。 +- 每次修改小程序源码后执行 `npm run build:weapp`,微信开发者工具固定打开 `wechat_wc/dist`。 +- `project.config.json`、`app.json` 等由构建生成时,以 `src/app.config.ts` 与项目配置为唯一来源;新增页面、tab 页必须先更新源配置。 +- 联调接口地址只从 `.env`/构建变量读取,不在页面内写死 `127.0.0.1`、域名或密钥。 + +## 2. 页面与路由规范 + +微信将页面组织为页面栈;`navigateTo` 只能打开非 tab 页,`switchTab` 只能切到 tab 页。连续发起的路由会排队,因此一次用户操作只能发起一次导航。 + +| 场景 | 使用方式 | 本项目约束 | +| --- | --- | --- | +| 打开详情、编辑、结算等非 tab 页面 | `Taro.navigateTo` | 由列表项或按钮发起一次,不再在父级触摸结束时二次跳转。 | +| 返回上一页 | `Taro.navigateBack` | 返回页在 `useDidShow` 中刷新可能已变更的数据。 | +| 切换首页、商品、设计清单、订单、我的 | `Taro.switchTab` | 目标必须在 `src/app.config.ts` 的 `tabBar.list` 中。 | +| 替换当前非 tab 页面 | `Taro.redirectTo` | 仅在不应保留返回栈时使用。 | + +- `useEffect(..., [])` 只承担一次初始化;地址、订单、设计清单等从子页返回后可能变更的数据,必须在 `useDidShow` 刷新。 +- 路由路径比较前统一为 `/pages/...` 格式,避免 `pages/...` 与 `/pages/...` 导致选中态误判。 +- 跳转函数必须有明确的唯一职责:设置筛选条件、调用一次路由 API;不得混入重复路由、延时跳转或无关 Toast 控制。 + +## 3. 自定义 tabBar 规范 + +项目使用 `tabBar.custom = true`。微信规范要求仍完整声明 tabBar 页面;自定义组件仅负责渲染,并且每个 tab 页的 tabBar 实例彼此独立。 + +- 选中态唯一来源是当前 tab 页路径;进入、返回或外部 `switchTab` 后都应同步一次。 +- 单击由子 tab 项处理;拖动仅由拖动结束逻辑处理。两者不可同时触发同一次 `switchTab`。 +- 灰色选中遮罩只跟随一个 `activeTab` 状态;普通切换保留 CSS 的 `left` 过渡,拖动期间才临时关闭过渡。 +- 自定义 tabBar 根节点维持可命中(`pointer-events: auto`)及正确层级;不得为了修复一个页面而随意改动全部 tabBar 的视觉样式。 +- 对 tabBar 的任何修改,至少验证:首页 → 我的、我的快捷入口 → 设计清单/订单、拖动切换、返回 tab 页四种路径。 + +## 4. 数据、登录与接口规范 + +- 账户维度的数据(地址、设计、订单)以后端用户 ID 为准;本地 storage 只能用于离线兜底和首屏缓存。 +- 真机联调使用 `WX_MOCK_LOGIN=1` 时,后端使用稳定的本地测试用户;不得用一次性的 `wx.login` code 作为用户标识。 +- 地址、订单、设计列表请求成功后回写对应缓存;请求失败才回退缓存。不要并存两套相互覆盖的业务真相。 +- DTO/接口字段转换只放在 `src/utils/api/` 的 adapter 中。页面层只消费前端类型,避免每个页面各自转换省市区、订单状态或设计状态。 +- 对创建订单等写操作使用 requestId 幂等键;页面按钮在请求期间禁用,防止重复提交。 + +## 5. Toast、Loading 与错误处理 + +- 只关闭当前流程实际开启过的 Loading/Toast。禁止在每次点击前无条件调用 `hideToast()`;没有可关闭 Toast 时会产生运行时告警。 +- 网络失败应保留可理解的用户提示,并在控制台记录原始错误;不能用空白页、静默吞错或无限 loading 替代错误状态。 +- 真机控制台中与微信广告、日志目录等系统组件相关的文件不存在提示,需要与本项目业务报错分开判断;优先处理带有本项目页面、接口或栈信息的错误。 + +## 6. 本机联调与验收 + +1. 启动后端:在 `wxmp_backend` 执行 `docker compose up -d --build`,确认 `http://127.0.0.1:3090/health` 返回 `code: 0`。 +2. 编译前端:在 `wechat_wc` 执行 `npm run build:weapp`。 +3. 微信开发者工具打开 `wechat_wc/dist`,本机调试使用对应的 API 基址;真机调试使用已备案、已配置合法域名的 HTTPS 基址。 +4. 每个涉及导航或状态的改动至少验证:首次进入、返回页面、切换 tab、真机预览四项。 +5. 验收通过后只提交源码与必要配置/文档;不得把偶然生成的 source map、个人配置或密钥提交到仓库。 + +## 7. 修改前检查清单 + +- 该需求属于页面、路由、数据还是视觉?只改对应层。 +- 是否已有公共 API adapter、store 或组件可复用?有则复用,不复制一套逻辑。 +- 是否会影响 tab 页、页面栈或真机数据?会则补充 `useDidShow`/真机验证。 +- 是否能用一次状态更新和一次路由调用完成?能则不要叠加事件、计时器或重复 API。 +- 是否通过 `npm run build:weapp`?未通过不得交付。 + +## 8. 官方资料总索引与使用规则 + +以下八个官方入口已于 2026-09-16 连通性核对成功。它们是开发依据,不复制官网全文到仓库;每次新增微信能力、组件或部署方式时,必须先查对应入口及其下级规范。 + +| 官方资料 | 本项目何时必须查阅 | 当前约束 | +| --- | --- | --- | +| [小程序框架参考文档](https://developers.weixin.qq.com/miniprogram/dev/reference/index) | 改页面生命周期、路由、组件模型、渲染或分包 | 本项目使用 Taro 生成小程序代码,但必须遵守微信页面栈、tab 页和生命周期规则。 | +| [小程序组件参考文档](https://developers.weixin.qq.com/miniprogram/dev/component/) | 新增或修改 `View`、`ScrollView`、`Input`、`Picker`、`Button`、`Image` 等界面组件 | 先确认属性、事件和基础库兼容性;不以 Web DOM 行为作假设。 | +| [小程序 API 参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/) | 调用登录、网络、存储、媒体选择、支付、地址、文件或系统接口 | 所有 `Taro.*` 调用须对应微信 API 能力,并处理 success/fail 和真机差异。 | +| [小程序服务端 API 参考文档](https://developers.weixin.qq.com/miniprogram/dev/server/API/) | 接入真实微信登录、支付、消息、内容安全或服务端凭证 | AppSecret、access_token、支付证书只留在后端环境变量;前端不可保存或转发。 | +| [微信开发者工具参考文档](https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools) | 编译、预览、真机调试、上传、Source Map、性能诊断 | `dist` 为唯一导入目录;提交前至少完成开发者工具编译和真机预览。 | +| [微信云托管参考文档](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloudrun/src/basic/intro) | 评估将 Nest/Docker 后端迁移至微信云托管 | 当前后端仍以 Docker Compose 部署;迁移前必须重新确认镜像、域名、环境变量和网络方案。 | +| [微信云开发参考文档](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloud/basis/getting-started) | 评估云函数、云数据库、云存储或云调用 | 当前项目不混用云开发数据源;若启用,先写迁移方案和接口契约,避免与 PostgreSQL 双写。 | +| [小程序 AI 能力参考文档](https://developers.weixin.qq.com/miniprogram/dev/ai/guide) | 新增微信原生 AI 能力,或替换现有词云生成链路 | 现有词云走自建后端契约;切换前必须评估权限、资费、数据安全和失败降级。 | + +### 资料使用的最小闭环 + +1. 先在表中确定本次改动对应的官方资料。 +2. 只查与当前能力直接相关的下级页面、接口参数和基础库限制,避免整份文档复制或无目的阅读。 +3. 将结论落到一个公共 adapter、组件或配置文件;不要让每个页面自行实现同一套兼容逻辑。 +4. 在开发者工具和真机上验证后,才将“已支持”写入项目文档。 + +### 当前已直接采用的官方规则 + +- 页面栈与 `switchTab`/`navigateTo` 的目标限制。 +- `onShow`/`useDidShow` 用于从子页返回后的数据刷新。 +- 自定义 tabBar 必须完整声明 tab 页,并由组件维护选中态。 +- 真机调试与合法域名、服务端密钥隔离原则。 diff --git a/src/custom-tab-bar/index.config.ts b/src/custom-tab-bar/index.config.ts new file mode 100644 index 0000000..98842bd --- /dev/null +++ b/src/custom-tab-bar/index.config.ts @@ -0,0 +1,5 @@ +export default definePageConfig({ + // 自定义 tabBar 本质是小程序组件;明确输出组件配置,确保其独立 wxss 被加载。 + component: true, + styleIsolation: 'isolated' +}) diff --git a/src/custom-tab-bar/index.tsx b/src/custom-tab-bar/index.tsx index 95fe326..0230900 100644 --- a/src/custom-tab-bar/index.tsx +++ b/src/custom-tab-bar/index.tsx @@ -20,7 +20,9 @@ const TAB_CENTER_STEP = 100 / TABS.length const DRAG_THRESHOLD_PX = 12 function matchTab(path: string): string { - const idx = TABS.findIndex((t) => path.endsWith(t.pagePath)) + // 微信路由通常给出 pages/...,配置使用 /pages/...;统一格式后再匹配。 + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const idx = TABS.findIndex((t) => normalizedPath.endsWith(t.pagePath)) return TABS[idx >= 0 ? idx : 0].pagePath } @@ -46,6 +48,7 @@ export default function CustomTabBar() { const dragPercentRef = useRef(null) const startXRef = useRef(0) const barFrameRef = useRef<{ left: number; width: number } | null>(null) + const switchingRef = useRef(false) useEffect(() => { const handler = (t: 'light' | 'dark') => setResolvedTheme(t) @@ -96,8 +99,20 @@ export default function CustomTabBar() { }, [resolvedTheme]) const switchTab = (url: string) => { - setActiveTab(matchTab(url)) - Taro.switchTab({ url }) + const target = matchTab(url) + // 官方路由会将连续请求排队;同一触摸同时触发 onTouchEnd/onTap/onClick 时, + // 只允许首个请求进入队列,避免遮罩来回跳动。 + if (switchingRef.current || target === activeTab) return + switchingRef.current = true + setActiveTab(target) + Taro.switchTab({ + url, + complete: () => { + Taro.nextTick(() => { + switchingRef.current = false + }) + } + }) Taro.eventCenter.trigger('tabBarChange', url) } diff --git a/src/pages/address/index.tsx b/src/pages/address/index.tsx index 240c768..605b435 100644 --- a/src/pages/address/index.tsx +++ b/src/pages/address/index.tsx @@ -96,7 +96,20 @@ export default function AddressPage() { ? updateAddressApi(editing.id, payload) : createAddress(payload) save - .then(() => Taro.showToast({ title: '保存成功', icon: 'success' })) + .then((saved) => { + // 新地址在返回结算页前立即回写按账号隔离的缓存; + // 结算页 onShow 会据此先展示,再以服务端列表校准。 + const current = getAddressList() + const withoutSaved = current.filter(item => item.id !== saved.id) + const next = [saved, ...withoutSaved].map(item => ( + saved.isDefault + ? { ...item, isDefault: item.id === saved.id } + : item + )) + setList(next) + setAddressList(next) + Taro.showToast({ title: '保存成功', icon: 'success' }) + }) .catch(() => { // 服务端不可达时降级本地保存,联网后进入页面会刷新 if (editing) updateAddress(editing.id, payload) diff --git a/src/pages/checkout/index.tsx b/src/pages/checkout/index.tsx index 426b9c5..d82c0b1 100644 --- a/src/pages/checkout/index.tsx +++ b/src/pages/checkout/index.tsx @@ -1,8 +1,8 @@ import { View, Text, Image, Button } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { useState, useEffect } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { useState, useEffect, useCallback } from 'react' import './index.scss' -import { getDesignList, getDefaultAddress, getAddressList, setDesignList, type AddressItem } from '../../utils/store' +import { getDesignList, getAddressList, setAddressList, setDesignList, type AddressItem } from '../../utils/store' import { createDesign, fetchDesign } from '../../utils/api/design' import { fetchAddresses } from '../../utils/api/address' import { createOrder, payOrder } from '../../utils/api/order' @@ -24,6 +24,28 @@ export default function CheckoutPage() { const [serverTotal, setServerTotal] = useState(null) const [submitting, setSubmitting] = useState(false) + // 地址页 navigateBack 后,当前结算页不会重新挂载;因此必须在 onShow 刷新。 + // 先用本地缓存即时更新,再拉接口以服务端数据为准。 + const refreshAddresses = useCallback(async () => { + const apply = (addresses: AddressItem[]) => { + setAddrList(addresses) + setAddress(current => ( + addresses.find(item => item.id === current?.id) + || addresses.find(item => item.isDefault) + || addresses[0] + || null + )) + } + + try { + const addresses = await fetchAddresses() + setAddressList(addresses) + apply(addresses) + } catch { + apply(getAddressList()) + } + }, []) + useEffect(() => { const router = Taro.getCurrentInstance().router const params = router ? router.params : undefined @@ -33,19 +55,19 @@ export default function CheckoutPage() { try { const remote = await fetchDesign(dId) setDesign(remote) - const addresses = await fetchAddresses() - setAddrList(addresses) - setAddress(addresses.find(item => item.isDefault) || addresses[0] || null) } catch { const list = getDesignList() const d = list.find(x => x.id === dId) setDesign(d || null) - setAddress(getDefaultAddress()) - setAddrList(getAddressList()) } + await refreshAddresses() } void load() - }, []) + }, [refreshAddresses]) + + useDidShow(() => { + void refreshAddresses() + }) const handleConfirm = async () => { if (submitting) return diff --git a/src/pages/designList/index.scss b/src/pages/designList/index.scss index fe411ca..592121c 100644 --- a/src/pages/designList/index.scss +++ b/src/pages/designList/index.scss @@ -136,10 +136,11 @@ } .design-icon-img { - width: 48rpx; - height: 48rpx; + // 与订单列表的商品图保持同一规格,避免实拍图被压成图标大小。 + width: 96rpx; + height: 96rpx; flex-shrink: 0; - border-radius: 12rpx; + border-radius: 24rpx; background: var(--bg-input); } diff --git a/src/pages/designList/index.tsx b/src/pages/designList/index.tsx index 20cdc0b..7503f5c 100644 --- a/src/pages/designList/index.tsx +++ b/src/pages/designList/index.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react' import './index.scss' import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store' import { fetchDesignList, deleteDesigns } from '../../utils/api/design' -import { PRODUCT_ICON_MAP } from '../../utils/productConfig' +import { getProductById, PRODUCT_ICON_MAP } from '../../utils/productConfig' import { assetUrl } from '../../utils/asset' import { useThemeContext } from '../../context/ThemeContext' import { useSafeArea } from '../../hooks/useSafeArea' @@ -15,17 +15,14 @@ import ScrollTopMask from '../../components/ScrollTopMask' const STATUS_TABS = [ { code: 'all', label: '全部' }, - { code: 'toDesign', label: '待设计' }, - { code: 'undesigned', label: '未设计' }, + { code: 'undesigned', label: '待设计' }, { code: 'designing', label: '设计中' }, { code: 'processing', label: '生产中' }, { code: 'ordered', label: '已下单' } ] -const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned') - const STATUS_STYLE: Record = { - undesigned: { label: '未设计', cls: 'badge-pink' }, + undesigned: { label: '待设计', cls: 'badge-pink' }, designing: { label: '设计中', cls: 'badge-blue' }, processing: { label: '生产中', cls: 'badge-warning' }, ordered: { label: '已下单', cls: 'badge-green' } @@ -73,11 +70,10 @@ export default function DesignListPage() { init() }, [init]) + // “全部”用于汇总;其余标签与状态一一对应,单条设计只会落在一个栏位。 const filtered = activeTab === 'all' ? list - : activeTab === 'toDesign' - ? list.filter(d => d.status === 'undesigned' || d.status === 'designing') - : list.filter(d => d.status === activeTab) + : list.filter(d => d.status === activeTab) const goDesign = (item: DesignItem) => { if (item.status === 'ordered') { @@ -145,7 +141,7 @@ export default function DesignListPage() { {/* 状态筛选 */} - {VISIBLE_STATUS_TABS.map(tab => ( + {STATUS_TABS.map(tab => ( )} - + {item.productName} diff --git a/src/pages/orders/index.scss b/src/pages/orders/index.scss index e31c954..6d01a47 100644 --- a/src/pages/orders/index.scss +++ b/src/pages/orders/index.scss @@ -124,11 +124,12 @@ align-items: center; justify-content: center; flex-shrink: 0; + overflow: hidden; } .orders-page .order-icon-img { - width: 56rpx; - height: 56rpx; + width: 100%; + height: 100%; } .orders-page .order-info { diff --git a/src/pages/orders/index.tsx b/src/pages/orders/index.tsx index 34cd6b8..d905b54 100644 --- a/src/pages/orders/index.tsx +++ b/src/pages/orders/index.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react' import './index.scss' import { getOrderList, type OrderItem } from '../../utils/store' import { fetchOrders, payOrder, cancelOrder, confirmOrder, type ServerOrder } from '../../utils/api/order' -import { getProductIconImg } from '../../utils/productConfig' +import { getProductById, getProductIconImg } from '../../utils/productConfig' import { assetUrl } from '../../utils/asset' import { useThemeContext } from '../../context/ThemeContext' import { useSafeArea } from '../../hooks/useSafeArea' @@ -42,7 +42,8 @@ export default function OrdersPage() { const mapOrder = (order: ServerOrder): OrderItem => { const first = order.items[0] const statusCode = order.status === 'PENDING' ? 'pending' : order.status === 'PAID' || order.status === 'PROCESSING' ? 'paid' : order.status === 'SHIPPED' ? 'shipping' : order.status === 'COMPLETED' ? 'done' : order.status === 'PAYMENT_EXPIRED' ? 'expired' : 'cancelled' - return { id: order.id, productName: first?.name || '定制商品', productIcon: getProductIconImg({ id: first?.productId }), statusCode, date: new Date(order.createdAt).toLocaleString('zh-CN'), price: `¥${Number(order.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: order.orderNo, paymentExpiresAt: order.paymentExpiresAt } + const productPhoto = getProductById(first?.productId || '')?.images?.[0] + return { id: order.id, productName: first?.name || '定制商品', productIcon: productPhoto || getProductIconImg({ id: first?.productId }), statusCode, date: new Date(order.createdAt).toLocaleString('zh-CN'), price: `¥${Number(order.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: order.orderNo, paymentExpiresAt: order.paymentExpiresAt } } const load = async () => { try { const remote = await fetchOrders({ page: 1, pageSize: 100 }); setOrders(remote.list.map(mapOrder)) } catch { setOrders(getOrderList()) } @@ -149,7 +150,7 @@ export default function OrdersPage() { - + {order.productName} diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index ab72cc9..e73dfed 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -219,15 +219,16 @@ export default function ProfilePage() { } const handleQuickClick = (type: string) => { - Taro.hideToast() switch (type) { case 'toDesign': - Taro.setStorageSync('designList:filter', 'toDesign') + Taro.setStorageSync('designList:filter', 'undesigned') Taro.switchTab({ url: '/pages/designList/index' }) + Taro.eventCenter.trigger('tabBarChange', '/pages/designList/index') break case 'pending': Taro.setStorageSync('orders:filter', 'pending') Taro.switchTab({ url: '/pages/orders/index' }) + Taro.eventCenter.trigger('tabBarChange', '/pages/orders/index') break case 'paid': Taro.setStorageSync('orders:filter', 'paid')