feat(redeem): 固定兑换链接 /redeem(输入订单号 → 状态分流 → 兑换)
- 路径式入口 `https://<域名>/redeem`(不带 #):主站是 HashRouter, 故在 App.tsx 里早于 isWorkerSite() 插入独立 BrowserRouter 分支, 主站与打手站路由均不受影响;Caddy 已有 try_files 兜底,无需改部署 - 页面按 7 种状态渲染:not_found / unpaid / cancelled / refunded / pending_certificate / redeemable / verified - not_found 保留用户输入的订单号,不清空 - pending_certificate 只给显式「刷新」,不做自动轮询(公开限流接口,避免空转) - verified 只展示后端已掩码的券码,绝不渲染明文 certNo - confirm 不假设成功:一律按返回的 status 重渲染 - 纯函数抽到 utils/redeem.ts(无 React/antd、无 @ 别名),可直接跑单测
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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="商品">{summary?.productName || '—'}</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user