feat(r2): 阶段3 页面数据源切换到服务端 API(失败降级本地缓存)

- designList:列表/批量删除走 API,新增生产中(processing)徽标与筛选 tab
- address:增删改查走 API,默认唯一性由服务端事务保证
- product:加入清单走 createDesign(服务端 DRAFT)
- diy:进入即创建清单条目;保存时 category 裁剪 {id,mask,tone}、
  designData 与服务端数据合并(保护 wordcloud 分组,WCD 红线)、
  贴纸经 persistDesignMedia 持久化后整包提交(状态→designing)
- stickerEdit:贴纸编辑合并后整包提交
- types:DesignDataV1.category 收窄为契约 §2 冻结形状 {id, mask, tone}
- 全部写入点:API 成功回写本地缓存(checkout 等未迁移页面不受影响)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-12 03:51:46 +08:00
co-authored by Claude
parent fa434f765f
commit e557dbcb89
6 changed files with 155 additions and 57 deletions
+30 -13
View File
@@ -2,7 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { getAddressList, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
import type { AddressItem } from '../../types'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -26,7 +27,15 @@ export default function AddressPage() {
const [detail, setDetail] = useState('')
const [isDefault, setIsDefault] = useState(false)
const load = () => setList(getAddressList())
const load = () => {
// API 优先,成功后回写本地缓存;失败降级读缓存
fetchAddresses()
.then(data => {
setList(data)
setAddressList(data)
})
.catch(() => setList(getAddressList()))
}
useEffect(() => {
load()
@@ -62,8 +71,9 @@ export default function AddressPage() {
content: '删除后将无法恢复该地址',
success: (res) => {
if (res.confirm) {
deleteAddress(id)
load()
deleteAddressApi(id)
.catch(() => deleteAddress(id)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -80,15 +90,22 @@ export default function AddressPage() {
return
}
const payload = { name, phone, region, detail, isDefault }
if (editing) {
updateAddress(editing.id, payload)
} else {
addAddress(payload)
}
Taro.showToast({ title: '保存成功', icon: 'success' })
setShowForm(false)
resetForm()
load()
const save = editing
? updateAddressApi(editing.id, payload)
: createAddress(payload)
save
.then(() => Taro.showToast({ title: '保存成功', icon: 'success' }))
.catch(() => {
// 服务端不可达时降级本地保存,联网后进入页面会刷新
if (editing) updateAddress(editing.id, payload)
else addAddress(payload)
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
})
.finally(() => {
setShowForm(false)
resetForm()
load()
})
}
const onRegionChange = (e: any) => {
+16 -5
View File
@@ -2,7 +2,8 @@ import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback } from 'react'
import './index.scss'
import { getDesignList, removeDesigns, type DesignItem } from '../../utils/store'
import { getDesignList, setDesignList, removeDesigns, type DesignItem } from '../../utils/store'
import { fetchDesignList, deleteDesigns } from '../../utils/api'
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
@@ -17,6 +18,7 @@ const STATUS_TABS = [
{ code: 'toDesign', label: '待设计' },
{ code: 'undesigned', label: '未设计' },
{ code: 'designing', label: '设计中' },
{ code: 'processing', label: '生产中' },
{ code: 'ordered', label: '已下单' }
]
@@ -25,6 +27,7 @@ const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned')
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
undesigned: { label: '未设计', cls: 'badge-pink' },
designing: { label: '设计中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-warning' },
ordered: { label: '已下单', cls: 'badge-green' }
}
@@ -38,7 +41,13 @@ export default function DesignListPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const load = useCallback(() => {
setList(getDesignList())
// API 优先,成功后回写本地缓存(checkout 等页面仍读缓存);失败降级读缓存
fetchDesignList()
.then(data => {
setList(data)
setDesignList(data)
})
.catch(() => setList(getDesignList()))
}, [])
const init = useCallback(() => {
@@ -100,10 +109,12 @@ export default function DesignListPage() {
content: `确定删除选中的 ${selected.size} 个条目吗?`,
success: (res) => {
if (res.confirm) {
removeDesigns(Array.from(selected))
const ids = Array.from(selected)
setSelected(new Set())
setManaging(false)
load()
deleteDesigns(ids)
.catch(() => removeDesigns(ids)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -164,7 +175,7 @@ export default function DesignListPage() {
{/* 列表 */}
<ScrollView className='design-list' scrollY>
{filtered.map(item => {
const style = STATUS_STYLE[item.status]
const style = STATUS_STYLE[item.status] || STATUS_STYLE.undesigned
const rawIcon = (item.productIcon || '').startsWith('/icon/') || /^https?:/i.test(item.productIcon || '')
? item.productIcon
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
+64 -30
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api'
import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -27,6 +27,34 @@ export default function DIYPage() {
const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = useState(false)
/** 进入 DIY 即创建清单条目:服务端创建(DRAFT,阶段0 决策#6),失败降级本地 */
const createDesignEntry = (product: { id: string; name: string; price: number; icon: string }, count: number) => {
createDesign({
productId: product.id,
productName: product.name,
unitPrice: product.price,
count
})
.then(item => {
setDesignList([item, ...getDesignList()]) // 回写缓存,checkout 等页面仍读缓存
setDesignId(item.id)
})
.catch(() => {
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count,
status: 'undesigned',
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...getDesignList(), newDesign])
setDesignId(newDesign.id)
})
}
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
@@ -37,20 +65,9 @@ export default function DIYPage() {
const product = getProductById(productId)
if (product) {
setCategory(product)
setQuantity(Number(params ? params.quantity : undefined) || 1)
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count: Number(params ? params.quantity : undefined) || 1,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
const list = getDesignList()
setDesignList([...list, newDesign])
setDesignId(newDesign.id)
const count = Number(params ? params.quantity : undefined) || 1
setQuantity(count)
createDesignEntry(product, count)
}
return
}
@@ -89,19 +106,7 @@ export default function DIYPage() {
const found = getProductById(productId)
if (found) {
setCategory(found)
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: found.id,
productName: found.name,
productIcon: found.icon,
unitPrice: found.price,
count: 1,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
const list = getDesignList()
setDesignList([...list, newDesign])
setDesignId(newDesign.id)
createDesignEntry(found, 1)
}
}
}, [])
@@ -247,10 +252,39 @@ export default function DIYPage() {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId) {
if (designId && category) {
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 })
updateDesign(designId, { designData: data })
// category 裁剪为 {id, mask, tone}(阶段0 决策#2;契约约束#3:mask 必须保留)
const trimmedCategory = {
id: category.id,
mask: category.mask,
...(category.tone ? { tone: category.tone } : {})
}
// WCD 红线:基于服务端已有 designData 合并(保留 R4 写入的 wordcloud 分组等),只覆盖本页编辑的字段
const cached = getDesignList().find(d => d.id === designId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: trimmedCategory,
stickers: data.stickers || stickers
}
// 本地缓存同步(checkout 仍读缓存)
updateDesign(designId, { status: 'designing', designData })
try {
await updateDesignApi(designId, {
status: 'designing',
item: {
productId: category.id,
productName: category.name,
unitPrice: category.price,
count: quantity,
designData
}
})
} catch {
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
}
}
setPreviewMode(false)
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
+15 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import './index.scss'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { sketchImage } from '../../../utils/api'
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -232,7 +232,20 @@ export default function StickerEditPage() {
src: newSrc,
edits: { brightness, hue, contrast }
}
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
const d = dList[dIdx]
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
const designData = { ...d.designData, stickers }
updateDesign(designId, { designData }) // 本地缓存同步
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
updateDesignApi(designId, {
item: {
productId: d.productId,
productName: d.productName,
unitPrice: d.unitPrice,
count: d.count,
designData
}
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
},
+21 -5
View File
@@ -3,7 +3,8 @@ import Taro from '@tarojs/taro'
import { useState } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { addDesign } from '../../utils/store'
import { addDesign, getDesignList, setDesignList } from '../../utils/store'
import { createDesign } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -60,10 +61,25 @@ export default function ProductPage() {
}
const confirmAdd = () => {
addDesign(product, quantity)
setShowModal(false)
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1200)
// 写服务端(需登录,创建即 DRAFT),成功后回写本地缓存;失败降级本地
createDesign({
productId: product.id,
productName: product.name,
unitPrice: product.price,
count: quantity
})
.then(item => {
setDesignList([item, ...getDesignList()])
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
})
.catch(() => {
addDesign(product, quantity)
Taro.showToast({ title: `网络不可用,已加入本地清单 x${quantity}`, icon: 'none' })
})
.finally(() => {
setShowModal(false)
setTimeout(() => Taro.navigateBack(), 1200)
})
}
const goToDIY = () => {
+9 -2
View File
@@ -77,8 +77,15 @@ export interface StickerItem {
export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */
version?: 1
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
category?: ProductCategory
/**
* 商品品类(契约 §2 冻结:仅 {id, mask, tone}DIY 保存时裁剪);
* mask 是画布尺寸来源,说明见 docs/mask-config-guide.md
*/
category?: {
id: string
mask: MaskConfig
tone?: [number, number, number]
}
/** 底图(持久 URL),WCD 打包的画布底 */
background?: {
src: string