feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API

主要更新:
1. 按H5网页端完全重构匹配功能(match页面)
   - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募
   - 资源对接等类型弹出手机号/微信号输入框
   - 去掉重新匹配按钮,改为返回按钮

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

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

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

5. 新增文章数据API
   - /api/db/chapters - 获取完整书籍结构
   - 支持按ID获取单篇文章内容
This commit is contained in:
卡若
2026-01-21 15:49:12 +08:00
parent 1ee25e3dab
commit b60edb3d47
197 changed files with 34430 additions and 7345 deletions

View File

@@ -2,17 +2,7 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, List, User } from "lucide-react"
function PlanetIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" fill="currentColor" opacity="0.9" />
<ellipse cx="12" cy="12" rx="11" ry="4" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.6" />
<circle cx="9" cy="10" r="1.5" fill="white" opacity="0.4" />
</svg>
)
}
import { Home, List, User, Users } from "lucide-react"
export function BottomNav() {
const pathname = usePathname()
@@ -30,7 +20,7 @@ export function BottomNav() {
const navItems = [
{ href: "/", icon: Home, label: "首页" },
{ href: "/chapters", icon: List, label: "目录" },
{ href: "/match", icon: PlanetIcon, label: "匹配", isCenter: true },
{ href: "/match", icon: Users, label: "找伙伴", isCenter: true },
{ href: "/my", icon: User, label: "我的" },
]
@@ -42,7 +32,7 @@ export function BottomNav() {
const isActive = pathname === item.href
const Icon = item.icon
// 中间的匹配按钮特殊处理 - 使用小星球图标
// 中间的找伙伴按钮特殊处理
if (item.isCenter) {
return (
<Link key={index} href={item.href} className="flex flex-col items-center py-2 px-6 -mt-4">

View File

@@ -2,8 +2,8 @@
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Lock, Share2, Sparkles } from "lucide-react"
import { type Section, getFullBookPrice, getTotalSectionCount } from "@/lib/book-data"
import { ChevronLeft, Lock, Share2, Sparkles, ChevronRight, X, Copy, Check, QrCode } from "lucide-react"
import { type Section, getFullBookPrice, getTotalSectionCount, getNextSection, getPrevSection } from "@/lib/book-data"
import { useStore } from "@/lib/store"
import { PaymentModal } from "./payment-modal"
import { AuthModal } from "./modules/auth/auth-modal"
@@ -23,14 +23,41 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
const [paymentType, setPaymentType] = useState<"section" | "fullbook">("section")
const [readingProgress, setReadingProgress] = useState(0)
const [showPaywall, setShowPaywall] = useState(false)
const [showShareModal, setShowShareModal] = useState(false)
const [shareCopied, setShareCopied] = useState(false)
const { user, isLoggedIn, hasPurchased } = useStore()
const { user, isLoggedIn, hasPurchased, settings } = useStore()
const fullBookPrice = getFullBookPrice()
const totalSections = getTotalSectionCount()
const hasFullBook = user?.hasFullBook || false
const canAccess = section.isFree || hasFullBook || (isLoggedIn && hasPurchased(section.id))
// 获取下一篇和上一篇
const nextSection = getNextSection(section.id)
const prevSection = getPrevSection(section.id)
// 生成分享链接(带用户邀请码)
const getShareLink = () => {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const referralCode = user?.referralCode || ''
const shareUrl = `${baseUrl}/read/${section.id}${referralCode ? `?ref=${referralCode}` : ''}`
return shareUrl
}
// 生成小程序路径
const getMiniProgramPath = () => {
const referralCode = user?.referralCode || ''
return `/pages/read/read?id=${section.id}${referralCode ? `&ref=${referralCode}` : ''}`
}
// 如果没有访问权限,直接显示付费墙
useEffect(() => {
if (!canAccess && !isLoading) {
setShowPaywall(true)
}
}, [canAccess, isLoading])
// 阅读进度追踪
useEffect(() => {
const handleScroll = () => {
@@ -38,15 +65,11 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
const docHeight = document.documentElement.scrollHeight - window.innerHeight
const progress = docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0
setReadingProgress(progress)
if (progress >= 20 && !canAccess) {
setShowPaywall(true)
}
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [canAccess])
}, [])
// 加载内容
useEffect(() => {
@@ -82,6 +105,24 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
setIsPaymentOpen(true)
}
const handleShare = () => {
setShowShareModal(true)
}
const handleCopyLink = () => {
const shareLink = getShareLink()
navigator.clipboard.writeText(shareLink)
setShareCopied(true)
setTimeout(() => setShareCopied(false), 2000)
}
const handleShareToWechat = () => {
// 生成微信分享文案
const shareText = `📚 推荐阅读《${section.title}\n\n${content.slice(0, 100)}...\n\n👉 点击阅读:${getShareLink()}`
navigator.clipboard.writeText(shareText)
alert('分享文案已复制,请粘贴到微信发送给好友')
}
const contentLines = content.split("\n").filter((line) => line.trim())
const previewLineCount = Math.ceil(contentLines.length * 0.2) // 改为20%
const previewContent = contentLines.slice(0, previewLineCount).join("\n")
@@ -109,11 +150,7 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
{chapterTitle && <p className="text-xs text-gray-400 truncate">{chapterTitle}</p>}
</div>
<button
onClick={() => {
const url = window.location.href
navigator.clipboard.writeText(url)
alert("链接已复制")
}}
onClick={handleShare}
className="w-9 h-9 rounded-full bg-[#1c1c1e] flex items-center justify-center active:bg-[#2c2c2e]"
>
<Share2 className="w-4 h-4 text-gray-400" />
@@ -135,26 +172,78 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
{isLoading ? (
<div className="space-y-4">
{[...Array(8)].map((_, i) => (
{[75, 90, 65, 85, 70, 95, 80, 88].map((width, i) => (
<div
key={i}
className="h-4 bg-[#1c1c1e] rounded animate-pulse"
style={{ width: `${Math.random() * 40 + 60}%` }}
style={{ width: `${width}%` }}
/>
))}
</div>
) : canAccess ? (
// 完整内容
<article className="text-gray-300 leading-[1.9] text-[17px]">
{content.split("\n").map(
(paragraph, index) =>
paragraph.trim() && (
<p key={index} className="mb-6">
{paragraph}
</p>
),
)}
</article>
<>
<article className="text-gray-300 leading-[1.9] text-[17px]">
{content.split("\n").map(
(paragraph, index) =>
paragraph.trim() && (
<p key={index} className="mb-6">
{paragraph}
</p>
),
)}
</article>
{/* 底部章节导航 */}
<div className="mt-12 pt-8 border-t border-white/10">
<div className="flex items-center gap-3">
{prevSection ? (
<button
onClick={() => router.push(`/read/${prevSection.id}`)}
className="flex-1 max-w-[48%] p-3 rounded-xl bg-[#1c1c1e] border border-white/5 text-left hover:bg-[#2c2c2e] transition-colors"
>
<p className="text-[10px] text-gray-500 mb-0.5"></p>
<p className="text-xs text-white truncate">{prevSection.title}</p>
</button>
) : (
<div className="flex-1 max-w-[48%]" />
)}
{nextSection ? (
<button
onClick={() => router.push(`/read/${nextSection.id}`)}
className="flex-1 max-w-[48%] p-3 rounded-xl bg-gradient-to-r from-[#00CED1]/10 to-[#20B2AA]/10 border border-[#00CED1]/20 text-left hover:from-[#00CED1]/20 hover:to-[#20B2AA]/20 transition-colors"
>
<p className="text-[10px] text-[#00CED1] mb-0.5"></p>
<div className="flex items-center justify-between">
<p className="text-xs text-white truncate flex-1">{nextSection.title}</p>
<ChevronRight className="w-3 h-3 text-[#00CED1] flex-shrink-0 ml-1" />
</div>
</button>
) : (
<div className="flex-1 max-w-[48%] p-3 rounded-xl bg-[#1c1c1e] border border-white/5 text-center">
<p className="text-xs text-gray-400"> 🎉</p>
</div>
)}
</div>
{/* 分享提示 */}
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-[#FFD700]/10 to-transparent border border-[#FFD700]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white font-medium"></p>
<p className="text-xs text-gray-400 mt-1">90%</p>
</div>
<button
onClick={handleShare}
className="px-4 py-2 rounded-lg bg-[#FFD700] text-black text-sm font-medium"
>
</button>
</div>
</div>
</div>
</>
) : (
<div>
{/* 免费预览部分 */}
@@ -198,21 +287,24 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
</div>
</button>
<button
onClick={() => handlePurchaseClick("fullbook")}
className="w-full py-3.5 px-6 rounded-xl bg-gradient-to-r from-[#00CED1] to-[#20B2AA] text-white font-medium active:scale-[0.98] transition-transform shadow-lg shadow-[#00CED1]/20"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
<span> {totalSections} </span>
{/* 只有购买超过3章才显示全书购买选项 */}
{(user?.purchasedSections?.length || 0) >= 3 && (
<button
onClick={() => handlePurchaseClick("fullbook")}
className="w-full py-3.5 px-6 rounded-xl bg-gradient-to-r from-[#00CED1] to-[#20B2AA] text-white font-medium active:scale-[0.98] transition-transform shadow-lg shadow-[#00CED1]/20"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
<span> {totalSections} </span>
</div>
<div className="text-right">
<span className="text-lg font-bold">¥{fullBookPrice}</span>
<span className="text-xs ml-1 opacity-70">82%</span>
</div>
</div>
<div className="text-right">
<span className="text-lg font-bold">¥{fullBookPrice}</span>
<span className="text-xs ml-1 opacity-70">82%</span>
</div>
</div>
</button>
</button>
)}
</div>
<p className="text-xs text-gray-500">90%</p>
@@ -224,6 +316,101 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
)}
</main>
{/* 分享弹窗 */}
{showShareModal && (
<div className="fixed inset-0 z-50 flex items-end justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowShareModal(false)}
/>
<div className="relative w-full max-w-lg bg-[#1c1c1e] rounded-t-3xl p-6 pb-8 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white"></h3>
<button
onClick={() => setShowShareModal(false)}
className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* 分享链接预览 */}
<div className="p-4 rounded-xl bg-black/30 border border-white/10 mb-4">
<p className="text-xs text-gray-500 mb-2"></p>
<p className="text-sm text-[#00CED1] break-all font-mono">{getShareLink()}</p>
{user?.referralCode && (
<p className="text-xs text-gray-400 mt-2">
: <span className="text-[#FFD700]">{user.referralCode}</span> · 90%
</p>
)}
</div>
{/* 分享按钮 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<button
onClick={handleCopyLink}
className="flex flex-col items-center gap-2 p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="w-12 h-12 rounded-full bg-[#00CED1]/20 flex items-center justify-center">
{shareCopied ? (
<Check className="w-5 h-5 text-[#00CED1]" />
) : (
<Copy className="w-5 h-5 text-[#00CED1]" />
)}
</div>
<span className="text-xs text-gray-400">{shareCopied ? '已复制' : '复制链接'}</span>
</button>
<button
onClick={handleShareToWechat}
className="flex flex-col items-center gap-2 p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="w-12 h-12 rounded-full bg-[#07C160]/20 flex items-center justify-center">
<svg className="w-6 h-6 text-[#07C160]" viewBox="0 0 24 24" 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 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.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 01-1.162 1.178A1.17 1.17 0 014.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 01-1.162 1.178 1.17 1.17 0 01-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 01.598.082l1.584.926a.272.272 0 00.14.045c.133 0 .241-.108.241-.245 0-.06-.023-.118-.039-.177l-.326-1.233a.49.49 0 01.178-.553c1.527-1.122 2.505-2.787 2.505-4.638 0-3.265-2.88-5.958-6.524-6.106h-.542zm-2.054 2.865c.534 0 .967.44.967.982a.975.975 0 01-.967.983.975.975 0 01-.967-.983c0-.542.432-.982.967-.982zm5.058 0c.534 0 .967.44.967.982a.975.975 0 01-.967.983.975.975 0 01-.967-.983c0-.542.432-.982.967-.982z"/>
</svg>
</div>
<span className="text-xs text-gray-400"></span>
</button>
<button
onClick={() => {
const text = `📚 ${section.title}\n${getShareLink()}`
navigator.clipboard.writeText(text)
alert('朋友圈文案已复制')
}}
className="flex flex-col items-center gap-2 p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="w-12 h-12 rounded-full bg-[#07C160]/20 flex items-center justify-center">
<svg className="w-6 h-6 text-[#07C160]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.237 2.636 7.855 6.356 9.312l.213-.738A.75.75 0 019.3 20h5.4a.75.75 0 01.732.574l.212.738C19.364 19.855 22 16.237 22 12c0-5.523-4.477-10-10-10zm0 3a3 3 0 110 6 3 3 0 010-6zm-4.5 9a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm9 0a1.5 1.5 0 110 3 1.5 1.5 0 010-3z"/>
</svg>
</div>
<span className="text-xs text-gray-400"></span>
</button>
<button
onClick={() => router.push('/my/referral')}
className="flex flex-col items-center gap-2 p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="w-12 h-12 rounded-full bg-[#FFD700]/20 flex items-center justify-center">
<QrCode className="w-5 h-5 text-[#FFD700]" />
</div>
<span className="text-xs text-gray-400"></span>
</button>
</div>
{/* 小程序路径(开发者调试用) */}
{user?.isAdmin && (
<div className="p-3 rounded-lg bg-black/30 border border-white/5">
<p className="text-xs text-gray-500 mb-1"></p>
<p className="text-xs text-gray-400 font-mono break-all">{getMiniProgramPath()}</p>
</div>
)}
</div>
</div>
)}
{/* 登录弹窗 */}
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
@@ -235,7 +422,11 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
sectionId={section.id}
sectionTitle={section.title}
amount={paymentType === "section" ? section.price : fullBookPrice}
onSuccess={() => window.location.reload()}
onSuccess={() => {
setIsPaymentOpen(false)
// 刷新当前页面以显示解锁内容
window.location.reload()
}}
/>
</div>
)

View File

@@ -1,13 +1,29 @@
"use client"
import { usePathname } from "next/navigation"
import { useEffect, useState } from "react"
import { BottomNav } from "@/components/bottom-nav"
import { ConfigLoader } from "@/components/config-loader"
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const [mounted, setMounted] = useState(false)
const isAdmin = pathname?.startsWith("/admin")
useEffect(() => {
setMounted(true)
}, [])
// 服务端渲染时先返回通用布局
if (!mounted) {
return (
<div className="mx-auto max-w-[430px] min-h-screen bg-black shadow-2xl relative font-sans antialiased">
<ConfigLoader />
{children}
</div>
)
}
if (isAdmin) {
return (
<div className="min-h-screen bg-gray-100 text-gray-900 font-sans">
@@ -18,7 +34,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
}
return (
<div className="mx-auto max-w-[430px] min-h-screen bg-[#0a1628] shadow-2xl relative font-sans antialiased">
<div className="mx-auto max-w-[430px] min-h-screen bg-black shadow-2xl relative font-sans antialiased">
<ConfigLoader />
{children}
<BottomNav />

View File

@@ -12,7 +12,7 @@ export function BottomNav() {
const navItems = [
{ href: "/", icon: Home, label: "首页", id: "home" },
{ href: "/match", icon: Users, label: "匹配合作", id: "match" },
{ href: "/match", icon: Users, label: "找伙伴", id: "match" },
{ href: "/my", icon: User, label: "我的", id: "my" },
]

View File

@@ -95,7 +95,7 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
setError("")
}}
className={`flex-1 py-4 text-center transition-colors ${
tab === "login" ? "text-white border-b-2 border-[#ff3b5c]" : "text-white/40 hover:text-white"
tab === "login" ? "text-white border-b-2 border-[#00CED1]" : "text-white/40 hover:text-white"
}`}
>
@@ -106,7 +106,7 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
setError("")
}}
className={`flex-1 py-4 text-center transition-colors ${
tab === "register" ? "text-white border-b-2 border-[#ff3b5c]" : "text-white/40 hover:text-white"
tab === "register" ? "text-white border-b-2 border-[#00CED1]" : "text-white/40 hover:text-white"
}`}
>
@@ -146,17 +146,15 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
</div>
</div>
{error && <p className="text-[#ff3b5c] text-sm">{error}</p>}
{error && <p className="text-[#00CED1] text-sm">{error}</p>}
<Button
onClick={handleLogin}
disabled={isLoading}
className="w-full bg-[#ff3b5c] hover:bg-[#ff5c7a] text-white h-12 rounded-xl font-medium"
className="w-full bg-[#00CED1] hover:bg-[#00B4B7] text-white h-12 rounded-xl font-medium"
>
{isLoading ? "登录中..." : "登录"}
</Button>
<p className="text-center text-white/40 text-xs">11123456</p>
</div>
) : (
<div className="space-y-4">
@@ -217,12 +215,12 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
</div>
</div>
{error && <p className="text-[#ff3b5c] text-sm">{error}</p>}
{error && <p className="text-[#00CED1] text-sm">{error}</p>}
<Button
onClick={handleRegister}
disabled={isLoading}
className="w-full bg-[#ff3b5c] hover:bg-[#ff5c7a] text-white h-12 rounded-xl font-medium"
className="w-full bg-[#00CED1] hover:bg-[#00B4B7] text-white h-12 rounded-xl font-medium"
>
{isLoading ? "注册中..." : "立即注册"}
</Button>

View File

@@ -0,0 +1,291 @@
"use client"
import { useState, useEffect } from "react"
import { X, Settings, CheckCircle, AlertCircle, Zap } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useStore } from "@/lib/store"
interface AutoWithdrawModalProps {
isOpen: boolean
onClose: () => void
}
interface AutoWithdrawConfig {
enabled: boolean
minAmount: number
method: 'wechat' | 'alipay'
account: string
name: string
}
export function AutoWithdrawModal({ isOpen, onClose }: AutoWithdrawModalProps) {
const { user } = useStore()
const [config, setConfig] = useState<AutoWithdrawConfig>({
enabled: false,
minAmount: 100,
method: 'wechat',
account: '',
name: '',
})
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
// 加载已保存的配置
useEffect(() => {
if (isOpen && user?.id) {
const savedConfig = localStorage.getItem(`auto_withdraw_config_${user.id}`)
if (savedConfig) {
try {
setConfig(JSON.parse(savedConfig))
} catch {
// 忽略解析错误
}
}
}
}, [isOpen, user?.id])
if (!isOpen) return null
const handleSave = async () => {
if (!user?.id) return
// 验证
if (config.enabled) {
if (config.minAmount < 10) {
setError('最低提现金额不能少于10元')
return
}
if (!config.account) {
setError('请填写提现账号')
return
}
if (!config.name) {
setError('请填写真实姓名')
return
}
}
setIsLoading(true)
setError(null)
try {
// 保存配置到本地存储
localStorage.setItem(`auto_withdraw_config_${user.id}`, JSON.stringify(config))
// 如果启用了自动提现,也发送到服务器
if (config.enabled) {
await fetch('/api/distribution/auto-withdraw-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: user.id,
...config,
}),
})
}
setIsSuccess(true)
} catch (err) {
setError('保存失败,请重试')
console.error('保存自动提现配置失败:', err)
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
setIsSuccess(false)
setError(null)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* 遮罩 */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={handleClose}
/>
{/* 弹窗内容 */}
<div className="relative w-full max-w-md bg-gradient-to-b from-[#1a1a2e] to-[#0f0f1a] rounded-2xl overflow-hidden shadow-2xl border border-white/10 animate-in fade-in zoom-in-95 duration-200">
{/* 关闭按钮 */}
<button
onClick={handleClose}
className="absolute top-4 right-4 p-2 rounded-full bg-white/5 hover:bg-white/10 transition-colors z-10"
>
<X className="w-5 h-5 text-gray-400" />
</button>
{isSuccess ? (
/* 成功状态 */
<div className="p-8 flex flex-col items-center text-center">
<div className="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-10 h-10 text-green-400" />
</div>
<h3 className="text-xl font-bold text-white mb-2"></h3>
<p className="text-gray-400 text-sm mb-6">
{config.enabled
? `当可提现金额达到 ¥${config.minAmount} 时,将自动打款到您的${config.method === 'wechat' ? '微信' : '支付宝'}账户`
: '自动提现已关闭'
}
</p>
<Button
onClick={handleClose}
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white"
>
</Button>
</div>
) : (
/* 设置表单 */
<div className="p-6">
{/* 标题 */}
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[#38bdac]/20 rounded-xl flex items-center justify-center">
<Zap className="w-6 h-6 text-[#38bdac]" />
</div>
<div>
<h3 className="text-lg font-bold text-white"></h3>
<p className="text-sm text-gray-400"></p>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
</div>
)}
<div className="space-y-5">
{/* 启用开关 */}
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-gray-400" />
<span className="text-white font-medium"></span>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(checked) => setConfig({ ...config, enabled: checked })}
/>
</div>
{config.enabled && (
<>
{/* 最低金额 */}
<div className="space-y-2">
<Label className="text-gray-400"></Label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium">¥</span>
<Input
type="number"
min="10"
step="10"
value={config.minAmount}
onChange={(e) => setConfig({ ...config, minAmount: Number(e.target.value) })}
className="pl-8 bg-white/5 border-white/10 text-white h-12"
placeholder="100"
/>
</div>
<p className="text-xs text-gray-500">10</p>
</div>
{/* 提现方式 */}
<div className="space-y-2">
<Label className="text-gray-400"></Label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setConfig({ ...config, method: 'wechat' })}
className={`flex-1 py-3 px-4 rounded-xl border text-sm font-medium transition-all ${
config.method === 'wechat'
? 'border-green-500 bg-green-500/10 text-green-400'
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" viewBox="0 0 24 24" 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 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098c1.044.303 2.166.468 3.339.468h.319c-.081-.3-.126-.613-.126-.94 0-3.497 3.32-6.336 7.42-6.336.168 0 .335.005.5.015-.591-3.61-4.195-6.286-8.615-6.286z"/>
<path d="M18.695 9.37c-3.442 0-6.236 2.302-6.236 5.145 0 2.843 2.794 5.145 6.236 5.145.852 0 1.666-.135 2.412-.384a.632.632 0 01.523.072l1.394.816a.235.235 0 00.122.04.214.214 0 00.213-.215c0-.052-.021-.104-.035-.155l-.285-1.082a.434.434 0 01.156-.484c1.34-.987 2.195-2.443 2.195-4.073 0-2.843-2.794-5.145-6.236-5.145z"/>
</svg>
</div>
</button>
<button
type="button"
onClick={() => setConfig({ ...config, method: 'alipay' })}
className={`flex-1 py-3 px-4 rounded-xl border text-sm font-medium transition-all ${
config.method === 'alipay'
? 'border-blue-500 bg-blue-500/10 text-blue-400'
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.5 3h-15A1.5 1.5 0 003 4.5v15A1.5 1.5 0 004.5 21h15a1.5 1.5 0 001.5-1.5v-15A1.5 1.5 0 0019.5 3zm-8.35 13.42c-2.16 0-3.92-1.75-3.92-3.92s1.76-3.92 3.92-3.92 3.92 1.75 3.92 3.92-1.76 3.92-3.92 3.92zm6.52.98c-.87-.4-1.75-.8-2.64-1.18.67-.91 1.16-1.96 1.44-3.08h-2.16v-.85h2.58v-.5h-2.58v-.99h1.85c-.1-.35-.25-.68-.44-.99h-1.41V8.96h3.23v.85h1.16v.85h-1.7c.19.31.34.64.44.99h1.26v.85h-2.33c-.28 1.12-.77 2.17-1.44 3.08.89.38 1.77.78 2.64 1.18-.31.55-.63 1.1-.9 1.64z"/>
</svg>
</div>
</button>
</div>
</div>
{/* 收款账号 */}
<div className="space-y-2">
<Label className="text-gray-400">
{config.method === 'wechat' ? '微信openid' : '支付宝账号'}
</Label>
<Input
value={config.account}
onChange={(e) => setConfig({ ...config, account: e.target.value })}
className="bg-white/5 border-white/10 text-white h-12"
placeholder={config.method === 'wechat' ? '请输入微信openid' : '请输入支付宝账号'}
/>
{config.method === 'wechat' && (
<p className="text-xs text-gray-500">openid</p>
)}
</div>
{/* 真实姓名 */}
<div className="space-y-2">
<Label className="text-gray-400"></Label>
<Input
value={config.name}
onChange={(e) => setConfig({ ...config, name: e.target.value })}
className="bg-white/5 border-white/10 text-white h-12"
placeholder="请输入收款人真实姓名"
/>
<p className="text-xs text-gray-500"></p>
</div>
</>
)}
</div>
{/* 保存按钮 */}
<Button
onClick={handleSave}
disabled={isLoading}
className="w-full mt-6 h-12 bg-[#38bdac] hover:bg-[#2da396] text-white font-medium"
>
{isLoading ? '保存中...' : '保存设置'}
</Button>
{/* 提示 */}
<div className="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p className="text-xs text-yellow-400/80 leading-relaxed">
💡
openid
</p>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,307 @@
"use client"
import { useEffect, useState, useCallback } from 'react'
import { Bell, X, CheckCircle, AlertCircle, Clock, Wallet, Gift, Info } from 'lucide-react'
import { useStore } from '@/lib/store'
// 消息类型
interface NotificationMessage {
messageId: string
type: string
data: {
message?: string
title?: string
content?: string
amount?: number
commission?: number
daysRemaining?: number
[key: string]: unknown
}
timestamp: string
}
interface RealtimeNotificationProps {
onNewMessage?: (message: NotificationMessage) => void
}
export function RealtimeNotification({ onNewMessage }: RealtimeNotificationProps) {
const { user, isLoggedIn } = useStore()
const [notifications, setNotifications] = useState<NotificationMessage[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [showPanel, setShowPanel] = useState(false)
const [lastTimestamp, setLastTimestamp] = useState(new Date().toISOString())
// 获取消息
const fetchMessages = useCallback(async () => {
if (!isLoggedIn || !user?.id) return
try {
const response = await fetch(
`/api/distribution/messages?userId=${user.id}&since=${encodeURIComponent(lastTimestamp)}`
)
if (!response.ok) return
const data = await response.json()
if (data.success && data.messages?.length > 0) {
setNotifications(prev => {
const newMessages = data.messages.filter(
(m: NotificationMessage) => !prev.some(p => p.messageId === m.messageId)
)
if (newMessages.length > 0) {
// 更新未读数
setUnreadCount(c => c + newMessages.length)
// 显示Toast通知
newMessages.forEach((msg: NotificationMessage) => {
showToast(msg)
onNewMessage?.(msg)
})
// 更新最后时间戳
const latestTime = newMessages.reduce(
(max: string, m: NotificationMessage) => m.timestamp > max ? m.timestamp : max,
lastTimestamp
)
setLastTimestamp(latestTime)
return [...newMessages, ...prev].slice(0, 50) // 保留最近50条
}
return prev
})
}
} catch (error) {
console.error('[RealtimeNotification] 获取消息失败:', error)
}
}, [isLoggedIn, user?.id, lastTimestamp, onNewMessage])
// 轮询获取消息
useEffect(() => {
if (!isLoggedIn || !user?.id) return
// 立即获取一次
fetchMessages()
// 每5秒轮询一次
const intervalId = setInterval(fetchMessages, 5000)
return () => clearInterval(intervalId)
}, [isLoggedIn, user?.id, fetchMessages])
// 显示Toast通知
const showToast = (message: NotificationMessage) => {
// 创建Toast元素
const toast = document.createElement('div')
toast.className = 'fixed top-20 right-4 z-[100] animate-in slide-in-from-right duration-300'
toast.innerHTML = `
<div class="bg-[#1a1a2e] border border-gray-700 rounded-xl p-4 shadow-xl max-w-sm">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-full ${getIconBgClass(message.type)} flex items-center justify-center flex-shrink-0">
${getIconSvg(message.type)}
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm">${getTitle(message.type)}</p>
<p class="text-gray-400 text-xs mt-1 line-clamp-2">${message.data.message || message.data.content || ''}</p>
</div>
</div>
</div>
`
document.body.appendChild(toast)
// 3秒后移除
setTimeout(() => {
toast.classList.add('animate-out', 'slide-out-to-right')
setTimeout(() => toast.remove(), 300)
}, 3000)
}
// 获取图标背景色
const getIconBgClass = (type: string): string => {
switch (type) {
case 'binding_expiring':
return 'bg-orange-500/20'
case 'binding_expired':
return 'bg-red-500/20'
case 'binding_converted':
case 'earnings_added':
return 'bg-green-500/20'
case 'withdrawal_completed':
return 'bg-[#38bdac]/20'
case 'withdrawal_rejected':
return 'bg-red-500/20'
default:
return 'bg-blue-500/20'
}
}
// 获取图标SVG
const getIconSvg = (type: string): string => {
switch (type) {
case 'binding_expiring':
return '<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
case 'binding_expired':
return '<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
case 'binding_converted':
case 'earnings_added':
return '<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
case 'withdrawal_completed':
return '<svg class="w-5 h-5 text-[#38bdac]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
case 'withdrawal_rejected':
return '<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
default:
return '<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
}
}
// 获取标题
const getTitle = (type: string): string => {
switch (type) {
case 'binding_expiring':
return '绑定即将过期'
case 'binding_expired':
return '绑定已过期'
case 'binding_converted':
return '用户已付款'
case 'earnings_added':
return '收益增加'
case 'withdrawal_approved':
return '提现已通过'
case 'withdrawal_completed':
return '提现已到账'
case 'withdrawal_rejected':
return '提现被拒绝'
case 'system_notice':
return '系统通知'
default:
return '消息通知'
}
}
// 获取图标组件
const getIcon = (type: string) => {
switch (type) {
case 'binding_expiring':
return <Clock className="w-5 h-5 text-orange-400" />
case 'binding_expired':
return <AlertCircle className="w-5 h-5 text-red-400" />
case 'binding_converted':
case 'earnings_added':
return <Gift className="w-5 h-5 text-green-400" />
case 'withdrawal_completed':
return <CheckCircle className="w-5 h-5 text-[#38bdac]" />
case 'withdrawal_rejected':
return <AlertCircle className="w-5 h-5 text-red-400" />
default:
return <Info className="w-5 h-5 text-blue-400" />
}
}
// 标记消息已读
const markAsRead = async () => {
if (!user?.id || notifications.length === 0) return
const messageIds = notifications.slice(0, 10).map(n => n.messageId)
try {
await fetch('/api/distribution/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id, messageIds }),
})
setUnreadCount(0)
} catch (error) {
console.error('[RealtimeNotification] 标记已读失败:', error)
}
}
// 打开面板时标记已读
const handleOpenPanel = () => {
setShowPanel(true)
if (unreadCount > 0) {
markAsRead()
}
}
if (!isLoggedIn || !user) return null
return (
<>
{/* 通知铃铛按钮 */}
<button
onClick={handleOpenPanel}
className="relative p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<Bell className="w-5 h-5 text-white" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs text-white font-bold">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* 通知面板 */}
{showPanel && (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowPanel(false)}
/>
<div className="absolute top-16 right-4 w-80 max-h-[70vh] bg-[#1a1a2e] border border-gray-700 rounded-xl shadow-2xl overflow-hidden animate-in slide-in-from-top-2 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h3 className="text-white font-semibold"></h3>
<button
onClick={() => setShowPanel(false)}
className="p-1 rounded-full hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* 消息列表 */}
<div className="max-h-[50vh] overflow-auto">
{notifications.length === 0 ? (
<div className="py-12 text-center">
<Bell className="w-10 h-10 text-gray-600 mx-auto mb-2" />
<p className="text-gray-500 text-sm"></p>
</div>
) : (
<div className="divide-y divide-gray-700/50">
{notifications.map((notification) => (
<div
key={notification.messageId}
className="p-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-full ${getIconBgClass(notification.type)} flex items-center justify-center flex-shrink-0`}>
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium text-sm">
{getTitle(notification.type)}
</p>
<p className="text-gray-400 text-xs mt-1 line-clamp-2">
{notification.data.message || notification.data.content || ''}
</p>
<p className="text-gray-500 text-xs mt-2">
{new Date(notification.timestamp).toLocaleString('zh-CN')}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -2,6 +2,7 @@
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { getTotalSectionCount } from "@/lib/book-data"
interface PosterModalProps {
isOpen: boolean
@@ -14,6 +15,9 @@ interface PosterModalProps {
export function PosterModal({ isOpen, onClose, referralLink, referralCode, nickname }: PosterModalProps) {
if (!isOpen) return null
// 动态获取案例数量
const caseCount = getTotalSectionCount()
// Use a public QR code API
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(referralLink)}`
@@ -29,46 +33,92 @@ export function PosterModal({ isOpen, onClose, referralLink, referralCode, nickn
<X className="w-5 h-5" />
</button>
{/* Poster Content */}
<div className="bg-gradient-to-br from-indigo-900 to-purple-900 text-white p-6 flex flex-col items-center text-center relative overflow-hidden">
{/* Decorative circles */}
<div className="absolute top-0 left-0 w-32 h-32 bg-white/10 rounded-full -translate-x-1/2 -translate-y-1/2 blur-2xl" />
<div className="absolute bottom-0 right-0 w-40 h-40 bg-pink-500/20 rounded-full translate-x-1/3 translate-y-1/3 blur-2xl" />
{/* Poster Content - 销售/商业风格 */}
<div className="bg-gradient-to-br from-[#0a1628] via-[#0f2137] to-[#1a3a5c] text-white p-6 flex flex-col items-center text-center relative overflow-hidden">
{/* 装饰性元素 */}
<div className="absolute top-0 left-0 w-40 h-40 bg-[#00CED1]/10 rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl" />
<div className="absolute bottom-0 right-0 w-48 h-48 bg-[#FFD700]/10 rounded-full translate-x-1/3 translate-y-1/3 blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 border border-[#00CED1]/5 rounded-full" />
<div className="relative z-10 w-full flex flex-col items-center">
{/* Book Title */}
<h2 className="text-xl font-bold mb-1 leading-tight text-white">SOUL的<br/></h2>
<p className="text-white/80 text-xs mb-6"> · 55 · </p>
{/* 顶部标签 */}
<div className="flex items-center gap-2 mb-3">
<span className="px-3 py-1 text-[10px] font-bold bg-[#FFD700]/20 text-[#FFD700] rounded-full border border-[#FFD700]/30">
</span>
<span className="px-3 py-1 text-[10px] font-bold bg-[#00CED1]/20 text-[#00CED1] rounded-full border border-[#00CED1]/30">
</span>
</div>
{/* Cover Image Placeholder */}
<div className="w-32 h-44 bg-gray-200 rounded shadow-lg mb-6 overflow-hidden relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/images/image.png" alt="Book Cover" className="w-full h-full object-cover" />
{/* Book Title */}
<h2 className="text-2xl font-black mb-1 leading-tight">
<span className="bg-gradient-to-r from-white via-[#00CED1] to-white bg-clip-text text-transparent">
SOUL的
</span>
<br/>
<span className="text-white"></span>
</h2>
<p className="text-white/60 text-xs mb-4">Soul派对房的真实商业故事</p>
{/* 核心数据展示 */}
<div className="w-full grid grid-cols-3 gap-2 mb-4 px-2">
<div className="bg-white/5 rounded-lg p-2 border border-white/10">
<p className="text-2xl font-black text-[#FFD700]">{caseCount}</p>
<p className="text-[10px] text-white/50"></p>
</div>
<div className="bg-white/5 rounded-lg p-2 border border-white/10">
<p className="text-2xl font-black text-[#00CED1]">5%</p>
<p className="text-[10px] text-white/50"></p>
</div>
<div className="bg-white/5 rounded-lg p-2 border border-white/10">
<p className="text-2xl font-black text-[#E91E63]">90%</p>
<p className="text-[10px] text-white/50"></p>
</div>
</div>
{/* 特色标签 */}
<div className="flex flex-wrap justify-center gap-1 mb-4 px-4">
{["人性观察", "行业揭秘", "赚钱逻辑", "创业复盘", "资源对接"].map((tag) => (
<span key={tag} className="px-2 py-0.5 text-[10px] bg-white/5 text-white/70 rounded border border-white/10">
{tag}
</span>
))}
</div>
{/* Recommender Info */}
<div className="flex items-center gap-2 mb-4 bg-white/10 px-3 py-1.5 rounded-full backdrop-blur-sm">
<span className="text-xs text-white">: {nickname}</span>
<div className="flex items-center gap-2 mb-3 bg-[#00CED1]/10 px-4 py-2 rounded-full border border-[#00CED1]/20">
<div className="w-6 h-6 rounded-full bg-[#00CED1]/30 flex items-center justify-center text-[10px] font-bold text-[#00CED1]">
{nickname.charAt(0)}
</div>
<span className="text-xs text-[#00CED1]">{nickname} </span>
</div>
{/* 优惠说明 */}
<div className="w-full p-3 rounded-xl bg-gradient-to-r from-[#FFD700]/10 to-[#E91E63]/10 border border-[#FFD700]/20 mb-4">
<p className="text-center text-xs text-white/80">
<span className="text-[#00CED1] font-bold">5%</span>
</p>
</div>
{/* QR Code Section */}
<div className="bg-white p-2 rounded-lg shadow-lg mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCodeUrl} alt="QR Code" className="w-32 h-32" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCodeUrl} alt="QR Code" className="w-28 h-28" />
</div>
<p className="text-[10px] text-white/60 mb-1"></p>
<p className="text-xs font-mono tracking-wider text-white">: {referralCode}</p>
<p className="text-[10px] text-white/40 mb-1"> · </p>
<p className="text-xs font-mono tracking-wider text-[#00CED1]/80">: {referralCode}</p>
</div>
</div>
{/* Footer Actions */}
<div className="p-4 bg-gray-50 flex flex-col gap-2">
<p className="text-center text-xs text-gray-500 mb-1">
</p>
<Button onClick={onClose} className="w-full" variant="outline">
</Button>
<p className="text-center text-xs text-gray-500 mb-1">
</p>
<Button onClick={onClose} className="w-full" variant="outline">
</Button>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
"use client"
import { useState } from "react"
import { X, Wallet, CheckCircle } from "lucide-react"
import { useState, useEffect } from "react"
import { X, Wallet, CheckCircle, AlertCircle, Phone, MessageCircle, CreditCard } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -14,7 +14,7 @@ interface WithdrawalModalProps {
}
export function WithdrawalModal({ isOpen, onClose, availableAmount }: WithdrawalModalProps) {
const { requestWithdrawal } = useStore()
const { requestWithdrawal, user } = useStore()
const [amount, setAmount] = useState<string>("")
const [method, setMethod] = useState<"wechat" | "alipay">("wechat")
const [account, setAccount] = useState("")
@@ -22,6 +22,20 @@ export function WithdrawalModal({ isOpen, onClose, availableAmount }: Withdrawal
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
// 检查是否已绑定支付方式
const hasBindWechat = !!user?.wechat
const hasBindAlipay = !!user?.alipay
const hasAnyPaymentMethod = hasBindWechat || hasBindAlipay
// 自动填充已绑定的账号
useEffect(() => {
if (method === "wechat" && user?.wechat) {
setAccount(user.wechat)
} else if (method === "alipay" && user?.alipay) {
setAccount(user.alipay)
}
}, [method, user])
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
@@ -57,43 +71,91 @@ export function WithdrawalModal({ isOpen, onClose, availableAmount }: Withdrawal
onClose()
}
// 未绑定支付方式的提示
if (!hasAnyPaymentMethod) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={handleClose} />
<div className="relative w-full max-w-sm bg-[#1c1c1e] rounded-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<button
onClick={handleClose}
className="absolute top-3 right-3 p-1.5 bg-white/10 rounded-full text-white/60 hover:bg-white/20 z-10"
>
<X className="w-5 h-5" />
</button>
<div className="p-6 text-center">
<div className="w-16 h-16 bg-orange-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-orange-400" />
</div>
<h3 className="text-xl font-bold text-white mb-2"></h3>
<p className="text-white/60 text-sm mb-6">
"我的"
</p>
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="p-4 rounded-xl bg-white/5 border border-white/10">
<MessageCircle className="w-6 h-6 text-[#07C160] mx-auto mb-2" />
<p className="text-white/60 text-xs"></p>
</div>
<div className="p-4 rounded-xl bg-white/5 border border-white/10">
<CreditCard className="w-6 h-6 text-[#1677FF] mx-auto mb-2" />
<p className="text-white/60 text-xs"></p>
</div>
</div>
<Button
onClick={handleClose}
className="w-full bg-[#00CED1] hover:bg-[#00CED1]/90 text-black font-medium"
>
</Button>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={handleClose} />
<div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<div className="relative w-full max-w-sm bg-[#1c1c1e] rounded-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<button
onClick={handleClose}
className="absolute top-2 right-2 p-1.5 bg-black/10 rounded-full text-gray-500 hover:bg-black/20 z-10"
className="absolute top-3 right-3 p-1.5 bg-white/10 rounded-full text-white/60 hover:bg-white/20 z-10"
>
<X className="w-5 h-5" />
</button>
{isSuccess ? (
<div className="p-8 flex flex-col items-center text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-8 h-8 text-green-400" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-500 mb-6">
<h3 className="text-xl font-bold text-white mb-2"></h3>
<p className="text-sm text-white/60 mb-6">
1-3
</p>
<Button onClick={handleClose} className="w-full bg-green-600 hover:bg-green-700 text-white">
<Button onClick={handleClose} className="w-full bg-green-500 hover:bg-green-600 text-white">
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6">
<div className="flex items-center gap-2 mb-6">
<Wallet className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-bold text-gray-900"></h3>
<Wallet className="w-5 h-5 text-[#FFD700]" />
<h3 className="text-lg font-bold text-white"></h3>
</div>
<div className="space-y-4 mb-6">
<div className="space-y-2">
<Label htmlFor="amount"> (: ¥{availableAmount.toFixed(2)})</Label>
<Label htmlFor="amount" className="text-white/80">
<span className="text-[#00CED1]">(: ¥{availableAmount.toFixed(2)})</span>
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">¥</span>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-white/50">¥</span>
<Input
id="amount"
type="number"
@@ -102,64 +164,77 @@ export function WithdrawalModal({ isOpen, onClose, availableAmount }: Withdrawal
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="pl-7"
className="pl-7 bg-white/5 border-white/10 text-white placeholder:text-white/30"
placeholder="最低10元"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-4">
<button
type="button"
onClick={() => setMethod("wechat")}
className={`flex-1 py-2 px-4 rounded-lg border text-sm font-medium transition-colors ${
method === "wechat"
? "border-green-600 bg-green-50 text-green-700"
: "border-gray-200 hover:bg-gray-50 text-gray-600"
}`}
>
</button>
<button
type="button"
onClick={() => setMethod("alipay")}
className={`flex-1 py-2 px-4 rounded-lg border text-sm font-medium transition-colors ${
method === "alipay"
? "border-blue-600 bg-blue-50 text-blue-700"
: "border-gray-200 hover:bg-gray-50 text-gray-600"
}`}
>
</button>
<Label className="text-white/80"></Label>
<div className="flex gap-3">
{hasBindWechat && (
<button
type="button"
onClick={() => setMethod("wechat")}
className={`flex-1 py-3 px-4 rounded-xl border text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
method === "wechat"
? "border-[#07C160] bg-[#07C160]/10 text-[#07C160]"
: "border-white/10 bg-white/5 text-white/60"
}`}
>
<MessageCircle className="w-4 h-4" />
</button>
)}
{hasBindAlipay && (
<button
type="button"
onClick={() => setMethod("alipay")}
className={`flex-1 py-3 px-4 rounded-xl border text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
method === "alipay"
? "border-[#1677FF] bg-[#1677FF]/10 text-[#1677FF]"
: "border-white/10 bg-white/5 text-white/60"
}`}
>
<CreditCard className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="account">{method === "wechat" ? "微信号" : "支付宝账号"}</Label>
<Label htmlFor="account" className="text-white/80">
{method === "wechat" ? "微信号" : "支付宝账号"}
</Label>
<Input
id="account"
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder={method === "wechat" ? "请输入微信号" : "请输入支付宝账号"}
className="bg-white/5 border-white/10 text-white placeholder:text-white/30"
/>
{((method === "wechat" && user?.wechat) || (method === "alipay" && user?.alipay)) && (
<p className="text-xs text-[#00CED1]"></p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name" className="text-white/80"></Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入收款人真实姓名"
className="bg-white/5 border-white/10 text-white placeholder:text-white/30"
/>
</div>
</div>
<Button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white"
className="w-full bg-[#FFD700] hover:bg-[#FFD700]/90 text-black font-bold"
disabled={isSubmitting || !amount || !account || !name}
>
{isSubmitting ? "提交中..." : "确认提现"}

View File

@@ -1,9 +1,10 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { X, CheckCircle, Bitcoin, Globe, Copy, Check, QrCode, Shield, Users } from "lucide-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">
@@ -11,12 +12,6 @@ const WechatIcon = () => (
</svg>
)
const AlipayIcon = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M20.422 13.066c-.198-.07-.405-.137-.62-.202.107-.263.204-.534.29-.814h-3.32v-.93h3.927v-.627h-3.927v-1.18h-1.637c.07-.138.131-.28.184-.425l-1.483-.326a4.091 4.091 0 0 1-.405.75h-2.78v1.181H7.72v.627h2.932v.93H7.205v.652h5.784a9.296 9.296 0 0 1-.608.814 13.847 13.847 0 0 0-2.76-.93l-.483.652c1.038.273 1.96.608 2.766 1.008a8.483 8.483 0 0 1-3.603 1.484l.43.652c1.71-.378 3.103-1.03 4.18-1.957.665.395 1.223.835 1.67 1.32l.608-.652c-.44-.43-.984-.836-1.637-1.215a9.6 9.6 0 0 0 .72-.93c.182-.264.345-.53.488-.798.587.168 1.12.35 1.598.544 1.956.8 2.82 1.614 2.82 2.665 0 .727-.587 1.277-2.21 1.277-1.193 0-2.524-.203-3.996-.609l-.103.75c1.445.378 2.843.567 4.196.567 2.158 0 3.204-.748 3.204-2.013 0-1.382-1.183-2.437-3.58-3.413z" />
<path d="M21.714 4H2.286A2.286 2.286 0 0 0 0 6.286v11.428A2.286 2.286 0 0 0 2.286 20h19.428A2.286 2.286 0 0 0 24 17.714V6.286A2.286 2.286 0 0 0 21.714 4zM2.286 5.143h19.428c.631 0 1.143.512 1.143 1.143v8.08c-.957-.454-2.222-.903-3.75-1.346a9.8 9.8 0 0 0 .607-2.02h-4.571V9.286h5.143V8.143h-5.143V6.286H13.43v1.857H8.286v1.143h5.143V11H8.286v1.143h6.356a11.54 11.54 0 0 1-.916 1.512 16.648 16.648 0 0 0-3.3-1.12l-.576.78c1.242.328 2.348.73 3.31 1.21a10.175 10.175 0 0 1-4.317 1.78l.514.78c2.048-.454 3.718-1.237 5.008-2.344.796.472 1.464 1 2.004 1.583l.726-.78c-.527-.516-1.179-.996-1.96-1.455.327-.407.627-.839.9-1.295.264-.447.495-.907.694-1.38.7.2 1.341.412 1.916.637 2.343.96 3.378 1.935 3.378 3.195 0 .872-.703 1.532-2.647 1.532-1.43 0-3.023-.244-4.786-.732l-.123.9c1.73.454 3.407.68 5.03.68 2.585 0 3.84-.899 3.84-2.416 0-1.166-.69-2.152-2.066-2.96v-.001c-.24-.14-.495-.276-.77-.408V6.286a1.143 1.143 0 0 0-1.143-1.143z" />
</svg>
)
type PaymentMethod = "wechat" | "alipay" | "usdt" | "paypal" | "stripe" | "bank"
@@ -30,23 +25,26 @@ interface PaymentModalProps {
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>("alipay")
const [isProcessing, setIsProcessing] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
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()
useEffect(() => {
if (isOpen) {
setShowQRCode(false)
setIsSuccess(false)
setIsProcessing(false)
}
}, [isOpen])
const paymentConfig = settings?.paymentMethods || {
wechat: { enabled: true, qrCode: "", account: "", groupQrCode: "" },
alipay: { enabled: true, qrCode: "", account: "" },
@@ -57,30 +55,248 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
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 () => {
setShowQRCode(true)
if (paymentMethod === "wechat" && paymentConfig.wechat?.qrCode) {
const link = paymentConfig.wechat.qrCode
if (link.startsWith("http") || link.startsWith("weixin://")) {
window.open(link, "_blank")
}
} else if (paymentMethod === "alipay" && paymentConfig.alipay?.qrCode) {
const link = paymentConfig.alipay.qrCode
if (link.startsWith("http") || link.startsWith("alipays://")) {
window.open(link, "_blank")
}
// USDT和PayPal使用旧的手动确认流程
if (paymentMethod === "usdt" || paymentMethod === "paypal") {
setShowQRCode(true)
return
}
// 微信和支付宝使用新的API流程
await createPaymentOrder()
}
const confirmPayment = async () => {
setIsProcessing(true)
// USDT/PayPal手动确认
const handleCryptoConfirm = async () => {
setPaymentState("creating")
// 模拟确认支付(实际需要人工审核)
await new Promise((resolve) => setTimeout(resolve, 1000))
let success = false
@@ -90,24 +306,11 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
success = await purchaseFullBook(paymentMethod)
}
setIsProcessing(false)
if (success) {
setIsSuccess(true)
const groupUrl = paymentConfig.wechat?.groupQrCode
if (groupUrl) {
setTimeout(() => {
window.open(groupUrl, "_blank")
}, 800)
}
setTimeout(() => {
onSuccess()
onClose()
setIsSuccess(false)
setShowQRCode(false)
}, 2500)
await handlePaymentSuccess()
} else {
setPaymentState("error")
setErrorMessage("确认支付失败,请联系客服")
}
}
@@ -130,21 +333,13 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
iconBg: "rgba(7, 193, 96, 0.15)",
enabled: paymentConfig.wechat?.enabled ?? true,
},
{
id: "alipay",
name: "支付宝",
icon: <AlipayIcon />,
color: "#1677FF",
iconBg: "rgba(22, 119, 255, 0.15)",
enabled: paymentConfig.alipay?.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 ?? true,
enabled: paymentConfig.usdt?.enabled ?? false,
extra: `$${usdtAmount}`,
},
{
@@ -160,7 +355,7 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
const availableMethods = paymentMethods.filter((m) => m.enabled)
// 二维码/详情页面 - iOS毛玻璃风格
// 二维码/详情页面
if (showQRCode) {
const isCrypto = paymentMethod === "usdt"
const isPaypal = paymentMethod === "paypal"
@@ -170,8 +365,7 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
let address = ""
let displayAmount = `¥${amount.toFixed(2)}`
let title = "扫码支付"
let hint = "支付完成后,请点击下方按钮确认"
let qrCodeUrl = ""
let hint = "支付完成后,系统将自动确认"
if (isCrypto) {
address = paymentConfig.usdt?.address || ""
@@ -185,12 +379,10 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
hint = "请转账到以下PayPal账户"
} else if (isWechat) {
title = "微信支付"
qrCodeUrl = paymentConfig.wechat?.qrCode || ""
hint = "请使用微信扫码支付"
hint = "请使用微信扫描二维码支付"
} else if (isAlipay) {
title = "支付宝支付"
qrCodeUrl = paymentConfig.alipay?.qrCode || ""
hint = "请使用支付宝扫码支付"
hint = paymentUrl?.startsWith("http") ? "已打开支付页面,请在新窗口完成支付" : "请使用支付宝扫描二维码支付"
}
return (
@@ -218,24 +410,52 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
<p className="text-4xl font-bold text-[var(--app-brand)] glow-text">{displayAmount}</p>
</div>
{/* QR Code Display */}
{/* 错误提示 */}
{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">
{qrCodeUrl ? (
{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={qrCodeUrl || "/placeholder.svg"}
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>
<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>
)}
@@ -279,25 +499,47 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
{/* Action Buttons */}
<div className="flex gap-3">
<button
onClick={() => setShowQRCode(false)}
onClick={() => {
clearPolling()
setShowQRCode(false)
setPaymentState("idle")
setErrorMessage("")
}}
className="btn-ios-secondary flex-1"
>
</button>
<button
onClick={confirmPayment}
disabled={isProcessing}
className="btn-ios flex-1 glow disabled:opacity-50"
>
{isProcessing ? (
<div className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</div>
) : (
"已完成支付"
)}
</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>
@@ -306,7 +548,7 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
}
// 支付成功页面
if (isSuccess) {
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" />
@@ -334,7 +576,7 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
)
}
// 主支付选择页面 - iOS风格
// 主支付选择页面
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} />
@@ -421,13 +663,13 @@ export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, a
<div className="p-6 pt-0">
<button
onClick={handlePayment}
disabled={isProcessing || availableMethods.length === 0}
disabled={paymentState === "creating" || availableMethods.length === 0}
className="btn-ios w-full glow text-lg disabled:opacity-50"
>
{isProcessing ? (
{paymentState === "creating" ? (
<div className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
<Loader2 className="w-5 h-5 animate-spin" />
...
</div>
) : (
`确认支付 ¥${amount.toFixed(2)}`