平台 sku_order_list[].code 是商家维护的**内部 SKU**(如「幸运币2个」), product_name 只是店铺用来**引流**的长标题(如「【测试专用】和平精英皮肤小店入口…」)。 商家按内部 SKU 认货(后续履约匹配也用它),原来的展示把引流标题当商品名, 列表/详情/兑换页三处都会让人认错货。 改为以内部 SKU 为主、引流标题降级为副信息: - 后台订单列表「商品 / 任务」:itemTitle = 内部 SKU,skuNick = 引流标题 - 后台订单详情「商品明细」:商品 = 内部 SKU;SKU/规格 不再重复展示同一串 - 兑换中心 /redeem:商品 = 内部 SKU(粗体)+ 引流标题(灰色小字,新增 productTitle 字段) 实现只在 `source === 'douyin_mashida'` 时生效(resolveOrderItemTitle / resolveOrderItemSkuNick 加 douyin 分支),其它平台展示逻辑不变; 用真实订单形态补了单测。
177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
import { SearchOutlined } from '@ant-design/icons'
|
|
import { Alert, Button, Card, Descriptions, Form, Input, Result, Space, Typography } from 'antd'
|
|
import { useState } from 'react'
|
|
|
|
import { showError, showSuccess } from '@/lib/feedback'
|
|
import { confirmRedeemOrder, lookupRedeemOrder } from '@/services/redeem'
|
|
import type { RedeemOrderStatus, RedeemOrderView } from '@/types/redeem'
|
|
import { formatRedeemAmount, formatRedeemTime, resolveRedeemStatusView } from '@/utils/redeem'
|
|
|
|
/**
|
|
* 固定兑换链接页面(`https://<域名>/redeem`)。
|
|
*
|
|
* 只用 `useState` 驱动、不引 TanStack Query:这是一次性公开链接,
|
|
* 查询与确认都由用户显式触发,没有需要缓存/复用的服务端状态。
|
|
* 流程:输订单号 → lookup → 按 status 渲染 → 可兑换时 confirm。
|
|
*/
|
|
export default function RedeemPage() {
|
|
const [orderNo, setOrderNo] = useState('')
|
|
const [view, setView] = useState<RedeemOrderView | null>(null)
|
|
const [lookupLoading, setLookupLoading] = useState(false)
|
|
const [confirmLoading, setConfirmLoading] = useState(false)
|
|
|
|
async function submitLookup(values: { orderNo: string }) {
|
|
const nextOrderNo = String(values.orderNo || '').trim()
|
|
setOrderNo(nextOrderNo)
|
|
await runLookup(nextOrderNo)
|
|
}
|
|
|
|
async function runLookup(currentOrderNo: string) {
|
|
setLookupLoading(true)
|
|
try {
|
|
const response = await lookupRedeemOrder(currentOrderNo)
|
|
setView(response.data)
|
|
} catch (error) {
|
|
setView(null)
|
|
showError(error instanceof Error ? error.message : '查询失败')
|
|
} finally {
|
|
setLookupLoading(false)
|
|
}
|
|
}
|
|
|
|
function refreshLookup() {
|
|
if (!orderNo) return
|
|
void runLookup(orderNo)
|
|
}
|
|
|
|
async function submitConfirm() {
|
|
if (!orderNo) return
|
|
setConfirmLoading(true)
|
|
try {
|
|
const response = await confirmRedeemOrder(orderNo)
|
|
// confirm 在不可兑换时不抛错,会把当前状态原样返回:一律按 status 重新渲染。
|
|
setView(response.data)
|
|
|
|
if (response.data.status === 'verified') {
|
|
showSuccess(response.data.message || '兑换成功')
|
|
} else {
|
|
showError(response.data.message || '当前订单暂不可兑换')
|
|
}
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : '兑换失败')
|
|
} finally {
|
|
setConfirmLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="redeem-page">
|
|
<Card className="redeem-card" bordered={false}>
|
|
<Typography.Title level={3}>兑换中心</Typography.Title>
|
|
<Typography.Paragraph type="secondary">
|
|
请输入订单号,核对订单状态后按提示完成兑换。
|
|
</Typography.Paragraph>
|
|
|
|
<Form layout="vertical" onFinish={submitLookup}>
|
|
<Space.Compact className="full-width">
|
|
<Form.Item
|
|
name="orderNo"
|
|
className="redeem-order-input"
|
|
rules={[{ required: true, message: '请输入订单号' }]}
|
|
>
|
|
<Input placeholder="订单号" autoComplete="off" />
|
|
</Form.Item>
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
icon={<SearchOutlined />}
|
|
loading={lookupLoading}
|
|
>
|
|
查询
|
|
</Button>
|
|
</Space.Compact>
|
|
</Form>
|
|
|
|
{view?.status === 'not_found' ? (
|
|
<Alert
|
|
className="platform-section-gap"
|
|
type="warning"
|
|
showIcon
|
|
message={view.message || '订单号有误,或订单尚未同步,请稍后再试'}
|
|
/>
|
|
) : null}
|
|
|
|
{view &&
|
|
(view.status === 'unpaid' || view.status === 'cancelled' || view.status === 'refunded') ? (
|
|
<RedeemStatusResult status={view.status} message={view.message} />
|
|
) : null}
|
|
|
|
{view?.status === 'pending_certificate' ? (
|
|
<>
|
|
<RedeemStatusResult status={view.status} message={view.message} />
|
|
{/* 券在后台异步准备:不做自动轮询,由用户点「刷新」按需重查,避免空转请求。 */}
|
|
<Button block onClick={refreshLookup} loading={lookupLoading}>
|
|
刷新
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
|
|
{view?.status === 'redeemable' ? (
|
|
<div className="platform-section-gap">
|
|
<RedeemSummaryDetails summary={view.summary} />
|
|
<Button type="primary" block loading={confirmLoading} onClick={submitConfirm}>
|
|
确认兑换
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{view?.status === 'verified' ? (
|
|
<div className="platform-section-gap">
|
|
<RedeemStatusResult status={view.status} message={view.message} />
|
|
<Descriptions column={1} size="small" bordered>
|
|
{/* 只展示后端返回的掩码券码,绝不明文铺开 certNo。 */}
|
|
<Descriptions.Item label="券码">
|
|
{view.credential?.maskedCertNo || '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="兑换时间">
|
|
{formatRedeemTime(view.credential?.verifiedAt) || '—'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function RedeemStatusResult({ status, message }: { status: RedeemOrderStatus; message: string }) {
|
|
const { tone, title } = resolveRedeemStatusView(status)
|
|
|
|
return <Result status={tone} title={title} subTitle={message || undefined} />
|
|
}
|
|
|
|
function RedeemSummaryDetails({ summary }: { summary: RedeemOrderView['summary'] }) {
|
|
return (
|
|
<Descriptions column={1} size="small" bordered>
|
|
<Descriptions.Item label="商品">
|
|
<div className="cell-stack" style={{ gap: 2 }}>
|
|
<Typography.Text strong>{summary?.productName || '—'}</Typography.Text>
|
|
{/* 平台标题只是引流词,降级为副信息,避免买家误认商品 */}
|
|
{summary?.productTitle && summary.productTitle !== summary.productName ? (
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{summary.productTitle}
|
|
</Typography.Text>
|
|
) : null}
|
|
</div>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="数量">{summary ? summary.quantity : '—'}</Descriptions.Item>
|
|
<Descriptions.Item label="金额">
|
|
{summary ? formatRedeemAmount(summary.amountFen) : '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="支付时间">
|
|
{formatRedeemTime(summary?.paidAt) || '—'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
)
|
|
}
|