Files
soul-yongping/soul-admin/src/pages/content/ContentPage.tsx

1162 lines
45 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.

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 {
FileText,
BookOpen,
Settings2,
ChevronRight,
CheckCircle,
Edit3,
Save,
X,
RefreshCw,
Link2,
Download,
Upload,
Eye,
Database,
Plus,
Image as ImageIcon,
Search,
} from 'lucide-react'
import { get, post, put } from '@/api/client'
import { apiUrl } from '@/api/client'
interface SectionListItem {
id: string
title: string
price: number
isFree?: boolean
partId?: string
partTitle?: string
chapterId?: string
chapterTitle?: string
filePath?: string
}
interface Section {
id: string
title: string
price: number
filePath?: string
isFree?: boolean
}
interface Chapter {
id: string
title: string
sections: Section[]
}
interface Part {
id: string
title: string
chapters: Chapter[]
}
interface EditingSection {
id: string
title: string
price: number
content?: string
filePath?: string
isFree?: boolean
}
function buildTree(sections: SectionListItem[]): Part[] {
const partMap = new Map<
string,
{ id: string; title: string; chapters: Map<string, { id: string; title: string; sections: Section[] }> }
>()
for (const s of sections) {
const partId = s.partId || 'part-1'
const partTitle = s.partTitle || '未分类'
const chapterId = s.chapterId || 'chapter-1'
const chapterTitle = s.chapterTitle || '未分类'
if (!partMap.has(partId)) {
partMap.set(partId, { id: partId, title: partTitle, chapters: new Map() })
}
const part = partMap.get(partId)!
if (!part.chapters.has(chapterId)) {
part.chapters.set(chapterId, { id: chapterId, title: chapterTitle, sections: [] })
}
part.chapters.get(chapterId)!.sections.push({
id: s.id,
title: s.title,
price: s.price ?? 1,
filePath: s.filePath,
isFree: s.isFree,
})
}
return Array.from(partMap.values()).map((p) => ({
...p,
chapters: Array.from(p.chapters.values()),
}))
}
function parseTxtToJson(content: string, fileName: string): { id: string; title: string; price: number; content?: string; isFree?: boolean }[] {
const lines = content.split('\n')
const sections: { id: string; title: string; price: number; content?: string; isFree?: boolean }[] = []
let currentSection: { id: string; title: string; price: number; content?: string; isFree?: boolean } | null = 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,
isFree: sectionIndex <= 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,
isFree: true,
}
currentContent.push(line)
sectionIndex++
}
}
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) sections.push(currentSection)
}
return sections
}
export function ContentPage() {
const [sectionsList, setSectionsList] = useState<SectionListItem[]>([])
const [loading, setLoading] = useState(true)
const [expandedParts, setExpandedParts] = useState<string[]>([])
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<{ id: string; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
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 tree = buildTree(sectionsList)
const totalSections = sectionsList.length
const loadList = async () => {
setLoading(true)
try {
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
'/api/db/book?action=list',
)
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
if (expandedParts.length === 0 && tree.length > 0) {
setExpandedParts([tree[0].id])
}
} catch (e) {
console.error(e)
setSectionsList([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [])
useEffect(() => {
if (!loading && tree.length > 0 && expandedParts.length === 0) {
setExpandedParts([tree[0].id])
}
}, [loading, tree.length, expandedParts.length])
const togglePart = (partId: string) => {
setExpandedParts((prev) =>
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
)
}
const handleReadSection = async (section: Section & { filePath?: string }) => {
setIsLoadingContent(true)
try {
const data = await get<{ success?: boolean; section?: { title?: string; price?: number; content?: string }; error?: string }>(
`/api/db/book?action=read&id=${encodeURIComponent(section.id)}`,
)
if (data?.success && data.section) {
setEditingSection({
id: section.id,
title: data.section.title ?? section.title,
price: data.section.price ?? section.price,
content: data.section.content ?? '',
filePath: section.filePath,
isFree: section.isFree || section.price === 0,
})
} else {
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: '',
filePath: section.filePath,
isFree: section.isFree,
})
if (data && !(data as { success?: boolean }).success) {
alert('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
}
}
} catch (e) {
console.error(e)
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: '',
filePath: section.filePath,
isFree: section.isFree,
})
} finally {
setIsLoadingContent(false)
}
}
const handleSaveSection = async () => {
if (!editingSection) return
setIsSaving(true)
try {
let content = editingSection.content || ''
const titlePatterns = [
new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}\\s+.*$`, 'gm'),
new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}[:].*$`, 'gm'),
new RegExp(`^#\\s+.*${editingSection.title?.slice(0, 10).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`, 'gm'),
]
for (const pattern of titlePatterns) content = content.replace(pattern, '')
content = content.replace(/^\s*\n+/, '').trim()
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: editingSection.id,
title: editingSection.title,
price: editingSection.isFree ? 0 : editingSection.price,
content,
isFree: editingSection.isFree || editingSection.price === 0,
saveToFile: true,
})
if (res && (res as { success?: boolean }).success !== false) {
alert(`已保存章节: ${editingSection.title}`)
setEditingSection(null)
loadList()
} else {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('保存失败')
} finally {
setIsSaving(false)
}
}
const handleCreateSection = async () => {
if (!newSection.id || !newSection.title) {
alert('请填写章节ID和标题')
return
}
setIsSaving(true)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: newSection.id,
title: newSection.title,
price: newSection.price,
content: newSection.content,
partId: newSection.partId,
chapterId: newSection.chapterId,
saveToFile: false,
})
if (res && (res as { success?: boolean }).success !== false) {
alert(`章节创建成功: ${newSection.title}`)
setShowNewSectionModal(false)
setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '' })
loadList()
} else {
alert('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
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(apiUrl('/api/upload'), { method: 'POST', body: formData, credentials: 'include' })
const data = await res.json()
if (data?.success && data?.data?.url) {
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 (err) {
console.error(err)
alert('上传失败')
} finally {
setUploadingImage(false)
if (imageInputRef.current) imageInputRef.current.value = ''
}
}
const handleSearch = async () => {
if (!searchQuery.trim()) return
setIsSearching(true)
try {
const data = await get<{ success?: boolean; data?: { results?: typeof searchResults }; error?: string }>(
`/api/search?q=${encodeURIComponent(searchQuery)}`,
)
if (data?.success && (data as { data?: { results?: typeof searchResults } }).data?.results) {
setSearchResults((data as { data: { results: typeof searchResults } }).data.results)
} else {
setSearchResults([])
if (data && !(data as { success?: boolean }).success) {
alert('搜索失败: ' + (data as { error?: string }).error)
}
}
} catch (e) {
console.error(e)
setSearchResults([])
alert('搜索失败')
} finally {
setIsSearching(false)
}
}
const handleSyncToDatabase = async () => {
setIsSyncing(true)
try {
const res = await post<{ success?: boolean; message?: string; error?: string }>('/api/db/book', { action: 'sync' })
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { message?: string }).message || '同步成功')
loadList()
} else {
alert('同步失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('同步失败')
} finally {
setIsSyncing(false)
}
}
const handleExport = async () => {
setIsExporting(true)
try {
const res = await fetch(apiUrl('/api/db/book?action=export'), { credentials: 'include' })
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 (e) {
console.error(e)
alert('导出失败')
} finally {
setIsExporting(false)
}
}
const handleImport = async () => {
if (!importData.trim()) {
alert('请输入或上传JSON数据')
return
}
setIsImporting(true)
try {
const data = JSON.parse(importData)
const res = await post<{ success?: boolean; message?: string; error?: string }>('/api/db/book', { action: 'import', data })
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { message?: string }).message || '导入成功')
setShowImportModal(false)
setImportData('')
loadList()
} else {
alert('导入失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
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')) {
setImportData(content)
} else if (fileName.endsWith('.txt') || fileName.endsWith('.md') || fileName.endsWith('.markdown')) {
setImportData(JSON.stringify(parseTxtToJson(content, file.name), null, 2))
} else {
setImportData(content)
}
}
reader.readAsText(file)
}
const handleInitDatabase = async () => {
if (!confirm('确定要初始化数据库吗?这将创建所有必需的表结构。')) return
setIsInitializing(true)
try {
const res = await post<{ success?: boolean; data?: { message?: string }; error?: string }>('/api/db/init', {
adminToken: 'init_db_2025',
})
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { data?: { message?: string } }).data?.message || '初始化成功')
} else {
alert('初始化失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('初始化失败')
} finally {
setIsInitializing(false)
}
}
const handleSyncFeishu = async () => {
if (!feishuDocUrl.trim()) {
alert('请输入飞书文档链接')
return
}
setIsSyncing(true)
await new Promise((r) => setTimeout(r, 2000))
setIsSyncing(false)
setShowFeishuModal(false)
alert('飞书文档同步成功!')
}
const currentPart = tree.find((p) => p.id === newSection.partId)
const chaptersForPart = currentPart?.chapters ?? []
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"> {tree.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" showCloseButton>
<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": "内容..."}]\n\n或直接粘贴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.trim()}
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" showCloseButton>
<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" showCloseButton>
<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, chapterId: 'chapter-1' })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
{tree.map((part) => (
<SelectItem key={part.id} value={part.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
{part.title}
</SelectItem>
))}
{tree.length === 0 && (
<SelectItem value="part-1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
</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">
{chaptersForPart.map((ch) => (
<SelectItem key={ch.id} value={ch.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
{ch.title}
</SelectItem>
))}
{chaptersForPart.length === 0 && (
<SelectItem value="chapter-1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
</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" showCloseButton>
<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-3 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.isFree ? 0 : editingSection.price}
onChange={(e) =>
setEditingSection({
...editingSection,
price: Number(e.target.value),
isFree: Number(e.target.value) === 0,
})
}
disabled={editingSection.isFree}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={editingSection.isFree || editingSection.price === 0}
onChange={(e) =>
setEditingSection({
...editingSection,
isFree: e.target.checked,
price: e.target.checked ? 0 : 1,
})
}
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
<span className="ml-2 text-gray-400 text-sm"></span>
</label>
</div>
</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>
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
tree.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) => (
<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>
)}
{(result.partTitle || result.chapterTitle) && (
<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>
)
}