928 lines
50 KiB
TypeScript
928 lines
50 KiB
TypeScript
/**
|
||
* 章节树 - 仿照 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>
|
||
)
|
||
}
|