/** * 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序 * 整行可拖拽;节和章可跨篇 */ import { useCallback, useState } from 'react' import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } 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 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 /** 置顶章节ID列表 */ pinnedSectionIds?: string[] } export function ChapterTree({ parts, expandedParts, onTogglePart, onReorder, onReadSection, onDeleteSection, onAddSectionInPart, onAddChapterInPart, onDeleteChapter, onEditPart, onDeletePart, onEditChapter, selectedSectionIds = [], onToggleSectionSelect, onShowSectionOrders, pinnedSectionIds = [], }: 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 (
{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 (
{ 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)} >
{onToggleSectionSelect && ( )}
{part.chapters[0].title} | {sec.title} {pinnedSectionIds.includes(sec.id) && }
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {sec.price === 0 || sec.isFree ? ( 免费 ) : ( ¥{sec.price} )} 点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && ( )}
) } // 2026每日派对干货:独立篇章,带六点拖拽、可拖可放 const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货') if (is2026Daily) { const partDragOver = isDragOver('part', part.id) return (
{ 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)} >

{part.title}

共 {sectionCount} 节

e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {onAddSectionInPart && ( )} {onEditPart && ( )} {onDeletePart && ( )} {chapterCount}章 {isExpanded ? ( ) : ( )}
{isExpanded && part.chapters.length > 0 && (
{part.chapters.map((chapter) => (

{chapter.title}

e.stopPropagation()}> {onEditChapter && ( )} {onAddChapterInPart && ( )} {onDeleteChapter && ( )}
{chapter.sections.map((section) => { const secDragOver = isDragOver('section', section.id) return (
{ 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) }} onClick={() => onReadSection(section)} className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-pointer 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, })} >
{onToggleSectionSelect && ( )} {section.id} {section.title} {pinnedSectionIds.includes(section.id) && }
e.stopPropagation()}> 点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)} 热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名 {onShowSectionOrders && ( )}
) })}
))}
)}
) } // 附录:平铺章节列表,每节带六点拖拽、可拖可放 if (isFuLu) { return (

附录

{part.chapters.map((ch, chIdx) => ch.sections.length > 0 ? ch.sections.map((sec) => { const secDragOver = isDragOver('section', sec.id) return (
{ 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, })} >
{onToggleSectionSelect && ( )} 附录{chIdx + 1} | {ch.title} | {sec.title} {pinnedSectionIds.includes(sec.id) && }
点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && ( )}
) }) : (
附录{chIdx + 1} | {ch.title}(空)
), )}
) } // 尾声:单节,带六点拖拽、可拖可放 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 (
{ 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)} >
{onToggleSectionSelect && ( )}
{part.chapters[0].title} | {sec.title}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {sec.price === 0 || sec.isFree ? ( 免费 ) : ( ¥{sec.price} )} 点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && ( )}
) } if (isWeiSheng) { // 尾声多章节:平铺展示,每节带六点拖拽、可拖可放 return (

尾声

{part.chapters.map((ch) => ch.sections.map((sec) => { const secDragOver = isDragOver('section', sec.id) return (
{ 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, })} >
{onToggleSectionSelect && ( )} {ch.title} | {sec.title}
点击 {(sec.clickCount ?? 0)} · 付款 {(sec.payCount ?? 0)} 热度 {(sec.hotScore ?? 0).toFixed(1)} · 第{(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}名 {onShowSectionOrders && ( )}
) }), )}
) } // 普通篇:卡片 + 章/节 return (
{ 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)} >
{partLabel(partIndex)}

{part.title}

共 {sectionCount} 节

e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {onAddSectionInPart && ( )} {onEditPart && ( )} {onDeletePart && ( )} {chapterCount}章 {isExpanded ? ( ) : ( )}
{isExpanded && (
{part.chapters.map((chapter) => { const chDragOver = isDragOver('chapter', chapter.id) return (
{ 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'}`} >

{chapter.title}

e.stopPropagation()}> {onEditChapter && ( )} {onAddChapterInPart && ( )} {onDeleteChapter && ( )}
{chapter.sections.map((section) => { const secDragOver = isDragOver('section', section.id) return (
{ 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, })} >
{onToggleSectionSelect && ( )}
{section.id} {section.title} {pinnedSectionIds.includes(section.id) && }
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {section.isNew && ( NEW )} {section.price === 0 || section.isFree ? ( 免费 ) : ( ¥{section.price} )} 点击 {(section.clickCount ?? 0)} · 付款 {(section.payCount ?? 0)} 热度 {(section.hotScore ?? 0).toFixed(1)} · 第{(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}名 {onShowSectionOrders && ( )}
) })}
) })}
)}
) })}
) }