forked from wangziqi/gongxue-base
feat: add student checkout pages
This commit is contained in:
@@ -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 返回参数。
|
||||
- 普通学生端不直接调用退款命令。售后入口只复制订单与客服联系信息,退款申请、审核、供应商退款和权益撤销在租户后台权限流完成。
|
||||
|
||||
## 当前租户后台页面
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
3
apps/taro/src/pages/student/checkout/index.config.ts
Normal file
3
apps/taro/src/pages/student/checkout/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '会员收银台',
|
||||
});
|
||||
269
apps/taro/src/pages/student/checkout/index.tsx
Normal file
269
apps/taro/src/pages/student/checkout/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/order-detail/index.config.ts
Normal file
3
apps/taro/src/pages/student/order-detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单详情',
|
||||
});
|
||||
200
apps/taro/src/pages/student/order-detail/index.tsx
Normal file
200
apps/taro/src/pages/student/order-detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? `®ionId=${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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、PocketBase JSON dry-run 报告 | 字段映射 UI、真实数据 dry-run 执行验收和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析和练习/模考报告第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版 | 学生端收银台/订单详情/售后入口和刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版 | 刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析和练习/模考报告第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版;下一步补学生端收银台/订单详情/售后入口和刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计增强和小程序兼容验证。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版;下一步补刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
### P1:商用收费和运营能力
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、背单词、知识手册、分数线、资料、个人中心。
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、导入问题查看、模板预览和导入后复检的第一版操作能力。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、商城收银台、订单详情、售后入口、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习和收藏复习已经有第一版页面。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
- `content_entries`
|
||||
- `content_nodes`
|
||||
@@ -70,13 +70,15 @@
|
||||
| 错题/收藏 | `apps/taro/src/pages/student/review/index.tsx` | `wrong-questions/review-plan`、`wrong-questions/resolve`、`favorites/questions`、`practice-sessions` |
|
||||
| 练习报告 | `apps/taro/src/pages/student/reports/index.tsx` | `practice-sessions/report`、`practice-reports` |
|
||||
| 视频解析 | `apps/taro/src/pages/student/video/index.tsx` | `questions/videos`、`videos/play` |
|
||||
| 会员收银台 | `apps/taro/src/pages/student/checkout/index.tsx` | `svip-plans`、`coupons/claim`、`commerce/orders`、`payments/create`、`orders/status` |
|
||||
| 订单详情 | `apps/taro/src/pages/student/order-detail/index.tsx` | `commerce/orders/detail`、`commerce/orders/status`、`payments/create` |
|
||||
| 背单词 | `apps/taro/src/pages/student/vocabulary/index.tsx` | `vocabulary-units`、`vocabulary-words`、`vocabulary/review-plan`、`vocabulary/review` |
|
||||
| 知识手册 | `apps/taro/src/pages/student/handbook/index.tsx` | `handbook-subjects`、`handbook-chapters`、`handbook-entries` |
|
||||
| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/records` |
|
||||
| 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download` |
|
||||
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard` |
|
||||
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补收银台、订单详情和小程序分享/支付。
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序分享/支付容器体验。
|
||||
|
||||
## 已落地的 Taro 租户后台页面
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
- H5 和小程序共用同一套业务 API client。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析和练习/模考报告第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;下一步补租户后台深度写操作、学生端收银台/订单详情、状态管理和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;下一步补租户后台深度写操作、刷题细节 UI、状态管理和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
8. 个人中心
|
||||
- 会员权益、订单、激活码兑换
|
||||
- 错题本、收藏夹、学习统计、排行榜、勋章
|
||||
- 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版
|
||||
|
||||
### 前端接入原则
|
||||
|
||||
@@ -210,8 +211,8 @@
|
||||
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题已接第一版;继续补订单收银台、订单详情、售后入口和刷题细节 UI。
|
||||
2. 补租户后台写操作台:公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检已接第一版;继续补导入上传 preview/import、字段映射编辑、冲突处理详情、角色模板、CRM/分佣。
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检已接第一版;继续补导入上传 preview/import、字段映射编辑、冲突处理详情、角色模板、CRM/分佣。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
|
||||
@@ -169,7 +169,7 @@ tenant:<tenantId>:theme
|
||||
| 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` |
|
||||
| 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` |
|
||||
| 资料下载/预览 | `/api/catalog/assets`、`/api/catalog/assets/preview`、`/api/catalog/assets/download` |
|
||||
| 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
|
||||
| 商城/收银台 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
|
||||
| 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` |
|
||||
| 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` |
|
||||
| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges` |
|
||||
@@ -232,6 +232,21 @@ tenant:<tenantId>:theme
|
||||
- `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。
|
||||
- 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。
|
||||
|
||||
## 学生端支付与售后契约
|
||||
|
||||
学生端 `pages/student/checkout/index` 和 `pages/student/order-detail/index` 已接第一版。前端只传递套餐、地区、优惠券和支付 provider;最终金额、优惠抵扣、订单状态、支付记录、权益发放都以后端返回为准。
|
||||
|
||||
推荐流程:
|
||||
|
||||
1. `GET /api/catalog/svip-plans` 加载可购买套餐。
|
||||
2. 如有优惠券,先调 `POST /api/commerce/coupons/claim`,仅用于领取和展示预计抵扣。
|
||||
3. 调 `POST /api/commerce/orders` 创建订单,后端会重新计算最终金额和抵扣。
|
||||
4. 非零元订单调 `POST /api/commerce/payments/create` 获取支付参数。
|
||||
5. H5 支付可跳转 provider 返回的 URL;微信小程序支付用 provider 返回参数调用 `Taro.requestPayment`。
|
||||
6. 支付后调 `/api/commerce/orders/status` 轮询状态,已支付订单的权益由后端 webhook/补偿 worker 幂等发放。
|
||||
|
||||
普通学生端不直接调用退款接口。退款申请、审核、供应商退款、全额退款权益撤销均在租户后台权限流中完成;学生端只展示订单详情、状态和售后联系入口。
|
||||
|
||||
## 勋章
|
||||
|
||||
学生个人中心或学习成就页调用:
|
||||
@@ -1364,6 +1379,8 @@ pages/student/practice/index
|
||||
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
|
||||
@@ -1376,7 +1393,7 @@ pages/student/profile/index
|
||||
```text
|
||||
src/services/catalog.ts 目录、题库、单词、手册、分数线、资料
|
||||
src/services/learning.ts 练习 session、答题、收藏、错题/收藏复习、交卷报告、单词复习
|
||||
src/services/commerce.ts 套餐、订单、权益、激活码
|
||||
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
||||
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
||||
src/services/video.ts 题目视频列表、播放签名
|
||||
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、公共题库采纳/同步、导入复检
|
||||
@@ -1396,7 +1413,7 @@ npm run build:taro:h5:platform
|
||||
|
||||
下一批前端开发重点:
|
||||
|
||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页和模考交卷报告已接第一版;下一批继续补订单收银台、订单详情、售后入口、刷题细节 UI 和小程序支付/分享。
|
||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
||||
- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检第一版;下一批继续补完整写入表单、导入上传 preview/import 操作台、字段映射编辑、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。
|
||||
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||
|
||||
Reference in New Issue
Block a user