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

1804 lines
73 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, useCallback } 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 {
BookOpen,
Settings2,
Edit3,
Save,
X,
RefreshCw,
Link2,
Plus,
Image as ImageIcon,
Search,
Trophy,
ChevronLeft,
ChevronRight as ChevronRightIcon,
Pin,
Star,
} from 'lucide-react'
import { get, put, post, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { apiUrl } from '@/api/client'
interface SectionListItem {
id: string
title: string
price: number
isFree?: boolean
isNew?: boolean
partId?: string
partTitle?: string
chapterId?: string
chapterTitle?: string
filePath?: string
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
interface Section {
id: string
title: string
price: number
filePath?: string
isFree?: boolean
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
interface Chapter {
id: string
title: string
sections: Section[]
}
interface Part {
id: string
title: string
chapters: Chapter[]
}
interface SectionOrder {
id?: string
orderSn?: string
userId?: string
openId?: string
amount?: number
status?: string
createdAt?: string
payTime?: string
}
interface EditingSection {
id: string
title: string
price: number
content?: string
filePath?: string
isFree?: boolean
isNew?: boolean
isPinned?: boolean
hotScore?: number
}
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,
isNew: s.isNew,
clickCount: s.clickCount ?? 0,
payCount: s.payCount ?? 0,
hotScore: s.hotScore ?? 0,
hotRank: s.hotRank ?? 0,
})
}
// 确保「2026每日派对干货」篇章存在不在第六篇编号体系内
const DAILY_PART_ID = 'part-2026-daily'
const DAILY_PART_TITLE = '2026每日派对干货'
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
if (!hasDailyPart) {
partMap.set(DAILY_PART_ID, {
id: DAILY_PART_ID,
title: DAILY_PART_TITLE,
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
})
}
const parts = Array.from(partMap.values()).map((p) => ({
...p,
chapters: Array.from(p.chapters.values()),
}))
// 固定顺序序言首位2026每日派对干货附录前附录/尾声末位
const orderKey = (t: string) => {
if (t.includes('序言')) return 0
if (t.includes(DAILY_PART_TITLE)) return 1.5
if (t.includes('附录')) return 2
if (t.includes('尾声')) return 3
return 1
}
return parts.sort((a, b) => {
const ka = orderKey(a.title)
const kb = orderKey(b.title)
if (ka !== kb) return ka - kb
return 0
})
}
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 [showNewSectionModal, setShowNewSectionModal] = useState(false)
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 imageInputRef = useRef<HTMLInputElement>(null)
const [newSection, setNewSection] = useState({
id: '',
title: '',
price: 1,
partId: 'part-1',
chapterId: 'chapter-1',
content: '',
})
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
const [isSavingPartTitle, setIsSavingPartTitle] = useState(false)
const [showNewPartModal, setShowNewPartModal] = useState(false)
const [editingChapter, setEditingChapter] = useState<{ part: Part; chapter: Chapter; title: string } | null>(null)
const [isSavingChapterTitle, setIsSavingChapterTitle] = useState(false)
const [selectedSectionIds, setSelectedSectionIds] = useState<string[]>([])
const [showBatchMoveModal, setShowBatchMoveModal] = useState(false)
const [batchMoveTargetPartId, setBatchMoveTargetPartId] = useState('')
const [batchMoveTargetChapterId, setBatchMoveTargetChapterId] = useState('')
const [isMoving, setIsMoving] = useState(false)
const [newPartTitle, setNewPartTitle] = useState('')
const [isSavingPart, setIsSavingPart] = useState(false)
const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null)
const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false)
const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false)
const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 })
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
const [rankingPage, setRankingPage] = useState(1)
const [pinnedSectionIds, setPinnedSectionIds] = useState<string[]>([])
const [pinnedLoading, setPinnedLoading] = useState(false)
const [previewPercent, setPreviewPercent] = useState(20)
const [previewPercentLoading, setPreviewPercentLoading] = useState(false)
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
const tree = buildTree(sectionsList)
const totalSections = sectionsList.length
const rankedSections = [...sectionsList].sort((a, b) => (b.hotScore ?? 0) - (a.hotScore ?? 0))
const RANKING_PAGE_SIZE = 10
const rankingTotalPages = Math.max(1, Math.ceil(rankedSections.length / RANKING_PAGE_SIZE))
const rankingPageSections = rankedSections.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
const loadList = async () => {
setLoading(true)
try {
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
'/api/db/book?action=list',
{ cache: 'no-store' as RequestCache },
)
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
} catch (e) {
console.error(e)
setSectionsList([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [])
const togglePart = (partId: string) => {
setExpandedParts((prev) =>
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
)
}
const handleReorderTree = useCallback(
(items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]): Promise<void> => {
const prev = sectionsList
const newList: SectionListItem[] = items.flatMap((it) => {
const s = prev.find((x) => x.id === it.id)
if (!s) return []
return [{ ...s, partId: it.partId, partTitle: it.partTitle, chapterId: it.chapterId, chapterTitle: it.chapterTitle }]
})
setSectionsList(newList)
put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
.then((res) => {
if (res && (res as { success?: boolean }).success === false) {
setSectionsList(prev)
alert('排序失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : '未知错误'))
}
})
.catch((e) => {
setSectionsList(prev)
console.error('排序失败:', e)
alert('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
})
return Promise.resolve()
},
[sectionsList],
)
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
try {
const res = await del<{ success?: boolean; error?: string }>(
`/api/db/book?id=${encodeURIComponent(section.id)}`,
)
if (res && (res as { success?: boolean }).success !== false) {
alert('已删除')
loadList()
} else {
alert('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('删除失败')
}
}
const loadRankingWeights = useCallback(async () => {
setRankingWeightsLoading(true)
try {
const data = await get<{ success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }>(
'/api/db/config/full?key=article_ranking_weights',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }).data
if (d && typeof d.readWeight === 'number' && typeof d.recencyWeight === 'number' && typeof d.payWeight === 'number') {
setRankingWeights({
readWeight: Math.max(0, Math.min(1, d.readWeight)),
recencyWeight: Math.max(0, Math.min(1, d.recencyWeight)),
payWeight: Math.max(0, Math.min(1, d.payWeight)),
})
}
} catch {
// 使用默认值
} finally {
setRankingWeightsLoading(false)
}
}, [])
useEffect(() => {
if (showRankingAlgorithmModal) loadRankingWeights()
}, [showRankingAlgorithmModal, loadRankingWeights])
const handleSaveRankingWeights = async () => {
const { readWeight, recencyWeight, payWeight } = rankingWeights
const sum = readWeight + recencyWeight + payWeight
if (Math.abs(sum - 1) > 0.001) {
alert('三个权重之和必须等于 1')
return
}
setRankingWeightsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'article_ranking_weights',
value: { readWeight, recencyWeight, payWeight },
description: '文章排名算法权重',
})
if (res && (res as { success?: boolean }).success !== false) {
alert('已保存')
loadList()
} else {
alert('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
}
} catch (e) {
console.error(e)
alert('保存失败')
} finally {
setRankingWeightsSaving(false)
}
}
const loadPinnedSections = useCallback(async () => {
setPinnedLoading(true)
try {
const data = await get<{ success?: boolean; data?: string[] }>(
'/api/db/config/full?key=pinned_section_ids',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { data?: string[] }).data
if (Array.isArray(d)) setPinnedSectionIds(d)
} catch { /* keep default */ } finally { setPinnedLoading(false) }
}, [])
const handleTogglePin = async (sectionId: string) => {
const next = pinnedSectionIds.includes(sectionId)
? pinnedSectionIds.filter((id) => id !== sectionId)
: [...pinnedSectionIds, sectionId]
setPinnedSectionIds(next)
try {
await post<{ success?: boolean }>('/api/db/config', {
key: 'pinned_section_ids',
value: next,
description: '强制置顶章节ID列表精选推荐/首页最新更新)',
})
} catch { setPinnedSectionIds(pinnedSectionIds) }
}
const loadPreviewPercent = useCallback(async () => {
setPreviewPercentLoading(true)
try {
const data = await get<{ success?: boolean; data?: number }>(
'/api/db/config/full?key=unpaid_preview_percent',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { data?: number }).data
if (typeof d === 'number' && d > 0 && d <= 100) setPreviewPercent(d)
} catch { /* keep default */ } finally { setPreviewPercentLoading(false) }
}, [])
const handleSavePreviewPercent = async () => {
if (previewPercent < 1 || previewPercent > 100) { alert('预览比例需在 1~100 之间'); return }
setPreviewPercentSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'unpaid_preview_percent',
value: previewPercent,
description: '小程序未付费内容默认预览比例(%)',
})
if (res && (res as { success?: boolean }).success !== false) alert('已保存')
else alert('保存失败: ' + ((res as { error?: string }).error || ''))
} catch { alert('保存失败') } finally { setPreviewPercentSaving(false) }
}
useEffect(() => { loadPinnedSections(); loadPreviewPercent() }, [loadPinnedSections, loadPreviewPercent])
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
setSectionOrdersLoading(true)
try {
const data = await get<{ success?: boolean; orders?: SectionOrder[] }>(
`/api/db/book?action=section-orders&id=${encodeURIComponent(section.id)}`,
)
const orders = data?.success && Array.isArray(data.orders) ? data.orders : []
setSectionOrdersModal((prev) => (prev ? { ...prev, orders } : null))
} catch (e) {
console.error(e)
setSectionOrdersModal((prev) => (prev ? { ...prev, orders: [] } : null))
} finally {
setSectionOrdersLoading(false)
}
}
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) {
const sec = data.section as { isNew?: boolean }
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,
isNew: sec.isNew ?? section.isNew,
isPinned: pinnedSectionIds.includes(section.id),
hotScore: section.hotScore ?? 0,
})
} else {
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: '',
filePath: section.filePath,
isFree: section.isFree,
isNew: section.isNew,
isPinned: pinnedSectionIds.includes(section.id),
hotScore: section.hotScore ?? 0,
})
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,
isNew: editingSection.isNew,
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 handleAddSectionInPart = (part: Part) => {
setNewSection((prev) => ({
...prev,
partId: part.id,
chapterId: part.chapters[0]?.id ?? 'chapter-1',
}))
setShowNewSectionModal(true)
}
const handleEditPartTitle = (part: Part) => {
setEditingPart({ id: part.id, title: part.title })
}
const handleSavePartTitle = async () => {
if (!editingPart?.title?.trim()) return
setIsSavingPartTitle(true)
try {
const items = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || 'part-1',
partTitle: s.partId === editingPart.id ? editingPart.title.trim() : (s.partTitle || ''),
chapterId: s.chapterId || 'chapter-1',
chapterTitle: s.chapterTitle || '',
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
action: 'reorder',
items,
})
if (res && (res as { success?: boolean }).success !== false) {
setEditingPart(null)
loadList()
} else {
alert('更新篇名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('更新篇名失败')
} finally {
setIsSavingPartTitle(false)
}
}
const handleAddChapterInPart = (part: Part) => {
const nextChNum = part.chapters.length + 1
const newChapterId = `chapter-${part.id}-${nextChNum}-${Date.now()}`
setNewSection({
id: `${nextChNum}.1`,
title: '新章节',
price: 1,
partId: part.id,
chapterId: newChapterId,
content: '',
})
setShowNewSectionModal(true)
}
const handleEditChapter = (part: Part, chapter: Chapter) => {
setEditingChapter({ part, chapter, title: chapter.title })
}
const handleSaveChapterTitle = async () => {
if (!editingChapter?.title?.trim()) return
setIsSavingChapterTitle(true)
try {
const items = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || editingChapter.part.id,
partTitle: s.partId === editingChapter.part.id ? editingChapter.part.title : (s.partTitle || ''),
chapterId: s.chapterId || editingChapter.chapter.id,
chapterTitle:
s.partId === editingChapter.part.id && s.chapterId === editingChapter.chapter.id
? editingChapter.title.trim()
: (s.chapterTitle || ''),
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
if (res && (res as { success?: boolean }).success !== false) {
setEditingChapter(null)
loadList()
} else {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('保存失败')
} finally {
setIsSavingChapterTitle(false)
}
}
const handleDeleteChapter = async (part: Part, chapter: Chapter) => {
const sectionIds = chapter.sections.map((s) => s.id)
if (sectionIds.length === 0) {
alert('该章下无小节,无需删除')
return
}
if (!confirm(`确定要删除「第${part.chapters.indexOf(chapter) + 1}章 | ${chapter.title}」吗?将删除共 ${sectionIds.length} 节,此操作不可恢复。`)) return
try {
for (const id of sectionIds) {
await del<{ success?: boolean; error?: string }>(`/api/db/book?id=${encodeURIComponent(id)}`)
}
loadList()
} catch (e) {
console.error(e)
alert('删除失败')
}
}
const handleCreatePart = async () => {
if (!newPartTitle.trim()) {
alert('请输入篇名')
return
}
setIsSavingPart(true)
try {
const partId = `part-new-${Date.now()}`
const chapterId = `chapter-1`
const sectionId = `part-placeholder-${Date.now()}`
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
id: sectionId,
title: '占位节(可编辑)',
price: 0,
content: '',
partId,
partTitle: newPartTitle.trim(),
chapterId,
chapterTitle: '第1章 | 待编辑',
saveToFile: false,
})
if (res && (res as { success?: boolean }).success !== false) {
alert(`篇「${newPartTitle}」创建成功,请编辑占位节`)
setShowNewPartModal(false)
setNewPartTitle('')
loadList()
} else {
alert('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('创建失败')
} finally {
setIsSavingPart(false)
}
}
const handleBatchMoveToTarget = async () => {
if (selectedSectionIds.length === 0) {
alert('请先勾选要移动的章节')
return
}
const targetPart = tree.find((p) => p.id === batchMoveTargetPartId)
const targetChapter = targetPart?.chapters.find((c) => c.id === batchMoveTargetChapterId)
if (!targetPart || !targetChapter || !batchMoveTargetPartId || !batchMoveTargetChapterId) {
alert('请选择目标篇和章')
return
}
setIsMoving(true)
try {
const buildFallbackReorderItems = () => {
const selectedSet = new Set(selectedSectionIds)
const baseItems = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || '',
partTitle: s.partTitle || '',
chapterId: s.chapterId || '',
chapterTitle: s.chapterTitle || '',
}))
const movingItems = baseItems
.filter((item) => selectedSet.has(item.id))
.map((item) => ({
...item,
partId: batchMoveTargetPartId,
partTitle: targetPart.title || batchMoveTargetPartId,
chapterId: batchMoveTargetChapterId,
chapterTitle: targetChapter.title || batchMoveTargetChapterId,
}))
const remainingItems = baseItems.filter((item) => !selectedSet.has(item.id))
let insertIndex = remainingItems.length
for (let i = remainingItems.length - 1; i >= 0; i -= 1) {
const item = remainingItems[i]
if (item.partId === batchMoveTargetPartId && item.chapterId === batchMoveTargetChapterId) {
insertIndex = i + 1
break
}
}
return [
...remainingItems.slice(0, insertIndex),
...movingItems,
...remainingItems.slice(insertIndex),
]
}
const tryFallbackReorder = async () => {
const reorderItems = buildFallbackReorderItems()
const reorderRes = await put<{ success?: boolean; error?: string }>(
'/api/db/book',
{ action: 'reorder', items: reorderItems },
)
if (reorderRes && (reorderRes as { success?: boolean }).success !== false) {
alert(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
await loadList()
return true
}
return false
}
const payload = {
action: 'move-sections',
sectionIds: selectedSectionIds,
targetPartId: batchMoveTargetPartId,
targetChapterId: batchMoveTargetChapterId,
targetPartTitle: targetPart.title || batchMoveTargetPartId,
targetChapterTitle: targetChapter.title || batchMoveTargetChapterId,
}
const res = await put<{ success?: boolean; error?: string; count?: number }>('/api/db/book', payload)
if (res && (res as { success?: boolean }).success !== false) {
alert(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}`)
setShowBatchMoveModal(false)
setSelectedSectionIds([])
await loadList()
} else {
const errorMessage = res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error || '' : '未知错误'
if (errorMessage.includes('缺少 id') || errorMessage.includes('无效的 action')) {
const fallbackOk = await tryFallbackReorder()
if (fallbackOk) return
}
alert('移动失败: ' + errorMessage)
}
} catch (e) {
console.error(e)
alert('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
} finally {
setIsMoving(false)
}
}
const toggleSectionSelect = (sectionId: string) => {
setSelectedSectionIds((prev) =>
prev.includes(sectionId) ? prev.filter((id) => id !== sectionId) : [...prev, sectionId],
)
}
const handleDeletePart = async (part: Part) => {
const sectionIds = sectionsList.filter((s) => s.partId === part.id).map((s) => s.id)
if (sectionIds.length === 0) {
alert('该篇下暂无小节可删除')
return
}
if (!confirm(`确定要删除「${part.title}」整篇吗?将删除共 ${sectionIds.length} 节内容,此操作不可恢复。`)) return
try {
for (const id of sectionIds) {
await del<{ success?: boolean; error?: string }>(`/api/db/book?id=${encodeURIComponent(id)}`)
}
loadList()
} catch (e) {
console.error(e)
alert('删除失败')
}
}
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 currentPart = tree.find((p) => p.id === newSection.partId)
const chaptersForPart = currentPart?.chapters ?? []
return (
<div className="p-8 w-full">
<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={() => setShowRankingAlgorithmModal(true)}
variant="outline"
className="border-amber-500/50 text-amber-400 hover:bg-amber-500/10 bg-transparent"
>
<Settings2 className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => {
const url = import.meta.env.VITE_API_DOC_URL || (typeof window !== 'undefined' ? `${window.location.origin}/api-doc` : '')
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Link2 className="w-4 h-4 mr-2" />
API
</Button>
</div>
</div>
{/* 新建章节弹窗 */}
<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={!!editingPart} onOpenChange={(open) => !open && setEditingPart(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
{editingPart && (
<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"
value={editingPart.title}
onChange={(e) => setEditingPart({ ...editingPart, title: e.target.value })}
placeholder="输入篇名"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditingPart(null)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSavePartTitle}
disabled={isSavingPartTitle || !editingPart?.title?.trim()}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSavingPartTitle ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑章节名称弹窗 */}
<Dialog open={!!editingChapter} onOpenChange={(open) => !open && setEditingChapter(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
{editingChapter && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300">8</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={editingChapter.title}
onChange={(e) => setEditingChapter({ ...editingChapter, title: e.target.value })}
placeholder="输入章节名称"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditingChapter(null)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSaveChapterTitle}
disabled={isSavingChapterTitle || !editingChapter?.title?.trim()}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSavingChapterTitle ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 批量移动至指定目录弹窗 */}
<Dialog
open={showBatchMoveModal}
onOpenChange={(open) => {
setShowBatchMoveModal(open)
if (open && tree.length > 0) {
const first = tree[0]
setBatchMoveTargetPartId(first.id)
setBatchMoveTargetChapterId(first.chapters[0]?.id ?? '')
}
}}
>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white"></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-gray-400 text-sm">
<span className="text-[#38bdac] font-medium">{selectedSectionIds.length}</span>
</p>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select
value={batchMoveTargetPartId}
onValueChange={(v) => {
setBatchMoveTargetPartId(v)
const part = tree.find((p) => p.id === v)
setBatchMoveTargetChapterId(part?.chapters[0]?.id ?? '')
}}
>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue placeholder="选择篇" />
</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>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select value={batchMoveTargetChapterId} onValueChange={setBatchMoveTargetChapterId}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue placeholder="选择章" />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
{(tree.find((p) => p.id === batchMoveTargetPartId)?.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>
<DialogFooter>
<Button variant="outline" onClick={() => setShowBatchMoveModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
</Button>
<Button
onClick={handleBatchMoveToTarget}
disabled={isMoving || selectedSectionIds.length === 0}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isMoving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'确认移动'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 付款记录弹窗 */}
<Dialog open={!!sectionOrdersModal} onOpenChange={(open) => !open && setSectionOrdersModal(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-3xl max-h-[85vh] overflow-hidden flex flex-col" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white">
{sectionOrdersModal?.section.title ?? ''}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
{sectionOrdersLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : sectionOrdersModal && sectionOrdersModal.orders.length === 0 ? (
<p className="text-gray-500 text-center py-6"></p>
) : sectionOrdersModal ? (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-gray-700 text-left text-gray-400">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2">ID</th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
</tr>
</thead>
<tbody>
{sectionOrdersModal.orders.map((o) => (
<tr key={o.id ?? o.orderSn ?? ''} className="border-b border-gray-700/50">
<td className="py-2 pr-2 text-gray-300">{o.orderSn ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">{o.userId ?? o.openId ?? '-'}</td>
<td className="py-2 pr-2 text-gray-300">¥{o.amount ?? 0}</td>
<td className="py-2 pr-2 text-gray-300">{o.status ?? '-'}</td>
<td className="py-2 pr-2 text-gray-500">{o.payTime ?? o.createdAt ?? '-'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
</DialogContent>
</Dialog>
{/* 排名算法:权重可编辑 */}
<Dialog open={showRankingAlgorithmModal} onOpenChange={setShowRankingAlgorithmModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Settings2 className="w-5 h-5 text-amber-400" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-gray-400"> = × + × + × 1</p>
{rankingWeightsLoading ? (
<p className="text-gray-500">...</p>
) : (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.readWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, readWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.recencyWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, recencyWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
className="bg-[#0a1628] border-gray-700 text-white"
value={rankingWeights.payWeight}
onChange={(e) => setRankingWeights((w) => ({ ...w, payWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
/>
</div>
</div>
<p className="text-xs text-gray-500">: {(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight).toFixed(1)}</p>
<ul className="list-disc list-inside space-y-1 text-xs text-gray-400">
<li> 20 201</li>
<li> 30 301</li>
<li> 20 201</li>
</ul>
<Button
onClick={handleSaveRankingWeights}
disabled={rankingWeightsSaving || Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 1) > 0.001}
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
>
{rankingWeightsSaving ? '保存中...' : '保存权重'}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
{/* 新建篇弹窗 */}
<Dialog open={showNewPartModal} onOpenChange={setShowNewPartModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Plus className="w-5 h-5 text-amber-400" />
</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"
value={newPartTitle}
onChange={(e) => setNewPartTitle(e.target.value)}
placeholder="输入篇名"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => { setShowNewPartModal(false); setNewPartTitle('') }}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleCreatePart}
disabled={isSavingPart || !newPartTitle.trim()}
className="bg-amber-500 hover:bg-amber-600 text-white"
>
{isSavingPart ? (
<>
<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] flex flex-col p-0 gap-0" showCloseButton>
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
{editingSection && (
<div className="flex-1 overflow-y-auto min-h-0 px-6 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}
onChange={(e) => setEditingSection({ ...editingSection, id: e.target.value })}
placeholder="如: 9.15"
/>
</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 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.isNew ?? false}
onChange={(e) =>
setEditingSection({
...editingSection,
isNew: e.target.checked,
})
}
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"> NEW</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 className="shrink-0 px-6 py-4 border-t border-gray-700/50">
<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="ranking" className="data-[state=active]:bg-amber-500/20 data-[state=active]:text-amber-400 text-gray-400">
<Trophy 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>
</TabsList>
<TabsContent value="chapters" className="space-y-4">
{/* 书籍信息卡片 */}
<div className="rounded-2xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[#38bdac] flex items-center justify-center text-white shadow-lg shadow-[#38bdac]/20 shrink-0">
<BookOpen className="w-6 h-6" />
</div>
<div>
<h2 className="font-bold text-base text-white leading-tight mb-1">SOUL的创业实验场</h2>
<p className="text-xs text-gray-500">Soul派对房的真实商业故事</p>
</div>
</div>
<div className="text-center shrink-0">
<span className="block text-2xl font-bold text-[#38bdac]">{totalSections}</span>
<span className="text-xs text-gray-500"></span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setShowNewSectionModal(true)}
className="flex-1 min-w-[120px] bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => setShowNewPartModal(true)}
className="flex-1 min-w-[120px] bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={() => setShowBatchMoveModal(true)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{selectedSectionIds.length}
</Button>
</div>
{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>
) : (
<ChapterTree
parts={tree}
expandedParts={expandedParts}
onTogglePart={togglePart}
onReorder={handleReorderTree}
onReadSection={handleReadSection}
onDeleteSection={handleDeleteSection}
onAddSectionInPart={handleAddSectionInPart}
onAddChapterInPart={handleAddChapterInPart}
onDeleteChapter={handleDeleteChapter}
onEditPart={handleEditPartTitle}
onDeletePart={handleDeletePart}
onEditChapter={handleEditChapter}
selectedSectionIds={selectedSectionIds}
onToggleSectionSelect={toggleSectionSelect}
onShowSectionOrders={handleShowSectionOrders}
/>
)}
</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="ranking" className="space-y-4">
{/* 内容显示规则 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="pb-3">
<CardTitle className="text-white text-base flex items-center gap-2">
<Settings2 className="w-4 h-4 text-[#38bdac]" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<Label className="text-gray-400 text-sm whitespace-nowrap"></Label>
<Input
type="number"
min="1"
max="100"
className="bg-[#0a1628] border-gray-700 text-white w-20"
value={previewPercent}
onChange={(e) => setPreviewPercent(Math.max(1, Math.min(100, Number(e.target.value) || 20)))}
disabled={previewPercentLoading}
/>
<span className="text-gray-500 text-sm">%</span>
</div>
<Button
size="sm"
onClick={handleSavePreviewPercent}
disabled={previewPercentSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{previewPercentSaving ? '保存中...' : '保存'}
</Button>
<span className="text-xs text-gray-500"> {previewPercent}% </span>
</div>
</CardContent>
</Card>
{/* 排行榜 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-white text-base flex items-center gap-2">
<Trophy className="w-4 h-4 text-amber-400" />
<span className="text-xs text-gray-500 font-normal ml-2"> · {rankedSections.length} </span>
</CardTitle>
<div className="flex items-center gap-1 text-sm">
<Button
variant="ghost"
size="sm"
disabled={rankingPage <= 1}
onClick={() => setRankingPage((p) => Math.max(1, p - 1))}
className="text-gray-400 hover:text-white h-7 w-7 p-0"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-gray-400 min-w-[60px] text-center">{rankingPage} / {rankingTotalPages}</span>
<Button
variant="ghost"
size="sm"
disabled={rankingPage >= rankingTotalPages}
onClick={() => setRankingPage((p) => Math.min(rankingTotalPages, p + 1))}
className="text-gray-400 hover:text-white h-7 w-7 p-0"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-0">
{/* 表头 */}
<div className="grid grid-cols-[40px_40px_1fr_80px_80px_80px_60px] gap-2 px-3 py-2 text-xs text-gray-500 border-b border-gray-700/50">
<span></span>
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
</div>
{rankingPageSections.map((s, idx) => {
const globalRank = (rankingPage - 1) * RANKING_PAGE_SIZE + idx + 1
const isPinned = pinnedSectionIds.includes(s.id)
return (
<div
key={s.id}
className={`grid grid-cols-[40px_40px_1fr_80px_80px_80px_60px] gap-2 px-3 py-2.5 items-center border-b border-gray-700/30 hover:bg-[#162840] transition-colors ${isPinned ? 'bg-amber-500/5' : ''}`}
>
<span className={`text-sm font-bold ${globalRank <= 3 ? 'text-amber-400' : 'text-gray-500'}`}>
{globalRank <= 3 ? ['🥇', '🥈', '🥉'][globalRank - 1] : `#${globalRank}`}
</span>
<Button
variant="ghost"
size="sm"
className={`h-6 w-6 p-0 ${isPinned ? 'text-amber-400' : 'text-gray-600 hover:text-amber-400'}`}
onClick={() => handleTogglePin(s.id)}
disabled={pinnedLoading}
title={isPinned ? '取消置顶' : '强制置顶(精选推荐/首页最新更新)'}
>
{isPinned ? <Star className="w-3.5 h-3.5 fill-current" /> : <Pin className="w-3.5 h-3.5" />}
</Button>
<div className="min-w-0">
<span className="text-white text-sm truncate block">{s.title}</span>
<span className="text-gray-600 text-xs">{s.partTitle} · {s.chapterTitle}</span>
</div>
<span className="text-right text-sm text-blue-400 font-mono">{s.clickCount ?? 0}</span>
<span className="text-right text-sm text-green-400 font-mono">{s.payCount ?? 0}</span>
<span className="text-right text-sm text-amber-400 font-mono">{(s.hotScore ?? 0).toFixed(1)}</span>
<div className="text-right">
<Button
variant="ghost"
size="sm"
className="text-gray-500 hover:text-[#38bdac] h-6 px-1"
onClick={() => handleShowSectionOrders({ id: s.id, title: s.title, price: s.price })}
title="付款记录"
>
<BookOpen className="w-3 h-3" />
</Button>
</div>
</div>
)
})}
{rankingPageSections.length === 0 && (
<div className="py-8 text-center text-gray-500"></div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}