Files
order_site/apps/frontend/src/pages/redeem/RedeemPage.tsx
T
yml2213 7834ee7bef feat(douyin-mashida): 商品展示统一改为内部 SKU(引流标题降级)
平台 sku_order_list[].code 是商家维护的**内部 SKU**(如「幸运币2个」),
product_name 只是店铺用来**引流**的长标题(如「【测试专用】和平精英皮肤小店入口…」)。
商家按内部 SKU 认货(后续履约匹配也用它),原来的展示把引流标题当商品名,
列表/详情/兑换页三处都会让人认错货。

改为以内部 SKU 为主、引流标题降级为副信息:
- 后台订单列表「商品 / 任务」:itemTitle = 内部 SKU,skuNick = 引流标题
- 后台订单详情「商品明细」:商品 = 内部 SKU;SKU/规格 不再重复展示同一串
- 兑换中心 /redeem:商品 = 内部 SKU(粗体)+ 引流标题(灰色小字,新增 productTitle 字段)

实现只在 `source === 'douyin_mashida'` 时生效(resolveOrderItemTitle /
resolveOrderItemSkuNick 加 douyin 分支),其它平台展示逻辑不变;
用真实订单形态补了单测。
2026-10-09 13:31:45 +08:00

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>
)
}