Files
soul/app/admin/chapters/page.tsx
卡若 7ff181f743 fix: 全面优化小程序功能
🔧 数据库配置:
- 切换到腾讯云外网数据库
- 配置连接参数和连接池

🎨 界面优化:
- 未登录时只显示登录按钮,隐藏其他功能
- 优化登录卡片样式
- 修复章节图标和标题对齐问题

💳 支付流程优化:
- 增加重复购买检测,避免重复支付
- 优化openId获取逻辑,支持静默获取
- 已登录用户可直接支付,无需重复登录

📊 后台管理:
- 创建章节管理API (/api/admin/chapters)
- 创建章节管理页面 (/admin/chapters)
- 支持查看所有章节、修改价格、设置免费状态
2026-01-23 17:25:15 +08:00

293 lines
12 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
interface Section {
id: string
title: string
price: number
isFree: boolean
status: string
}
interface Chapter {
id: string
title: string
sections?: Section[]
price?: number
isFree?: boolean
status?: string
}
interface Part {
id: string
title: string
type: string
chapters: Chapter[]
}
interface Stats {
totalSections: number
freeSections: number
paidSections: number
totalParts: number
}
export default function ChaptersManagement() {
const [structure, setStructure] = useState<Part[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [expandedParts, setExpandedParts] = useState<string[]>([])
const [editingSection, setEditingSection] = useState<string | null>(null)
const [editPrice, setEditPrice] = useState<number>(1)
useEffect(() => {
loadChapters()
}, [])
const loadChapters = async () => {
try {
const response = await fetch('/api/admin/chapters')
const data = await response.json()
if (data.success) {
setStructure(data.data.structure)
setStats(data.data.stats)
}
} catch (error) {
console.error('加载章节失败:', error)
} finally {
setLoading(false)
}
}
const togglePart = (partId: string) => {
setExpandedParts(prev =>
prev.includes(partId)
? prev.filter(id => id !== partId)
: [...prev, partId]
)
}
const handleUpdatePrice = async (sectionId: string) => {
try {
const response = await fetch('/api/admin/chapters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'updatePrice',
chapterId: sectionId,
data: { price: editPrice }
})
})
const result = await response.json()
if (result.success) {
alert('价格更新成功')
setEditingSection(null)
loadChapters()
}
} catch (error) {
console.error('更新价格失败:', error)
}
}
const handleToggleFree = async (sectionId: string, currentFree: boolean) => {
try {
const response = await fetch('/api/admin/chapters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'toggleFree',
chapterId: sectionId,
data: { isFree: !currentFree }
})
})
const result = await response.json()
if (result.success) {
alert('状态更新成功')
loadChapters()
}
} catch (error) {
console.error('更新状态失败:', error)
}
}
if (loading) {
return (
<div className="min-h-screen bg-black text-white flex items-center justify-center">
<div className="text-xl">...</div>
</div>
)
}
return (
<div className="min-h-screen bg-black text-white">
{/* 导航栏 */}
<div className="sticky top-0 bg-black/90 backdrop-blur border-b border-white/10 z-50">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin" className="text-white/60 hover:text-white"> </Link>
<h1 className="text-xl font-bold"></h1>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => setExpandedParts(structure.map(p => p.id))}
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20"
>
</button>
<button
onClick={() => setExpandedParts([])}
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20"
>
</button>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 py-8">
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-gradient-to-br from-cyan-500/20 to-cyan-500/5 border border-cyan-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-cyan-400">{stats.totalSections}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
<div className="bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-green-400">{stats.freeSections}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-500/5 border border-yellow-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-yellow-400">{stats.paidSections}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
<div className="bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-purple-400">{stats.totalParts}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
</div>
)}
{/* 章节列表 */}
<div className="space-y-4">
{structure.map(part => (
<div key={part.id} className="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
{/* 篇标题 */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5"
onClick={() => togglePart(part.id)}
>
<div className="flex items-center gap-3">
<span className="text-2xl">
{part.type === 'preface' ? '📖' :
part.type === 'epilogue' ? '🎬' :
part.type === 'appendix' ? '📎' : '📚'}
</span>
<span className="font-semibold">{part.title}</span>
<span className="text-white/40 text-sm">
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
</span>
</div>
<span className="text-white/40">
{expandedParts.includes(part.id) ? '▲' : '▼'}
</span>
</div>
{/* 章节内容 */}
{expandedParts.includes(part.id) && (
<div className="border-t border-white/10">
{part.chapters.map(chapter => (
<div key={chapter.id} className="border-b border-white/5 last:border-b-0">
{/* 章标题 */}
{chapter.sections ? (
<>
<div className="px-6 py-3 bg-white/5 text-white/70 font-medium">
{chapter.title}
</div>
{/* 小节列表 */}
<div className="divide-y divide-white/5">
{chapter.sections.map(section => (
<div key={section.id} className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
<div className="flex items-center gap-3">
<span className={section.isFree ? 'text-green-400' : 'text-yellow-400'}>
{section.isFree ? '🔓' : '🔒'}
</span>
<span className="text-white/80">{section.id}</span>
<span className="text-white/60">{section.title}</span>
</div>
<div className="flex items-center gap-3">
{editingSection === section.id ? (
<div className="flex items-center gap-2">
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="w-20 px-2 py-1 bg-white/10 border border-white/20 rounded text-white"
min="0"
step="0.1"
/>
<button
onClick={() => handleUpdatePrice(section.id)}
className="px-3 py-1 bg-cyan-500 text-black rounded text-sm"
>
</button>
<button
onClick={() => setEditingSection(null)}
className="px-3 py-1 bg-white/20 rounded text-sm"
>
</button>
</div>
) : (
<>
<span className={`px-2 py-1 rounded text-xs ${section.isFree ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>
{section.isFree ? '免费' : `¥${section.price}`}
</span>
<button
onClick={() => {
setEditingSection(section.id)
setEditPrice(section.price)
}}
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20"
>
</button>
<button
onClick={() => handleToggleFree(section.id, section.isFree)}
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20"
>
{section.isFree ? '设为付费' : '设为免费'}
</button>
</>
)}
</div>
</div>
))}
</div>
</>
) : (
<div className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
<div className="flex items-center gap-3">
<span className={chapter.isFree ? 'text-green-400' : 'text-yellow-400'}>
{chapter.isFree ? '🔓' : '🔒'}
</span>
<span className="text-white/80">{chapter.title}</span>
</div>
<span className={`px-2 py-1 rounded text-xs ${chapter.isFree ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>
{chapter.isFree ? '免费' : `¥${chapter.price || 1}`}
</span>
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}