Files
soul-yongping/components/chapter-content.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

434 lines
19 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 { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
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"
interface ChapterContentProps {
section: Section & { filePath: string }
partTitle: string
chapterTitle: string
}
export function ChapterContent({ section, partTitle, chapterTitle }: ChapterContentProps) {
const router = useRouter()
const [content, setContent] = useState<string>("")
const [isLoading, setIsLoading] = useState(true)
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
const [isAuthOpen, setIsAuthOpen] = useState(false)
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, 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 = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
const progress = docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0
setReadingProgress(progress)
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
// 加载内容
useEffect(() => {
async function loadContent() {
try {
if (section.content) {
setContent(section.content)
setIsLoading(false)
return
}
const response = await fetch(`/api/content?path=${encodeURIComponent(section.filePath)}`)
if (response.ok) {
const data = await response.json()
setContent(data.content || "")
}
} catch (error) {
console.error("Failed to load content:", error)
} finally {
setIsLoading(false)
}
}
loadContent()
}, [section.filePath, section.content])
const handlePurchaseClick = (type: "section" | "fullbook") => {
if (!isLoggedIn) {
setIsAuthOpen(true)
return
}
setPaymentType(type)
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")
return (
<div className="min-h-screen bg-black text-white">
<div className="fixed top-0 left-0 right-0 z-50 h-0.5 bg-[#1c1c1e]">
<div
className="h-full bg-gradient-to-r from-[#00CED1] to-[#20B2AA] transition-all duration-150"
style={{ width: `${readingProgress}%` }}
/>
</div>
{/* 顶部导航 */}
<header className="sticky top-0 z-40 bg-black/80 backdrop-blur-xl border-b border-white/5">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<button
onClick={() => router.push("/chapters")}
className="w-9 h-9 rounded-full bg-[#1c1c1e] flex items-center justify-center active:bg-[#2c2c2e]"
>
<ChevronLeft className="w-5 h-5 text-gray-400" />
</button>
<div className="text-center flex-1 px-4">
{partTitle && <p className="text-[10px] text-gray-500">{partTitle}</p>}
{chapterTitle && <p className="text-xs text-gray-400 truncate">{chapterTitle}</p>}
</div>
<button
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" />
</button>
</div>
</header>
{/* 阅读内容 */}
<main className="max-w-2xl mx-auto px-5 py-8 pb-32">
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<span className="text-[#00CED1] text-sm font-medium bg-[#00CED1]/10 px-3 py-1 rounded-full">
{section.id}
</span>
{section.isFree && <span className="text-xs text-[#00CED1] bg-[#00CED1]/10 px-2 py-0.5 rounded"></span>}
</div>
<h1 className="text-2xl font-bold text-white leading-tight">{section.title}</h1>
</div>
{isLoading ? (
<div className="space-y-4">
{[75, 90, 65, 85, 70, 95, 80, 88].map((width, i) => (
<div
key={i}
className="h-4 bg-[#1c1c1e] rounded animate-pulse"
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>
{/* 底部章节导航 */}
<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>
{/* 免费预览部分 */}
<article className="text-gray-300 leading-[1.9] text-[17px]">
{previewContent.split("\n").map(
(paragraph, index) =>
paragraph.trim() && (
<p key={index} className="mb-6">
{paragraph}
</p>
),
)}
</article>
{showPaywall && (
<>
{/* 渐变遮罩 */}
<div className="relative">
<div className="absolute -top-32 left-0 right-0 h-32 bg-gradient-to-t from-black to-transparent pointer-events-none" />
</div>
<div className="mt-8 p-6 rounded-2xl bg-gradient-to-b from-[#1c1c1e] to-[#2c2c2e] border border-[#00CED1]/20">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-[#00CED1]/10 flex items-center justify-center">
<Lock className="w-8 h-8 text-[#00CED1]" />
</div>
<h3 className="text-xl font-semibold text-white mb-2"></h3>
<p className="text-gray-400 text-sm mb-6">
20%{isLoggedIn ? "购买后继续阅读" : "登录并购买后继续阅读"}
</p>
{/* 购买选项 */}
<div className="space-y-3 mb-6">
<button
onClick={() => handlePurchaseClick("section")}
className="w-full py-3.5 px-6 rounded-xl bg-[#2c2c2e] border border-white/10 text-white font-medium active:scale-[0.98] transition-transform"
>
<div className="flex items-center justify-between">
<span></span>
<span className="text-[#00CED1]">¥{section.price}</span>
</div>
</button>
{/* 只有购买超过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>
</button>
)}
</div>
<p className="text-xs text-gray-500">90%</p>
</div>
</div>
</>
)}
</div>
)}
</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)} />
{/* 支付弹窗 */}
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
type={paymentType}
sectionId={section.id}
sectionTitle={section.title}
amount={paymentType === "section" ? section.price : fullBookPrice}
onSuccess={() => {
setIsPaymentOpen(false)
// 刷新当前页面以显示解锁内容
window.location.reload()
}}
/>
</div>
)
}