Files
soul/app/admin/content/page.tsx
卡若 4dd2f9f4a7 feat: 完善后台管理+搜索功能+分销系统
主要更新:
- 后台菜单精简(9项→6项)
- 新增搜索功能(敏感信息过滤)
- 分销绑定和提现系统完善
- 数据库初始化API(自动修复表结构)
- 用户管理:显示绑定关系详情
- 小程序:上下章导航优化、匹配页面重构
- 修复hydration和数据类型问题
2026-01-25 19:37:59 +08:00

1087 lines
40 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, useRef, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { bookData } from "@/lib/book-data"
import {
FileText,
BookOpen,
Settings2,
ChevronRight,
CheckCircle,
Edit3,
Save,
X,
RefreshCw,
Link2,
Download,
Upload,
Eye,
Database,
Plus,
Image as ImageIcon,
Trash2,
Search,
} from "lucide-react"
interface EditingSection {
id: string
title: string
price: number
content?: string
filePath?: string
isNew?: boolean
partId?: string
chapterId?: string
}
export default function ContentPage() {
const [expandedParts, setExpandedParts] = useState<string[]>(["part-1"])
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
const [isSyncing, setIsSyncing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isInitializing, setIsInitializing] = useState(false)
const [feishuDocUrl, setFeishuDocUrl] = useState("")
const [showFeishuModal, setShowFeishuModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
const [importData, setImportData] = useState("")
const [isLoadingContent, setIsLoadingContent] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<any[]>([])
const [isSearching, setIsSearching] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
// 新建章节表单
const [newSection, setNewSection] = useState({
id: "",
title: "",
price: 1,
partId: "part-1",
chapterId: "chapter-1",
content: "",
})
const togglePart = (partId: string) => {
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
}
const totalSections = bookData.reduce(
(sum, part) => sum + part.chapters.reduce((cSum, ch) => cSum + ch.sections.length, 0),
0,
)
// 读取章节内容
const handleReadSection = async (section: { id: string; title: string; price: number; filePath: string }) => {
setIsLoadingContent(true)
try {
const res = await fetch(`/api/db/book?action=read&id=${section.id}`)
const data = await res.json()
if (data.success) {
setEditingSection({
id: section.id,
title: data.section.title || section.title,
price: data.section.price || section.price,
content: data.section.content || "",
filePath: section.filePath,
})
} else {
// 如果API失败设置空内容
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: "",
filePath: section.filePath,
})
alert("无法读取文件内容: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Read section error:", error)
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: "",
filePath: section.filePath,
})
} finally {
setIsLoadingContent(false)
}
}
// 保存章节
const handleSaveSection = async () => {
if (!editingSection) return
setIsSaving(true)
try {
const res = await fetch('/api/db/book', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingSection.id,
title: editingSection.title,
price: editingSection.price,
content: editingSection.content,
saveToFile: true, // 同时保存到文件系统
})
})
const data = await res.json()
if (data.success) {
alert(`已保存章节: ${editingSection.title}`)
setEditingSection(null)
} else {
alert("保存失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Save section error:", error)
alert("保存失败")
} finally {
setIsSaving(false)
}
}
// 创建新章节
const handleCreateSection = async () => {
if (!newSection.id || !newSection.title) {
alert("请填写章节ID和标题")
return
}
setIsSaving(true)
try {
const res = await fetch('/api/db/book', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newSection.id,
title: newSection.title,
price: newSection.price,
content: newSection.content,
partId: newSection.partId,
chapterId: newSection.chapterId,
saveToFile: false, // 新建章节暂不保存到文件系统
})
})
const data = await res.json()
if (data.success) {
alert(`章节创建成功: ${newSection.title}`)
setShowNewSectionModal(false)
setNewSection({ id: "", title: "", price: 1, partId: "part-1", chapterId: "chapter-1", content: "" })
} else {
alert("创建失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Create section error:", error)
alert("创建失败")
} finally {
setIsSaving(false)
}
}
// 上传图片
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingImage(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-images')
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const data = await res.json()
if (data.success) {
// 插入图片Markdown到内容
const imageMarkdown = `![${file.name}](${data.data.url})`
if (editingSection) {
setEditingSection({
...editingSection,
content: (editingSection.content || '') + '\n\n' + imageMarkdown
})
}
alert(`图片上传成功: ${data.data.url}`)
} else {
alert("上传失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Image upload error:", error)
alert("上传失败")
} finally {
setUploadingImage(false)
if (imageInputRef.current) {
imageInputRef.current.value = ''
}
}
}
// 搜索内容
const handleSearch = async () => {
if (!searchQuery.trim()) return
setIsSearching(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
const data = await res.json()
if (data.success) {
setSearchResults(data.data.results || [])
} else {
alert("搜索失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Search error:", error)
alert("搜索失败")
} finally {
setIsSearching(false)
}
}
// 同步到数据库
const handleSyncToDatabase = async () => {
setIsSyncing(true)
try {
const res = await fetch('/api/db/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'sync' })
})
const data = await res.json()
if (data.success) {
alert(data.message)
} else {
alert("同步失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Sync error:", error)
alert("同步失败")
} finally {
setIsSyncing(false)
}
}
// 导出所有章节
const handleExport = async () => {
setIsExporting(true)
try {
const res = await fetch('/api/db/book?action=export')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `book_sections_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
alert("导出成功")
} catch (error) {
console.error("Export error:", error)
alert("导出失败")
} finally {
setIsExporting(false)
}
}
// 导入章节
const handleImport = async () => {
if (!importData) {
alert("请输入或上传JSON数据")
return
}
setIsImporting(true)
try {
const data = JSON.parse(importData)
const res = await fetch('/api/db/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'import', data })
})
const result = await res.json()
if (result.success) {
alert(result.message)
setShowImportModal(false)
setImportData("")
} else {
alert("导入失败: " + (result.error || "未知错误"))
}
} catch (error) {
console.error("Import error:", error)
alert("导入失败: JSON格式错误")
} finally {
setIsImporting(false)
}
}
// 文件上传
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result as string
const fileName = file.name.toLowerCase()
// 根据文件类型处理
if (fileName.endsWith('.json')) {
// JSON文件直接使用
setImportData(content)
} else if (fileName.endsWith('.txt') || fileName.endsWith('.md') || fileName.endsWith('.markdown')) {
// TXT/MD文件自动解析为JSON格式
const parsedData = parseTxtToJson(content, file.name)
setImportData(JSON.stringify(parsedData, null, 2))
} else {
setImportData(content)
}
}
reader.readAsText(file)
}
// 解析TXT/MD文件为JSON格式
const parseTxtToJson = (content: string, fileName: string) => {
const lines = content.split('\n')
const sections: any[] = []
let currentSection: any = null
let currentContent: string[] = []
let sectionIndex = 1
for (const line of lines) {
// 检测标题行(以#开头或数字+点开头)
const titleMatch = line.match(/^#+\s+(.+)$/) || line.match(/^(\d+[\.\、]\s*.+)$/)
if (titleMatch) {
// 保存前一个章节
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
// 开始新章节
currentSection = {
id: `import-${sectionIndex}`,
title: titleMatch[1].replace(/^#+\s*/, '').trim(),
price: 1,
is_free: sectionIndex <= 3, // 前3章免费
}
currentContent = []
sectionIndex++
} else if (currentSection) {
currentContent.push(line)
} else if (line.trim()) {
// 没有标题但有内容,创建默认章节
currentSection = {
id: `import-${sectionIndex}`,
title: fileName.replace(/\.(txt|md|markdown)$/i, ''),
price: 1,
is_free: true,
}
currentContent.push(line)
sectionIndex++
}
}
// 保存最后一个章节
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
return sections
}
// 初始化数据库
const handleInitDatabase = async () => {
if (!confirm("确定要初始化数据库吗?这将创建所有必需的表结构。")) return
setIsInitializing(true)
try {
const res = await fetch('/api/db/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adminToken: 'init_db_2025' })
})
const data = await res.json()
if (data.success) {
alert(data.data?.message || '初始化成功')
} else {
alert("初始化失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Init database error:", error)
alert("初始化失败")
} finally {
setIsInitializing(false)
}
}
const handleSyncFeishu = async () => {
if (!feishuDocUrl) {
alert("请输入飞书文档链接")
return
}
setIsSyncing(true)
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsSyncing(false)
setShowFeishuModal(false)
alert("飞书文档同步成功!")
}
return (
<div className="p-8 max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1">
{bookData.length} · {totalSections}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleInitDatabase}
disabled={isInitializing}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Database className="w-4 h-4 mr-2" />
{isInitializing ? "初始化中..." : "初始化数据库"}
</Button>
<Button
onClick={handleSyncToDatabase}
disabled={isSyncing}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={() => setShowImportModal(true)}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleExport}
disabled={isExporting}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? "导出中..." : "导出"}
</Button>
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 导入弹窗 */}
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Upload className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"> ( JSON / TXT / MD)</Label>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt,.md,.markdown"
onChange={handleFileUpload}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="w-full border-dashed border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
(JSON/TXT/MD)
</Button>
<p className="text-xs text-gray-500">
JSON格式: 直接导入章节数据<br/>
TXT/MD格式: 自动解析为章节内容
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] font-mono text-sm placeholder:text-gray-500"
placeholder='JSON格式: [{"id": "1-1", "title": "章节标题", "content": "内容..."}]&#10;&#10;或直接粘贴TXT/MD内容系统将自动解析'
value={importData}
onChange={(e) => setImportData(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowImportModal(false)
setImportData("")
}}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleImport}
disabled={isImporting || !importData}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 飞书同步弹窗 */}
<Dialog open={showFeishuModal} onOpenChange={setShowFeishuModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="https://xxx.feishu.cn/docx/..."
value={feishuDocUrl}
onChange={(e) => setFeishuDocUrl(e.target.value)}
/>
<p className="text-xs text-gray-500">访</p>
</div>
<div className="bg-[#38bdac]/10 border border-[#38bdac]/30 rounded-lg p-3">
<p className="text-[#38bdac] text-sm">
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowFeishuModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSyncFeishu}
disabled={isSyncing}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSyncing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 新建章节弹窗 */}
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Plus className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如: 9.15"
value={newSection.id}
onChange={(e) => setNewSection({ ...newSection, id: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> ()</Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={newSection.price}
onChange={(e) => setNewSection({ ...newSection, price: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="输入章节标题"
value={newSection.title}
onChange={(e) => setNewSection({ ...newSection, title: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select value={newSection.partId} onValueChange={(v) => setNewSection({ ...newSection, partId: v })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
{bookData.map((part) => (
<SelectItem key={part.id} value={part.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
{part.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select value={newSection.chapterId} onValueChange={(v) => setNewSection({ ...newSection, chapterId: v })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
{bookData.find(p => p.id === newSection.partId)?.chapters.map((ch) => (
<SelectItem key={ch.id} value={ch.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
{ch.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (Markdown格式)</Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[300px] font-mono text-sm placeholder:text-gray-500"
placeholder="输入章节内容..."
value={newSection.content}
onChange={(e) => setNewSection({ ...newSection, content: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewSectionModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleCreateSection}
disabled={isSaving || !newSection.id || !newSection.title}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 章节编辑弹窗 */}
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
{editingSection && (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={editingSection.id}
disabled
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> ()</Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={editingSection.price}
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={editingSection.title}
onChange={(e) => setEditingSection({ ...editingSection, title: e.target.value })}
/>
</div>
{editingSection.filePath && (
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-gray-400 text-sm"
value={editingSection.filePath}
disabled
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-gray-300"> (Markdown格式)</Label>
<div className="flex gap-2">
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={() => imageInputRef.current?.click()}
disabled={uploadingImage}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{uploadingImage ? (
<RefreshCw className="w-4 h-4 mr-1 animate-spin" />
) : (
<ImageIcon className="w-4 h-4 mr-1" />
)}
</Button>
</div>
</div>
{isLoadingContent ? (
<div className="bg-[#0a1628] border border-gray-700 rounded-md min-h-[400px] flex items-center justify-center">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[400px] font-mono text-sm placeholder:text-gray-500"
placeholder="此处输入章节内容支持Markdown格式..."
value={editingSection.content}
onChange={(e) => setEditingSection({ ...editingSection, content: e.target.value })}
/>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditingSection(null)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSaveSection}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Tabs defaultValue="chapters" className="space-y-6">
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1">
<TabsTrigger
value="chapters"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
>
<BookOpen className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="search"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
>
<Search className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="hooks"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
>
<Settings2 className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
<TabsContent value="chapters" className="space-y-4">
{/* 新建章节按钮 */}
<Button
onClick={() => setShowNewSectionModal(true)}
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
{bookData.map((part, partIndex) => (
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
<CardHeader
className="cursor-pointer hover:bg-[#162840] transition-colors"
onClick={() => togglePart(part.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[#38bdac] font-mono text-sm">0{partIndex + 1}</span>
<CardTitle className="text-white">{part.title}</CardTitle>
<Badge variant="outline" className="text-gray-400 border-gray-600">
{part.chapters.reduce((sum, ch) => sum + ch.sections.length, 0)}
</Badge>
</div>
<ChevronRight
className={`w-5 h-5 text-gray-400 transition-transform ${
expandedParts.includes(part.id) ? "rotate-90" : ""
}`}
/>
</div>
</CardHeader>
{expandedParts.includes(part.id) && (
<CardContent className="pt-0 pb-4">
<div className="space-y-3 pl-8 border-l-2 border-gray-700">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<h4 className="font-medium text-gray-300">{chapter.title}</h4>
<div className="space-y-1">
{chapter.sections.map((section) => (
<div
key={section.id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840] text-sm group transition-colors"
>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-[#38bdac]" />
<span className="text-gray-400">{section.title}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-medium">
{section.price === 0 ? "免费" : `¥${section.price}`}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleReadSection(section)}
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReadSection(section)}
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit3 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
))}
</TabsContent>
<TabsContent value="search" className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 flex-1"
placeholder="搜索标题或内容..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSearching ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{/* 搜索结果 */}
{searchResults.length > 0 && (
<div className="space-y-2 mt-4">
<p className="text-gray-400 text-sm"> {searchResults.length} </p>
{searchResults.map((result: any) => (
<div
key={result.id}
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors"
onClick={() => handleReadSection({ id: result.id, title: result.title, price: result.price || 1, filePath: '' })}
>
<div className="flex items-center justify-between">
<div>
<span className="text-[#38bdac] font-mono text-xs mr-2">{result.id}</span>
<span className="text-white">{result.title}</span>
</div>
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
{result.matchType === 'title' ? '标题匹配' : '内容匹配'}
</Badge>
</div>
{result.snippet && (
<p className="text-gray-500 text-xs mt-2 line-clamp-2">{result.snippet}</p>
)}
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="hooks" className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="hook-chapter" className="text-gray-300">
</Label>
<Select defaultValue="3">
<SelectTrigger id="hook-chapter" className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue placeholder="选择章节" />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
<SelectItem value="1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
</SelectItem>
<SelectItem value="2" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
</SelectItem>
<SelectItem value="3" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
()
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid w-full gap-1.5">
<Label htmlFor="message" className="text-gray-300">
</Label>
<Textarea
placeholder="输入引导用户加群的文案..."
id="message"
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
defaultValue="阅读更多精彩内容请加入Soul创业实验派对群..."
/>
</div>
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white"></Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}