feat: add student checkout pages

This commit is contained in:
Codex
2026-06-29 13:17:49 +08:00
parent 2776ce12a5
commit 65905aaf8f
13 changed files with 666 additions and 15 deletions

View File

@@ -34,6 +34,8 @@ pages/student/practice/index 创建练习 session、答题、收藏、反馈
pages/student/review/index 错题本、收藏夹、错题/收藏复习
pages/student/reports/index 练习报告、模考报告、历史报告
pages/student/video/index 题目视频解析、播放签名
pages/student/checkout/index 会员收银台、优惠券领取、下单、支付参数
pages/student/order-detail/index 订单详情、状态轮询、继续支付、售后入口
pages/student/vocabulary/index 单词单元、复习计划、认识/再记、收藏
pages/student/handbook/index 手册科目、章节、知识点阅读
pages/student/scoreline/index 分数线列表
@@ -41,7 +43,13 @@ pages/student/assets/index 资料列表、预览签名、下载签名
pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章
```
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、题目反馈、视频解析、交卷报告、错题本收藏夹第一版;后续应继续参照旧题库样式完善刷题细节、收银台、订单详情、售后入口和小程序兼容。
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、题目反馈、视频解析、交卷报告、错题本收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善刷题细节、支付容器体验和小程序兼容。
学生端商城链路的安全边界:
- `checkout` 只提交套餐、地区、支付 provider 和优惠券信息,最终价格、折扣、订单状态和权益发放全部以后端 `/api/commerce/*` 为准。
- H5 支付遇到支付宝 WAP 或 provider 返回的 URL 时可跳转;微信小程序支付通过 `Taro.requestPayment` 适配 provider 返回参数。
- 普通学生端不直接调用退款命令。售后入口只复制订单与客服联系信息,退款申请、审核、供应商退款和权益撤销在租户后台权限流完成。
## 当前租户后台页面

View File

@@ -9,6 +9,8 @@ export default defineAppConfig({
'pages/student/review/index',
'pages/student/reports/index',
'pages/student/video/index',
'pages/student/checkout/index',
'pages/student/order-detail/index',
'pages/student/vocabulary/index',
'pages/student/handbook/index',
'pages/student/scoreline/index',

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '会员收银台',
});

View File

@@ -0,0 +1,269 @@
import { useEffect, useMemo, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import {
claimCoupon,
createOrder,
createPayment,
loadOrderStatus,
loadSvipPlans,
type CouponClaimResult,
type OrderDetail,
type PaymentCreateResult,
type SvipPlan,
} from '@/services/commerce';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
type PayProvider = 'alipay' | 'wechat_pay' | 'manual';
const providerLabels: Record<PayProvider, string> = {
alipay: '支付宝',
wechat_pay: '微信支付',
manual: '线下支付',
};
function centsToYuan(value?: number | null) {
return ((value || 0) / 100).toFixed(2);
}
function planPriceCents(plan?: SvipPlan | null) {
if (!plan) return 0;
if (typeof plan.priceCents === 'number') return plan.priceCents;
if (typeof plan.price === 'number') return Math.round(plan.price * 100);
return 0;
}
function encodeQuery(input: Record<string, string | undefined>) {
return Object.entries(input)
.filter(([, value]) => value)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value || '')}`)
.join('&');
}
function buildPaymentUrl(result?: PaymentCreateResult) {
const params = result?.paymentParams || {};
const directUrl = params.url || params.payUrl || params.paymentUrl || params.qrCodeUrl;
if (typeof directUrl === 'string' && directUrl) return directUrl;
const gateway = params.gateway;
const query = params.query;
if (typeof gateway === 'string' && query && typeof query === 'object' && !Array.isArray(query)) {
const queryString = encodeQuery(query as Record<string, string | undefined>);
return queryString ? `${gateway}?${queryString}` : gateway;
}
return '';
}
function tryOpenPaymentUrl(url: string) {
if (!url) return false;
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.location.href = url;
return true;
}
Taro.setClipboardData({ data: url });
return true;
}
export default function StudentCheckoutPage() {
const router = useRouter();
const params = router.params || {};
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [plans, setPlans] = useState<SvipPlan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState(params.planId || '');
const [provider, setProvider] = useState<PayProvider>(params.provider === 'wechat_pay' ? 'wechat_pay' : params.provider === 'manual' ? 'manual' : 'alipay');
const [couponCode, setCouponCode] = useState(String(params.couponCode || ''));
const [coupon, setCoupon] = useState<CouponClaimResult | null>(null);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [payment, setPayment] = useState<PaymentCreateResult | null>(null);
const [busy, setBusy] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useEffect(() => {
Promise.all([
loadProfile().catch(() => ({ item: null })),
loadSvipPlans(String(params.regionId || '') || undefined).catch(() => ({ items: [] })),
])
.then(([profilePayload, planPayload]) => {
const nextPlans = planPayload.items || [];
setProfile(profilePayload.item || null);
setPlans(nextPlans);
if (!selectedPlanId && nextPlans[0]) setSelectedPlanId(nextPlans[0].id);
})
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '收银台加载失败'));
}, []);
const selectedPlan = useMemo(() => plans.find(item => item.id === selectedPlanId) || null, [plans, selectedPlanId]);
const regionId = String(params.regionId || profile?.target?.regionId || selectedPlan?.regionId || '');
const discountCents = coupon?.coupon?.discountCents || 0;
const estimateAmountCents = Math.max(0, planPriceCents(selectedPlan) - discountCents);
const paymentUrl = buildPaymentUrl(payment || undefined);
async function handleClaimCoupon() {
if (!couponCode.trim()) {
setError('请先输入优惠券码。');
return;
}
if (!selectedPlanId) {
setError('请先选择会员套餐。');
return;
}
setBusy('coupon');
setError('');
setMessage('');
try {
const payload = await claimCoupon({ code: couponCode.trim(), planId: selectedPlanId, regionId: regionId || undefined });
setCoupon(payload);
setMessage(`优惠券已领取,可抵扣 ¥${centsToYuan(payload.coupon?.discountCents || 0)}`);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '优惠券领取失败');
} finally {
setBusy('');
}
}
async function pollStatus(orderNo: string) {
const payload = await loadOrderStatus(orderNo);
if (payload.item?.status === 'paid') {
setOrder(prev => prev ? { ...prev, status: 'paid', paidAt: payload.item?.paidAt || prev.paidAt } : prev);
setMessage('订单已支付,会员权益已由后端开通。');
return;
}
setMessage(`当前订单状态:${payload.item?.status || '未知'},如已完成支付请稍后刷新。`);
}
async function handleCreateOrder() {
if (!selectedPlan) {
setError('请选择会员套餐。');
return;
}
setBusy('order');
setError('');
setMessage('');
setPayment(null);
try {
const orderPayload = await createOrder({
planId: selectedPlan.id,
regionId: regionId || undefined,
payProvider: provider,
payMethod: provider === 'alipay' ? 'wap' : provider === 'wechat_pay' ? 'jsapi' : 'manual',
couponCode: couponCode.trim() || undefined,
couponRedemptionId: coupon?.redemption?.id,
});
const nextOrder = orderPayload.item || null;
setOrder(nextOrder);
if (!nextOrder?.orderNo) {
setError('订单创建结果缺少订单号。');
return;
}
if (nextOrder.status === 'paid') {
setMessage('订单已支付,会员权益已开通。');
return;
}
if (nextOrder.amountCents === 0) {
await pollStatus(nextOrder.orderNo);
return;
}
const paymentPayload = await createPayment({
orderNo: nextOrder.orderNo,
provider,
returnUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
});
setPayment(paymentPayload.item || null);
setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '下单失败');
} finally {
setBusy('');
}
}
async function handleOpenPayment() {
if (!payment) return;
if (payment.provider === 'wechat_pay' && process.env.TARO_ENV === 'weapp') {
const paramsForWeapp = payment.paymentParams || {};
Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option)
.then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined)
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '微信支付未完成'));
return;
}
if (!paymentUrl) {
await Taro.setClipboardData({ data: JSON.stringify(payment.paymentParams || {}) });
setMessage('支付参数已复制,请交给支付容器或客服处理。');
return;
}
tryOpenPaymentUrl(paymentUrl);
}
return (
<View className='student-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>Checkout</Text>
<Text className='student-title'></Text>
<Text className='student-subtitle'></Text>
</View>
<Button className='secondary-button' onClick={() => Taro.navigateBack()}></Button>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{plans.map(item => (
<View className={`list-row ${item.id === selectedPlanId ? 'active' : ''}`} key={item.id} onClick={() => setSelectedPlanId(item.id)}>
<Text className='row-main'>{item.name} · ¥{centsToYuan(planPriceCents(item))}</Text>
<Text className='row-meta'>{item.days === -1 ? '永久会员' : `${item.days || 0}`} {item.badge ? ` · ${item.badge}` : ''}</Text>
{item.desc ? <Text className='row-meta'>{item.desc}</Text> : null}
</View>
))}
</View>
{!plans.length ? <View className='empty-state'> SVIP </View> : null}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='toolbar checkout-toolbar'>
<Input className='input' placeholder='输入优惠券码' value={couponCode} onInput={event => setCouponCode(String(event.detail.value || ''))} />
<Button className='secondary-button' loading={busy === 'coupon'} onClick={handleClaimCoupon}></Button>
</View>
{coupon?.coupon ? <Text className='success-text'> {coupon.coupon.code || couponCode} ¥{centsToYuan(coupon.coupon.discountCents || 0)}</Text> : null}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='toolbar wrap'>
{(['alipay', 'wechat_pay', 'manual'] as PayProvider[]).map(item => (
<Button key={item} className={`pill-button ${provider === item ? 'active' : ''}`} onClick={() => setProvider(item)}>
{providerLabels[item]}
</Button>
))}
</View>
</View>
<View className='section-block'>
<View className='quiet-panel'>
<View className='amount-row'>
<Text className='row-meta'></Text>
<Text className='amount-text'>¥{centsToYuan(order?.amountCents ?? estimateAmountCents)}</Text>
</View>
<Text className='row-meta'>{profile?.target?.regionName || regionId || '跟随套餐'} · {providerLabels[provider]}</Text>
{order?.orderNo ? <Text className='row-meta'>{order.orderNo} · {order.status}</Text> : null}
<View className='toolbar wrap checkout-actions'>
<Button className='primary-button' loading={busy === 'order'} onClick={handleCreateOrder}>
{order?.orderNo ? '重新发起支付' : '提交订单'}
</Button>
{payment ? <Button className='primary-button' onClick={handleOpenPayment}></Button> : null}
{order?.orderNo ? <Button className='secondary-button' onClick={() => pollStatus(order.orderNo)}></Button> : null}
{order?.orderNo ? <Button className='secondary-button' onClick={() => Taro.navigateTo({ url: `/pages/student/order-detail/index?orderNo=${encodeURIComponent(order.orderNo)}` })}></Button> : null}
</View>
{payment?.provider === 'manual' ? <Text className='row-meta'>线</Text> : null}
{paymentUrl ? <Text className='row-meta break-text'>{paymentUrl}</Text> : null}
</View>
</View>
{message ? <Text className='success-text'>{message}</Text> : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单详情',
});

View File

@@ -0,0 +1,200 @@
import { useEffect, useMemo, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import {
createPayment,
loadOrderDetail,
loadOrderStatus,
type OrderDetail,
type OrderStatus,
type PaymentCreateResult,
} from '@/services/commerce';
import { getTenantContext } from '@/services/api';
import '../student.css';
function centsToYuan(value?: number | null) {
return ((value || 0) / 100).toFixed(2);
}
function recordText(record: Record<string, unknown>, keys: string[], fallback = '-') {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value) return value;
if (typeof value === 'number') return String(value);
}
return fallback;
}
function encodeQuery(input: Record<string, string | undefined>) {
return Object.entries(input)
.filter(([, value]) => value)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value || '')}`)
.join('&');
}
function paymentUrl(result?: PaymentCreateResult | null) {
const params = result?.paymentParams || {};
const direct = params.url || params.payUrl || params.paymentUrl || params.qrCodeUrl;
if (typeof direct === 'string' && direct) return direct;
const gateway = params.gateway;
const query = params.query;
if (typeof gateway === 'string' && query && typeof query === 'object' && !Array.isArray(query)) {
const queryString = encodeQuery(query as Record<string, string | undefined>);
return queryString ? `${gateway}?${queryString}` : gateway;
}
return '';
}
export default function StudentOrderDetailPage() {
const router = useRouter();
const orderNo = router.params?.orderNo || '';
const tenant = getTenantContext();
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [status, setStatus] = useState<OrderStatus | null>(null);
const [payment, setPayment] = useState<PaymentCreateResult | null>(null);
const [busy, setBusy] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const items = useMemo(() => detail?.items || [], [detail]);
const payments = useMemo(() => detail?.payments || [], [detail]);
const redemptions = useMemo(() => detail?.couponRedemptions || [], [detail]);
const payUrl = paymentUrl(payment);
const canContinuePay = detail?.status === 'pending' || status?.status === 'pending';
function reload() {
if (!orderNo) {
setError('缺少订单号。');
return;
}
setBusy('load');
setError('');
Promise.all([
loadOrderDetail(orderNo),
loadOrderStatus(orderNo),
])
.then(([detailPayload, statusPayload]) => {
setDetail(detailPayload.item || null);
setStatus(statusPayload.item || null);
})
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '订单加载失败'))
.finally(() => setBusy(''));
}
useEffect(() => {
reload();
}, [orderNo]);
async function handleContinuePay(provider?: string | null) {
if (!orderNo) return;
setBusy('pay');
setError('');
setMessage('');
try {
const payload = await createPayment({
orderNo,
provider: provider || detail?.payProvider || status?.payProvider || 'alipay',
returnUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
});
const nextPayment = payload.item || null;
setPayment(nextPayment);
const url = paymentUrl(nextPayment);
if (url && process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.location.href = url;
} else if (url) {
await Taro.setClipboardData({ data: url });
setMessage('支付链接已复制。');
} else {
setMessage(nextPayment?.provider === 'manual' ? '线下支付订单已生成,请联系教务或客服确认。' : '支付参数已生成。');
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '继续支付失败');
} finally {
setBusy('');
}
}
async function copyAfterSalesInfo() {
const serviceText = tenant?.branding?.slogan || tenant?.branding?.brandName || '请联系当前租户客服处理售后';
await Taro.setClipboardData({
data: `订单号:${orderNo}\n售后说明${serviceText}`,
});
setMessage('订单售后信息已复制。');
}
return (
<View className='student-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>Order</Text>
<Text className='student-title'></Text>
<Text className='student-subtitle'>退</Text>
</View>
<Button className='secondary-button' onClick={() => Taro.navigateBack()}></Button>
</View>
<View className='section-block'>
<View className='quiet-panel'>
<View className='amount-row'>
<Text className='row-meta'>{detail?.orderNo || orderNo}</Text>
<Text className='status-badge'>{status?.status || detail?.status || '加载中'}</Text>
</View>
<Text className='row-main'>{detail?.productName || '会员订单'}</Text>
<Text className='amount-text'>¥{centsToYuan(detail?.amountCents ?? status?.amountCents)}</Text>
<Text className='row-meta'>退 ¥{centsToYuan(detail?.refundedAmountCents ?? status?.refundedAmountCents)} · {detail?.payProvider || status?.payProvider || '-'}</Text>
<Text className='row-meta'> {detail?.createdAt || '-'} · {detail?.paidAt || status?.paidAt || '-'}</Text>
<View className='toolbar wrap checkout-actions'>
<Button className='secondary-button' loading={busy === 'load'} onClick={reload}></Button>
{canContinuePay ? <Button className='primary-button' loading={busy === 'pay'} onClick={() => handleContinuePay(detail?.payProvider)}></Button> : null}
<Button className='secondary-button' onClick={copyAfterSalesInfo}></Button>
</View>
{payUrl ? <Text className='row-meta break-text'>{payUrl}</Text> : null}
</View>
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{items.map((item, index) => (
<View className='list-row' key={recordText(item, ['id', 'itemId'], `item-${index}`)}>
<Text className='row-main'>{recordText(item, ['name', 'itemType'])}</Text>
<Text className='row-meta'> {recordText(item, ['quantity'])} · ¥{centsToYuan(Number(item.totalAmountCents || 0))}</Text>
</View>
))}
</View>
{!items.length ? <View className='empty-state'></View> : null}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{payments.map((item, index) => (
<View className='list-row' key={recordText(item, ['id', 'providerTradeNo'], `payment-${index}`)}>
<Text className='row-main'>{recordText(item, ['provider'])} · {recordText(item, ['status'])}</Text>
<Text className='row-meta'>{recordText(item, ['method'])} · ¥{centsToYuan(Number(item.amountCents || 0))}</Text>
<Text className='row-meta'> {recordText(item, ['providerTradeNo'])}</Text>
</View>
))}
</View>
{!payments.length ? <View className='empty-state'></View> : null}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{redemptions.map((item, index) => (
<View className='list-row' key={recordText(item, ['id', 'couponCode'], `coupon-${index}`)}>
<Text className='row-main'>{recordText(item, ['couponCode'])}</Text>
<Text className='row-meta'>{recordText(item, ['status'])} · ¥{centsToYuan(Number(item.discountAppliedCents || 0))}</Text>
</View>
))}
</View>
{!redemptions.length ? <View className='empty-state'></View> : null}
</View>
{message ? <Text className='success-text'>{message}</Text> : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);
}

View File

@@ -110,12 +110,15 @@ export default function StudentProfilePage() {
<Text className='section-heading'></Text>
<View className='list-stack'>
{plans.slice(0, 4).map(item => (
<View className='list-row' key={item.id}>
<View className='list-row' key={item.id} onClick={() => Taro.navigateTo({ url: `/pages/student/checkout/index?planId=${encodeURIComponent(item.id)}${profile?.target?.regionId ? `&regionId=${encodeURIComponent(profile.target.regionId)}` : ''}` })}>
<Text className='row-main'>{item.name} · ¥{String(item.price ?? 0)}</Text>
<Text className='row-meta'>{item.days === -1 ? '永久' : `${item.days || 0}`} {item.badge ? ` · ${item.badge}` : ''}</Text>
</View>
))}
</View>
<View className='toolbar wrap'>
<Button className='primary-button' onClick={() => Taro.navigateTo({ url: `/pages/student/checkout/index${profile?.target?.regionId ? `?regionId=${encodeURIComponent(profile.target.regionId)}` : ''}` })}></Button>
</View>
</View>
<View className='section-block'>
@@ -130,7 +133,7 @@ export default function StudentProfilePage() {
<Text className='section-heading'></Text>
<View className='list-stack'>
{orders.slice(0, 5).map(item => (
<View className='list-row' key={item.id}>
<View className='list-row' key={item.id} onClick={() => Taro.navigateTo({ url: `/pages/student/order-detail/index?orderNo=${encodeURIComponent(item.orderNo)}` })}>
<Text className='row-main'>{item.productName || item.orderNo}</Text>
<Text className='row-meta'>{item.status} · ¥{((item.amountCents || 0) / 100).toFixed(2)}</Text>
</View>

View File

@@ -242,3 +242,51 @@
border-radius: 8px;
background: #0f172a;
}
.checkout-toolbar {
align-items: center;
overflow-x: visible;
}
.checkout-toolbar .input {
flex: 1;
min-width: 0;
}
.checkout-actions {
margin-top: 18px;
}
.amount-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.amount-text {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 40px;
font-weight: 850;
line-height: 1.15;
}
.status-badge {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border-radius: 8px;
background: #ecfdf5;
color: #047857;
font-size: 22px;
font-weight: 750;
}
.break-text {
word-break: break-all;
overflow-wrap: anywhere;
}

View File

@@ -7,6 +7,7 @@ export interface SvipPlan {
priceCents?: number;
originalPrice?: number | null;
days?: number;
regionId?: string | null;
desc?: string | null;
badge?: string | null;
recommended?: boolean;
@@ -16,10 +17,75 @@ export interface OrderItem {
id: string;
orderNo: string;
status: string;
productType?: string | null;
productName?: string | null;
amountCents?: number;
amount?: number;
refundedAmountCents?: number;
payMethod?: string | null;
payProvider?: string | null;
tradeNo?: string | null;
planId?: string | null;
days?: number | null;
regionId?: string | null;
paidAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface OrderDetail extends OrderItem {
pricing?: Record<string, unknown> | null;
payments?: Record<string, unknown>[];
items?: Record<string, unknown>[];
couponRedemptions?: Record<string, unknown>[];
}
export interface OrderStatus {
orderNo: string;
status: string;
amountCents?: number;
amount?: number;
refundedAmountCents?: number;
payProvider?: string | null;
payMethod?: string | null;
tradeNo?: string | null;
paidAt?: string | null;
updatedAt?: string;
payment?: Record<string, unknown> | null;
}
export interface PaymentCreateResult {
orderNo: string;
provider: 'wechat_pay' | 'alipay' | 'manual' | string;
method: string;
paymentParams?: Record<string, unknown>;
}
export interface CouponClaimResult {
valid?: boolean;
idempotent?: boolean;
coupon?: {
code?: string;
discountType?: string | null;
discountValue?: number | null;
discountCents?: number;
redemptionId?: string | null;
redemptionStatus?: string | null;
plan?: {
id?: string;
name?: string;
price?: number;
priceCents?: number;
days?: number;
regionId?: string | null;
};
};
redemption?: {
id?: string;
status?: string;
claimedAt?: string | null;
usedAt?: string | null;
};
}
export async function loadSvipPlans(regionId?: string) {
@@ -33,8 +99,9 @@ export async function createOrder(body: {
payProvider?: string;
quantity?: number;
couponCode?: string;
couponRedemptionId?: string;
}) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/commerce/orders', {
return apiRequest<{ item?: OrderDetail }>('/api/commerce/orders', {
method: 'POST',
body,
});
@@ -44,6 +111,34 @@ export async function loadOrders(limit = 20) {
return apiRequest<{ items?: OrderItem[] }>('/api/commerce/orders', { query: { limit } });
}
export async function loadOrderDetail(orderNo: string) {
return apiRequest<{ item?: OrderDetail }>('/api/commerce/orders/detail', { query: { orderNo } });
}
export async function loadOrderStatus(orderNo: string) {
return apiRequest<{ item?: OrderStatus }>('/api/commerce/orders/status', { query: { orderNo } });
}
export async function claimCoupon(body: { code: string; planId?: string; regionId?: string }) {
return apiRequest<CouponClaimResult>('/api/commerce/coupons/claim', {
method: 'POST',
body,
});
}
export async function createPayment(body: {
orderNo: string;
provider?: 'wechat_pay' | 'alipay' | 'manual' | string;
openId?: string;
returnUrl?: string;
quitUrl?: string;
}) {
return apiRequest<{ item?: PaymentCreateResult }>('/api/commerce/payments/create', {
method: 'POST',
body,
});
}
export async function loadEntitlements() {
return apiRequest<{ items?: Record<string, unknown>[]; summary?: Record<string, unknown> }>('/api/commerce/entitlements');
}