From 65905aaf8f2ce2894795e5a102c26451f1ee8bc4 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 13:17:49 +0800 Subject: [PATCH] feat: add student checkout pages --- apps/taro/README.md | 10 +- apps/taro/src/app.config.ts | 2 + .../pages/student/checkout/index.config.ts | 3 + .../taro/src/pages/student/checkout/index.tsx | 269 ++++++++++++++++++ .../student/order-detail/index.config.ts | 3 + .../src/pages/student/order-detail/index.tsx | 200 +++++++++++++ apps/taro/src/pages/student/profile/index.tsx | 7 +- apps/taro/src/pages/student/student.css | 48 ++++ apps/taro/src/services/commerce.ts | 97 ++++++- docs/refactor/backend-handoff-roadmap.md | 4 +- docs/refactor/frontend-handoff-index.md | 8 +- docs/refactor/next-development-todo.md | 7 +- docs/refactor/taro-frontend-integration.md | 23 +- 13 files changed, 666 insertions(+), 15 deletions(-) create mode 100644 apps/taro/src/pages/student/checkout/index.config.ts create mode 100644 apps/taro/src/pages/student/checkout/index.tsx create mode 100644 apps/taro/src/pages/student/order-detail/index.config.ts create mode 100644 apps/taro/src/pages/student/order-detail/index.tsx diff --git a/apps/taro/README.md b/apps/taro/README.md index e3d0b668..21177a7d 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -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 返回参数。 +- 普通学生端不直接调用退款命令。售后入口只复制订单与客服联系信息,退款申请、审核、供应商退款和权益撤销在租户后台权限流完成。 ## 当前租户后台页面 diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index 3a76a42e..217d518f 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -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', diff --git a/apps/taro/src/pages/student/checkout/index.config.ts b/apps/taro/src/pages/student/checkout/index.config.ts new file mode 100644 index 00000000..00c38807 --- /dev/null +++ b/apps/taro/src/pages/student/checkout/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '会员收银台', +}); diff --git a/apps/taro/src/pages/student/checkout/index.tsx b/apps/taro/src/pages/student/checkout/index.tsx new file mode 100644 index 00000000..88a4f941 --- /dev/null +++ b/apps/taro/src/pages/student/checkout/index.tsx @@ -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 = { + 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) { + 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); + 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(null); + const [plans, setPlans] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(params.planId || ''); + const [provider, setProvider] = useState(params.provider === 'wechat_pay' ? 'wechat_pay' : params.provider === 'manual' ? 'manual' : 'alipay'); + const [couponCode, setCouponCode] = useState(String(params.couponCode || '')); + const [coupon, setCoupon] = useState(null); + const [order, setOrder] = useState(null); + const [payment, setPayment] = useState(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 ( + + + + Checkout + 会员收银台 + 金额、优惠、权益和支付状态全部由后端计算与确认。 + + + + + + 选择套餐 + + {plans.map(item => ( + setSelectedPlanId(item.id)}> + {item.name} · ¥{centsToYuan(planPriceCents(item))} + {item.days === -1 ? '永久会员' : `${item.days || 0} 天`} {item.badge ? ` · ${item.badge}` : ''} + {item.desc ? {item.desc} : null} + + ))} + + {!plans.length ? 暂无可购买套餐,请联系租户后台配置 SVIP 套餐。 : null} + + + + 优惠券 + + setCouponCode(String(event.detail.value || ''))} /> + + + {coupon?.coupon ? 优惠券 {coupon.coupon.code || couponCode}:抵扣 ¥{centsToYuan(coupon.coupon.discountCents || 0)} : null} + + + + 支付方式 + + {(['alipay', 'wechat_pay', 'manual'] as PayProvider[]).map(item => ( + + ))} + + + + + + + 预估应付 + ¥{centsToYuan(order?.amountCents ?? estimateAmountCents)} + + 地区:{profile?.target?.regionName || regionId || '跟随套餐'} · 支付:{providerLabels[provider]} + {order?.orderNo ? 订单号:{order.orderNo} · {order.status} : null} + + + {payment ? : null} + {order?.orderNo ? : null} + {order?.orderNo ? : null} + + {payment?.provider === 'manual' ? 线下支付不会由学生端确认,需租户后台具备支付权限的人员确认后才会开通权益。 : null} + {paymentUrl ? 支付链接:{paymentUrl} : null} + + + + {message ? {message} : null} + {error ? {error} : null} + + ); +} diff --git a/apps/taro/src/pages/student/order-detail/index.config.ts b/apps/taro/src/pages/student/order-detail/index.config.ts new file mode 100644 index 00000000..0e3abb18 --- /dev/null +++ b/apps/taro/src/pages/student/order-detail/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '订单详情', +}); diff --git a/apps/taro/src/pages/student/order-detail/index.tsx b/apps/taro/src/pages/student/order-detail/index.tsx new file mode 100644 index 00000000..41e00965 --- /dev/null +++ b/apps/taro/src/pages/student/order-detail/index.tsx @@ -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, 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) { + 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); + 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(null); + const [status, setStatus] = useState(null); + const [payment, setPayment] = useState(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 ( + + + + Order + 订单详情 + 学生端只查看订单和售后入口,退款审核与确认在租户后台完成。 + + + + + + + + {detail?.orderNo || orderNo} + {status?.status || detail?.status || '加载中'} + + {detail?.productName || '会员订单'} + ¥{centsToYuan(detail?.amountCents ?? status?.amountCents)} + 已退金额 ¥{centsToYuan(detail?.refundedAmountCents ?? status?.refundedAmountCents)} · 支付方式 {detail?.payProvider || status?.payProvider || '-'} + 创建时间 {detail?.createdAt || '-'} · 支付时间 {detail?.paidAt || status?.paidAt || '-'} + + + {canContinuePay ? : null} + + + {payUrl ? 支付链接:{payUrl} : null} + + + + + 订单项目 + + {items.map((item, index) => ( + + {recordText(item, ['name', 'itemType'])} + 数量 {recordText(item, ['quantity'])} · 小计 ¥{centsToYuan(Number(item.totalAmountCents || 0))} + + ))} + + {!items.length ? 暂无订单项目。 : null} + + + + 支付记录 + + {payments.map((item, index) => ( + + {recordText(item, ['provider'])} · {recordText(item, ['status'])} + {recordText(item, ['method'])} · ¥{centsToYuan(Number(item.amountCents || 0))} + 交易号 {recordText(item, ['providerTradeNo'])} + + ))} + + {!payments.length ? 暂无支付记录。 : null} + + + + 优惠记录 + + {redemptions.map((item, index) => ( + + {recordText(item, ['couponCode'])} + {recordText(item, ['status'])} · 抵扣 ¥{centsToYuan(Number(item.discountAppliedCents || 0))} + + ))} + + {!redemptions.length ? 暂无优惠记录。 : null} + + + {message ? {message} : null} + {error ? {error} : null} + + ); +} diff --git a/apps/taro/src/pages/student/profile/index.tsx b/apps/taro/src/pages/student/profile/index.tsx index acc29841..ea941d20 100644 --- a/apps/taro/src/pages/student/profile/index.tsx +++ b/apps/taro/src/pages/student/profile/index.tsx @@ -110,12 +110,15 @@ export default function StudentProfilePage() { 会员套餐 {plans.slice(0, 4).map(item => ( - + Taro.navigateTo({ url: `/pages/student/checkout/index?planId=${encodeURIComponent(item.id)}${profile?.target?.regionId ? `®ionId=${encodeURIComponent(profile.target.regionId)}` : ''}` })}> {item.name} · ¥{String(item.price ?? 0)} {item.days === -1 ? '永久' : `${item.days || 0} 天`} {item.badge ? ` · ${item.badge}` : ''} ))} + + + @@ -130,7 +133,7 @@ export default function StudentProfilePage() { 订单 {orders.slice(0, 5).map(item => ( - + Taro.navigateTo({ url: `/pages/student/order-detail/index?orderNo=${encodeURIComponent(item.orderNo)}` })}> {item.productName || item.orderNo} {item.status} · ¥{((item.amountCents || 0) / 100).toFixed(2)} diff --git a/apps/taro/src/pages/student/student.css b/apps/taro/src/pages/student/student.css index 57767df9..d779c9e0 100644 --- a/apps/taro/src/pages/student/student.css +++ b/apps/taro/src/pages/student/student.css @@ -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; +} diff --git a/apps/taro/src/services/commerce.ts b/apps/taro/src/services/commerce.ts index d2a65ccf..c5a05221 100644 --- a/apps/taro/src/services/commerce.ts +++ b/apps/taro/src/services/commerce.ts @@ -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 | null; + payments?: Record[]; + items?: Record[]; + couponRedemptions?: Record[]; +} + +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 | null; +} + +export interface PaymentCreateResult { + orderNo: string; + provider: 'wechat_pay' | 'alipay' | 'manual' | string; + method: string; + paymentParams?: Record; +} + +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 }>('/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('/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[]; summary?: Record }>('/api/commerce/entitlements'); } diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 3645aa19..d9bd93bb 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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:商用收费和运营能力 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 33306264..b437c74f 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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 租户后台页面 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index ddfb88f3..36483262 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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 迁移。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 4765433d..aaf5c0c3 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -169,7 +169,7 @@ tenant::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::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/*` 作为小程序登录适配层。