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

928 lines
50 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.

/**
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
import { useCallback, useState } from 'react'
import { ChevronRight, ChevronDown, BookOpen, Eye, Edit3, Trash2, GripVertical, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
const PART_LABELS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
export interface SectionItem {
id: string
title: string
price: number
isFree?: boolean
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
export interface ChapterItem {
id: string
title: string
sections: SectionItem[]
}
export interface PartItem {
id: string
title: string
chapters: ChapterItem[]
}
type DragType = 'part' | 'chapter' | 'section'
function parseDragData(data: string): { type: DragType; id: string } | null {
if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) }
if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) }
if (data.startsWith('section:')) return { type: 'section', id: data.slice(8) }
return null
}
interface ChapterTreeProps {
parts: PartItem[]
expandedParts: string[]
onTogglePart: (partId: string) => void
onReorder: (items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]) => Promise<void>
onReadSection: (s: SectionItem) => void
onDeleteSection: (s: SectionItem) => void
onAddSectionInPart?: (part: PartItem) => void
onAddChapterInPart?: (part: PartItem) => void
onDeleteChapter?: (part: PartItem, chapter: ChapterItem) => void
onEditPart?: (part: PartItem) => void
onDeletePart?: (part: PartItem) => void
onEditChapter?: (part: PartItem, chapter: ChapterItem) => void
/** 批量移动:勾选章节 */
selectedSectionIds?: string[]
onToggleSectionSelect?: (sectionId: string) => void
/** 查看某节的付款记录 */
onShowSectionOrders?: (s: SectionItem) => void
}
export function ChapterTree({
parts,
expandedParts,
onTogglePart,
onReorder,
onReadSection,
onDeleteSection,
onAddSectionInPart,
onAddChapterInPart,
onDeleteChapter,
onEditPart,
onDeletePart,
onEditChapter,
selectedSectionIds = [],
onToggleSectionSelect,
onShowSectionOrders,
}: ChapterTreeProps) {
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
const isDragging = (type: DragType, id: string) => draggingItem?.type === type && draggingItem?.id === id
const isDragOver = (type: DragType, id: string) => dragOverTarget?.type === type && dragOverTarget?.id === id
const buildSectionsList = useCallback(
(): { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] => {
const list: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] = []
for (const part of parts) {
for (const ch of part.chapters) {
for (const s of ch.sections) {
list.push({
id: s.id,
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})
}
}
}
return list
},
[parts],
)
const handleDrop = useCallback(
async (e: React.DragEvent, toType: DragType, toId: string, toContext?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => {
e.preventDefault()
e.stopPropagation()
const data = e.dataTransfer.getData('text/plain')
const from = parseDragData(data)
if (!from) return
if (from.type === toType && from.id === toId) return
const sections = buildSectionsList()
const sectionMap = new Map(sections.map((x) => [x.id, x]))
// 所有篇/章/节均可拖拽与作为落点(与后台顺序一致)
if (from.type === 'part' && toType === 'part') {
const partOrder = parts.map((p) => p.id)
const fromIdx = partOrder.indexOf(from.id)
const toIdx = partOrder.indexOf(toId)
if (fromIdx === -1 || toIdx === -1) return
const next = [...partOrder]
next.splice(fromIdx, 1)
next.splice(fromIdx < toIdx ? toIdx - 1 : toIdx, 0, from.id)
const newList: typeof sections = []
for (const pid of next) {
const p = parts.find((x) => x.id === pid)
if (!p) continue
for (const ch of p.chapters) {
for (const s of ch.sections) {
const ctx = sectionMap.get(s.id)
if (ctx) newList.push(ctx)
}
}
}
await onReorder(newList)
return
}
if (from.type === 'chapter' && (toType === 'chapter' || toType === 'section' || toType === 'part')) {
const srcPart = parts.find((p) => p.chapters.some((c) => c.id === from.id))
const srcCh = srcPart?.chapters.find((c) => c.id === from.id)
if (!srcPart || !srcCh) return
let targetPartId: string
let targetPartTitle: string
let insertAfterId: string | null = null
if (toType === 'section') {
const ctx = sectionMap.get(toId)
if (!ctx) return
targetPartId = ctx.partId
targetPartTitle = ctx.partTitle
insertAfterId = toId
} else if (toType === 'chapter') {
const part = parts.find((p) => p.chapters.some((c) => c.id === toId))
const ch = part?.chapters.find((c) => c.id === toId)
if (!part || !ch) return
targetPartId = part.id
targetPartTitle = part.title
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
insertAfterId = lastInCh?.id ?? null
} else {
const part = parts.find((p) => p.id === toId)
if (!part || !part.chapters[0]) return
targetPartId = part.id
targetPartTitle = part.title
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
insertAfterId = firstChSections[firstChSections.length - 1]?.id ?? null
}
const movingIds = srcCh.sections.map((s) => s.id)
const rest = sections.filter((s) => !movingIds.includes(s.id))
let targetIdx = rest.length
if (insertAfterId) {
const idx = rest.findIndex((s) => s.id === insertAfterId)
if (idx >= 0) targetIdx = idx + 1
}
const moving = movingIds.map((id) => {
const s = sectionMap.get(id)!
return {
...s,
partId: targetPartId,
partTitle: targetPartTitle,
chapterId: srcCh.id,
chapterTitle: srcCh.title,
}
})
await onReorder([...rest.slice(0, targetIdx), ...moving, ...rest.slice(targetIdx)])
return
}
if (from.type === 'section' && (toType === 'section' || toType === 'chapter' || toType === 'part')) {
if (!toContext) return
const { partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle } = toContext
let toIdx: number
if (toType === 'section') {
toIdx = sections.findIndex((s) => s.id === toId)
} else if (toType === 'chapter') {
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
toIdx = lastInCh ? sections.findIndex((s) => s.id === lastInCh.id) + 1 : sections.length
} else {
const part = parts.find((p) => p.id === toId)
if (!part?.chapters[0]) return
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
const last = firstChSections[firstChSections.length - 1]
toIdx = last ? sections.findIndex((s) => s.id === last.id) + 1 : 0
}
const fromIdx = sections.findIndex((s) => s.id === from.id)
if (fromIdx === -1) return
const next = sections.filter((s) => s.id !== from.id)
const insertIdx = fromIdx < toIdx ? toIdx - 1 : toIdx
const moved = sections[fromIdx]
const newItem = { ...moved, partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle }
next.splice(insertIdx, 0, newItem)
await onReorder(next)
}
},
[parts, buildSectionsList, onReorder],
)
const droppableHandlers = (type: DragType, id: string, ctx?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => ({
onDragEnter: (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type, id })
},
onDragOver: (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type, id })
},
onDragLeave: () => setDragOverTarget(null),
onDrop: (e: React.DragEvent) => {
setDragOverTarget(null)
const from = parseDragData(e.dataTransfer.getData('text/plain'))
if (!from) return
if (type === 'section' && from.type === 'section' && from.id === id) return
if (type === 'part') {
if (from.type === 'part') handleDrop(e, 'part', id)
else {
const part = parts.find((p) => p.id === id)
const firstCh = part?.chapters[0]
if (firstCh && ctx) handleDrop(e, 'part', id, ctx)
}
} else if (type === 'chapter' && ctx) {
if (from.type === 'section' || from.type === 'chapter') handleDrop(e, 'chapter', id, ctx)
} else if (type === 'section' && ctx) {
handleDrop(e, 'section', id, ctx)
}
},
})
const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1)
return (
<div className="space-y-3">
{parts.map((part, partIndex) => {
const isXuYan = part.title === '序言' || part.title.includes('序言')
const isWeiSheng = part.title === '尾声' || part.title.includes('尾声')
const isFuLu = part.title === '附录' || part.title.includes('附录')
const partDragOver = isDragOver('part', part.id)
const isExpanded = expandedParts.includes(part.id)
const chapterCount = part.chapters.length
const sectionCount = part.chapters.reduce((s, ch) => s + ch.sections.length, 0)
// 序言:单行卡片,带六点拖拽、可拖可放
if (isXuYan && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
const sec = part.chapters[0].sections[0]
const secDragOver = isDragOver('section', sec.id)
const ctx = { partId: part.id, partTitle: part.title, chapterId: part.chapters[0].id, chapterTitle: part.chapters[0].title }
return (
<div
key={part.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors cursor-grab active:cursor-grabbing select-none min-h-[40px] ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, ctx)}
>
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
<BookOpen className="w-4 h-4 text-gray-400" />
</div>
<span className="font-medium text-gray-200 truncate">
{part.chapters[0].title} | {sec.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{sec.price === 0 || sec.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
)
}
// 2026每日派对干货独立篇章带六点拖拽、可拖可放
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
if (is2026Daily) {
const partDragOver = isDragOver('part', part.id)
return (
<div
key={part.id}
className={`rounded-xl border overflow-hidden transition-all duration-200 ${partDragOver ? 'border-[#38bdac] ring-2 ring-[#38bdac]/40 bg-[#38bdac]/5' : 'border-gray-700/50 bg-[#1C1C1E]'}`}
{...droppableHandlers('part', part.id, {
partId: part.id,
partTitle: part.title,
chapterId: part.chapters[0]?.id ?? '',
chapterTitle: part.chapters[0]?.title ?? '',
})}
>
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'part:' + part.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'part', id: part.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${isDragging('part', part.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/50'}`}
onClick={() => onTogglePart(part.id)}
>
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac]/80 flex items-center justify-center text-white font-bold shrink-0">
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
</div>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{onAddSectionInPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onAddSectionInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="在本篇下新增章节">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onEditPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onDeletePart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
</div>
</div>
{isExpanded && part.chapters.length > 0 && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<div className="flex items-center gap-2 w-full">
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{onEditChapter && (
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onAddChapterInPart && (
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onDeleteChapter && (
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => {
const secDragOver = isDragOver('section', section.id)
return (
<div
key={section.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + section.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: section.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
})}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(section.id)}
onChange={() => onToggleSectionSelect(section.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</div>
)
}
// 附录:平铺章节列表,每节带六点拖拽、可拖可放
if (isFuLu) {
return (
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
<h3 className="text-sm font-medium text-gray-400 mb-4"></h3>
<div className="space-y-3">
{part.chapters.map((ch, chIdx) =>
ch.sections.length > 0
? ch.sections.map((sec) => {
const secDragOver = isDragOver('section', sec.id)
return (
<div
key={sec.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex justify-between items-center py-2 select-none rounded px-2 -mx-2 group cursor-grab active:cursor-grabbing min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, {
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<span className="text-sm text-gray-300 truncate">{chIdx + 1} | {ch.title} | {sec.title}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
</div>
)
})
: (
<div key={ch.id} className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2">
<span className="text-sm text-gray-500">{chIdx + 1} | {ch.title}</span>
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
</div>
),
)}
</div>
</div>
)
}
// 尾声:单节,带六点拖拽、可拖可放
if (isWeiSheng && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
const sec = part.chapters[0].sections[0]
const secDragOver = isDragOver('section', sec.id)
const ctx = { partId: part.id, partTitle: part.title, chapterId: part.chapters[0].id, chapterTitle: part.chapters[0].title }
return (
<div
key={part.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors cursor-grab active:cursor-grabbing select-none min-h-[40px] ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, ctx)}
>
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
<BookOpen className="w-4 h-4 text-gray-400" />
</div>
<span className="font-medium text-gray-200 truncate">
{part.chapters[0].title} | {sec.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{sec.price === 0 || sec.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
</div>
)
}
if (isWeiSheng) {
// 尾声多章节:平铺展示,每节带六点拖拽、可拖可放
return (
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
<h3 className="text-sm font-medium text-gray-400 mb-4"></h3>
<div className="space-y-3">
{part.chapters.map((ch) =>
ch.sections.map((sec) => {
const secDragOver = isDragOver('section', sec.id)
return (
<div
key={sec.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex justify-between items-center py-2 select-none rounded px-2 -mx-2 cursor-grab active:cursor-grabbing min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, {
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<span className="text-sm text-gray-300">{ch.title} | {sec.title}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
)
}),
)}
</div>
</div>
)
}
// 普通篇:卡片 + 章/节
return (
<div
key={part.id}
className={`rounded-xl border bg-[#1C1C1E] overflow-hidden transition-all duration-200 ${partDragOver ? 'border-[#38bdac] ring-2 ring-[#38bdac]/40 bg-[#38bdac]/5' : 'border-gray-700/50'}`}
{...droppableHandlers('part', part.id, {
partId: part.id,
partTitle: part.title,
chapterId: part.chapters[0]?.id ?? '',
chapterTitle: part.chapters[0]?.title ?? '',
})}
>
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'part:' + part.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'part', id: part.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${isDragging('part', part.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac] rounded-xl shadow-xl shadow-[#38bdac]/20' : 'hover:bg-[#162840]/50'}`}
onClick={() => onTogglePart(part.id)}
>
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-white font-bold shadow-lg shadow-[#38bdac]/30 shrink-0">
{partLabel(partIndex)}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
</div>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{onAddSectionInPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onAddSectionInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="在本篇下新增章节">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onEditPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onDeletePart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter) => {
const chDragOver = isDragOver('chapter', chapter.id)
return (
<div key={chapter.id} className="space-y-2">
<div className="flex items-center gap-2 w-full">
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'chapter:' + chapter.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'chapter', id: chapter.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
onDragEnter={(e) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type: 'chapter', id: chapter.id })
}}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type: 'chapter', id: chapter.id })
}}
onDragLeave={() => setDragOverTarget(null)}
onDrop={(e) => {
setDragOverTarget(null)
const from = parseDragData(e.dataTransfer.getData('text/plain'))
if (!from) return
const ctx = { partId: part.id, partTitle: part.title, chapterId: chapter.id, chapterTitle: chapter.title }
if (from.type === 'section') handleDrop(e, 'chapter', chapter.id, ctx)
else if (from.type === 'chapter') handleDrop(e, 'chapter', chapter.id, ctx)
}}
className={`flex-1 min-w-0 py-2 px-2 rounded cursor-grab active:cursor-grabbing select-none -mx-2 transition-all duration-200 flex items-center gap-2 ${chDragOver ? 'bg-[#38bdac]/15 ring-1 ring-[#38bdac]/50' : ''} ${isDragging('chapter', chapter.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/30'}`}
>
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
</div>
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{onEditChapter && (
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onAddChapterInPart && (
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onDeleteChapter && (
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => {
const secDragOver = isDragOver('section', section.id)
return (
<div
key={section.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + section.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: section.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between py-2 px-3 rounded-lg group cursor-grab active:cursor-grabbing select-none min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac] shadow-lg' : 'hover:bg-[#162840]/50'}`}
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
})}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(section.id)}
onChange={() => onToggleSectionSelect(section.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
<div
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
/>
<span className="text-sm text-gray-200 truncate">
{section.id} {section.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{section.isNew && (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">NEW</span>
)}
{section.price === 0 || section.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{section.price}</span>
)}
<span className="text-[10px] text-gray-500" title="点击次数 · 付款笔数"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5 shrink-0">
</Button>
)}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}