feat(r3-order-pay): 结算与订单支付流程、支付倒计时/商品图组件,接口地址改为构建期注入
- 新增 PaymentCountdown、ProductImage 组件 - 完善 checkout/orders/orderDetail 订单支付链路与地址、商品、设计数据 - request 接口地址改为构建期注入并保留真实网络错误,默认兜底线上地址 - 同步重新构建 dist 产物
This commit is contained in:
+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 }))
|
||||
}
|
||||
|
||||
/** 分类列表 */
|
||||
|
||||
Reference in New Issue
Block a user