feat(r3-order-pay): 结算与订单支付流程、支付倒计时/商品图组件,接口地址改为构建期注入
- 新增 PaymentCountdown、ProductImage 组件 - 完善 checkout/orders/orderDetail 订单支付链路与地址、商品、设计数据 - request 接口地址改为构建期注入并保留真实网络错误,默认兜底线上地址 - 同步重新构建 dist 产物
This commit is contained in:
@@ -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: [
|
||||
|
||||
Vendored
+8
@@ -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>
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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()}]);
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -82,14 +82,6 @@ interface DesignDataV1 {
|
||||
- `imageSrc/imagePos` 保留为兼容旧版字段;新写入统一走 `background.src/pos`,
|
||||
WCD 打包器同时兜底读旧字段。
|
||||
|
||||
> **实现注记(2026-09-12,DIY 修正,非版本升级)**:`stickers[].width/height` 的语义为
|
||||
> **画布显示像素**(坐标空间 = `category.mask` 的宽高),`x/y` 使用画布坐标空间
|
||||
> (原点 = mask 左上角;**允许超出画布边界**——2026-09-12 需求确认:贴纸可拖出画布
|
||||
> 自由摆放,渲染端裁切显示,WCD 打包按原值还原,不做钳制)。
|
||||
> 渲染、碰撞检测、WCD 打包三方按同一语义消费,打包时不再做像素换算。
|
||||
> 历史数据中按「图片原始像素」写入的值(width > mask 宽高)由读取端按 mask 等比归一化兼容,
|
||||
> 不做存量迁移。`addSticker` 时即归一化到画布空间(建议初始占 mask 短边 60%)。
|
||||
|
||||
## 3. 冻结约束(R2 需落地)
|
||||
|
||||
| # | 约束 | 说明 |
|
||||
|
||||
@@ -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 是交易链路的前置,优先合入。
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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 呈现(契约 §4:edits 不进 WCD
|
||||
document.json,仅 manifest.meta,src 保持原样对 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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
@@ -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
|
||||
/** 服务端 createdAt(ISO),展示时取日期部分 */
|
||||
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
@@ -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 forbidNonWhitelisted:undefined 字段会被 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
@@ -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(→SUBMITTED,DIY 保存设计时) */
|
||||
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
@@ -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`) }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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_ 并缓存 */
|
||||
|
||||
Reference in New Issue
Block a user