Files
soul-yongping/components/payment-modal.tsx
卡若 b60edb3d47 feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API
主要更新:
1. 按H5网页端完全重构匹配功能(match页面)
   - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募
   - 资源对接等类型弹出手机号/微信号输入框
   - 去掉重新匹配按钮,改为返回按钮

2. 修复所有卡片对齐和宽度问题
   - 目录页附录卡片居中
   - 首页阅读进度卡片满宽度
   - 我的页面菜单卡片对齐
   - 推广中心分享卡片统一宽度

3. 修复目录页图标和文字对齐
   - section-icon固定40rpx宽高
   - section-title与图标垂直居中

4. 更新真实完整文章标题(62篇)
   - 从book目录读取真实markdown文件名
   - 替换之前的简化标题

5. 新增文章数据API
   - /api/db/chapters - 获取完整书籍结构
   - 支持按ID获取单篇文章内容
2026-01-21 15:49:12 +08:00

686 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useState, useEffect, useCallback, useRef } from "react"
import { X, CheckCircle, Bitcoin, Globe, Copy, Check, QrCode, Shield, Users, Loader2, AlertCircle } from "lucide-react"
import { useStore } from "@/lib/store"
import QRCode from "qrcode"
const WechatIcon = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
</svg>
)
type PaymentMethod = "wechat" | "alipay" | "usdt" | "paypal" | "stripe" | "bank"
interface PaymentModalProps {
isOpen: boolean
onClose: () => void
type: "section" | "fullbook"
sectionId?: string
sectionTitle?: string
amount: number
onSuccess: () => void
}
// 支付状态类型
type PaymentState = "idle" | "creating" | "paying" | "polling" | "success" | "error"
export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, amount, onSuccess }: PaymentModalProps) {
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat")
const [paymentState, setPaymentState] = useState<PaymentState>("idle")
const [errorMessage, setErrorMessage] = useState("")
const [showQRCode, setShowQRCode] = useState(false)
const [qrCodeDataUrl, setQrCodeDataUrl] = useState("")
const [paymentUrl, setPaymentUrl] = useState("")
const [orderSn, setOrderSn] = useState("")
const [tradeSn, setTradeSn] = useState("")
const [currentGateway, setCurrentGateway] = useState("") // 当前支付网关
const [copied, setCopied] = useState(false)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
const pollingCountRef = useRef(0)
const { purchaseSection, purchaseFullBook, user, settings } = useStore()
const paymentConfig = settings?.paymentMethods || {
wechat: { enabled: true, qrCode: "", account: "", groupQrCode: "" },
alipay: { enabled: true, qrCode: "", account: "" },
usdt: { enabled: true, network: "TRC20", address: "", exchangeRate: 7.2 },
paypal: { enabled: false, email: "", exchangeRate: 7.2 },
}
const usdtAmount = (amount / (paymentConfig.usdt?.exchangeRate || 7.2)).toFixed(2)
const paypalAmount = (amount / (paymentConfig.paypal?.exchangeRate || 7.2)).toFixed(2)
// 清理轮询
const clearPolling = useCallback(() => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
pollingCountRef.current = 0
}, [])
// 重置状态
const resetState = useCallback(() => {
setPaymentState("idle")
setShowQRCode(false)
setQrCodeDataUrl("")
setPaymentUrl("")
setOrderSn("")
setTradeSn("")
setCurrentGateway("")
setErrorMessage("")
clearPolling()
}, [clearPolling])
useEffect(() => {
if (isOpen) {
resetState()
}
return () => {
clearPolling()
}
}, [isOpen, resetState, clearPolling])
// 创建订单并获取支付参数
const createPaymentOrder = async () => {
setPaymentState("creating")
setErrorMessage("")
try {
const response = await fetch("/api/payment/create-order", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: user?.id || "anonymous",
type,
sectionId,
sectionTitle,
amount,
paymentMethod,
referralCode: user?.referredBy,
}),
})
const result = await response.json()
if (result.code !== 200) {
throw new Error(result.message || "创建订单失败")
}
const { orderSn: newOrderSn, tradeSn: newTradeSn, paymentData, gateway } = result.data
setOrderSn(newOrderSn)
setTradeSn(newTradeSn)
// 保存网关信息用于后续查询
const gatewayId = gateway || (paymentMethod === "wechat" ? "wechat_native" : "alipay_wap")
setCurrentGateway(gatewayId)
// 根据支付方式处理不同的返回数据
if (paymentData.type === "url") {
// URL类型跳转支付支付宝WAP/WEB
setPaymentUrl(paymentData.payload)
setShowQRCode(true)
setPaymentState("paying")
// 打开支付页面
window.open(paymentData.payload, "_blank")
// 开始轮询支付状态,传递网关信息
startPolling(newTradeSn, gatewayId)
} else if (paymentData.type === "qrcode") {
// 二维码类型扫码支付微信Native/支付宝QR
const qrUrl = paymentData.payload
// 生成二维码图片
const dataUrl = await QRCode.toDataURL(qrUrl, {
width: 200,
margin: 2,
color: {
dark: "#000000",
light: "#ffffff",
},
})
setQrCodeDataUrl(dataUrl)
setPaymentUrl(qrUrl)
setShowQRCode(true)
setPaymentState("paying")
// 开始轮询支付状态,传递网关信息
startPolling(newTradeSn, gatewayId)
} else if (paymentData.type === "json") {
// JSON类型JSAPI支付需要调用JS SDK
console.log("JSAPI支付参数:", paymentData.payload)
// 这里需要调用微信JS SDK
setPaymentState("error")
setErrorMessage("JSAPI支付需要在微信内打开")
}
} catch (error) {
console.error("创建订单失败:", error)
setPaymentState("error")
setErrorMessage(error instanceof Error ? error.message : "创建订单失败,请重试")
}
}
// 轮询支付状态
const startPolling = (tradeSnToQuery: string, gateway?: string) => {
setPaymentState("polling")
pollingCountRef.current = 0
pollingRef.current = setInterval(async () => {
pollingCountRef.current++
// 最多轮询60次5分钟
if (pollingCountRef.current > 60) {
clearPolling()
setPaymentState("error")
setErrorMessage("支付超时,请重新发起支付")
return
}
try {
// 构建查询URL包含网关参数以提高查询准确性
let queryUrl = `/api/payment/query?tradeSn=${tradeSnToQuery}`
if (gateway) {
queryUrl += `&gateway=${gateway}`
}
const response = await fetch(queryUrl)
const result = await response.json()
if (result.code === 200 && result.data) {
const { status } = result.data
if (status === "paid") {
// 支付成功
clearPolling()
await handlePaymentSuccess()
} else if (status === "closed" || status === "refunded") {
// 订单已关闭
clearPolling()
setPaymentState("error")
setErrorMessage("订单已关闭")
}
// paying状态继续轮询
}
} catch (error) {
console.error("查询支付状态失败:", error)
// 查询失败继续轮询
}
}, 5000) // 每5秒查询一次
}
// 处理支付成功
const handlePaymentSuccess = async () => {
setPaymentState("success")
// 调用store更新购买状态
let success = false
if (type === "section" && sectionId) {
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
} else if (type === "fullbook") {
success = await purchaseFullBook(paymentMethod)
}
// 打开社群二维码
const groupUrl = paymentConfig.wechat?.groupQrCode
if (groupUrl) {
setTimeout(() => {
window.open(groupUrl, "_blank")
}, 800)
}
// 关闭弹窗
setTimeout(() => {
onSuccess()
onClose()
resetState()
}, 2500)
}
// 手动确认支付(用于轮询失效的情况)
const handleManualConfirm = async () => {
if (!tradeSn) return
setPaymentState("polling")
try {
// 构建查询URL包含网关参数
let queryUrl = `/api/payment/query?tradeSn=${tradeSn}`
if (currentGateway) {
queryUrl += `&gateway=${currentGateway}`
}
const response = await fetch(queryUrl)
const result = await response.json()
if (result.code === 200 && result.data?.status === "paid") {
await handlePaymentSuccess()
} else {
setErrorMessage("未检测到支付,请确认是否已完成支付")
setPaymentState("paying")
}
} catch (error) {
setErrorMessage("查询支付状态失败,请稍后重试")
setPaymentState("paying")
}
}
const handleCopyAddress = (address: string) => {
navigator.clipboard.writeText(address)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
// 处理开始支付
const handlePayment = async () => {
// USDT和PayPal使用旧的手动确认流程
if (paymentMethod === "usdt" || paymentMethod === "paypal") {
setShowQRCode(true)
return
}
// 微信和支付宝使用新的API流程
await createPaymentOrder()
}
// USDT/PayPal手动确认
const handleCryptoConfirm = async () => {
setPaymentState("creating")
// 模拟确认支付(实际需要人工审核)
await new Promise((resolve) => setTimeout(resolve, 1000))
let success = false
if (type === "section" && sectionId) {
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
} else if (type === "fullbook") {
success = await purchaseFullBook(paymentMethod)
}
if (success) {
await handlePaymentSuccess()
} else {
setPaymentState("error")
setErrorMessage("确认支付失败,请联系客服")
}
}
if (!isOpen) return null
const paymentMethods: {
id: PaymentMethod
name: string
icon: React.ReactNode
color: string
iconBg: string
enabled: boolean
extra?: string
}[] = [
{
id: "wechat",
name: "微信支付",
icon: <WechatIcon />,
color: "#07C160",
iconBg: "rgba(7, 193, 96, 0.15)",
enabled: paymentConfig.wechat?.enabled ?? true,
},
{
id: "usdt",
name: `USDT (${paymentConfig.usdt?.network || "TRC20"})`,
icon: <Bitcoin className="w-5 h-5" />,
color: "#26A17B",
iconBg: "rgba(38, 161, 123, 0.15)",
enabled: paymentConfig.usdt?.enabled ?? false,
extra: `$${usdtAmount}`,
},
{
id: "paypal",
name: "PayPal",
icon: <Globe className="w-5 h-5" />,
color: "#003087",
iconBg: "rgba(0, 48, 135, 0.15)",
enabled: paymentConfig.paypal?.enabled ?? false,
extra: `$${paypalAmount}`,
},
]
const availableMethods = paymentMethods.filter((m) => m.enabled)
// 二维码/详情页面
if (showQRCode) {
const isCrypto = paymentMethod === "usdt"
const isPaypal = paymentMethod === "paypal"
const isWechat = paymentMethod === "wechat"
const isAlipay = paymentMethod === "alipay"
let address = ""
let displayAmount = `¥${amount.toFixed(2)}`
let title = "扫码支付"
let hint = "支付完成后,系统将自动确认"
if (isCrypto) {
address = paymentConfig.usdt?.address || ""
displayAmount = `$${usdtAmount} USDT`
title = "USDT支付"
hint = "请转账到以下地址,完成后点击确认"
} else if (isPaypal) {
address = paymentConfig.paypal?.email || ""
displayAmount = `$${paypalAmount} USD`
title = "PayPal支付"
hint = "请转账到以下PayPal账户"
} else if (isWechat) {
title = "微信支付"
hint = "请使用微信扫描二维码支付"
} else if (isAlipay) {
title = "支付宝支付"
hint = paymentUrl?.startsWith("http") ? "已打开支付页面,请在新窗口完成支付" : "请使用支付宝扫描二维码支付"
}
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-md modal-overlay" onClick={onClose} />
<div className="relative w-full sm:max-w-md glass-modal overflow-hidden safe-bottom modal-content sm:m-4">
{/* 顶部把手 - 仅移动端 */}
<div className="flex justify-center pt-3 pb-2 sm:hidden">
<div className="w-9 h-1 rounded-full bg-[var(--app-text-tertiary)]" />
</div>
<button
onClick={onClose}
className="absolute top-4 right-4 w-8 h-8 rounded-full bg-[var(--app-bg-secondary)] flex items-center justify-center text-[var(--app-text-secondary)] hover:text-white z-10 touch-feedback"
>
<X className="w-4 h-4" />
</button>
<div className="p-6 pt-4 sm:pt-10">
{/* 标题 */}
<h3 className="text-xl font-semibold text-white text-center mb-2">{title}</h3>
{/* 金额显示 */}
<div className="text-center mb-6">
<p className="text-4xl font-bold text-[var(--app-brand)] glow-text">{displayAmount}</p>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="glass-card p-4 mb-4 border-red-500/30">
<div className="flex items-center gap-3 text-red-400">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
</div>
)}
{/* QR Code Display - 微信/支付宝 */}
{(isWechat || isAlipay) && (
<div className="flex flex-col items-center mb-6">
<div className="w-52 h-52 bg-white rounded-2xl p-4 mb-4 flex items-center justify-center shadow-lg">
{paymentState === "creating" ? (
<div className="flex flex-col items-center text-gray-400">
<Loader2 className="w-12 h-12 animate-spin mb-2 text-[var(--app-brand)]" />
<span className="text-sm text-gray-600">...</span>
</div>
) : qrCodeDataUrl ? (
<img
src={qrCodeDataUrl}
alt="支付二维码"
className="w-full h-full object-contain rounded-lg"
/>
) : paymentUrl?.startsWith("http") ? (
<div className="flex flex-col items-center text-gray-600">
<CheckCircle className="w-12 h-12 text-green-500 mb-2" />
<span className="text-sm text-center"></span>
</div>
) : (
<div className="flex flex-col items-center text-gray-400">
<QrCode className="w-16 h-16 mb-2" />
<span className="text-sm text-center">...</span>
</div>
)}
</div>
<p className="text-[var(--app-text-tertiary)] text-sm">{hint}</p>
{/* 轮询状态指示 */}
{paymentState === "polling" && (
<div className="flex items-center gap-2 mt-3 text-[var(--app-brand)]">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span>
</div>
)}
</div>
)}
{/* Crypto/PayPal Address */}
{(isCrypto || isPaypal) && (
<div className="mb-6">
<div className="glass-card p-4">
<p className="text-[var(--app-text-tertiary)] text-xs mb-2">
{isCrypto ? `收款地址 (${paymentConfig.usdt?.network})` : "PayPal账户"}
</p>
<div className="flex items-center gap-2">
<p className="text-white text-sm break-all flex-1 font-mono bg-[var(--app-bg-secondary)] p-3 rounded-lg">
{address || "请在后台配置收款地址"}
</p>
{address && (
<button
onClick={() => handleCopyAddress(address)}
className="w-10 h-10 rounded-xl bg-[var(--app-brand-light)] flex items-center justify-center text-[var(--app-brand)] touch-feedback"
>
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
</button>
)}
</div>
</div>
<p className="text-[var(--app-text-tertiary)] text-sm mt-3 text-center">{hint}</p>
</div>
)}
{/* 提示信息 */}
<div className="glass-card p-4 mb-6 border-[var(--app-brand)]/20">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[var(--app-brand-light)] flex items-center justify-center">
<Shield className="w-5 h-5 text-[var(--app-brand)]" />
</div>
<p className="text-[var(--app-text-secondary)] text-sm flex-1">
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<button
onClick={() => {
clearPolling()
setShowQRCode(false)
setPaymentState("idle")
setErrorMessage("")
}}
className="btn-ios-secondary flex-1"
>
</button>
{(isCrypto || isPaypal) ? (
<button
onClick={handleCryptoConfirm}
disabled={paymentState === "creating"}
className="btn-ios flex-1 glow disabled:opacity-50"
>
{paymentState === "creating" ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</div>
) : (
"我已支付"
)}
</button>
) : (
<button
onClick={handleManualConfirm}
disabled={paymentState === "polling"}
className="btn-ios flex-1 glow disabled:opacity-50"
>
{paymentState === "polling" ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</div>
) : (
"已完成支付"
)}
</button>
)}
</div>
</div>
</div>
</div>
)
}
// 支付成功页面
if (paymentState === "success") {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-md modal-overlay" />
<div className="relative w-full max-w-sm glass-modal overflow-hidden modal-content">
<div className="p-8 text-center">
{/* 成功动画 */}
<div className="w-24 h-24 mx-auto mb-6 rounded-full bg-[var(--app-brand-light)] flex items-center justify-center">
<CheckCircle className="w-12 h-12 text-[var(--app-brand)]" />
</div>
<h3 className="text-2xl font-semibold text-white mb-2"></h3>
<p className="text-[var(--app-text-secondary)] mb-4">
{type === "fullbook" ? "您已解锁全部内容" : "您已解锁本节内容"}
</p>
{paymentConfig.wechat?.groupQrCode && (
<div className="glass-card p-4 mt-4">
<div className="flex items-center justify-center gap-2 text-[#07C160]">
<Users className="w-5 h-5" />
<span className="text-sm">...</span>
</div>
</div>
)}
</div>
</div>
</div>
)
}
// 主支付选择页面
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-md modal-overlay" onClick={onClose} />
<div className="relative w-full sm:max-w-md glass-modal overflow-hidden safe-bottom modal-content sm:m-4 max-h-[90vh] overflow-y-auto scrollbar-hide">
{/* 顶部把手 - 仅移动端 */}
<div className="flex justify-center pt-3 pb-2 sm:hidden sticky top-0 bg-transparent">
<div className="w-9 h-1 rounded-full bg-[var(--app-text-tertiary)]" />
</div>
<button
onClick={onClose}
className="absolute top-4 right-4 w-8 h-8 rounded-full bg-[var(--app-bg-secondary)] flex items-center justify-center text-[var(--app-text-secondary)] hover:text-white z-10 touch-feedback"
>
<X className="w-4 h-4" />
</button>
{/* Header */}
<div className="px-6 pt-2 sm:pt-10 pb-4">
<h3 className="text-xl font-semibold text-white mb-1"></h3>
<p className="text-[var(--app-text-tertiary)] text-sm">
{type === "fullbook" ? "购买整本书,解锁全部内容" : `购买: ${sectionTitle}`}
</p>
</div>
{/* Amount */}
<div className="px-6 py-6 text-center border-y border-[var(--app-separator)]">
<p className="text-[var(--app-text-tertiary)] text-sm mb-2"></p>
<p className="text-5xl font-bold text-white">¥{amount.toFixed(2)}</p>
{(paymentMethod === "usdt" || paymentMethod === "paypal") && (
<p className="text-[var(--app-brand)] text-sm mt-2">
${paymentMethod === "usdt" ? usdtAmount : paypalAmount} USD
</p>
)}
{user?.referredBy && (
<div className="inline-flex items-center gap-2 mt-4 px-4 py-2 rounded-full bg-[var(--app-brand-light)]">
<Users className="w-4 h-4 text-[var(--app-brand)]" />
<span className="text-[var(--app-brand)] text-sm">
{settings?.distributorShare || 90}%
</span>
</div>
)}
</div>
{/* Payment Methods */}
<div className="p-6">
<p className="text-[var(--app-text-tertiary)] text-sm mb-4"></p>
<div className="space-y-3">
{availableMethods.map((method) => (
<button
key={method.id}
onClick={() => setPaymentMethod(method.id)}
className={`w-full p-4 rounded-xl flex items-center gap-4 transition-all touch-feedback ${
paymentMethod === method.id
? "glass-card-light border-[var(--app-brand)]/30"
: "glass-card hover:border-[var(--glass-border-light)]"
}`}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ backgroundColor: method.iconBg, color: method.color }}
>
{method.icon}
</div>
<div className="flex-1 text-left">
<span className="text-white font-medium">{method.name}</span>
{method.extra && (
<span className="text-[var(--app-text-tertiary)] text-sm ml-2">{method.extra}</span>
)}
</div>
{paymentMethod === method.id && (
<div className="w-6 h-6 rounded-full bg-[var(--app-brand)] flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
</div>
)}
</button>
))}
{availableMethods.length === 0 && (
<p className="text-[var(--app-text-tertiary)] text-center py-8"></p>
)}
</div>
</div>
{/* Submit Button */}
<div className="p-6 pt-0">
<button
onClick={handlePayment}
disabled={paymentState === "creating" || availableMethods.length === 0}
className="btn-ios w-full glow text-lg disabled:opacity-50"
>
{paymentState === "creating" ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
...
</div>
) : (
`确认支付 ¥${amount.toFixed(2)}`
)}
</button>
<p className="text-[var(--app-text-tertiary)] text-xs text-center mt-4">
</p>
</div>
</div>
</div>
)
}