Files
soul/app/admin/content/page.tsx

728 lines
27 KiB
TypeScript
Raw Normal View History

"use client"
import { useState, useRef } 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,
} from "lucide-react"
interface EditingSection {
id: string
title: string
price: number
content?: string
filePath?: 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 [importData, setImportData] = useState("")
const [isLoadingContent, setIsLoadingContent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
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: section.title,
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
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("保存失败")
}
}
// 同步到数据库
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' })
const data = await res.json()
if (data.success) {
alert(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={!!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
className="bg-[#0a1628] border-gray-700 text-white text-sm"
value={editingSection.filePath || ""}
disabled
/>
</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>
<div className="space-y-2">
<Label className="text-gray-300"> ()</Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white w-32"
value={editingSection.price}
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (Markdown格式)</Label>
{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} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<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="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">
{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="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>
)
}