diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 86fe17a1..72aff456 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { } from 'react-router' import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' +import { isRedeemPath } from '@/utils/redeem' import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site' import { hasWorkerSession } from '@/utils/worker-auth' import { resolveLegacyWorkerPath } from '@/utils/worker-routes' @@ -36,6 +37,7 @@ const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlat const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage')) const CollectPage = lazy(() => import('@/pages/collect/CollectPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) +const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage')) const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage')) const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage')) @@ -88,6 +90,12 @@ export default function App() { return } + // 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断, + // 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。 + if (isRedeemPath(window.location.pathname)) { + return + } + if (isWorkerSite()) { return } @@ -137,6 +145,19 @@ function AdminApplication() { ) } +function RedeemApplication() { + return ( + + }> + + } /> + } /> + + + + ) +} + function WorkerApplication() { return ( diff --git a/apps/frontend/src/pages/redeem/RedeemPage.tsx b/apps/frontend/src/pages/redeem/RedeemPage.tsx new file mode 100644 index 00000000..110fd0fd --- /dev/null +++ b/apps/frontend/src/pages/redeem/RedeemPage.tsx @@ -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(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 ( +
+ + 兑换中心 + + 请输入订单号,核对订单状态后按提示完成兑换。 + + +
+ + + + + + +
+ + {view?.status === 'not_found' ? ( + + ) : null} + + {view && + (view.status === 'unpaid' || view.status === 'cancelled' || view.status === 'refunded') ? ( + + ) : null} + + {view?.status === 'pending_certificate' ? ( + <> + + {/* 券在后台异步准备:不做自动轮询,由用户点「刷新」按需重查,避免空转请求。 */} + + + ) : null} + + {view?.status === 'redeemable' ? ( +
+ + +
+ ) : null} + + {view?.status === 'verified' ? ( +
+ + + {/* 只展示后端返回的掩码券码,绝不明文铺开 certNo。 */} + + {view.credential?.maskedCertNo || '—'} + + + {formatRedeemTime(view.credential?.verifiedAt) || '—'} + + +
+ ) : null} +
+
+ ) +} + +function RedeemStatusResult({ status, message }: { status: RedeemOrderStatus; message: string }) { + const { tone, title } = resolveRedeemStatusView(status) + + return +} + +function RedeemSummaryDetails({ summary }: { summary: RedeemOrderView['summary'] }) { + return ( + + {summary?.productName || '—'} + {summary ? summary.quantity : '—'} + + {summary ? formatRedeemAmount(summary.amountFen) : '—'} + + + {formatRedeemTime(summary?.paidAt) || '—'} + + + ) +} diff --git a/apps/frontend/src/services/redeem.ts b/apps/frontend/src/services/redeem.ts new file mode 100644 index 00000000..f931da41 --- /dev/null +++ b/apps/frontend/src/services/redeem.ts @@ -0,0 +1,16 @@ +import { apiPost } from '@/lib/http' +import type { RedeemOrderView } from '@/types/redeem' + +/** + * 固定兑换链接的公开接口封装。 + * + * 两者都返回同一个 `RedeemOrderView`:`confirm` 在不可兑换时**不抛错**, + * 会把当前状态原样返回,所以调用方必须按 `status` 渲染,不能假设一定成功。 + */ +export function lookupRedeemOrder(orderNo: string) { + return apiPost('/api/v1/redeem/lookup', { orderNo }) +} + +export function confirmRedeemOrder(orderNo: string) { + return apiPost('/api/v1/redeem/confirm', { orderNo }) +} diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index a62e2b15..f953a04f 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -453,6 +453,25 @@ margin-bottom: 0; } +/* ===== 兑换中心 (Redeem Page) ===== */ + +.redeem-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px 16px; + background: #f5f7fb; +} + +.redeem-card { + width: min(520px, 100%); +} + +.redeem-order-input { + flex: 1; + margin-bottom: 0; +} + /* ===== 买家页面响应式断点 ===== */ @media (max-width: 720px) { diff --git a/apps/frontend/src/types/redeem.ts b/apps/frontend/src/types/redeem.ts new file mode 100644 index 00000000..4796f368 --- /dev/null +++ b/apps/frontend/src/types/redeem.ts @@ -0,0 +1,39 @@ +/** + * 固定兑换链接(`https://<域名>/redeem`)的接口类型。 + * + * 与后端 `apps/backend/src/services/redeem/redeem-service.ts` 的 `RedeemOrderView` 保持一致: + * 只包含脱敏摘要,不含买家手机号 / 地址 / open_id;券码一律走 `maskedCertNo`。 + */ + +export type RedeemOrderStatus = + | 'not_found' + | 'unpaid' + | 'cancelled' + | 'refunded' + | 'pending_certificate' + | 'redeemable' + | 'verified' + +export interface RedeemOrderSummary { + productName: string + quantity: number + amountFen: number + paidAt: string | null +} + +export interface RedeemCredential { + /** 完整券码:仅保留在类型里,页面上禁止明文展示 */ + certNo: string + /** 掩码券码(前 6 后 4),页面展示用 */ + maskedCertNo: string + verifiedAt: string | null +} + +export interface RedeemOrderView { + status: RedeemOrderStatus + orderNo: string + /** 脱敏摘要(不含买家隐私字段) */ + summary: RedeemOrderSummary | null + message: string + credential: RedeemCredential | null +} diff --git a/apps/frontend/src/utils/redeem.ts b/apps/frontend/src/utils/redeem.ts new file mode 100644 index 00000000..3e1d2b74 --- /dev/null +++ b/apps/frontend/src/utils/redeem.ts @@ -0,0 +1,82 @@ +/** + * 固定兑换链接(`/redeem`)的纯展示逻辑。 + * + * 这个页面是「无 token 公开链接」:用户只输订单号,系统按订单状态决定下一步。 + * 把路径判定、金额格式化、状态 → 文案/色调集中在这里,页面只负责渲染。 + * 刻意写成纯函数:不依赖 React / antd,也不使用 `@/` 别名, + * 方便 `node --experimental-strip-types --test` 直接跑单测。 + */ +import type { RedeemOrderStatus } from '../types/redeem' + +export type { RedeemOrderStatus } + +/** 状态色调:与 antd Result / Alert 的语义色一致 */ +export type RedeemStatusTone = 'success' | 'warning' | 'error' | 'info' + +export type RedeemStatusView = { + tone: RedeemStatusTone + /** 页面主标题(中性文案,不出现平台名) */ + title: string +} + +/** + * 固定兑换链接是**路径式**(`https://<域名>/redeem`,不带 `#`), + * 判断时只认 `/redeem`(容忍一个或多个结尾斜杠),`/redeemx`、`/` 都不算。 + */ +export function isRedeemPath(pathname: unknown): boolean { + const normalized = String(pathname ?? '').trim() + if (!normalized) return false + + return normalized.replace(/\/+$/, '') === '/redeem' +} + +/** + * 分 → 元展示(与后台 `formatMoney` 同款格式)。 + * 空值 / 非法值按 0 处理:`1990 → ¥19.90`、`0 → ¥0.00`。 + */ +export function formatRedeemAmount(fen: unknown): string { + return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}` +} + +/** + * 时间 → `YYYY-MM-DD HH:mm`;空值或非法时间返回空串(调用方自行显示占位符)。 + */ +export function formatRedeemTime(value: unknown): string { + const normalized = String(value ?? '').trim() + if (!normalized) return '' + + const date = new Date(normalized) + if (Number.isNaN(date.getTime())) return '' + + const pad = (input: number) => String(input).padStart(2, '0') + const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}` + return `${datePart} ${timePart}` +} + +/** + * 订单状态 → 页面标题与色调。 + * + * 后端以后新增状态时不能崩:未知状态一律回退成中性提示。 + * 注意 `pending_certificate` 是「稍后再试」而不是「失败」,所以用 info 而不是 error。 + */ +export function resolveRedeemStatusView(status: RedeemOrderStatus): RedeemStatusView { + switch (status) { + case 'not_found': + return { tone: 'warning', title: '未找到订单' } + case 'unpaid': + return { tone: 'warning', title: '订单未支付' } + case 'cancelled': + return { tone: 'error', title: '订单已取消' } + case 'refunded': + return { tone: 'error', title: '订单已退款' } + case 'pending_certificate': + return { tone: 'info', title: '兑换券准备中' } + case 'redeemable': + return { tone: 'success', title: '可以兑换' } + case 'verified': + return { tone: 'success', title: '已兑换' } + default: + return { tone: 'info', title: '订单状态未知' } + } +} diff --git a/apps/frontend/tests/redeem.test.ts b/apps/frontend/tests/redeem.test.ts new file mode 100644 index 00000000..0ddb63a8 --- /dev/null +++ b/apps/frontend/tests/redeem.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + formatRedeemAmount, + formatRedeemTime, + isRedeemPath, + resolveRedeemStatusView, +} from '../src/utils/redeem.ts' +import type { RedeemOrderStatus } from '../src/types/redeem.ts' + +/** 故意构造后端未来可能新增的状态,验证未知状态不会崩 */ +const FUTURE_STATUS = 'reviewing' as unknown as RedeemOrderStatus + +test('isRedeemPath:只认路径式 /redeem,容忍结尾斜杠', () => { + assert.equal(isRedeemPath('/redeem'), true) + assert.equal(isRedeemPath('/redeem/'), true) + assert.equal(isRedeemPath('/redeem//'), true) +}) + +test('isRedeemPath:/redeemx、/、空串都不算兑换路径', () => { + assert.equal(isRedeemPath('/redeemx'), false) + assert.equal(isRedeemPath('/'), false) + assert.equal(isRedeemPath('/admin/redeem'), false) + assert.equal(isRedeemPath(''), false) + assert.equal(isRedeemPath(null), false) +}) + +test('formatRedeemAmount:分转元保留两位小数', () => { + assert.equal(formatRedeemAmount(1990), '¥19.90') + assert.equal(formatRedeemAmount(0), '¥0.00') + assert.equal(formatRedeemAmount(5), '¥0.05') +}) + +test('resolveRedeemStatusView:7 个状态各有标题与色调', () => { + assert.deepEqual(resolveRedeemStatusView('not_found'), { + tone: 'warning', + title: '未找到订单', + }) + assert.deepEqual(resolveRedeemStatusView('unpaid'), { tone: 'warning', title: '订单未支付' }) + assert.deepEqual(resolveRedeemStatusView('cancelled'), { tone: 'error', title: '订单已取消' }) + assert.deepEqual(resolveRedeemStatusView('refunded'), { tone: 'error', title: '订单已退款' }) + assert.deepEqual(resolveRedeemStatusView('pending_certificate'), { + tone: 'info', + title: '兑换券准备中', + }) + assert.deepEqual(resolveRedeemStatusView('redeemable'), { + tone: 'success', + title: '可以兑换', + }) + assert.deepEqual(resolveRedeemStatusView('verified'), { tone: 'success', title: '已兑换' }) +}) + +test('resolveRedeemStatusView:未知状态回退为中性提示', () => { + assert.deepEqual(resolveRedeemStatusView(FUTURE_STATUS), { + tone: 'info', + title: '订单状态未知', + }) +}) + +test('formatRedeemTime:空值或非法时间返回空串,合法时间格式化为 YYYY-MM-DD HH:mm', () => { + assert.equal(formatRedeemTime(''), '') + assert.equal(formatRedeemTime(null), '') + assert.equal(formatRedeemTime(' '), '') + assert.equal(formatRedeemTime('不是时间'), '') + assert.match(formatRedeemTime('2026-09-17T10:00:00'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) +})