1804 lines
73 KiB
TypeScript
1804 lines
73 KiB
TypeScript
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 = ``
|
||
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 名:得分 20~1</li>
|
||
<li>最近更新前 30 篇:得分 30~1</li>
|
||
<li>付款数前 20 名:得分 20~1</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>
|
||
)
|
||
}
|