feat(r3-order-pay): 结算与订单支付流程、支付倒计时/商品图组件,接口地址改为构建期注入

- 新增 PaymentCountdown、ProductImage 组件
- 完善 checkout/orders/orderDetail 订单支付链路与地址、商品、设计数据
- request 接口地址改为构建期注入并保留真实网络错误,默认兜底线上地址
- 同步重新构建 dist 产物
This commit is contained in:
fenjoyoung
2026-09-13 14:50:39 +08:00
parent a13e380009
commit 8fdf0b1b4f
50 changed files with 895 additions and 777 deletions
+10
View File
@@ -1,5 +1,14 @@
const path = require('path')
require('dotenv').config({ path: path.resolve(__dirname, '../.env') })
// Taro 构建配置运行在 Node 中;把小程序运行时需要的地址在构建期注入,
// 避免把 process.env 原样带入微信小程序运行时(小程序没有 Node 的 process)。
// dev:weapp 时 NODE_ENV=development,默认读 .env.development 走本地联调;
// build:weapp 时 NODE_ENV=production,默认读 .env.production/.env 走线上。
// 仍可用 TARO_APP_ENV 显式覆盖。默认必须安全(线上),避免队友构建出连本机的包。
const appEnv = process.env.TARO_APP_ENV || (process.env.NODE_ENV === 'development' ? 'development' : 'production')
const envName = appEnv === 'production' ? '.env.production' : '.env.development'
// 环境专用文件必须覆盖 .env 中的默认值,否则本地构建会误用线上 API 地址。
require('dotenv').config({ path: path.resolve(__dirname, `../${envName}`), override: true })
const config = {
projectName: 'smart-engraving-miniapp',
date: '2026-7-20',
@@ -14,6 +23,7 @@ const config = {
plugins: [],
defineConstants: {
__OSS_BASE_URL__: JSON.stringify(process.env.OSS_BASE_URL || ''),
__API_BASE_URL__: JSON.stringify(process.env.TARO_APP_API_BASE_URL || 'https://wxbackend.tokenleaping.com'),
},
copy: {
patterns: [
+8
View File
@@ -133,6 +133,14 @@
</image>
</template>
<template name="tmpl_0_15">
<canvas canvas-id="{{i.p0}}" disable-scroll="{{xs.b(i.p1,!1)}}" binderror="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongtap="eh" type="{{i.p2}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</canvas>
</template>
<template name="tmpl_0_8">
<block>{{i.v}}</block>
</template>
+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
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
+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([[205],{6296:function(s,e,a){var c=a(7842),n=a(118),r=a(758),i=a.n(r),o=a(9703),t=a(9201),l=a(5414),d=a(9674),h=a(3053),p=a(5234),m=a(4848);function x(){var s=(0,o.wR)(),e=s.resolvedTheme,a=(0,t.c)();(0,l.l)(e);var c=function(s){i().navigateTo({url:"/pages/shop/detail/index?id=".concat(s)})};return(0,m.jsxs)(n.Ss,{className:"shop-page theme-".concat(e),children:[(0,m.jsx)(d.A,{}),(0,m.jsx)(h.A,{title:"\u667a\u7ed8\u7cbe\u9009",targetSelector:".shop-header"}),(0,m.jsxs)(n.Ss,{className:"shop-list",children:[(0,m.jsxs)(n.Ss,{className:"shop-header",style:{paddingTop:"".concat(a.statusBarHeight+12,"px")},children:[(0,m.jsx)(n.EY,{className:"shop-header-title",children:"\u667a\u7ed8\u7cbe\u9009"}),(0,m.jsx)(n.EY,{className:"shop-header-sub",children:"\u6fc0\u5149\u5fae\u96d5\u5b9a\u5236\u597d\u7269"})]}),(0,m.jsx)(n.Ss,{className:"shop-grid",children:p.zv.map(function(s){var e=s.images[0]||s.iconImg||"";return(0,m.jsxs)(n.Ss,{className:"shop-card",onTap:function(){return c(s.id)},children:[(0,m.jsx)(n.Ss,{className:"shop-card-img-wrap",children:(0,m.jsx)(n._V,{className:"shop-card-img",src:e,mode:"aspectFill",lazyLoad:!0})}),(0,m.jsxs)(n.Ss,{className:"shop-card-body",children:[(0,m.jsx)(n.EY,{className:"shop-card-name",children:s.name}),(0,m.jsx)(n.EY,{className:"shop-card-desc",children:s.desc||""}),(0,m.jsxs)(n.EY,{className:"shop-card-price",children:["\xa5",s.price]})]})]},s.id)})})]})]})}var u={navigationStyle:"custom",enablePageMeta:!0};Page((0,c.createPageConfig)(x,"pages/shop/index",{root:{cn:[]}},u||{}))}},function(s){var e=function(e){return s(s.s=e)};s.O(0,[907,96,76],function(){return e(6296)});s.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[205],{6296:function(s,e,a){var c=a(7842),n=a(5544),r=a(118),i=a(758),t=a.n(i),o=a(6540),l=a(9703),h=a(9201),d=a(5414),p=a(9674),u=a(3053),m=a(5234),x=a(2371),g=a(1133),j=a(4848);function f(){var s=(0,l.wR)(),e=s.resolvedTheme,a=(0,h.c)();(0,d.l)(e);var c=(0,o.useState)(m.zv),i=(0,n.A)(c,2),f=i[0],N=i[1],S=(0,o.useState)(!1),v=(0,n.A)(S,2),b=v[0],E=v[1];(0,o.useEffect)(function(){(0,x.j0)({page:1,pageSize:100}).then(function(s){N(s.list),E(!1)}).catch(function(){return E(!0)})},[]);var w=function(s){t().navigateTo({url:"/pages/shop/detail/index?id=".concat(s)})};return(0,j.jsxs)(r.Ss,{className:"shop-page theme-".concat(e),children:[(0,j.jsx)(p.A,{}),(0,j.jsx)(u.A,{title:"\u667a\u7ed8\u7cbe\u9009",targetSelector:".shop-header"}),(0,j.jsxs)(r.Ss,{className:"shop-list",children:[(0,j.jsxs)(r.Ss,{className:"shop-header",style:{paddingTop:"".concat(a.statusBarHeight+12,"px")},children:[(0,j.jsx)(r.EY,{className:"shop-header-title",children:"\u667a\u7ed8\u7cbe\u9009"}),(0,j.jsx)(r.EY,{className:"shop-header-sub",children:"\u6fc0\u5149\u5fae\u96d5\u5b9a\u5236\u597d\u7269"}),b&&(0,j.jsx)(r.EY,{className:"shop-header-sub",children:"\u7f51\u7edc\u4e0d\u53ef\u7528\uff0c\u5f53\u524d\u663e\u793a\u672c\u5730\u7f13\u5b58"})]}),(0,j.jsx)(r.Ss,{className:"shop-grid",children:f.map(function(s){var e=s.images[0]||s.iconImg||"";return(0,j.jsxs)(r.Ss,{className:"shop-card",onTap:function(){return w(s.id)},children:[(0,j.jsx)(r.Ss,{className:"shop-card-img-wrap",children:(0,j.jsx)(g.A,{className:"shop-card-img",src:e,fallback:s.iconImg||s.icon,mode:"aspectFill",lazyLoad:!0})}),(0,j.jsxs)(r.Ss,{className:"shop-card-body",children:[(0,j.jsx)(r.EY,{className:"shop-card-name",children:s.name}),(0,j.jsx)(r.EY,{className:"shop-card-desc",children:s.desc||""}),(0,j.jsxs)(r.EY,{className:"shop-card-price",children:["\xa5",s.price]})]})]},s.id)})})]})]})}var N={navigationStyle:"custom",enablePageMeta:!0};Page((0,c.createPageConfig)(f,"pages/shop/index",{root:{cn:[]}},N||{}))}},function(s){var e=function(e){return s(s.s=e)};s.O(0,[907,96,76],function(){return e(6296)});s.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
-8
View File
@@ -82,14 +82,6 @@ interface DesignDataV1 {
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`
WCD 打包器同时兜底读旧字段。
> **实现注记(2026-09-12DIY 修正,非版本升级)**`stickers[].width/height` 的语义为
> **画布显示像素**(坐标空间 = `category.mask` 的宽高),`x/y` 使用画布坐标空间
> (原点 = mask 左上角;**允许超出画布边界**——2026-09-12 需求确认:贴纸可拖出画布
> 自由摆放,渲染端裁切显示,WCD 打包按原值还原,不做钳制)。
> 渲染、碰撞检测、WCD 打包三方按同一语义消费,打包时不再做像素换算。
> 历史数据中按「图片原始像素」写入的值(width > mask 宽高)由读取端按 mask 等比归一化兼容,
> 不做存量迁移。`addSticker` 时即归一化到画布空间(建议初始占 mask 短边 60%)。
## 3. 冻结约束(R2 需落地)
| # | 约束 | 说明 |
-4
View File
@@ -11,10 +11,6 @@
| [R3 订单 + 支付占位](route-r3-order-pay.md) | `feat/r3-order-pay` | 服务端下单、金额重算、订单状态机、支付未配置占位 |
| [R4 上传 + 词云 + 线稿](route-r4-upload-wordcloud.md) | `feat/r4-upload-wordcloud` | COS 上传、wordcloud 契约冻结、sketch、任务轮询 |
**已收口路线的交接说明**(后续线路接入前必读):
- [R2 交接收口说明](R2/README.md)——R3/R4/R1 接入服务端化地址与设计清单前的事实、红线与待办。
## 合并顺序与依赖
- R1 是交易链路的前置,优先合入。
+42
View File
@@ -0,0 +1,42 @@
import { Text } from '@tarojs/components'
import { useEffect, useRef, useState } from 'react'
interface PaymentCountdownProps {
deadline?: string | null
className?: string
onExpired?: () => void
}
const remainingSeconds = (deadline?: string | null) => {
if (!deadline) return null
const timestamp = new Date(deadline).getTime()
if (Number.isNaN(timestamp)) return null
return Math.max(0, Math.ceil((timestamp - Date.now()) / 1000))
}
/** 待付款订单的显示倒计时;支付资格仍由服务端截止时间最终校验。 */
export default function PaymentCountdown({ deadline, className, onExpired }: PaymentCountdownProps) {
const [seconds, setSeconds] = useState<number | null>(() => remainingSeconds(deadline))
const notified = useRef(false)
useEffect(() => {
notified.current = false
const tick = () => setSeconds(remainingSeconds(deadline))
tick()
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
}, [deadline])
useEffect(() => {
if (seconds === 0 && !notified.current) {
notified.current = true
onExpired?.()
}
}, [onExpired, seconds])
if (seconds === null) return null
if (seconds === 0) return <Text className={className}></Text>
const minutes = Math.floor(seconds / 60)
const remaining = seconds % 60
return <Text className={className}> {String(minutes).padStart(2, '0')}:{String(remaining).padStart(2, '0')}</Text>
}
+46
View File
@@ -0,0 +1,46 @@
import { Image } from '@tarojs/components'
import { useEffect, useState } from 'react'
import { assetUrl } from '../../utils/asset'
interface ProductImageProps {
src?: string | null
fallback?: string | null
className?: string
mode?: any
style?: any
lazyLoad?: boolean
}
/**
* 商品图优先显示 OSS 图片;资源桶超时或图片不存在时退回小程序包内图标,
* 避免首页、商城和详情页出现空白图片区域。
*/
export default function ProductImage({
src,
fallback = '/icon/四角星.svg',
className,
mode = 'aspectFit',
style,
lazyLoad = false,
}: ProductImageProps) {
const primary = assetUrl(src)
const fallbackSrc = assetUrl(fallback)
const [displaySrc, setDisplaySrc] = useState(primary || fallbackSrc)
useEffect(() => {
setDisplaySrc(primary || fallbackSrc)
}, [primary, fallbackSrc])
return (
<Image
className={className}
src={displaySrc}
mode={mode}
style={style}
lazyLoad={lazyLoad}
onError={() => {
if (displaySrc !== fallbackSrc) setDisplaySrc(fallbackSrc)
}}
/>
)
}
+27 -32
View File
@@ -2,8 +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, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
import { getAddressList, addAddress, updateAddress as updateLocalAddress, deleteAddress as deleteLocalAddress, setAddressList } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress, deleteAddress, setDefaultAddress } from '../../utils/api/address'
import type { AddressItem } from '../../types'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -27,16 +27,14 @@ export default function AddressPage() {
const [detail, setDetail] = useState('')
const [isDefault, setIsDefault] = useState(false)
const load = () => {
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getAddressList())
fetchAddresses()
.then(data => {
setList(data)
setAddressList(data)
})
.catch(() => setList(getAddressList()))
const load = async () => {
try {
const remote = await fetchAddresses()
setList(remote)
setAddressList(remote)
} catch {
setList(getAddressList())
}
}
useEffect(() => {
@@ -67,15 +65,17 @@ export default function AddressPage() {
setShowForm(true)
}
const handleSetDefault = async (id: string) => {
try { const next = await setDefaultAddress(id); setList(prev => prev.map(item => ({ ...item, isDefault: item.id === next.id }))); Taro.showToast({ title: '已设为默认', icon: 'success' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '设置失败', icon: 'none' }) }
}
const handleDelete = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '删除后将无法恢复该地址',
success: (res) => {
if (res.confirm) {
deleteAddressApi(id)
.catch(() => deleteAddress(id)) // 失败降级本地删除
.finally(load)
deleteAddress(id).then(() => load()).catch(error => { deleteLocalAddress(id); load(); Taro.showToast({ title: (error as Error).message || '删除失败', icon: 'none' }) })
}
}
})
@@ -86,28 +86,22 @@ export default function AddressPage() {
Taro.setClipboardData({ data: text })
}
const handleSave = () => {
const handleSave = async () => {
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
const payload = { name, phone, region, detail, isDefault }
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()
})
try {
const saved = editing ? await updateAddress(editing.id, payload) : await createAddress(payload)
const next = editing ? list.map(item => item.id === saved.id ? saved : item) : [saved, ...list]
setList(next); setAddressList(next)
Taro.showToast({ title: '保存成功', icon: 'success' }); setShowForm(false); resetForm()
} catch (error) {
// API 失败时保留原有本地兜底,避免离线编辑丢失。
if (editing) updateLocalAddress(editing.id, payload); else addAddress(payload)
Taro.showToast({ title: (error as Error).message || '保存失败,已暂存本地', icon: 'none' }); setShowForm(false); resetForm(); load()
}
}
const onRegionChange = (e: any) => {
@@ -149,6 +143,7 @@ export default function AddressPage() {
<View className='address-actions'>
<Text className='address-act' onTap={() => openEdit(item)}></Text>
<Text className='address-act' onTap={() => handleCopy(item)}></Text>
{!item.isDefault && <Text className='address-act' onTap={() => handleSetDefault(item.id)}></Text>}
<Text className='address-act delete' onTap={() => handleDelete(item.id)}></Text>
</View>
</View>
+62 -16
View File
@@ -2,7 +2,10 @@ import { View, Text, Image, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getDesignList, designToOrder, getDefaultAddress, getAddressList, type AddressItem } from '../../utils/store'
import { getDesignList, getDefaultAddress, getAddressList, 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'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -18,20 +21,34 @@ export default function CheckoutPage() {
const [address, setAddress] = useState<AddressItem | null>(null)
const [showAddrPicker, setShowAddrPicker] = useState(false)
const [addrList, setAddrList] = useState<AddressItem[]>([])
const [serverTotal, setServerTotal] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
const dId = params ? params.designId : undefined
if (!dId) return
const list = getDesignList()
const d = list.find(x => x.id === dId)
setDesign(d || null)
setAddress(getDefaultAddress())
setAddrList(getAddressList())
const load = async () => {
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())
}
}
void load()
}, [])
const handleConfirm = () => {
const handleConfirm = async () => {
if (submitting) return
if (!address) {
Taro.showToast({ title: '请先添加收货地址', icon: 'none' })
return
@@ -39,13 +56,42 @@ export default function CheckoutPage() {
Taro.showModal({
title: '确认下单',
content: `确认后将从设计清单生成订单,并寄送至:\n${formatAddress(address.region, address.detail)}`,
success: (res) => {
success: async (res) => {
if (res.confirm) {
designToOrder(design.id)
Taro.showToast({ title: '下单成功', icon: 'success' })
setTimeout(() => {
Taro.switchTab({ url: '/pages/index/index' })
}, 1500)
setSubmitting(true)
try {
// 直购在极短的网络抖动下可能仍保留本地 DSG... 草稿。
// 下单前补建服务端设计清单,确保永远不会把临时 id 传给订单接口。
let designListId = design.id
if (String(designListId).startsWith('DSG')) {
const saved = await createDesign({
productId: design.productId,
productName: design.productName,
unitPrice: design.unitPrice,
count: design.count,
designData: design.designData,
})
setDesignList(getDesignList().map(item => item.id === design.id ? saved : item))
setDesign(saved)
designListId = saved.id
}
const order = await createOrder({
designListId,
addressId: address.id,
requestId: `checkout-${designListId}-${Date.now()}`,
items: [{ productId: design.productId, quantity: design.count }],
})
setServerTotal(order.totalAmount)
const payment = await payOrder(order.id)
if (!payment.configured) {
Taro.showModal({ title: '订单已创建', content: '当前暂不支持微信支付,订单已保留在待付款。', showCancel: false, success: () => Taro.switchTab({ url: '/pages/orders/index' }) })
} else {
Taro.showToast({ title: '订单已创建', icon: 'success' })
Taro.switchTab({ url: '/pages/orders/index' })
}
} catch (error) {
Taro.showToast({ title: (error as Error).message || '下单失败,请重试', icon: 'none' })
} finally { setSubmitting(false) }
}
}
})
@@ -156,7 +202,7 @@ export default function CheckoutPage() {
</View>
<View className='info-row total-row'>
<Text className='info-label'></Text>
<Text className='info-total'>¥{(design.unitPrice * design.count).toFixed(2)}</Text>
<Text className='info-total'>¥{(serverTotal ?? design.unitPrice * design.count).toFixed(2)}</Text>
</View>
</View>
@@ -190,8 +236,8 @@ export default function CheckoutPage() {
<View className='btn-secondary' onTap={() => Taro.navigateBack()}>
<Text></Text>
</View>
<View className='btn-primary' onTap={handleConfirm}>
<Text></Text>
<View className={`btn-primary ${submitting ? 'disabled' : ''}`} onTap={handleConfirm}>
<Text>{submitting ? '提交中…' : '确认下单'}</Text>
</View>
</BottomActionBar>
+6 -16
View File
@@ -2,8 +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, setDesignList, removeDesigns, type DesignItem } from '../../utils/store'
import { fetchDesignList, deleteDesigns } from '../../utils/api'
import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store'
import { fetchDesignList, deleteDesigns } from '../../utils/api/design'
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
@@ -27,7 +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' },
processing: { label: '生产中', cls: 'badge-blue' },
ordered: { label: '已下单', cls: 'badge-green' }
}
@@ -41,15 +41,7 @@ export default function DesignListPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const load = useCallback(() => {
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getDesignList())
fetchDesignList()
.then(data => {
setList(data)
setDesignList(data)
})
.catch(() => setList(getDesignList()))
fetchDesignList().then(remote => { setList(remote); setDesignList(remote) }).catch(() => setList(getDesignList()))
}, [])
const init = useCallback(() => {
@@ -112,11 +104,9 @@ export default function DesignListPage() {
success: (res) => {
if (res.confirm) {
const ids = Array.from(selected)
deleteDesigns(ids).then(() => load()).catch(() => removeDesigns(ids))
setSelected(new Set())
setManaging(false)
deleteDesigns(ids)
.catch(() => removeDesigns(ids)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -177,7 +167,7 @@ export default function DesignListPage() {
{/* 列表 */}
<ScrollView className='design-list' scrollY>
{filtered.map(item => {
const style = STATUS_STYLE[item.status] || STATUS_STYLE.undesigned
const style = STATUS_STYLE[item.status]
const rawIcon = (item.productIcon || '').startsWith('/icon/') || /^https?:/i.test(item.productIcon || '')
? item.productIcon
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
-3
View File
@@ -103,8 +103,6 @@
position: absolute;
max-width: 100%;
max-height: 100%;
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
transform-origin: top left;
z-index: 5;
border: 2rpx solid transparent;
border-radius: 8rpx;
@@ -393,7 +391,6 @@
position: absolute;
max-width: 100%;
max-height: 100%;
transform-origin: top left;
z-index: 5;
}
+60 -181
View File
@@ -1,10 +1,11 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useRef } from 'react'
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 { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
import { getDesignList, setDesignList, updateDesign as updateLocalDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api'
import { createDesign, updateDesign as updateRemoteDesign } from '../../utils/api/design'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -26,41 +27,7 @@ export default function DIYPage() {
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = useState(false)
/** 进入 DIY 即创建清单条目,并推进到「设计中」(问题3 修正:进入工作台即 SUBMITTED),失败降级本地 */
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)
return updateDesignApi(item.id, { status: 'designing' })
.then(updated => {
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: updated.status } : d))
})
.catch(() => {
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: 'designing' as const } : d))
})
})
.catch(() => {
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...getDesignList(), newDesign])
setDesignId(newDesign.id)
})
}
const [isCreatingDesign, setIsCreatingDesign] = useState(false)
useEffect(() => {
const router = Taro.getCurrentInstance().router
@@ -68,13 +35,46 @@ export default function DIYPage() {
const source = params ? params.source : undefined
const productId = (params && params.productId) || (params && params.category)
const createDraftForProduct = async (product: any, count: number) => {
const draft: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10),
}
// 先写本地草稿,保证离线时仍能编辑;在线时立即换成服务端真实 id。
setDesignList([...getDesignList(), draft])
setDesignId(draft.id)
setIsCreatingDesign(true)
try {
const saved = await createDesign({
productId: draft.productId,
productName: draft.productName,
unitPrice: draft.unitPrice,
count: draft.count,
designData: draft.designData,
})
setDesignList(getDesignList().map(item => item.id === draft.id ? saved : item))
setDesignId(saved.id)
} catch {
// 保留本地草稿;结算页会在网络恢复后继续走本地兜底。
} finally {
setIsCreatingDesign(false)
}
}
if (source === 'product' && productId) {
const product = getProductById(productId)
if (product) {
setCategory(product)
const count = Number(params ? params.quantity : undefined) || 1
setQuantity(count)
createDesignEntry(product, count)
void createDraftForProduct(product, count)
}
return
}
@@ -88,42 +88,22 @@ export default function DIYPage() {
setQuantity(design.count)
const product = getProductById(design.productId)
if (product) setCategory(product)
const dd = design.designData
const rawStickers = dd ? dd.stickers : undefined
if (rawStickers) {
// 问题1 留意点:旧数据 width/height 为图片原始像素语义,读取端按 mask 等比归一化
//(契约实现注记,不做存量迁移);新语义数据(≤mask 短边 60%)原样通过
const mask = (dd && dd.category && dd.category.mask) || (product ? product.mask : undefined)
const normalized: StickerItem[] = mask
? rawStickers.map(s => {
if (s.width > mask.width || s.height > mask.height) {
const k = (Math.min(mask.width, mask.height) * 0.6) / Math.max(s.width, s.height)
return { ...s, width: Math.round(s.width * k), height: Math.round(s.height * k) }
}
return s
})
: rawStickers
setStickers(normalized)
} else if (dd ? dd.imageSrc : undefined) {
if (design.designData ? design.designData.stickers : undefined) {
setStickers(design.designData.stickers)
} else if (design.designData ? design.designData.imageSrc : undefined) {
// 兼容旧版数据
const s: StickerItem = {
id: 'legacy_' + Date.now(),
src: dd.imageSrc,
x: dd.imagePos ? dd.imagePos.x || 0 : 0,
y: dd.imagePos ? dd.imagePos.y || 0 : 0,
scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
src: design.designData.imageSrc,
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
width: 200,
height: 200,
isOverlapping: false
}
setStickers([s])
}
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing
if (design.status === 'undesigned') {
updateDesign(dId, { status: 'designing' })
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
}
}
return
}
@@ -133,71 +113,11 @@ export default function DIYPage() {
const found = getProductById(productId)
if (found) {
setCategory(found)
createDesignEntry(found, 1)
void createDraftForProduct(found, 1)
}
}
}, [])
// ---------- 草稿持久化(修正工作流 问题2----------
// 贴纸变化防抖 800ms:本地缓存即时同步 + 服务端静默同步;不推状态(SUBMITTED 只由显式动作触发)
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestRef = useRef({ designId: '', quantity: 1, category: null as any, stickers: [] as StickerItem[] })
latestRef.current = { designId, quantity, category, stickers }
const saveDraft = () => {
const { designId: dId, quantity: qty, category: cat, stickers: st } = latestRef.current
if (!dId || !cat) return
// WCD 红线:基于缓存中服务端最新 designData 合并(保留 wordcloud 分组),只覆盖本页编辑字段
const cached = getDesignList().find(d => d.id === dId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: { id: cat.id, mask: cat.mask, ...(cat.tone ? { tone: cat.tone } : {}) },
stickers: st
}
updateDesign(dId, { designData }) // 本地缓存即时同步(问题4:贴纸编辑页从这里读取)
updateDesignApi(dId, {
item: { productId: cat.id, productName: cat.name, unitPrice: cat.price, count: qty, designData }
}).catch(() => {}) // 草稿静默降级:失败仅落缓存
}
const flushDraft = () => {
if (draftTimerRef.current) {
clearTimeout(draftTimerRef.current)
draftTimerRef.current = null
}
saveDraft()
}
// 隐藏页面(切后台/跳转)时强制落盘,防止防抖窗口内的改动丢失
Taro.useDidHide(flushDraft)
// 卸载时同样落盘
useEffect(() => flushDraft, [])
// eslint-disable-next-line react-hooks/exhaustive-deps
const skipDraftRef = useRef(true)
useEffect(() => {
if (skipDraftRef.current) { skipDraftRef.current = false; return }
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
draftTimerRef.current = setTimeout(saveDraft, 800)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stickers])
// 问题5 留意点:页面栈返回(贴纸编辑页保存后 navigateBack)不触发重新挂载,
// 需在 onShow 从缓存同步最新贴纸(src/edits),否则工作台仍渲染旧 state。
// 首次 onShow 跳过——mount 读取端已做过旧数据归一化,避免被未归一化的缓存覆盖。
const skipShowRef = useRef(true)
Taro.useDidShow(() => {
if (skipShowRef.current) { skipShowRef.current = false; return }
const dId = latestRef.current.designId
if (!dId) return
const cached = getDesignList().find(d => d.id === dId)
const list = cached && cached.designData ? cached.designData.stickers : undefined
if (list && list.length) {
setStickers(checkOverlap(list))
}
})
// 矩形碰撞检测
const checkOverlap = (list: StickerItem[]) => {
const newList = list.map(s => ({ ...s, isOverlapping: false }))
@@ -236,17 +156,14 @@ export default function DIYPage() {
Taro.getImageInfo({
src,
success: (info) => {
// 问题1 修正:归一化到画布坐标空间(width/height = 画布显示像素,契约实现注记)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / Math.max(info.width, info.height)
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: Math.round(info.width * k),
height: Math.round(info.height * k),
width: info.width,
height: info.height,
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -255,17 +172,15 @@ export default function DIYPage() {
setActiveStickerId(newSticker.id)
},
fail: () => {
// fallback 尺寸(同样按画布空间归一化)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / 200
// fallback 尺寸
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: Math.round(200 * k),
height: Math.round(200 * k),
width: 200,
height: 200,
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -297,7 +212,6 @@ export default function DIYPage() {
const handleStickerTouchMove = (e: any) => {
if (!isDragging || !activeStickerId) return
const touch = e.touches[0]
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
const next = stickers.map(s => {
if (s.id !== activeStickerId) return s
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
@@ -310,7 +224,6 @@ export default function DIYPage() {
const handleEditSticker = () => {
if (!activeStickerId || !designId) return
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
Taro.navigateTo({
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
})
@@ -325,18 +238,6 @@ export default function DIYPage() {
setStickers(checked)
}
// 问题5 修正:渲染端消费贴纸 edits(亮度/色相/对比度 → CSS filter),
// 与贴纸编辑页同一呈现逻辑;checkout 预览消费 edits 记入 R3 待办
const stickerFilter = (s: StickerItem) => {
const ed = s.edits
if (!ed) return undefined
const b = ed.brightness || 0
const h = ed.hue || 0
const c = ed.contrast || 0
if (b === 0 && h === 0 && c === 0) return undefined
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
}
const goBack = () => {
Taro.navigateBack()
}
@@ -354,42 +255,22 @@ export default function DIYPage() {
}
const handleComplete = async () => {
if (isCreatingDesign) {
Taro.showToast({ title: '正在创建设计,请稍候', icon: 'none' })
return
}
if (hasOverlap) {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId && category) {
if (designId) {
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 })
// 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 })
updateLocalDesign(designId, { designData: data })
try {
await updateDesignApi(designId, {
status: 'designing',
item: {
productId: category.id,
productName: category.name,
unitPrice: category.price,
count: quantity,
designData
}
})
await updateRemoteDesign(designId, { designData: data, status: 'designing' })
} catch {
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
// 离线草稿继续由本地存储承接;线上草稿会在下一次操作时重试同步。
}
}
setPreviewMode(false)
@@ -437,7 +318,6 @@ export default function DIYPage() {
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
zIndex: activeStickerId === s.id ? 10 : 5,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
@@ -523,7 +403,6 @@ export default function DIYPage() {
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
-37
View File
@@ -81,16 +81,6 @@
box-shadow: var(--shadow-card);
}
/* 预览图(Image + CSS filter,替代 Canvas 预览) */
.edit-preview-img {
width: 100%;
height: 480rpx;
background: var(--bg-input);
border-radius: 24rpx;
border: 1rpx solid var(--border-strong);
box-shadow: var(--shadow-card);
}
/* 工具面板 */
.edit-tools {
padding: 24rpx;
@@ -236,30 +226,3 @@
.mt-20 {
margin-top: 24rpx;
}
/* 空态(贴纸未找到) */
.edit-empty {
margin: 24rpx 24rpx 0;
padding: 60rpx 40rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.edit-empty-title {
font-size: 32rpx;
font-weight: 600;
}
.edit-empty-desc {
font-size: 26rpx;
opacity: 0.65;
text-align: center;
}
.edit-empty .btn-primary {
padding: 16rpx 60rpx;
border-radius: 44rpx;
}
+242 -142
View File
@@ -1,9 +1,10 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useRef } from 'react'
import { View, Text, Canvas, Image } from '@tarojs/components'
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
import { useState, useEffect, useRef, useCallback } from 'react'
import './index.scss'
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { sketchImage } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -12,59 +13,55 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
/* ============================================================
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4edits 不进 WCD
document.json,仅 manifest.metasrc 保持原样对 R4 无影响)。
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
============================================================ */
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect */
/** 拖动手势滑块组件 */
interface SliderProps {
id: string
value: number
min: number
max: number
step?: number
onChange: (val: number) => void
showValue?: boolean
format?: (v: number) => string
}
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
const rectRef = useRef<{ left: number; width: number } | null>(null)
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
const trackRef = useRef<any>(null)
const [dragging, setDragging] = useState(false)
const measure = (cb?: () => void) => {
Taro.createSelectorQuery()
.select(`#${id}`)
.boundingClientRect((rect: any) => {
if (rect && rect.width > 0) rectRef.current = { left: rect.left, width: rect.width }
if (cb) cb()
})
.exec()
}
const valueFromX = (clientX: number) => {
const rect = rectRef.current
if (!rect || !rect.width) return value
const computedFromClientX = (clientX: number) => {
if (!trackRef.current) return value
const query = Taro.createSelectorQuery().in(trackRef.current)
// 注意:这里需要取 track 元素的位置信息
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
return clamp(min + (max - min) * ratio, min, max)
let raw = min + (max - min) * ratio
if (step > 0) {
raw = Math.round(raw / step) * step
}
return clamp(raw, min, max)
}
const handleTouchStart = (e: any) => {
setDragging(true)
const touch = e.touches && e.touches[0]
if (!touch) return
measure(() => onChange(valueFromX(touch.clientX)))
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
}
const handleTouchMove = (e: any) => {
if (!dragging) return
const touch = e.touches && e.touches[0]
if (!touch) return
if (!rectRef.current) {
measure(() => onChange(valueFromX(touch.clientX)))
return
}
onChange(valueFromX(touch.clientX))
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
}
const handleTouchEnd = () => {
setDragging(false)
}
const pct = `${((value - min) / (max - min)) * 100}%`
@@ -72,14 +69,14 @@ function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
return (
<View className='slider-wrap'>
<View
id={id}
className='slider-track'
ref={trackRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
onTouchEnd={handleTouchEnd}
>
<View className='slider-fill' style={{ width: pct }} />
<View className='slider-thumb' style={{ left: pct }} />
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
</View>
<Text className='slider-value'>{format ? format(value) : value}</Text>
</View>
@@ -93,15 +90,50 @@ export default function StickerEditPage() {
const [designId, setDesignId] = useState('')
const [stickerId, setStickerId] = useState('')
const [sticker, setSticker] = useState<StickerItem | null>(null)
const [notFound, setNotFound] = useState(false)
// 裁剪/线稿产生的 src 替换(保存时一并落库;取消则丢弃)
const [pendingSrc, setPendingSrc] = useState<string | null>(null)
// 编辑参数
const [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false)
const [canvasReady, setCanvasReady] = useState(false)
const canvasRef = useRef<any>(null)
const ctxRef = useRef<any>(null)
const canvasNodeRef = useRef<any>(null)
const canvasSizeRef = useRef<{width:number, height:number}>({width:0,height:0})
// 使用 nextTick + 重试初始化 Canvas(小程序 2D)
const initCanvas = useCallback((st: StickerItem) => {
if (canvasReady) return
Taro.nextTick(() => {
const query = Taro.createSelectorQuery()
query.select('#editCanvas')
.fields({ node: true, size: true })
.exec((res: any) => {
const resFirst = res && res[0]
const canvas = resFirst ? resFirst.node : undefined
if (!canvas) {
setTimeout(() => initCanvas(st), 300)
return
}
const ctx = canvas.getContext('2d')
let cw = (res[0] && res[0].width) || 300
let ch = (res[0] && res[0].height) || 400
if (cw <= 0) cw = 300
if (ch <= 0) ch = 400
const dpr = Taro.getSystemInfoSync().pixelRatio || 1
canvas.width = Math.round(cw * dpr)
canvas.height = Math.round(ch * dpr)
ctx.scale(dpr, dpr)
canvasNodeRef.current = canvas
ctxRef.current = ctx
canvasSizeRef.current = { width: cw, height: ch }
setCanvasReady(true)
drawSticker(ctx, cw, ch, st)
})
})
}, [canvasReady])
useEffect(() => {
const router = Taro.getCurrentInstance().router
@@ -119,44 +151,96 @@ export default function StickerEditPage() {
setBrightness((st.edits && st.edits.brightness) || 0)
setHue((st.edits && st.edits.hue) || 0)
setContrast((st.edits && st.edits.contrast) || 0)
} else {
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
setNotFound(true)
initCanvas(st)
}
}, [])
}, [initCanvas])
/** canvas 上绘制带滤镜的贴纸 */
const drawSticker = (ctx: any, cw: number, ch: number, st: StickerItem, opts?: { brightness: number; hue: number; contrast: number }) => {
if (!ctx || !canvasNodeRef.current) return
const canvas = canvasNodeRef.current
// 防护:cw/ch 为 0 时容易绘制异常
if (!cw || !ch) return
const imgW = st.width || 200
const imgH = st.height || 200
const img = canvas.createImage()
img.onload = () => {
ctx.clearRect(0, 0, cw, ch)
ctx.save()
// 居中缩放显示(限制最大为画板 90%)
const scale = Math.min(cw / imgW, ch / imgH, 4) * 0.9
const w = imgW * scale
const h = imgH * scale
const x = (cw - w) / 2
const y = (ch - h) / 2
// 应用滤镜(小程序 Canvas 2D 支持 filter 属性 2.16.0+
const b = opts ? opts.brightness : brightness
const hVal = opts ? opts.hue : hue
const c = opts ? opts.contrast : contrast
if (b !== 0 || hVal !== 0 || c !== 0) {
ctx.filter = `brightness(${100 + b}%) hue-rotate(${hVal}deg) contrast(${100 + c}%)`
} else {
ctx.filter = 'none'
}
ctx.drawImage(img, x, y, w, h)
ctx.restore()
}
img.onerror = (err: any) => {
console.error('Canvas load image error:', err, st.src)
Taro.showToast({ title: '图片加载失败', icon: 'none' })
}
if (st.src) {
img.src = st.src
}
}
/** 重新绘制(参数变化时) */
const redraw = () => {
if (!sticker || !ctxRef.current || !canvasNodeRef.current) return
const { width, height } = canvasSizeRef.current
drawSticker(ctxRef.current, width, height, sticker, { brightness, hue, contrast })
}
useEffect(() => {
redraw()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [brightness, hue, contrast])
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
const handleSave = () => {
if (!sticker || !designId) return
setLoading(true)
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
const d = dList[dIdx]
const stickers = (d.designData && d.designData.stickers) || []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
stickers[sIdx] = {
...stickers[sIdx],
...(pendingSrc ? { src: pendingSrc } : {}),
edits: { brightness, hue, contrast }
}
// 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
// 使用 Canvas 2D 导出
// @ts-ignore
Taro.canvasToTempFilePath({
canvas: canvasNodeRef.current,
quality: 0.92,
success: (res: any) => {
const newSrc = res.tempFilePath
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); return }
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); return }
stickers[sIdx] = {
...stickers[sIdx],
src: newSrc,
edits: { brightness, hue, contrast }
}
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
},
fail: () => {
setLoading(false)
Taro.showToast({ title: '保存失败', icon: 'none' })
}
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
setLoading(false)
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
})
}
/** 裁剪:调用微信cropImage */
@@ -174,62 +258,47 @@ export default function StickerEditPage() {
success: (res) => {
const newSticker = { ...sticker, src: res.tempFilePath }
setSticker(newSticker)
setPendingSrc(res.tempFilePath)
// 重绘
setTimeout(() => redraw(), 200)
}
})
}
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
const handleSketch = async () => {
if (!sticker) return
setLoading(true)
try {
const res = await sketchImage(sticker.src)
if (res?.imageUrl) {
setSticker({ ...sticker, src: res.imageUrl })
setPendingSrc(res.imageUrl)
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
setSticker(newSticker)
setLoading(false)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no imageUrl')
}
} catch {
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
// 如果接口不可用/失败,降级为前端灰度+边缘检测
applyFrontendSketch()
}
}
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
? 'none'
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
if (notFound) {
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<ScrollTopMask title="编辑贴纸" targetSelector=".edit-header" showBack />
<View className='sticker-edit-page'>
<View className='edit-header' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'></Text>
<Text className='edit-save' />
</View>
<View className='edit-empty surface-card mt-20'>
<Text className='edit-empty-title'></Text>
<Text className='edit-empty-desc'></Text>
<View className='btn-primary' onTap={() => Taro.navigateBack()}>
<Text></Text>
</View>
</View>
</View>
</View>
)
/** 前端线稿降级方案:灰度+反相高对比 */
const applyFrontendSketch = () => {
if (!sticker || !ctxRef.current) { setLoading(false); return }
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
const bPct = `${((brightness + 100) / 200) * 100}%`
const hPct = `${((hue + 180) / 360) * 100}%`
const cPct = `${((contrast + 100) / 200) * 100}%`
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
@@ -242,13 +311,13 @@ export default function StickerEditPage() {
<Text className='edit-save' onTap={handleSave}></Text>
</View>
{/* 预览Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter */}
{/* Canvas 预览 */}
<View className='edit-canvas-wrap'>
<Image
className='edit-preview-img'
src={sticker ? sticker.src : ''}
mode='aspectFit'
style={{ filter: previewFilter }}
<Canvas
id='editCanvas'
type='2d'
className='edit-canvas'
style={{ width: '100%', height: '480rpx' }}
/>
</View>
@@ -267,42 +336,73 @@ export default function StickerEditPage() {
</View>
{/* 亮度 */}
<View className='edit-row slider-row'>
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
// 取得滑轨位置
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setBrightness(clamp(v, -100, 100))
}).exec()
}}
>
<Text className='edit-label'></Text>
<TouchSlider
id='slider-brightness'
value={brightness}
min={-100}
max={100}
onChange={setBrightness}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: bPct }} />
<View className='slider-thumb' style={{ left: bPct }} />
</View>
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
</View>
</View>
{/* 色相 */}
<View className='edit-row slider-row'>
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-180 + 360 * ratio) / 1) * 1
setHue(clamp(v, -180, 180))
}).exec()
}}
>
<Text className='edit-label'></Text>
<TouchSlider
id='slider-hue'
value={hue}
min={-180}
max={180}
onChange={setHue}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: hPct }} />
<View className='slider-thumb' style={{ left: hPct }} />
</View>
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
</View>
</View>
{/* 对比度 */}
<View className='edit-row slider-row'>
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setContrast(clamp(v, -100, 100))
}).exec()
}}
>
<Text className='edit-label'></Text>
<TouchSlider
id='slider-contrast'
value={contrast}
min={-100}
max={100}
onChange={setContrast}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: cPct }} />
<View className='slider-thumb' style={{ left: cPct }} />
</View>
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
</View>
</View>
</View>
+25 -8
View File
@@ -1,16 +1,20 @@
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import './index.scss'
import { CATEGORIES } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { fetchProducts } from '../../utils/api/product'
import type { ProductCategory } from '../../types'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import ProductImage from '../../components/ProductImage'
import { getProductIconImg } from '../../utils/productConfig'
// 成品展示配图(从 img 里取对应实物图)
// 成品展示配图/img 路径由 assetUrl 根据 OSS_BASE_URL 拼接为资源桶地址。
const SHOWCASE_LIST = [
{
title: '毕业纪念笔记本',
@@ -56,7 +60,7 @@ const SHOWCASE_LIST = [
}
]
// 品类对应的实物片映射(首页卡片顶部大图)
// 品类对应的实物片映射(首页卡片顶部大图)
const PRODUCT_IMG_MAP: Record<string, string> = {
'notebook-small': assetUrl('/img/book_small/The1.jpg'),
'notebook-large': assetUrl('/img/book_big/The1.jpg'),
@@ -70,6 +74,8 @@ export default function Index() {
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [searchKey, setSearchKey] = useState('')
const [products, setProducts] = useState<ProductCategory[]>(CATEGORIES)
useEffect(() => { fetchProducts({ page: 1, pageSize: 100 }).then(result => setProducts(result.list)).catch(() => { /* 静态目录兜底 */ }) }, [])
const navigateToProduct = (categoryId: string) => {
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
@@ -87,10 +93,10 @@ export default function Index() {
}
const filteredCategories = searchKey.trim()
? CATEGORIES.filter(
? products.filter(
(c) => c.name.includes(searchKey) || c.desc.includes(searchKey)
)
: CATEGORIES
: products
const handleSearch = (e: any) => {
setSearchKey(e.detail.value)
@@ -144,7 +150,12 @@ export default function Index() {
{SHOWCASE_LIST.map((item, idx) => (
<SwiperItem key={idx} className='showcase-swiper-item'>
<View className='hero-block'>
<Image className='hero-media' src={item.image} mode='aspectFill' />
<ProductImage
className='hero-media'
src={item.image}
fallback={getProductIconImg(CATEGORIES.find(category => category.id === item.categoryId))}
mode='aspectFill'
/>
<View className='glass-overlay'>
<Text className='hero-title'>{item.title}</Text>
<Text className='hero-desc'>{item.desc}</Text>
@@ -166,9 +177,10 @@ export default function Index() {
onTap={() => navigateToProduct(cat.id)}
>
<View className='media-card-image'>
<Image
<ProductImage
className='media-card-img'
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
fallback={getProductIconImg(cat)}
mode='aspectFill'
/>
</View>
@@ -200,7 +212,12 @@ export default function Index() {
onTap={() => navigateFromShowcase(item)}
>
<View className='media-card-image'>
<Image className='media-card-img' src={item.image} mode='aspectFill' />
<ProductImage
className='media-card-img'
src={item.image}
fallback={getProductIconImg(CATEGORIES.find(category => category.id === item.categoryId))}
mode='aspectFill'
/>
</View>
<View className='media-card-body'>
<Text className='media-card-title'>{item.title}</Text>
+9
View File
@@ -82,6 +82,15 @@
color: var(--text-secondary);
}
.payment-countdown {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
font-weight: 700;
color: var(--accent-primary);
font-variant-numeric: tabular-nums;
}
/* --- 通用卡片 --- */
.detail-card {
margin-bottom: 24rpx;
+42 -35
View File
@@ -7,9 +7,12 @@ import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import { getOrderList, getDefaultAddress, getAddressList, type AddressItem, type OrderItem } from '../../utils/store'
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { getOrderList, type AddressItem, type OrderItem } from '../../utils/store'
import { fetchOrderDetail, payOrder, cancelOrder, confirmOrder } from '../../utils/api/order'
import { getProductIconImg } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import BottomActionBar from '../../components/BottomActionBar'
import PaymentCountdown from '../../components/PaymentCountdown'
export default function OrderDetailPage() {
const { theme, resolvedTheme } = useThemeContext()
@@ -19,39 +22,33 @@ export default function OrderDetailPage() {
const params = router ? router.params : {}
const orderId = (params && params.orderId) || ''
const [order, setOrder] = useState<OrderItem | null>(null)
const [order, setOrder] = useState<any>(null)
const [address, setAddress] = useState<AddressItem | null>(null)
const [showAddrPicker, setShowAddrPicker] = useState(false)
const [addrList, setAddrList] = useState<AddressItem[]>([])
useEffect(() => {
const orders = getOrderList()
const o = orders.find(x => x.id === orderId)
if (o) {
setOrder(o)
// 优先显示订单已有地址,否则用默认地址
if ((o as any).address) {
setAddress((o as any).address)
} else {
setAddress(getDefaultAddress())
}
}
setAddrList(getAddressList())
fetchOrderDetail(orderId).then(remote => {
const first = remote.items[0]
setOrder({ id: remote.id, orderNo: remote.orderNo, productId: first?.productId, productName: first?.name || '定制商品', productIcon: getProductIconImg({ id: first?.productId }), statusCode: remote.status === 'PENDING' ? 'pending' : remote.status === 'PAID' || remote.status === 'PROCESSING' ? 'paid' : remote.status === 'SHIPPED' ? 'shipping' : remote.status === 'COMPLETED' ? 'done' : remote.status === 'PAYMENT_EXPIRED' ? 'expired' : 'cancelled', date: new Date(remote.createdAt).toLocaleString('zh-CN'), price: `¥${Number(remote.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: remote.orderNo, paymentExpiresAt: remote.paymentExpiresAt })
const snapshot = remote.addressSnapshot
setAddress({ id: snapshot.id || '', name: snapshot.name, phone: snapshot.phone, region: [snapshot.province, snapshot.city, snapshot.district], detail: snapshot.detail, isDefault: false })
}).catch(() => {
const local = getOrderList().find(x => x.id === orderId)
if (local) setOrder(local)
})
}, [orderId])
const handlePickAddress = (addr: AddressItem) => {
setAddress(addr)
void addr
setShowAddrPicker(false)
// 同步更新订单里的地址
const orders = getOrderList()
const idx = orders.findIndex(x => x.id === orderId)
if (idx >= 0) {
orders[idx] = { ...orders[idx], address: addr } as any
// 这里不直接写回 store,用临时状态即可,实际后端会有 order update API
}
Taro.showToast({ title: '地址已更改', icon: 'success' })
Taro.showToast({ title: '订单地址创建后不可更改', icon: 'none' })
}
const handlePay = async () => { try { const result = await payOrder(orderId); Taro.showToast({ title: result.configured ? '支付准备中' : '支付未配置', icon: 'none' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '支付失败', icon: 'none' }) } }
const handleCancel = async () => { try { await cancelOrder(orderId); Taro.showToast({ title: '订单已取消', icon: 'success' }); setTimeout(() => Taro.navigateBack(), 500) } catch (error) { Taro.showToast({ title: (error as Error).message || '取消失败', icon: 'none' }) } }
const handleConfirm = async () => { try { await confirmOrder(orderId); Taro.showToast({ title: '已确认收货', icon: 'success' }); setTimeout(() => Taro.navigateBack(), 500) } catch (error) { Taro.showToast({ title: (error as Error).message || '操作失败', icon: 'none' }) } }
if (!order) {
return (
<View className={`theme-${resolvedTheme}`}>
@@ -95,13 +92,22 @@ export default function OrderDetailPage() {
{order.statusCode === 'paid' && '待发货'}
{order.statusCode === 'shipping' && '待收货'}
{order.statusCode === 'done' && '已完成'}
{order.statusCode === 'expired' && '订单超时未支付'}
</Text>
<Text className='status-desc'>
{order.statusCode === 'pending' && '请在30分钟内完成支付'}
{order.statusCode === 'paid' && '商品正在打包中,即将发货'}
{order.statusCode === 'shipping' && '快递运输中,请注意查收'}
{order.statusCode === 'done' && '交易已完成,感谢惠顾'}
{order.statusCode === 'expired' && '该订单已超过 30 分钟支付时限'}
</Text>
{order.statusCode === 'pending' && (
<PaymentCountdown
className='payment-countdown'
deadline={order.paymentExpiresAt}
onExpired={() => setOrder((current: any) => current ? { ...current, statusCode: 'expired' } : current)}
/>
)}
</View>
{/* 物流信息(仅 shipping/done */}
@@ -112,31 +118,31 @@ export default function OrderDetailPage() {
<Image className='card-icon-img' src={assetUrl('/icon/包裹.png')} mode='aspectFit' />
<Text className='logistics-title'></Text>
</View>
<Text className='logistics-num'>单号: SF1234567890</Text>
<Text className='logistics-num'></Text>
</View>
<View className='timeline'>
<View className='timeline-item active'>
<View className='timeline-dot' />
<View className='timeline-content'>
<Text className='timeline-status'></Text>
<Text className='timeline-time'>2024-01-15 14:30</Text>
<Text className='timeline-desc'></Text>
<Text className='timeline-time'></Text>
<Text className='timeline-desc'></Text>
</View>
</View>
<View className='timeline-item'>
<View className='timeline-dot' />
<View className='timeline-content'>
<Text className='timeline-status'></Text>
<Text className='timeline-time'>2024-01-15 09:00</Text>
<Text className='timeline-desc'></Text>
<Text className='timeline-time'></Text>
<Text className='timeline-desc'></Text>
</View>
</View>
<View className='timeline-item'>
<View className='timeline-dot' />
<View className='timeline-content'>
<Text className='timeline-status'></Text>
<Text className='timeline-time'>2024-01-14 20:15</Text>
<Text className='timeline-desc'></Text>
<Text className='timeline-time'></Text>
<Text className='timeline-desc'></Text>
</View>
</View>
</View>
@@ -149,7 +155,7 @@ export default function OrderDetailPage() {
<View className='product-icon-wrapper'>
<Image
className='product-icon-img'
src={assetUrl((order.productIcon || '').startsWith('/icon/') || /^https?:/i.test(order.productIcon || '') ? order.productIcon : PRODUCT_ICON_MAP[order.productId] || '/icon/四角星.svg')}
src={order.productIcon || assetUrl('/icon/四角星.svg')}
mode='aspectFit'
/>
</View>
@@ -171,9 +177,7 @@ export default function OrderDetailPage() {
<Image className='card-icon-img' src={assetUrl('/icon/地址.png')} mode='aspectFit' />
<Text className='address-title'></Text>
</View>
<View className='address-change' onTap={() => setShowAddrPicker(true)}>
<Text className='change-text'></Text>
</View>
<Text className='change-text'></Text>
</View>
{address ? (
<View className='address-body'>
@@ -209,6 +213,9 @@ export default function OrderDetailPage() {
</View>
</View>
{order.statusCode === 'pending' && <BottomActionBar><View className='btn-secondary' onTap={handleCancel}><Text></Text></View><View className='btn-primary' onTap={handlePay}><Text></Text></View></BottomActionBar>}
{order.statusCode === 'shipping' && <BottomActionBar><View className='btn-primary' onTap={handleConfirm}><Text></Text></View></BottomActionBar>}
{/* 地址选择底部弹窗 */}
{showAddrPicker && (
<View className='modal-overlay' style={{ alignItems: 'flex-end', justifyContent: 'flex-end' }}>
+9
View File
@@ -167,6 +167,15 @@
font-variant-numeric: tabular-nums;
}
.orders-page .order-countdown {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.4;
color: var(--accent-primary);
font-variant-numeric: tabular-nums;
}
.orders-page .order-footer {
display: flex;
align-items: center;
+42 -8
View File
@@ -3,7 +3,8 @@ import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getOrderList, type OrderItem } from '../../utils/store'
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { fetchOrders, payOrder, cancelOrder, confirmOrder, type ServerOrder } from '../../utils/api/order'
import { getProductIconImg } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -11,13 +12,15 @@ import { useStatusBar } from '../../hooks/useStatusBar'
import LoginGuard from '../../components/LoginGuard'
import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import PaymentCountdown from '../../components/PaymentCountdown'
const STATUS_TABS = [
{ code: 'all', label: '全部' },
{ code: 'pending', label: '待付款' },
{ code: 'paid', label: '待发货' },
{ code: 'shipping', label: '待收货' },
{ code: 'done', label: '已完成' }
{ code: 'done', label: '已完成' },
{ code: 'expired', label: '已超时' }
]
const STATUS_MAP: Record<string, { label: string; badge: string }> = {
@@ -25,7 +28,8 @@ const STATUS_MAP: Record<string, { label: string; badge: string }> = {
paid: { label: '待发货', badge: 'badge-blue' },
shipping: { label: '待收货', badge: 'badge-blue' },
done: { label: '已完成', badge: 'badge-green' },
cancelled: { label: '已取消', badge: 'badge-gray' }
cancelled: { label: '已取消', badge: 'badge-gray' },
expired: { label: '订单超时未支付', badge: 'badge-gray' }
}
export default function OrdersPage() {
@@ -35,7 +39,14 @@ export default function OrdersPage() {
const [activeTab, setActiveTab] = useState('all')
const [orders, setOrders] = useState<OrderItem[]>([])
const load = () => setOrders(getOrderList())
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 load = async () => {
try { const remote = await fetchOrders({ page: 1, pageSize: 100 }); setOrders(remote.list.map(mapOrder)) } catch { setOrders(getOrderList()) }
}
useEffect(load, [])
@@ -82,6 +93,19 @@ export default function OrdersPage() {
Taro.navigateTo({ url: `/pages/orderDetail/index?orderId=${order.id}` })
}
const handlePay = async (order: OrderItem, e: any) => {
e.stopPropagation()
try { const result = await payOrder(order.id); Taro.showToast({ title: result.configured ? '支付准备中' : '支付未配置', icon: 'none' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '支付失败', icon: 'none' }) }
}
const handleCancel = async (order: OrderItem, e: any) => {
e.stopPropagation()
try { await cancelOrder(order.id); await load(); Taro.showToast({ title: '订单已取消', icon: 'success' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '取消失败', icon: 'none' }) }
}
const handleConfirm = async (order: OrderItem, e: any) => {
e.stopPropagation()
try { await confirmOrder(order.id); await load(); Taro.showToast({ title: '已确认收货', icon: 'success' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '操作失败', icon: 'none' }) }
}
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
@@ -131,6 +155,13 @@ export default function OrdersPage() {
<Text className='order-product'>{order.productName}</Text>
<Text className='order-sku'>{order.sku}</Text>
<Text className='order-meta'>: {order.count} | {order.id}</Text>
{order.statusCode === 'pending' && (
<PaymentCountdown
className='order-countdown'
deadline={order.paymentExpiresAt}
onExpired={() => setOrders(current => current.map(item => item.id === order.id ? { ...item, statusCode: 'expired' } : item))}
/>
)}
</View>
</View>
<View className='order-footer'>
@@ -140,9 +171,12 @@ export default function OrdersPage() {
</View>
<View className='order-actions'>
{order.statusCode === 'pending' && (
<View className='btn-primary order-btn'>
<Text></Text>
</View>
<>
<View className='btn-primary order-btn' onTap={(e) => handlePay(order, e)}>
<Text></Text>
</View>
<View className='btn-secondary order-btn' onTap={(e) => handleCancel(order, e)}><Text></Text></View>
</>
)}
{order.statusCode === 'paid' && (
<View className='btn-secondary order-btn'>
@@ -154,7 +188,7 @@ export default function OrdersPage() {
<View className='btn-secondary order-btn' onTap={(e) => { e.stopPropagation(); Taro.showToast({ title: '查看物流', icon: 'none' }) }}>
<Text></Text>
</View>
<View className='btn-primary order-btn' onTap={(e) => { e.stopPropagation(); Taro.showToast({ title: '确认收货', icon: 'none' }) }}>
<View className='btn-primary order-btn' onTap={(e) => handleConfirm(order, e)}>
<Text></Text>
</View>
</>
+25 -24
View File
@@ -1,10 +1,9 @@
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
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'
@@ -12,6 +11,9 @@ import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import BottomActionBar from '../../components/BottomActionBar'
import { getProductIconImg } from '../../utils/productConfig'
import { fetchProduct } from '../../utils/api/product'
import { createDesign } from '../../utils/api/design'
import ProductImage from '../../components/ProductImage'
export default function ProductPage() {
const { theme, resolvedTheme } = useThemeContext()
@@ -23,7 +25,8 @@ export default function ProductPage() {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
const productId = params && params.id ? params.id : ''
const product = getProductById(productId)
const [product, setProduct] = useState(getProductById(productId))
useEffect(() => { if (productId) fetchProduct(productId).then(setProduct).catch(() => { /* 本地配置兜底 */ }) }, [productId])
if (!product) {
return (
@@ -60,26 +63,19 @@ export default function ProductPage() {
setShowModal(true)
}
const confirmAdd = () => {
// 写服务端(需登录,创建即 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 confirmAdd = async () => {
const local = addDesign(product, quantity)
try {
// 用服务端返回的 id 替换临时 DSG... id。后续从设计清单进入结算时,
// 就不会把本地临时 id 当作 designListId 传给后端。
const saved = await createDesign({ productId: product.id, productName: product.name, unitPrice: product.price, count: quantity, designData: local.designData })
setDesignList(getDesignList().map(item => item.id === local.id ? saved : item))
} catch {
// 网络不可用时仍可保留本地草稿,恢复网络后可从设计清单继续编辑。
}
setShowModal(false)
Taro.showToast({ title: `已加入设计清单 x${quantity}`, icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1200)
}
const goToDIY = () => {
@@ -129,7 +125,12 @@ export default function ProductPage() {
product.images.map((img, idx) => (
<SwiperItem key={idx}>
<View className='hero-block'>
<Image className='hero-image' src={img} mode='aspectFill' />
<ProductImage
className='hero-image'
src={img}
fallback={getProductIconImg(product)}
mode='aspectFill'
/>
</View>
</SwiperItem>
))
+15 -9
View File
@@ -11,6 +11,7 @@ import ScrollTopMask from '../../components/ScrollTopMask'
import { login, getMe, updateProfile, getToken } from '../../utils/api'
import { safeHideLoading } from '../../utils/request'
import { assetUrl } from '../../utils/asset'
import { fetchOrders } from '../../utils/api/order'
const ICON_MAP: Record<string, string> = {
'我的设计': assetUrl('/icon/调色盘.png'),
@@ -60,16 +61,21 @@ export default function ProfilePage() {
toDesign: 0, pending: 0, paid: 0, shipping: 0, done: 0
})
const loadStats = () => {
const orders = getOrderList()
const loadStats = async () => {
const designs = getDesignList()
setOrderStats({
toDesign: designs.filter(d => d.status === 'undesigned' || d.status === 'designing').length,
pending: orders.filter(o => o.statusCode === 'pending').length,
paid: orders.filter(o => o.statusCode === 'paid').length,
shipping: orders.filter(o => o.statusCode === 'shipping').length,
done: orders.filter(o => o.statusCode === 'done').length
})
try {
const remote = (await fetchOrders({ page: 1, pageSize: 100 })).list
setOrderStats({
toDesign: designs.filter(d => d.status === 'undesigned' || d.status === 'designing').length,
pending: remote.filter(o => o.status === 'PENDING').length,
paid: remote.filter(o => o.status === 'PAID' || o.status === 'PROCESSING').length,
shipping: remote.filter(o => o.status === 'SHIPPED').length,
done: remote.filter(o => o.status === 'COMPLETED').length
})
} catch {
const orders = getOrderList()
setOrderStats({ toDesign: designs.filter(d => d.status === 'undesigned' || d.status === 'designing').length, pending: orders.filter(o => o.statusCode === 'pending').length, paid: orders.filter(o => o.statusCode === 'paid').length, shipping: orders.filter(o => o.statusCode === 'shipping').length, done: orders.filter(o => o.statusCode === 'done').length })
}
}
const sync = () => {
+11 -3
View File
@@ -1,8 +1,9 @@
import { View, Text, Image, ScrollView } from '@tarojs/components'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback, useMemo } from 'react'
import './index.scss'
import { PRODUCTS } from '../../../utils/productConfig'
import { fetchProduct } from '../../../utils/api/product'
import { ProductCategory } from '../../../types'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -10,6 +11,7 @@ import { useThemeContext } from '../../../context/ThemeContext'
import ThemedPageMeta from '../../../components/ThemedPageMeta'
import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
import ProductImage from '../../../components/ProductImage'
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
@@ -26,7 +28,12 @@ export default function ShopDetailPage() {
const routerParams = Taro.getCurrentInstance().router
const routerParamsValues = routerParams ? routerParams.params : undefined
const productId = routerParamsValues ? routerParamsValues.id : ''
const product: ProductCategory | undefined = PRODUCTS.find(p => p.id === productId)
const [product, setProduct] = useState<ProductCategory | undefined>(PRODUCTS.find(p => p.id === productId))
useEffect(() => {
if (!productId) return
fetchProduct(productId).then(setProduct).catch(() => { /* 保留本地兜底 */ })
}, [productId])
useEffect(() => {
try {
@@ -132,9 +139,10 @@ export default function ShopDetailPage() {
transform: `translateY(${winHeight * heroStyles.translateY / 100}px)`
}}
>
<Image
<ProductImage
className="shop-hero-img"
src={(product.images && product.images[0]) || product.iconImg || ''}
fallback={product.iconImg || product.icon}
mode="aspectFill"
style={{
width: '100%',
+13 -4
View File
@@ -1,5 +1,6 @@
import { View, Text, Image } from '@tarojs/components'
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -7,11 +8,17 @@ import ThemedPageMeta from '../../components/ThemedPageMeta'
import ScrollTopMask from '../../components/ScrollTopMask'
import './index.scss'
import { PRODUCTS } from '../../utils/productConfig'
import { fetchProducts } from '../../utils/api/product'
import type { ProductCategory } from '../../types'
import ProductImage from '../../components/ProductImage'
export default function ShopPage() {
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [products, setProducts] = useState<ProductCategory[]>(PRODUCTS)
const [error, setError] = useState(false)
useEffect(() => { fetchProducts({ page: 1, pageSize: 100 }).then(result => { setProducts(result.list); setError(false) }).catch(() => setError(true)) }, [])
const openDetail = (productId: string) => {
Taro.navigateTo({ url: `/pages/shop/detail/index?id=${productId}` })
@@ -24,11 +31,12 @@ export default function ShopPage() {
<View className="shop-list">
<View className="shop-header" style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className="shop-header-title"></Text>
<Text className="shop-header-sub"></Text>
<Text className="shop-header-sub"></Text>
{error && <Text className="shop-header-sub"></Text>}
</View>
<View className="shop-grid">
{PRODUCTS.map(product => {
{products.map(product => {
const imageSrc = product.images[0] || product.iconImg || ''
return (
<View
@@ -37,9 +45,10 @@ export default function ShopPage() {
onTap={() => openDetail(product.id)}
>
<View className="shop-card-img-wrap">
<Image
<ProductImage
className="shop-card-img"
src={imageSrc}
fallback={product.iconImg || product.icon}
mode="aspectFill"
lazyLoad
/>
+7 -21
View File
@@ -77,15 +77,8 @@ export interface StickerItem {
export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */
version?: 1
/**
* 商品品类(契约 §2 冻结:仅 {id, mask, tone}DIY 保存时裁剪);
* mask 是画布尺寸来源,说明见 docs/mask-config-guide.md
*/
category?: {
id: string
mask: MaskConfig
tone?: [number, number, number]
}
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
category?: ProductCategory
/** 底图(持久 URL),WCD 打包的画布底 */
background?: {
src: string
@@ -149,23 +142,17 @@ export interface WordCloudDispatchResult {
message?: string
}
/** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5 */
/** 设计清单条目 */
export interface DesignItem {
id: string
productId: string
productName: string
/**
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
*/
productIcon?: string
productIcon: string
unitPrice: number
count: number
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
status: 'undesigned' | 'designing' | 'processing' | 'ordered'
designData?: DesignDataV1
orderId?: string
/** 服务端 createdAtISO),展示时取日期部分 */
createdAt: string
}
@@ -174,14 +161,15 @@ export interface OrderItem {
id: string
productName: string
productIcon: string
statusCode: 'pending' | 'paid' | 'shipping' | 'done'
statusCode: 'pending' | 'paid' | 'shipping' | 'done' | 'cancelled' | 'expired'
date: string
price: string
count: number
sku: string
paymentExpiresAt?: string | null
}
/** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts */
/** 收货地址 */
export interface AddressItem {
id: string
name: string
@@ -189,8 +177,6 @@ export interface AddressItem {
region: string[] // [province, city, district]
detail: string // 门牌号/详细地址
isDefault: boolean
/** 服务端创建时间(ISO 8601),本地缓存数据可能没有 */
createdAt?: string
}
/** 编辑器中的图片状态(向后兼容) */
+24 -74
View File
@@ -1,93 +1,43 @@
/**
* R2 收货地址接口(api-contract-v1 §4)。
*
* 唯一负责 region: [province, city, district] ↔ 后端 province/city/district
* 双向转换的文件;页面与 store 不得出现第二次转换(route-r2 §3)。
*/
import http from '../request'
import type { AddressItem } from '../../types'
/** 后端 Address 结构(契约 §4:四个独立地址字段) */
interface ServerAddress {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
type AddressPayload = Omit<AddressItem, 'id' | 'region'> & { region: string[] }
type ServerAddress = { id: string; name: string; phone: string; province: string; city: string; district: string; detail: string; isDefault: boolean }
/** ServerAddress → 前端 AddressItem(合并省市区为 region 数组) */
function toAddressItem(rec: ServerAddress): AddressItem {
return {
id: rec.id,
name: rec.name,
phone: rec.phone,
region: [rec.province, rec.city, rec.district],
detail: rec.detail,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
}
}
const toClient = (address: ServerAddress): AddressItem => ({
id: address.id,
name: address.name,
phone: address.phone,
region: [address.province, address.city, address.district],
detail: address.detail,
isDefault: address.isDefault,
})
/** 前端 region 数组 → 后端四个独立字段 */
function toServerFields(addr: {
name?: string
phone?: string
region?: string[]
detail?: string
isDefault?: boolean
}): Record<string, unknown> {
// 后端 ValidationPipe forbidNonWhitelistedundefined 字段会被 JSON 序列化丢弃,
// 这里显式展开保证只传后端声明的字段
const body: Record<string, unknown> = {}
if (addr.name !== undefined) body.name = addr.name
if (addr.phone !== undefined) body.phone = addr.phone
if (addr.region !== undefined) {
const [province = '', city = '', district = ''] = addr.region
body.province = province
body.city = city
body.district = district
}
if (addr.detail !== undefined) body.detail = addr.detail
if (addr.isDefault !== undefined) body.isDefault = addr.isDefault
return body
}
const toServer = (address: Partial<AddressPayload>) => ({
...(address.name === undefined ? {} : { name: address.name }),
...(address.phone === undefined ? {} : { phone: address.phone }),
...(address.region ? { province: address.region[0] || '', city: address.region[1] || '', district: address.region[2] || '' } : {}),
...(address.detail === undefined ? {} : { detail: address.detail }),
...(address.isDefault === undefined ? {} : { isDefault: address.isDefault }),
})
/** 我的收货地址列表(默认地址在前) */
export async function fetchAddresses(): Promise<AddressItem[]> {
const list = await http.get<ServerAddress[]>('/api/addresses')
return (list || []).map(toAddressItem)
const rows = await http.get<ServerAddress[]>('/api/addresses')
return rows.map(toClient)
}
/** 新增收货地址(首个地址后端自动设为默认) */
export async function createAddress(
addr: Omit<AddressItem, 'id' | 'createdAt'>,
): Promise<AddressItem> {
const rec = await http.post<ServerAddress>('/api/addresses', toServerFields(addr))
return toAddressItem(rec)
export async function createAddress(address: Omit<AddressItem, 'id'>): Promise<AddressItem> {
return toClient(await http.post<ServerAddress>('/api/addresses', toServer(address)))
}
/** 更新本人地址(只传显式给出的字段;isDefault=true 由后端事务保证唯一默认) */
export async function updateAddress(
id: string,
patch: Partial<Omit<AddressItem, 'id' | 'createdAt'>>,
): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}`, toServerFields(patch))
return toAddressItem(rec)
export async function updateAddress(id: string, patch: Partial<AddressItem>): Promise<AddressItem> {
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}`, toServer(patch)))
}
/** 删除本人地址(若删的是默认地址,后端自动补偿最新一条为默认) */
export async function deleteAddress(id: string): Promise<void> {
await http.del(`/api/addresses/${id}`)
}
/** 设为默认地址 */
export async function setDefaultAddress(id: string): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}/default`)
return toAddressItem(rec)
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}/default`))
}
+40 -99
View File
@@ -1,119 +1,60 @@
/**
* R2 设计清单接口(api-contract-v1 §5)。
*
* 映射约定(阶段0 决策,见 docs/r2-workflow.md):
* - 一条前端 DesignItem = 一条后端 DesignList 记录,items 固定 1 个元素;
* - title 由 productName 承担(后端必填);
* - productIcon 不入库(后端 forbidNonWhitelisted 会拒绝),页面按 productId 兜底推导;
* - 状态映射:DRAFT↔undesigned、SUBMITTED↔designing、PROCESSING↔processing、DONE↔ordered
* - 前端只允许提交 designing(→SUBMITTED),PROCESSING/DONE 由 R3 驱动。
*/
import http from '../request'
import type { DesignDataV1, DesignItem } from '../../types'
import { getProductIconImg } from '../productConfig'
/** 后端 DesignListStatus → 前端状态码(契约 §5 映射表) */
const SERVER_STATUS_MAP: Record<string, DesignItem['status']> = {
DRAFT: 'undesigned',
SUBMITTED: 'designing',
PROCESSING: 'processing',
DONE: 'ordered',
type ServerEntry = { productId: string; productName: string; unitPrice: number; count: number; designData?: DesignDataV1 }
type ServerList = { id: string; title: string; items: ServerEntry[]; status: 'DRAFT' | 'SUBMITTED' | 'PROCESSING' | 'DONE'; orderId?: string; createdAt: string }
const statusToClient = (item: ServerList): DesignItem['status'] => {
if (item.orderId || item.status === 'DONE') return 'ordered'
if (item.status === 'PROCESSING') return 'processing'
if (item.status === 'DRAFT') return 'undesigned'
return 'designing'
}
/** 清单条目(后端 items[] 固定 1 个元素,契约 §5) */
export interface DesignListEntryPayload {
productId: string
productName: string
unitPrice: number
count: number
designData?: DesignDataV1
}
const statusToServer = (status: DesignItem['status']) =>
status === 'undesigned' ? 'DRAFT' : status === 'designing' ? 'SUBMITTED' : status === 'processing' ? 'PROCESSING' : 'DONE'
/** 后端 DesignList 记录结构 */
interface ServerDesignList {
id: string
userId?: string
title: string
items: (Partial<DesignListEntryPayload> & Record<string, unknown>)[]
status: string
createdAt: string
updatedAt?: string
}
/** ServerDesignList → 前端 DesignItem */
function toDesignItem(rec: ServerDesignList): DesignItem {
const entry = rec.items && rec.items[0]
const toClient = (list: ServerList): DesignItem => {
const item = list.items[0]
return {
id: rec.id,
productId: entry?.productId ?? '',
productName: entry?.productName || rec.title,
// productIcon 有意不返回:服务端不存储,页面按 PRODUCT_ICON_MAP[productId] 兜底
unitPrice: entry?.unitPrice ?? 0,
count: entry?.count ?? 1,
status: SERVER_STATUS_MAP[rec.status] ?? 'undesigned',
designData: entry?.designData,
createdAt: (rec.createdAt || '').slice(0, 10),
id: list.id,
productId: item.productId,
productName: item.productName,
productIcon: getProductIconImg({ id: item.productId }),
unitPrice: Number(item.unitPrice),
count: item.count,
status: statusToClient(list),
designData: item.designData,
orderId: list.orderId,
createdAt: list.createdAt,
}
}
/** 我的设计清单(createdAt 倒序) */
export async function fetchDesignList(): Promise<DesignItem[]> {
const list = await http.get<ServerDesignList[]>('/api/design-list')
return (list || []).map(toDesignItem)
const rows = await http.get<ServerList[]>('/api/design-list')
return rows.filter(row => row.items?.length > 0).map(toClient)
}
/** 创建清单(一条设计一条清单;后端初始状态 DRAFT/undesigned */
export async function createDesign(entry: DesignListEntryPayload): Promise<DesignItem> {
const rec = await http.post<ServerDesignList>('/api/design-list', {
title: entry.productName,
items: [
{
productId: entry.productId,
productName: entry.productName,
unitPrice: entry.unitPrice,
count: entry.count,
...(entry.designData !== undefined ? { designData: entry.designData } : {}),
},
],
})
return toDesignItem(rec)
export async function fetchDesign(id: string): Promise<DesignItem> {
return toClient(await http.get<ServerList>(`/api/design-list/${id}`))
}
export interface UpdateDesignPayload {
/** 新标题(前端一般不传) */
title?: string
/** 状态迁移:前端只允许 designing(→SUBMITTEDDIY 保存设计时) */
status?: 'designing'
/** 条目整体替换(items 是全量覆盖,修改 designData 时必须带全 4 个基本字段) */
item?: DesignListEntryPayload
export async function createDesign(item: Omit<DesignItem, 'id' | 'createdAt' | 'status' | 'productIcon'>): Promise<DesignItem> {
return toClient(await http.post<ServerList>('/api/design-list', { title: item.productName, items: [{ productId: item.productId, productName: item.productName, unitPrice: item.unitPrice, count: item.count, designData: item.designData }] }))
}
/** 更新本人清单(部分更新:只传显式给出的字段) */
export async function updateDesign(id: string, payload: UpdateDesignPayload): Promise<DesignItem> {
const body: Record<string, unknown> = {}
if (payload.title !== undefined) body.title = payload.title
if (payload.status !== undefined) body.status = 'SUBMITTED'
if (payload.item !== undefined) {
body.items = [
{
productId: payload.item.productId,
productName: payload.item.productName,
unitPrice: payload.item.unitPrice,
count: payload.item.count,
...(payload.item.designData !== undefined ? { designData: payload.item.designData } : {}),
},
]
export async function updateDesign(id: string, patch: Partial<DesignItem>): Promise<DesignItem> {
const current = await fetchDesign(id)
const merged = { ...current, ...patch }
const data: Record<string, unknown> = {}
if (patch.designData !== undefined || patch.productId !== undefined || patch.productName !== undefined || patch.unitPrice !== undefined || patch.count !== undefined) {
data.items = [{ productId: merged.productId, productName: merged.productName, unitPrice: merged.unitPrice, count: merged.count, designData: merged.designData }]
}
const rec = await http.patch<ServerDesignList>(`/api/design-list/${id}`, body)
return toDesignItem(rec)
if (patch.productName !== undefined) data.title = patch.productName
if (patch.status !== undefined) data.status = statusToServer(patch.status)
return toClient(await http.patch<ServerList>(`/api/design-list/${id}`, data))
}
/** 删除本人清单 */
export async function deleteDesign(id: string): Promise<void> {
await http.del(`/api/design-list/${id}`)
}
/** 批量删除(后端只删本人条目,返回实际删除数;部分 id 无效不报错) */
export async function deleteDesigns(ids: string[]): Promise<number> {
const res = await http.post<{ deleted: number }>('/api/design-list/batch-delete', { ids })
return res?.deleted ?? 0
}
export async function deleteDesign(id: string): Promise<void> { await http.del(`/api/design-list/${id}`) }
export async function deleteDesigns(ids: string[]): Promise<void> { await http.post('/api/design-list/batch-delete', { ids }) }
+45 -7
View File
@@ -1,7 +1,45 @@
/**
* R3 订单接口归属文件。
* 后端 /api/orders 就绪后,在这里补充:
* createOrder / fetchOrders / fetchOrderDetail / payOrder
* 金额一律以服务端重算结果为准,前端只提交商品/设计数据与地址快照。
*/
export {}
import http from '../request'
export type ServerOrderStatus = 'PENDING' | 'PAID' | 'PROCESSING' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'PAYMENT_EXPIRED'
export interface ServerOrderItem {
id: string
productId?: string
name: string
price: number
quantity: number
}
export interface ServerOrder {
id: string
orderNo: string
status: ServerOrderStatus
totalAmount: number
items: ServerOrderItem[]
addressSnapshot: { id?: string; name: string; phone: string; province: string; city: string; district: string; detail: string }
designListId?: string
createdAt: string
paidAt?: string | null
paymentExpiresAt?: string | null
payment?: { status: string }
}
export interface OrderPage { list: ServerOrder[]; total: number; page: number; pageSize: number }
export interface CreateOrderInput {
designListId?: string
addressId: string
requestId?: string
items: { productId: string; quantity: number }[]
}
export function createOrder(input: CreateOrderInput): Promise<ServerOrder> { return http.post('/api/orders', input) }
export function fetchOrders(params: { status?: ServerOrderStatus; page?: number; pageSize?: number } = {}): Promise<OrderPage> {
const query = new URLSearchParams()
if (params.status) query.set('status', params.status)
if (params.page) query.set('page', String(params.page))
if (params.pageSize) query.set('pageSize', String(params.pageSize))
const suffix = query.toString()
return http.get(`/api/orders${suffix ? `?${suffix}` : ''}`)
}
export function fetchOrderDetail(id: string): Promise<ServerOrder> { return http.get(`/api/orders/${id}`) }
export function payOrder(id: string): Promise<{ configured: boolean; message: string }> { return http.post(`/api/payments/${id}/pay`) }
export function cancelOrder(id: string): Promise<ServerOrder> { return http.post(`/api/orders/${id}/cancel`) }
export function confirmOrder(id: string): Promise<ServerOrder> { return http.patch(`/api/orders/${id}/confirm`) }
+30 -4
View File
@@ -1,13 +1,39 @@
import http from '../request'
import type { ProductCategory } from '../../types'
import { assetUrl } from '../asset'
export interface ProductPage { list: ProductCategory[]; total: number; page: number; pageSize: number }
const normalize = (product: any): ProductCategory => ({
id: product.id,
name: product.name,
desc: product.subtitle || product.description || '',
icon: assetUrl(product.iconImg || '/icon/四角星.svg'),
iconImg: assetUrl(product.iconImg),
price: Number(product.price),
originalPrice: product.originalPrice == null ? undefined : Number(product.originalPrice),
leadTime: product.leadTime || '7-10个工作日',
description: product.description || product.subtitle || '',
subtitle: product.subtitle,
tone: Array.isArray(product.tone) && product.tone.length === 3 ? product.tone : undefined,
story: product.story,
scene: product.scene,
tags: product.tags || [],
specs: Array.isArray(product.specs) ? product.specs : [],
mask: product.mask || { shape: 'rect', width: 300, height: 420 },
images: Array.isArray(product.images) ? product.images.map(assetUrl) : [],
})
/** 商品列表 */
export async function fetchProducts() {
return http.get('/api/products', { auth: false })
export async function fetchProducts(params: { page?: number; pageSize?: number; categoryId?: string; keyword?: string } = {}): Promise<ProductPage> {
const query = Object.entries(params).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`).join('&')
const result = await http.get<{ list: any[]; total: number; page: number; pageSize: number }>(`/api/products${query ? `?${query}` : ''}`, { auth: false })
return { ...result, list: result.list.map(normalize) }
}
/** 商品详情 */
export async function fetchProduct(id: string) {
return http.get(`/api/products/${id}`, { auth: false })
export async function fetchProduct(id: string): Promise<ProductCategory> {
return normalize(await http.get(`/api/products/${id}`, { auth: false }))
}
/** 分类列表 */
+32 -7
View File
@@ -1,11 +1,17 @@
import Taro from '@tarojs/taro'
declare const __API_BASE_URL__: string | undefined
/**
* 后端接口请求封装
* 后端统一响应:{ code, message, data }code === 0 表示成功
* token 通过 Authorization: Bearer <token> 传入
*/
export const BASE_URL = 'https://wxbackend.tokenleaping.com'
// 地址由 Taro 在构建期注入;微信小程序运行时不提供 Node 的 process 对象。
// 兜底必须是线上地址,保证缺少环境变量时不会连到某台开发机的本机。
export const BASE_URL = typeof __API_BASE_URL__ !== 'undefined' && __API_BASE_URL__
? __API_BASE_URL__
: 'https://wxbackend.tokenleaping.com'
const TOKEN_KEY = 'smart_access_token'
@@ -85,6 +91,29 @@ async function sendRequest(
})
}
/**
* 保留微信运行时给出的请求失败原因。此前所有失败都被改写为“网络异常”,
* 会掩盖如「域名未配置」「连接被拒绝」等实际可定位的信息。
*/
function createNetworkError(path: string, method: string, cause: unknown): Error & { statusCode?: number } {
const url = `${BASE_URL}${path}`
const raw = cause as { errMsg?: unknown; message?: unknown } | null
const detail = typeof raw?.errMsg === 'string'
? raw.errMsg
: typeof raw?.message === 'string'
? raw.message
: typeof cause === 'string'
? cause
: '未知网络错误'
// 不记录请求体或 Authorization,避免 code / token 出现在调试日志中。
console.error('[API] 请求失败', { method, url, detail, cause })
const err = new Error(`网络请求失败:${detail}`) as Error & { statusCode?: number }
err.statusCode = -1
return err
}
export async function request<T = unknown>(
path: string,
options: RequestOptions = {},
@@ -104,9 +133,7 @@ export async function request<T = unknown>(
try {
res = await sendRequest(path, method, data, buildHeaders())
} catch (e) {
const err = new Error('网络异常,请稍后重试') as Error & { statusCode?: number }
err.statusCode = -1
throw err
throw createNetworkError(path, method, e)
}
// HTTP 未授权:先尝试自动续登一次,成功则用新 token 重试原请求
@@ -116,9 +143,7 @@ export async function request<T = unknown>(
try {
res = await sendRequest(path, method, data, buildHeaders())
} catch (e) {
const err = new Error('网络异常,请稍后重试') as Error & { statusCode?: number }
err.statusCode = -1
throw err
throw createNetworkError(path, method, e)
}
}
}
-7
View File
@@ -1,10 +1,3 @@
/**
* 收货地址本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/address.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { AddressItem } from '../../types'
import { key } from './keys'
-7
View File
@@ -1,10 +1,3 @@
/**
* 设计清单本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/design.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { DesignItem } from '../../types'
import { assetUrl } from '../asset'
+4 -2
View File
@@ -5,9 +5,11 @@ export const USER_KEY = 'smart_user_info'
/** 当前激活 openid 在 Storage 中的 key */
export const ACTIVE_USER_KEY = 'smart_active_openid'
/** 判断 openid 是否为历史版 mock 残留(形如 mock_xxx */
/** 判断 openid 是否为历史版仅前端 mock 残留(形如 mock_xxx
* 本地后端 WX_MOCK_LOGIN=1 返回 mock-xxx,属于可校验的联调用户,不能清除其 token。
*/
export function isMockOpenid(openid?: string | null): boolean {
return !!openid && (openid.startsWith('mock_') || openid.startsWith('mock-'))
return !!openid && openid.startsWith('mock_')
}
/** 取当前用户 openid;没有登录态时回退 _guest_ 并缓存 */