2284 lines
101 KiB
TypeScript
2284 lines
101 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
||
import toast from '@/utils/toast'
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import RichEditor, { type PersonItem, type LinkTagItem, type RichEditorRef } from '@/components/RichEditor'
|
||
import '@/components/RichEditor.css'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
BookOpen,
|
||
Settings2,
|
||
Edit3,
|
||
Save,
|
||
X,
|
||
RefreshCw,
|
||
Link2,
|
||
Plus,
|
||
Search,
|
||
Trophy,
|
||
ChevronLeft,
|
||
ChevronRight as ChevronRightIcon,
|
||
Pin,
|
||
Star,
|
||
Hash,
|
||
ExternalLink,
|
||
Pencil,
|
||
Check,
|
||
} from 'lucide-react'
|
||
import { get, put, post, del } from '@/api/client'
|
||
import { ChapterTree } from './ChapterTree'
|
||
import { apiUrl } from '@/api/client'
|
||
|
||
interface SectionListItem {
|
||
id: string
|
||
title: string
|
||
price: number
|
||
isFree?: boolean
|
||
isNew?: boolean
|
||
partId?: string
|
||
partTitle?: string
|
||
chapterId?: string
|
||
chapterTitle?: string
|
||
filePath?: string
|
||
clickCount?: number
|
||
payCount?: number
|
||
hotScore?: number
|
||
hotRank?: number
|
||
}
|
||
|
||
interface Section {
|
||
id: string
|
||
title: string
|
||
price: number
|
||
filePath?: string
|
||
isFree?: boolean
|
||
isNew?: boolean
|
||
clickCount?: number
|
||
payCount?: number
|
||
hotScore?: number
|
||
hotRank?: number
|
||
}
|
||
|
||
interface Chapter {
|
||
id: string
|
||
title: string
|
||
sections: Section[]
|
||
}
|
||
|
||
interface Part {
|
||
id: string
|
||
title: string
|
||
chapters: Chapter[]
|
||
}
|
||
|
||
interface SectionOrder {
|
||
id?: string
|
||
orderSn?: string
|
||
userId?: string
|
||
openId?: string
|
||
amount?: number
|
||
status?: string
|
||
createdAt?: string
|
||
payTime?: string
|
||
}
|
||
|
||
interface EditingSection {
|
||
id: string
|
||
originalId?: string
|
||
title: string
|
||
price: number
|
||
content?: string
|
||
filePath?: string
|
||
isFree?: boolean
|
||
isNew?: boolean
|
||
isPinned?: boolean
|
||
hotScore?: number
|
||
editionStandard?: boolean
|
||
editionPremium?: boolean
|
||
}
|
||
|
||
function buildTree(sections: SectionListItem[]): Part[] {
|
||
const partMap = new Map<
|
||
string,
|
||
{ id: string; title: string; chapters: Map<string, { id: string; title: string; sections: Section[] }> }
|
||
>()
|
||
for (const s of sections) {
|
||
const partId = s.partId || 'part-1'
|
||
const partTitle = s.partTitle || '未分类'
|
||
const chapterId = s.chapterId || 'chapter-1'
|
||
const chapterTitle = s.chapterTitle || '未分类'
|
||
if (!partMap.has(partId)) {
|
||
partMap.set(partId, { id: partId, title: partTitle, chapters: new Map() })
|
||
}
|
||
const part = partMap.get(partId)!
|
||
if (!part.chapters.has(chapterId)) {
|
||
part.chapters.set(chapterId, { id: chapterId, title: chapterTitle, sections: [] })
|
||
}
|
||
part.chapters.get(chapterId)!.sections.push({
|
||
id: s.id,
|
||
title: s.title,
|
||
price: s.price ?? 1,
|
||
filePath: s.filePath,
|
||
isFree: s.isFree,
|
||
isNew: s.isNew,
|
||
clickCount: s.clickCount ?? 0,
|
||
payCount: s.payCount ?? 0,
|
||
hotScore: s.hotScore ?? 0,
|
||
hotRank: s.hotRank ?? 0,
|
||
})
|
||
}
|
||
// 确保「2026每日派对干货」篇章存在(不在第六篇编号体系内)
|
||
const DAILY_PART_ID = 'part-2026-daily'
|
||
const DAILY_PART_TITLE = '2026每日派对干货'
|
||
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
|
||
if (!hasDailyPart) {
|
||
partMap.set(DAILY_PART_ID, {
|
||
id: DAILY_PART_ID,
|
||
title: DAILY_PART_TITLE,
|
||
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
|
||
})
|
||
}
|
||
const parts = Array.from(partMap.values()).map((p) => ({
|
||
...p,
|
||
chapters: Array.from(p.chapters.values()),
|
||
}))
|
||
// 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位
|
||
const orderKey = (t: string) => {
|
||
if (t.includes('序言')) return 0
|
||
if (t.includes(DAILY_PART_TITLE)) return 1.5
|
||
if (t.includes('附录')) return 2
|
||
if (t.includes('尾声')) return 3
|
||
return 1
|
||
}
|
||
return parts.sort((a, b) => {
|
||
const ka = orderKey(a.title)
|
||
const kb = orderKey(b.title)
|
||
if (ka !== kb) return ka - kb
|
||
return 0
|
||
})
|
||
}
|
||
|
||
export function ContentPage() {
|
||
const [sectionsList, setSectionsList] = useState<SectionListItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
||
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
|
||
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
|
||
const [isLoadingContent, setIsLoadingContent] = useState(false)
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [searchResults, setSearchResults] = useState<{ id: string; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
|
||
const [isSearching, setIsSearching] = useState(false)
|
||
|
||
const [newSection, setNewSection] = useState({
|
||
id: '',
|
||
title: '',
|
||
price: 1,
|
||
partId: 'part-1',
|
||
chapterId: 'chapter-1',
|
||
content: '',
|
||
editionStandard: true,
|
||
editionPremium: false,
|
||
isFree: false,
|
||
isNew: false,
|
||
isPinned: false,
|
||
hotScore: 0,
|
||
})
|
||
|
||
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
|
||
const [isSavingPartTitle, setIsSavingPartTitle] = useState(false)
|
||
const [showNewPartModal, setShowNewPartModal] = useState(false)
|
||
const [editingChapter, setEditingChapter] = useState<{ part: Part; chapter: Chapter; title: string } | null>(null)
|
||
const [isSavingChapterTitle, setIsSavingChapterTitle] = useState(false)
|
||
const [selectedSectionIds, setSelectedSectionIds] = useState<string[]>([])
|
||
const [showBatchMoveModal, setShowBatchMoveModal] = useState(false)
|
||
const [batchMoveTargetPartId, setBatchMoveTargetPartId] = useState('')
|
||
const [batchMoveTargetChapterId, setBatchMoveTargetChapterId] = useState('')
|
||
const [isMoving, setIsMoving] = useState(false)
|
||
const [newPartTitle, setNewPartTitle] = useState('')
|
||
const [isSavingPart, setIsSavingPart] = useState(false)
|
||
const [sectionOrdersModal, setSectionOrdersModal] = useState<{ section: Section; orders: SectionOrder[] } | null>(null)
|
||
const [sectionOrdersLoading, setSectionOrdersLoading] = useState(false)
|
||
const [showRankingAlgorithmModal, setShowRankingAlgorithmModal] = useState(false)
|
||
const [rankingWeights, setRankingWeights] = useState({ readWeight: 0.5, recencyWeight: 0.3, payWeight: 0.2 })
|
||
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
|
||
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
|
||
const [rankingPage, setRankingPage] = useState(1)
|
||
const [pinnedSectionIds, setPinnedSectionIds] = useState<string[]>([])
|
||
const [pinnedLoading, setPinnedLoading] = useState(false)
|
||
const [previewPercent, setPreviewPercent] = useState(20)
|
||
const [previewPercentLoading, setPreviewPercentLoading] = useState(false)
|
||
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
|
||
const [persons, setPersons] = useState<PersonItem[]>([])
|
||
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
|
||
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '', ckbApiKey: '' })
|
||
const [editingPersonKey, setEditingPersonKey] = useState<string | null>(null) // 正在编辑密钥的 personId
|
||
const [editingPersonKeyValue, setEditingPersonKeyValue] = useState('')
|
||
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
|
||
const richEditorRef = useRef<RichEditorRef>(null)
|
||
|
||
const tree = buildTree(sectionsList)
|
||
const totalSections = sectionsList.length
|
||
|
||
const rankedSections = [...sectionsList].sort((a, b) => (b.hotScore ?? 0) - (a.hotScore ?? 0))
|
||
const RANKING_PAGE_SIZE = 10
|
||
const rankingTotalPages = Math.max(1, Math.ceil(rankedSections.length / RANKING_PAGE_SIZE))
|
||
const rankingPageSections = rankedSections.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
|
||
|
||
const loadList = async () => {
|
||
setLoading(true)
|
||
try {
|
||
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
|
||
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
|
||
'/api/db/book?action=list',
|
||
{ cache: 'no-store' as RequestCache },
|
||
)
|
||
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
|
||
} catch (e) {
|
||
console.error(e)
|
||
setSectionsList([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadList()
|
||
}, [])
|
||
|
||
|
||
const togglePart = (partId: string) => {
|
||
setExpandedParts((prev) =>
|
||
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
|
||
)
|
||
}
|
||
|
||
const handleReorderTree = useCallback(
|
||
(items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]): Promise<void> => {
|
||
const prev = sectionsList
|
||
const newList: SectionListItem[] = items.flatMap((it) => {
|
||
const s = prev.find((x) => x.id === it.id)
|
||
if (!s) return []
|
||
return [{ ...s, partId: it.partId, partTitle: it.partTitle, chapterId: it.chapterId, chapterTitle: it.chapterTitle }]
|
||
})
|
||
setSectionsList(newList)
|
||
put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
|
||
.then((res) => {
|
||
if (res && (res as { success?: boolean }).success === false) {
|
||
setSectionsList(prev)
|
||
toast.error('排序失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
setSectionsList(prev)
|
||
console.error('排序失败:', e)
|
||
toast.error('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
|
||
})
|
||
return Promise.resolve()
|
||
},
|
||
[sectionsList],
|
||
)
|
||
|
||
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
|
||
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
|
||
try {
|
||
const res = await del<{ success?: boolean; error?: string }>(
|
||
`/api/db/book?id=${encodeURIComponent(section.id)}`,
|
||
)
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
toast.success('已删除')
|
||
loadList()
|
||
} else {
|
||
toast.error('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('删除失败')
|
||
}
|
||
}
|
||
|
||
const loadRankingWeights = useCallback(async () => {
|
||
setRankingWeightsLoading(true)
|
||
try {
|
||
const data = await get<{ success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }>(
|
||
'/api/db/config/full?key=article_ranking_weights',
|
||
{ cache: 'no-store' as RequestCache },
|
||
)
|
||
const d = data && (data as { success?: boolean; data?: { readWeight?: number; recencyWeight?: number; payWeight?: number } }).data
|
||
if (d && typeof d.readWeight === 'number' && typeof d.recencyWeight === 'number' && typeof d.payWeight === 'number') {
|
||
setRankingWeights({
|
||
readWeight: Math.max(0, Math.min(1, d.readWeight)),
|
||
recencyWeight: Math.max(0, Math.min(1, d.recencyWeight)),
|
||
payWeight: Math.max(0, Math.min(1, d.payWeight)),
|
||
})
|
||
}
|
||
} catch {
|
||
// 使用默认值
|
||
} finally {
|
||
setRankingWeightsLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (showRankingAlgorithmModal) loadRankingWeights()
|
||
}, [showRankingAlgorithmModal, loadRankingWeights])
|
||
|
||
const handleSaveRankingWeights = async () => {
|
||
const { readWeight, recencyWeight, payWeight } = rankingWeights
|
||
const sum = readWeight + recencyWeight + payWeight
|
||
if (Math.abs(sum - 1) > 0.001) {
|
||
toast.error('三个权重之和必须等于 1')
|
||
return
|
||
}
|
||
setRankingWeightsSaving(true)
|
||
try {
|
||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
|
||
key: 'article_ranking_weights',
|
||
value: { readWeight, recencyWeight, payWeight },
|
||
description: '文章排名算法权重',
|
||
})
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
toast.success('排名权重已保存')
|
||
loadList()
|
||
} else {
|
||
toast.error('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('保存失败')
|
||
} finally {
|
||
setRankingWeightsSaving(false)
|
||
}
|
||
}
|
||
|
||
const loadPinnedSections = useCallback(async () => {
|
||
setPinnedLoading(true)
|
||
try {
|
||
const data = await get<{ success?: boolean; data?: string[] }>(
|
||
'/api/db/config/full?key=pinned_section_ids',
|
||
{ cache: 'no-store' as RequestCache },
|
||
)
|
||
const d = data && (data as { data?: string[] }).data
|
||
if (Array.isArray(d)) setPinnedSectionIds(d)
|
||
} catch { /* keep default */ } finally { setPinnedLoading(false) }
|
||
}, [])
|
||
|
||
const loadPersons = useCallback(async () => {
|
||
try {
|
||
const data = await get<{ success?: boolean; persons?: { personId: string; name: string; label?: string; ckbApiKey?: string }[] }>('/api/db/persons')
|
||
if (data?.success && data.persons) setPersons(data.persons.map(p => ({ id: p.personId, name: p.name, label: p.label, ckbApiKey: p.ckbApiKey })))
|
||
} catch { /* ignore */ }
|
||
}, [])
|
||
|
||
const loadLinkTags = useCallback(async () => {
|
||
try {
|
||
const data = await get<{ success?: boolean; linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[] }>('/api/db/link-tags')
|
||
if (data?.success && data.linkTags) setLinkTags(data.linkTags.map(t => ({ id: t.tagId, label: t.label, url: t.url, type: t.type as 'url' | 'miniprogram', appId: t.appId, pagePath: t.pagePath })))
|
||
} catch { /* ignore */ }
|
||
}, [])
|
||
|
||
const handleTogglePin = async (sectionId: string) => {
|
||
const next = pinnedSectionIds.includes(sectionId)
|
||
? pinnedSectionIds.filter((id) => id !== sectionId)
|
||
: [...pinnedSectionIds, sectionId]
|
||
setPinnedSectionIds(next)
|
||
try {
|
||
await post<{ success?: boolean }>('/api/db/config', {
|
||
key: 'pinned_section_ids',
|
||
value: next,
|
||
description: '强制置顶章节ID列表(精选推荐/首页最新更新)',
|
||
})
|
||
} catch { setPinnedSectionIds(pinnedSectionIds) }
|
||
}
|
||
|
||
const loadPreviewPercent = useCallback(async () => {
|
||
setPreviewPercentLoading(true)
|
||
try {
|
||
const data = await get<{ success?: boolean; data?: number }>(
|
||
'/api/db/config/full?key=unpaid_preview_percent',
|
||
{ cache: 'no-store' as RequestCache },
|
||
)
|
||
const d = data && (data as { data?: number }).data
|
||
if (typeof d === 'number' && d > 0 && d <= 100) setPreviewPercent(d)
|
||
} catch { /* keep default */ } finally { setPreviewPercentLoading(false) }
|
||
}, [])
|
||
|
||
const handleSavePreviewPercent = async () => {
|
||
if (previewPercent < 1 || previewPercent > 100) { toast.error('预览比例需在 1~100 之间'); return }
|
||
setPreviewPercentSaving(true)
|
||
try {
|
||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
|
||
key: 'unpaid_preview_percent',
|
||
value: previewPercent,
|
||
description: '小程序未付费内容默认预览比例(%)',
|
||
})
|
||
if (res && (res as { success?: boolean }).success !== false) toast.success('预览比例已保存')
|
||
else toast.error('保存失败: ' + ((res as { error?: string }).error || ''))
|
||
} catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) }
|
||
}
|
||
|
||
useEffect(() => { loadPinnedSections(); loadPreviewPercent(); loadPersons(); loadLinkTags() }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags])
|
||
|
||
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
|
||
setSectionOrdersModal({ section, orders: [] })
|
||
setSectionOrdersLoading(true)
|
||
try {
|
||
const data = await get<{ success?: boolean; orders?: SectionOrder[] }>(
|
||
`/api/db/book?action=section-orders&id=${encodeURIComponent(section.id)}`,
|
||
)
|
||
const orders = data?.success && Array.isArray(data.orders) ? data.orders : []
|
||
setSectionOrdersModal((prev) => (prev ? { ...prev, orders } : null))
|
||
} catch (e) {
|
||
console.error(e)
|
||
setSectionOrdersModal((prev) => (prev ? { ...prev, orders: [] } : null))
|
||
} finally {
|
||
setSectionOrdersLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleReadSection = async (section: Section & { filePath?: string }) => {
|
||
setIsLoadingContent(true)
|
||
try {
|
||
const data = await get<{ success?: boolean; section?: { title?: string; price?: number; content?: string; editionStandard?: boolean; editionPremium?: boolean }; error?: string }>(
|
||
`/api/db/book?action=read&id=${encodeURIComponent(section.id)}`,
|
||
)
|
||
if (data?.success && data.section) {
|
||
const sec = data.section as { isNew?: boolean; editionStandard?: boolean; editionPremium?: boolean }
|
||
const isPremium = sec.editionPremium === true
|
||
setEditingSection({
|
||
id: section.id,
|
||
originalId: section.id,
|
||
title: data.section.title ?? section.title,
|
||
price: data.section.price ?? section.price,
|
||
content: data.section.content ?? '',
|
||
filePath: section.filePath,
|
||
isFree: section.isFree || section.price === 0,
|
||
isNew: sec.isNew ?? section.isNew,
|
||
isPinned: pinnedSectionIds.includes(section.id),
|
||
hotScore: section.hotScore ?? 0,
|
||
editionStandard: isPremium ? false : (sec.editionStandard ?? true),
|
||
editionPremium: isPremium,
|
||
})
|
||
} else {
|
||
setEditingSection({
|
||
id: section.id,
|
||
originalId: section.id,
|
||
title: section.title,
|
||
price: section.price,
|
||
content: '',
|
||
filePath: section.filePath,
|
||
isFree: section.isFree,
|
||
isNew: section.isNew,
|
||
isPinned: pinnedSectionIds.includes(section.id),
|
||
hotScore: section.hotScore ?? 0,
|
||
editionStandard: true,
|
||
editionPremium: false,
|
||
})
|
||
if (data && !(data as { success?: boolean }).success) {
|
||
toast.error('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
setEditingSection({
|
||
id: section.id,
|
||
title: section.title,
|
||
price: section.price,
|
||
content: '',
|
||
filePath: section.filePath,
|
||
isFree: section.isFree,
|
||
})
|
||
} finally {
|
||
setIsLoadingContent(false)
|
||
}
|
||
}
|
||
|
||
const handleSaveSection = async () => {
|
||
if (!editingSection) return
|
||
setIsSaving(true)
|
||
try {
|
||
let content = editingSection.content || ''
|
||
const titlePatterns = [
|
||
new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}\\s+.*$`, 'gm'),
|
||
new RegExp(`^#+\\s*${editingSection.id.replace('.', '\\.')}[::].*$`, 'gm'),
|
||
new RegExp(`^#\\s+.*${editingSection.title?.slice(0, 10).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`, 'gm'),
|
||
]
|
||
for (const pattern of titlePatterns) content = content.replace(pattern, '')
|
||
content = content.replace(/^\s*\n+/, '').trim()
|
||
|
||
const originalId = editingSection.originalId || editingSection.id
|
||
const idChanged = editingSection.id !== originalId
|
||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
|
||
id: originalId,
|
||
...(idChanged ? { newId: editingSection.id } : {}),
|
||
title: editingSection.title,
|
||
price: editingSection.isFree ? 0 : editingSection.price,
|
||
content,
|
||
isFree: editingSection.isFree || editingSection.price === 0,
|
||
isNew: editingSection.isNew,
|
||
hotScore: editingSection.hotScore,
|
||
editionStandard: editingSection.editionPremium ? false : (editingSection.editionStandard ?? true),
|
||
editionPremium: editingSection.editionPremium ?? false,
|
||
saveToFile: true,
|
||
})
|
||
const effectiveId = idChanged ? editingSection.id : originalId
|
||
if (editingSection.isPinned !== pinnedSectionIds.includes(effectiveId)) {
|
||
await handleTogglePin(effectiveId)
|
||
}
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
toast.success(`已保存:${editingSection.title}`)
|
||
setEditingSection(null)
|
||
loadList()
|
||
} else {
|
||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('保存失败')
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateSection = async () => {
|
||
if (!newSection.id || !newSection.title) {
|
||
toast.error('请填写章节ID和标题')
|
||
return
|
||
}
|
||
setIsSaving(true)
|
||
try {
|
||
const currentPart = tree.find((p) => p.id === newSection.partId)
|
||
const currentChapter = currentPart?.chapters.find((c) => c.id === newSection.chapterId)
|
||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
|
||
id: newSection.id,
|
||
title: newSection.title,
|
||
price: newSection.isFree ? 0 : newSection.price,
|
||
content: newSection.content,
|
||
partId: newSection.partId,
|
||
partTitle: currentPart?.title ?? '',
|
||
chapterId: newSection.chapterId,
|
||
chapterTitle: currentChapter?.title ?? '',
|
||
isFree: newSection.isFree,
|
||
isNew: newSection.isNew,
|
||
editionStandard: newSection.editionPremium ? false : (newSection.editionStandard ?? true),
|
||
editionPremium: newSection.editionPremium ?? false,
|
||
hotScore: newSection.hotScore ?? 0,
|
||
saveToFile: false,
|
||
})
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
if (newSection.isPinned) {
|
||
const next = [...pinnedSectionIds, newSection.id]
|
||
setPinnedSectionIds(next)
|
||
try {
|
||
await post<{ success?: boolean }>('/api/db/config', {
|
||
key: 'pinned_section_ids',
|
||
value: next,
|
||
description: '强制置顶章节ID列表(精选推荐/首页最新更新)',
|
||
})
|
||
} catch { /* ignore */ }
|
||
}
|
||
toast.success(`章节创建成功:${newSection.title}`)
|
||
setShowNewSectionModal(false)
|
||
setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '', editionStandard: true, editionPremium: false, isFree: false, isNew: false, isPinned: false, hotScore: 0 })
|
||
loadList()
|
||
} else {
|
||
toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('创建失败')
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleAddSectionInPart = (part: Part) => {
|
||
setNewSection((prev) => ({
|
||
...prev,
|
||
partId: part.id,
|
||
chapterId: part.chapters[0]?.id ?? 'chapter-1',
|
||
}))
|
||
setShowNewSectionModal(true)
|
||
}
|
||
|
||
const handleEditPartTitle = (part: Part) => {
|
||
setEditingPart({ id: part.id, title: part.title })
|
||
}
|
||
|
||
const handleSavePartTitle = async () => {
|
||
if (!editingPart?.title?.trim()) return
|
||
setIsSavingPartTitle(true)
|
||
try {
|
||
const items = sectionsList.map((s) => ({
|
||
id: s.id,
|
||
partId: s.partId || 'part-1',
|
||
partTitle: s.partId === editingPart.id ? editingPart.title.trim() : (s.partTitle || ''),
|
||
chapterId: s.chapterId || 'chapter-1',
|
||
chapterTitle: s.chapterTitle || '',
|
||
}))
|
||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
|
||
action: 'reorder',
|
||
items,
|
||
})
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
const newTitle = editingPart.title.trim()
|
||
setSectionsList((prev) =>
|
||
prev.map((s) =>
|
||
s.partId === editingPart.id ? { ...s, partTitle: newTitle } : s
|
||
)
|
||
)
|
||
setEditingPart(null)
|
||
loadList()
|
||
} else {
|
||
toast.error('更新篇名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('更新篇名失败')
|
||
} finally {
|
||
setIsSavingPartTitle(false)
|
||
}
|
||
}
|
||
|
||
const handleAddChapterInPart = (part: Part) => {
|
||
const nextChNum = part.chapters.length + 1
|
||
const newChapterId = `chapter-${part.id}-${nextChNum}-${Date.now()}`
|
||
setNewSection({
|
||
id: `${nextChNum}.1`,
|
||
title: '新章节',
|
||
price: 1,
|
||
partId: part.id,
|
||
chapterId: newChapterId,
|
||
content: '',
|
||
editionStandard: true,
|
||
editionPremium: false,
|
||
isFree: false,
|
||
isNew: false,
|
||
isPinned: false,
|
||
hotScore: 0,
|
||
})
|
||
setShowNewSectionModal(true)
|
||
}
|
||
|
||
const handleEditChapter = (part: Part, chapter: Chapter) => {
|
||
setEditingChapter({ part, chapter, title: chapter.title })
|
||
}
|
||
|
||
const handleSaveChapterTitle = async () => {
|
||
if (!editingChapter?.title?.trim()) return
|
||
setIsSavingChapterTitle(true)
|
||
try {
|
||
const items = sectionsList.map((s) => ({
|
||
id: s.id,
|
||
partId: s.partId || editingChapter.part.id,
|
||
partTitle: s.partId === editingChapter.part.id ? editingChapter.part.title : (s.partTitle || ''),
|
||
chapterId: s.chapterId || editingChapter.chapter.id,
|
||
chapterTitle:
|
||
s.partId === editingChapter.part.id && s.chapterId === editingChapter.chapter.id
|
||
? editingChapter.title.trim()
|
||
: (s.chapterTitle || ''),
|
||
}))
|
||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
const newTitle = editingChapter.title.trim()
|
||
const partId = editingChapter.part.id
|
||
const chapterId = editingChapter.chapter.id
|
||
setSectionsList((prev) =>
|
||
prev.map((s) =>
|
||
s.partId === partId && s.chapterId === chapterId
|
||
? { ...s, chapterTitle: newTitle }
|
||
: s
|
||
)
|
||
)
|
||
setEditingChapter(null)
|
||
loadList()
|
||
} else {
|
||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('保存失败')
|
||
} finally {
|
||
setIsSavingChapterTitle(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteChapter = async (part: Part, chapter: Chapter) => {
|
||
const sectionIds = chapter.sections.map((s) => s.id)
|
||
if (sectionIds.length === 0) {
|
||
toast.info('该章下无小节,无需删除')
|
||
return
|
||
}
|
||
if (!confirm(`确定要删除「第${part.chapters.indexOf(chapter) + 1}章 | ${chapter.title}」吗?将删除共 ${sectionIds.length} 节,此操作不可恢复。`)) return
|
||
try {
|
||
for (const id of sectionIds) {
|
||
await del<{ success?: boolean; error?: string }>(`/api/db/book?id=${encodeURIComponent(id)}`)
|
||
}
|
||
loadList()
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('删除失败')
|
||
}
|
||
}
|
||
|
||
const handleCreatePart = async () => {
|
||
if (!newPartTitle.trim()) {
|
||
toast.error('请输入篇名')
|
||
return
|
||
}
|
||
setIsSavingPart(true)
|
||
try {
|
||
const partId = `part-new-${Date.now()}`
|
||
const chapterId = `chapter-1`
|
||
const sectionId = `part-placeholder-${Date.now()}`
|
||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
|
||
id: sectionId,
|
||
title: '占位节(可编辑)',
|
||
price: 0,
|
||
content: '',
|
||
partId,
|
||
partTitle: newPartTitle.trim(),
|
||
chapterId,
|
||
chapterTitle: '第1章 | 待编辑',
|
||
saveToFile: false,
|
||
})
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
toast.success(`篇「${newPartTitle}」创建成功`)
|
||
setShowNewPartModal(false)
|
||
setNewPartTitle('')
|
||
loadList()
|
||
} else {
|
||
toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('创建失败')
|
||
} finally {
|
||
setIsSavingPart(false)
|
||
}
|
||
}
|
||
|
||
const handleBatchMoveToTarget = async () => {
|
||
if (selectedSectionIds.length === 0) {
|
||
toast.error('请先勾选要移动的章节')
|
||
return
|
||
}
|
||
const targetPart = tree.find((p) => p.id === batchMoveTargetPartId)
|
||
const targetChapter = targetPart?.chapters.find((c) => c.id === batchMoveTargetChapterId)
|
||
if (!targetPart || !targetChapter || !batchMoveTargetPartId || !batchMoveTargetChapterId) {
|
||
toast.error('请选择目标篇和章')
|
||
return
|
||
}
|
||
setIsMoving(true)
|
||
try {
|
||
const buildFallbackReorderItems = () => {
|
||
const selectedSet = new Set(selectedSectionIds)
|
||
const baseItems = sectionsList.map((s) => ({
|
||
id: s.id,
|
||
partId: s.partId || '',
|
||
partTitle: s.partTitle || '',
|
||
chapterId: s.chapterId || '',
|
||
chapterTitle: s.chapterTitle || '',
|
||
}))
|
||
const movingItems = baseItems
|
||
.filter((item) => selectedSet.has(item.id))
|
||
.map((item) => ({
|
||
...item,
|
||
partId: batchMoveTargetPartId,
|
||
partTitle: targetPart.title || batchMoveTargetPartId,
|
||
chapterId: batchMoveTargetChapterId,
|
||
chapterTitle: targetChapter.title || batchMoveTargetChapterId,
|
||
}))
|
||
const remainingItems = baseItems.filter((item) => !selectedSet.has(item.id))
|
||
let insertIndex = remainingItems.length
|
||
for (let i = remainingItems.length - 1; i >= 0; i -= 1) {
|
||
const item = remainingItems[i]
|
||
if (item.partId === batchMoveTargetPartId && item.chapterId === batchMoveTargetChapterId) {
|
||
insertIndex = i + 1
|
||
break
|
||
}
|
||
}
|
||
return [
|
||
...remainingItems.slice(0, insertIndex),
|
||
...movingItems,
|
||
...remainingItems.slice(insertIndex),
|
||
]
|
||
}
|
||
|
||
const tryFallbackReorder = async () => {
|
||
const reorderItems = buildFallbackReorderItems()
|
||
const reorderRes = await put<{ success?: boolean; error?: string }>(
|
||
'/api/db/book',
|
||
{ action: 'reorder', items: reorderItems },
|
||
)
|
||
if (reorderRes && (reorderRes as { success?: boolean }).success !== false) {
|
||
toast.success(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}」`)
|
||
setShowBatchMoveModal(false)
|
||
setSelectedSectionIds([])
|
||
await loadList()
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
const payload = {
|
||
action: 'move-sections',
|
||
sectionIds: selectedSectionIds,
|
||
targetPartId: batchMoveTargetPartId,
|
||
targetChapterId: batchMoveTargetChapterId,
|
||
targetPartTitle: targetPart.title || batchMoveTargetPartId,
|
||
targetChapterTitle: targetChapter.title || batchMoveTargetChapterId,
|
||
}
|
||
const res = await put<{ success?: boolean; error?: string; count?: number }>('/api/db/book', payload)
|
||
if (res && (res as { success?: boolean }).success !== false) {
|
||
toast.success(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}」`)
|
||
setShowBatchMoveModal(false)
|
||
setSelectedSectionIds([])
|
||
await loadList()
|
||
} else {
|
||
const errorMessage = res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error || '' : '未知错误'
|
||
if (errorMessage.includes('缺少 id') || errorMessage.includes('无效的 action')) {
|
||
const fallbackOk = await tryFallbackReorder()
|
||
if (fallbackOk) return
|
||
}
|
||
toast.error('移动失败: ' + errorMessage)
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
|
||
} finally {
|
||
setIsMoving(false)
|
||
}
|
||
}
|
||
|
||
const toggleSectionSelect = (sectionId: string) => {
|
||
setSelectedSectionIds((prev) =>
|
||
prev.includes(sectionId) ? prev.filter((id) => id !== sectionId) : [...prev, sectionId],
|
||
)
|
||
}
|
||
|
||
const handleDeletePart = async (part: Part) => {
|
||
const sectionIds = sectionsList.filter((s) => s.partId === part.id).map((s) => s.id)
|
||
if (sectionIds.length === 0) {
|
||
toast.info('该篇下暂无小节可删除')
|
||
return
|
||
}
|
||
if (!confirm(`确定要删除「${part.title}」整篇吗?将删除共 ${sectionIds.length} 节内容,此操作不可恢复。`)) return
|
||
try {
|
||
for (const id of sectionIds) {
|
||
await del<{ success?: boolean; error?: string }>(`/api/db/book?id=${encodeURIComponent(id)}`)
|
||
}
|
||
loadList()
|
||
} catch (e) {
|
||
console.error(e)
|
||
toast.error('删除失败')
|
||
}
|
||
}
|
||
|
||
const handleSearch = async () => {
|
||
if (!searchQuery.trim()) return
|
||
setIsSearching(true)
|
||
try {
|
||
const data = await get<{ success?: boolean; data?: { results?: typeof searchResults }; error?: string }>(
|
||
`/api/search?q=${encodeURIComponent(searchQuery)}`,
|
||
)
|
||
if (data?.success && (data as { data?: { results?: typeof searchResults } }).data?.results) {
|
||
setSearchResults((data as { data: { results: typeof searchResults } }).data.results)
|
||
} else {
|
||
setSearchResults([])
|
||
if (data && !(data as { success?: boolean }).success) {
|
||
toast.error('搜索失败: ' + (data as { error?: string }).error)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
setSearchResults([])
|
||
toast.error('搜索失败')
|
||
} finally {
|
||
setIsSearching(false)
|
||
}
|
||
}
|
||
|
||
const currentPart = tree.find((p) => p.id === newSection.partId)
|
||
const chaptersForPart = currentPart?.chapters ?? []
|
||
|
||
return (
|
||
<div className="p-8 w-full">
|
||
<div className="flex justify-between items-center mb-8">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white">内容管理</h2>
|
||
<p className="text-gray-400 mt-1">共 {tree.length} 篇 · {totalSections} 节内容</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={() => setShowRankingAlgorithmModal(true)}
|
||
variant="outline"
|
||
className="border-amber-500/50 text-amber-400 hover:bg-amber-500/10 bg-transparent"
|
||
>
|
||
<Settings2 className="w-4 h-4 mr-2" />
|
||
排名算法
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
const url = import.meta.env.VITE_API_DOC_URL || (typeof window !== 'undefined' ? `${window.location.origin}/api-doc` : '')
|
||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||
}}
|
||
variant="outline"
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
<Link2 className="w-4 h-4 mr-2" />
|
||
API 接口
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 新建章节弹窗:与编辑章节样式功能一致 */}
|
||
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0" showCloseButton>
|
||
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Plus className="w-5 h-5 text-[#38bdac]" />
|
||
新建章节
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex-1 overflow-y-auto min-h-0 px-6 space-y-4 py-4">
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">章节ID *</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="如: 9.15"
|
||
value={newSection.id}
|
||
onChange={(e) => setNewSection({ ...newSection, id: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">价格 (元)</Label>
|
||
<Input
|
||
type="number"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={newSection.isFree ? 0 : newSection.price}
|
||
onChange={(e) =>
|
||
setNewSection({
|
||
...newSection,
|
||
price: Number(e.target.value),
|
||
isFree: Number(e.target.value) === 0,
|
||
})
|
||
}
|
||
disabled={newSection.isFree}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">免费</Label>
|
||
<div className="flex items-center h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={newSection.isFree}
|
||
onChange={(e) =>
|
||
setNewSection({
|
||
...newSection,
|
||
isFree: e.target.checked,
|
||
price: e.target.checked ? 0 : 1,
|
||
})
|
||
}
|
||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">设为免费</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">最新新增</Label>
|
||
<div className="flex items-center h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={newSection.isNew}
|
||
onChange={(e) => setNewSection({ ...newSection, isNew: e.target.checked })}
|
||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">标记 NEW</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">小程序直推</Label>
|
||
<div className="flex items-center h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={newSection.isPinned}
|
||
onChange={(e) => setNewSection({ ...newSection, isPinned: e.target.checked })}
|
||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-amber-400 focus:ring-amber-400"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">强制置顶到小程序首页</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">文章类型</Label>
|
||
<div className="flex items-center gap-4 h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="new-edition-type"
|
||
checked={newSection.editionPremium !== true}
|
||
onChange={() => setNewSection({ ...newSection, editionStandard: true, editionPremium: false })}
|
||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">普通版</span>
|
||
</label>
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="new-edition-type"
|
||
checked={newSection.editionPremium === true}
|
||
onChange={() => setNewSection({ ...newSection, editionStandard: false, editionPremium: true })}
|
||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">增值版</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">热度分</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={newSection.hotScore ?? 0}
|
||
onChange={(e) =>
|
||
setNewSection({
|
||
...newSection,
|
||
hotScore: Math.max(0, parseFloat(e.target.value) || 0),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">章节标题 *</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
placeholder="输入章节标题"
|
||
value={newSection.title}
|
||
onChange={(e) => setNewSection({ ...newSection, title: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">所属篇</Label>
|
||
<Select
|
||
value={newSection.partId}
|
||
onValueChange={(v) => {
|
||
const part = tree.find((p) => p.id === v)
|
||
setNewSection({
|
||
...newSection,
|
||
partId: v,
|
||
chapterId: part?.chapters[0]?.id ?? 'chapter-1',
|
||
})
|
||
}}
|
||
>
|
||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#0f2137] border-gray-700">
|
||
{tree.map((part) => (
|
||
<SelectItem key={part.id} value={part.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
||
{part.title}
|
||
</SelectItem>
|
||
))}
|
||
{tree.length === 0 && (
|
||
<SelectItem value="part-1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
||
默认篇
|
||
</SelectItem>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">所属章</Label>
|
||
<Select value={newSection.chapterId} onValueChange={(v) => setNewSection({ ...newSection, chapterId: v })}>
|
||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#0f2137] border-gray-700">
|
||
{chaptersForPart.map((ch) => (
|
||
<SelectItem key={ch.id} value={ch.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
||
{ch.title}
|
||
</SelectItem>
|
||
))}
|
||
{chaptersForPart.length === 0 && (
|
||
<SelectItem value="chapter-1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
||
默认章
|
||
</SelectItem>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">内容(富文本编辑器,支持 @链接AI人物 和 #链接标签)</Label>
|
||
<RichEditor
|
||
content={newSection.content || ''}
|
||
onChange={(html) => setNewSection({ ...newSection, content: html })}
|
||
onImageUpload={async (file: File) => {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('folder', 'book-images')
|
||
const res = await fetch(apiUrl('/api/upload'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
|
||
const data = await res.json()
|
||
return data?.data?.url || data?.url || ''
|
||
}}
|
||
persons={persons}
|
||
linkTags={linkTags}
|
||
placeholder="开始编辑内容... 输入 @ 可链接AI人物,工具栏可插入 #链接标签"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="shrink-0 px-6 py-4 border-t border-gray-700/50">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setShowNewSectionModal(false)}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleCreateSection}
|
||
disabled={isSaving || !newSection.id || !newSection.title}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{isSaving ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||
创建中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
创建章节
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 编辑篇名弹窗 */}
|
||
<Dialog open={!!editingPart} onOpenChange={(open) => !open && setEditingPart(null)}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
||
编辑篇名
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{editingPart && (
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">篇名</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={editingPart.title}
|
||
onChange={(e) => setEditingPart({ ...editingPart, title: e.target.value })}
|
||
placeholder="输入篇名"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setEditingPart(null)}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleSavePartTitle}
|
||
disabled={isSavingPartTitle || !editingPart?.title?.trim()}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{isSavingPartTitle ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||
保存中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="w-4 h-4 mr-2" />
|
||
保存
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 编辑章节名称弹窗 */}
|
||
<Dialog open={!!editingChapter} onOpenChange={(open) => !open && setEditingChapter(null)}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
||
编辑章节名称
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{editingChapter && (
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">章节名称(如:第8章|底层结构)</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={editingChapter.title}
|
||
onChange={(e) => setEditingChapter({ ...editingChapter, title: e.target.value })}
|
||
placeholder="输入章节名称"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setEditingChapter(null)}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveChapterTitle}
|
||
disabled={isSavingChapterTitle || !editingChapter?.title?.trim()}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{isSavingChapterTitle ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||
保存中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="w-4 h-4 mr-2" />
|
||
保存
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 批量移动至指定目录弹窗 */}
|
||
<Dialog
|
||
open={showBatchMoveModal}
|
||
onOpenChange={(open) => {
|
||
setShowBatchMoveModal(open)
|
||
if (open && tree.length > 0) {
|
||
const first = tree[0]
|
||
setBatchMoveTargetPartId(first.id)
|
||
setBatchMoveTargetChapterId(first.chapters[0]?.id ?? '')
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">批量移动至指定目录</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<p className="text-gray-400 text-sm">
|
||
已选 <span className="text-[#38bdac] font-medium">{selectedSectionIds.length}</span> 节,请选择目标篇与章。
|
||
</p>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">目标篇</Label>
|
||
<Select
|
||
value={batchMoveTargetPartId}
|
||
onValueChange={(v) => {
|
||
setBatchMoveTargetPartId(v)
|
||
const part = tree.find((p) => p.id === v)
|
||
setBatchMoveTargetChapterId(part?.chapters[0]?.id ?? '')
|
||
}}
|
||
>
|
||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
|
||
<SelectValue placeholder="选择篇" />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#0f2137] border-gray-700">
|
||
{tree.map((part) => (
|
||
<SelectItem key={part.id} value={part.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
||
{part.title}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">目标章</Label>
|
||
<Select value={batchMoveTargetChapterId} onValueChange={setBatchMoveTargetChapterId}>
|
||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
|
||
<SelectValue placeholder="选择章" />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#0f2137] border-gray-700">
|
||
{(tree.find((p) => p.id === batchMoveTargetPartId)?.chapters ?? []).map((ch) => (
|
||
<SelectItem key={ch.id} value={ch.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
||
{ch.title}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowBatchMoveModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleBatchMoveToTarget}
|
||
disabled={isMoving || selectedSectionIds.length === 0}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{isMoving ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||
移动中...
|
||
</>
|
||
) : (
|
||
'确认移动'
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 付款记录弹窗 */}
|
||
<Dialog open={!!sectionOrdersModal} onOpenChange={(open) => !open && setSectionOrdersModal(null)}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-3xl max-h-[85vh] overflow-hidden flex flex-col" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">
|
||
付款记录 — {sectionOrdersModal?.section.title ?? ''}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex-1 overflow-y-auto py-2">
|
||
{sectionOrdersLoading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||
<span className="ml-2 text-gray-400">加载中...</span>
|
||
</div>
|
||
) : sectionOrdersModal && sectionOrdersModal.orders.length === 0 ? (
|
||
<p className="text-gray-500 text-center py-6">暂无付款记录</p>
|
||
) : sectionOrdersModal ? (
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-gray-700 text-left text-gray-400">
|
||
<th className="py-2 pr-2">订单号</th>
|
||
<th className="py-2 pr-2">用户ID</th>
|
||
<th className="py-2 pr-2">金额</th>
|
||
<th className="py-2 pr-2">状态</th>
|
||
<th className="py-2 pr-2">支付时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sectionOrdersModal.orders.map((o) => (
|
||
<tr key={o.id ?? o.orderSn ?? ''} className="border-b border-gray-700/50">
|
||
<td className="py-2 pr-2">
|
||
<button
|
||
className="text-blue-400 hover:text-blue-300 hover:underline text-left truncate max-w-[180px] block"
|
||
title={`查看订单 ${o.orderSn}`}
|
||
onClick={() => window.open(`/orders?search=${o.orderSn ?? o.id ?? ''}`, '_blank')}
|
||
>
|
||
{o.orderSn ? (o.orderSn.length > 16 ? o.orderSn.slice(0, 8) + '...' + o.orderSn.slice(-6) : o.orderSn) : '-'}
|
||
</button>
|
||
</td>
|
||
<td className="py-2 pr-2">
|
||
<button
|
||
className="text-[#38bdac] hover:text-[#2da396] hover:underline text-left truncate max-w-[140px] block"
|
||
title={`查看用户 ${o.userId ?? o.openId ?? ''}`}
|
||
onClick={() => window.open(`/users?search=${o.userId ?? o.openId ?? ''}`, '_blank')}
|
||
>
|
||
{(() => {
|
||
const uid = o.userId ?? o.openId ?? '-'
|
||
return uid.length > 12 ? uid.slice(0, 6) + '...' + uid.slice(-4) : uid
|
||
})()}
|
||
</button>
|
||
</td>
|
||
<td className="py-2 pr-2 text-gray-300">¥{o.amount ?? 0}</td>
|
||
<td className="py-2 pr-2 text-gray-300">{o.status ?? '-'}</td>
|
||
<td className="py-2 pr-2 text-gray-500">{o.payTime ?? o.createdAt ?? '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : null}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 排名算法:权重可编辑 */}
|
||
<Dialog open={showRankingAlgorithmModal} onOpenChange={setShowRankingAlgorithmModal}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Settings2 className="w-5 h-5 text-amber-400" />
|
||
文章排名算法
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-2">
|
||
<p className="text-sm text-gray-400">热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分(三权重之和须为 1)</p>
|
||
{rankingWeightsLoading ? (
|
||
<p className="text-gray-500">加载中...</p>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">阅读权重</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
max="1"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={rankingWeights.readWeight}
|
||
onChange={(e) => setRankingWeights((w) => ({ ...w, readWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">新度权重</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
max="1"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={rankingWeights.recencyWeight}
|
||
onChange={(e) => setRankingWeights((w) => ({ ...w, recencyWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">付款权重</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
max="1"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={rankingWeights.payWeight}
|
||
onChange={(e) => setRankingWeights((w) => ({ ...w, payWeight: Math.max(0, Math.min(1, parseFloat(e.target.value) || 0)) }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500">当前之和: {(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight).toFixed(1)}</p>
|
||
<ul className="list-disc list-inside space-y-1 text-xs text-gray-400">
|
||
<li>阅读量前 20 名:第1名=20分、第2名=19分...第20名=1分</li>
|
||
<li>最近更新前 30 篇:第1名=30分、第2名=29分...第30名=1分</li>
|
||
<li>付款数前 20 名:第1名=20分、第2名=19分...第20名=1分</li>
|
||
<li>热度分可在编辑章节中手动覆盖</li>
|
||
</ul>
|
||
<Button
|
||
onClick={handleSaveRankingWeights}
|
||
disabled={rankingWeightsSaving || Math.abs(rankingWeights.readWeight + rankingWeights.recencyWeight + rankingWeights.payWeight - 1) > 0.001}
|
||
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
|
||
>
|
||
{rankingWeightsSaving ? '保存中...' : '保存权重'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 新建篇弹窗 */}
|
||
<Dialog open={showNewPartModal} onOpenChange={setShowNewPartModal}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Plus className="w-5 h-5 text-amber-400" />
|
||
新建篇
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">篇名(如:第六篇|真实的社会)</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={newPartTitle}
|
||
onChange={(e) => setNewPartTitle(e.target.value)}
|
||
placeholder="输入篇名"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => { setShowNewPartModal(false); setNewPartTitle('') }}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleCreatePart}
|
||
disabled={isSavingPart || !newPartTitle.trim()}
|
||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||
>
|
||
{isSavingPart ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||
创建中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
创建篇
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 章节编辑弹窗:内容区可滚动,底部保存按钮始终在一页内可见 */}
|
||
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0" showCloseButton>
|
||
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
||
编辑章节
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{editingSection && (
|
||
<div className="flex-1 overflow-y-auto min-h-0 px-6 space-y-4 py-4">
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">章节ID</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={editingSection.id}
|
||
onChange={(e) => setEditingSection({ ...editingSection, id: e.target.value })}
|
||
placeholder="如: 9.15"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">价格 (元)</Label>
|
||
<Input
|
||
type="number"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={editingSection.isFree ? 0 : editingSection.price}
|
||
onChange={(e) =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
price: Number(e.target.value),
|
||
isFree: Number(e.target.value) === 0,
|
||
})
|
||
}
|
||
disabled={editingSection.isFree}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">免费</Label>
|
||
<div className="flex items-center h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={editingSection.isFree || editingSection.price === 0}
|
||
onChange={(e) =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
isFree: e.target.checked,
|
||
price: e.target.checked ? 0 : 1,
|
||
})
|
||
}
|
||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">设为免费</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">最新新增</Label>
|
||
<div className="flex items-center h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={editingSection.isNew ?? false}
|
||
onChange={(e) =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
isNew: e.target.checked,
|
||
})
|
||
}
|
||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">标记 NEW</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">小程序直推</Label>
|
||
<div className="flex items-center h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={editingSection.isPinned ?? false}
|
||
onChange={(e) =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
isPinned: e.target.checked,
|
||
})
|
||
}
|
||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-amber-400 focus:ring-amber-400"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">强制置顶到小程序首页</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">文章类型</Label>
|
||
<div className="flex items-center gap-4 h-10">
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="edition-type"
|
||
checked={editingSection.editionPremium !== true}
|
||
onChange={() =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
editionStandard: true,
|
||
editionPremium: false,
|
||
})
|
||
}
|
||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">普通版</span>
|
||
</label>
|
||
<label className="flex items-center cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="edition-type"
|
||
checked={editingSection.editionPremium === true}
|
||
onChange={() =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
editionStandard: false,
|
||
editionPremium: true,
|
||
})
|
||
}
|
||
className="w-4 h-4 border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||
/>
|
||
<span className="ml-2 text-gray-400 text-sm">增值版</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">热度分</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={editingSection.hotScore ?? 0}
|
||
onChange={(e) =>
|
||
setEditingSection({
|
||
...editingSection,
|
||
hotScore: Math.max(0, parseFloat(e.target.value) || 0),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">章节标题</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white"
|
||
value={editingSection.title}
|
||
onChange={(e) => setEditingSection({ ...editingSection, title: e.target.value })}
|
||
/>
|
||
</div>
|
||
{editingSection.filePath && (
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">文件路径</Label>
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-gray-400 text-sm"
|
||
value={editingSection.filePath}
|
||
disabled
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-300">内容(富文本编辑器,支持 @链接AI人物 和 #链接标签)</Label>
|
||
{isLoadingContent ? (
|
||
<div className="bg-[#0a1628] border border-gray-700 rounded-md min-h-[400px] flex items-center justify-center">
|
||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||
<span className="ml-2 text-gray-400">加载中...</span>
|
||
</div>
|
||
) : (
|
||
<RichEditor
|
||
ref={richEditorRef}
|
||
content={editingSection.content || ''}
|
||
onChange={(html) => setEditingSection({ ...editingSection, content: html })}
|
||
onImageUpload={async (file: File) => {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('folder', 'book-images')
|
||
const res = await fetch(apiUrl('/api/upload'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
|
||
const data = await res.json()
|
||
return data?.data?.url || data?.url || ''
|
||
}}
|
||
persons={persons}
|
||
linkTags={linkTags}
|
||
placeholder="开始编辑内容... 输入 @ 可链接AI人物,工具栏可插入 #链接标签"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter className="shrink-0 px-6 py-4 border-t border-gray-700/50">
|
||
{editingSection && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => handleShowSectionOrders({ id: editingSection.id, title: editingSection.title, price: editingSection.price })}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent mr-auto"
|
||
>
|
||
<BookOpen className="w-4 h-4 mr-2" />
|
||
付款记录
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setEditingSection(null)}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
<X className="w-4 h-4 mr-2" />
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSaveSection} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||
{isSaving ? (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||
保存中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="w-4 h-4 mr-2" />
|
||
保存修改
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Tabs defaultValue="chapters" className="space-y-6">
|
||
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1">
|
||
<TabsTrigger value="chapters" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400">
|
||
<BookOpen className="w-4 h-4 mr-2" />
|
||
章节管理
|
||
</TabsTrigger>
|
||
<TabsTrigger value="ranking" className="data-[state=active]:bg-amber-500/20 data-[state=active]:text-amber-400 text-gray-400">
|
||
<Trophy className="w-4 h-4 mr-2" />
|
||
内容排行榜
|
||
</TabsTrigger>
|
||
<TabsTrigger value="search" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400">
|
||
<Search className="w-4 h-4 mr-2" />
|
||
内容搜索
|
||
</TabsTrigger>
|
||
<TabsTrigger value="link-ai" className="data-[state=active]:bg-purple-500/20 data-[state=active]:text-purple-400 text-gray-400">
|
||
<Link2 className="w-4 h-4 mr-2" />
|
||
链接AI
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="chapters" className="space-y-4">
|
||
{/* 书籍信息卡片 */}
|
||
<div className="rounded-2xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between shadow-sm">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-xl bg-[#38bdac] flex items-center justify-center text-white shadow-lg shadow-[#38bdac]/20 shrink-0">
|
||
<BookOpen className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<h2 className="font-bold text-base text-white leading-tight mb-1">一场SOUL的创业实验场</h2>
|
||
<p className="text-xs text-gray-500">来自Soul派对房的真实商业故事</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-center shrink-0">
|
||
<span className="block text-2xl font-bold text-[#38bdac]">{totalSections}</span>
|
||
<span className="text-xs text-gray-500">章节</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
onClick={() => setShowNewSectionModal(true)}
|
||
className="flex-1 min-w-[120px] bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
新建章节
|
||
</Button>
|
||
<Button
|
||
onClick={() => setShowNewPartModal(true)}
|
||
className="flex-1 min-w-[120px] bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
新建篇
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setShowBatchMoveModal(true)}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
批量移动(已选 {selectedSectionIds.length} 节)
|
||
</Button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||
<span className="ml-2 text-gray-400">加载中...</span>
|
||
</div>
|
||
) : (
|
||
<ChapterTree
|
||
parts={tree}
|
||
expandedParts={expandedParts}
|
||
onTogglePart={togglePart}
|
||
onReorder={handleReorderTree}
|
||
onReadSection={handleReadSection}
|
||
onDeleteSection={handleDeleteSection}
|
||
onAddSectionInPart={handleAddSectionInPart}
|
||
onAddChapterInPart={handleAddChapterInPart}
|
||
onDeleteChapter={handleDeleteChapter}
|
||
onEditPart={handleEditPartTitle}
|
||
onDeletePart={handleDeletePart}
|
||
onEditChapter={handleEditChapter}
|
||
selectedSectionIds={selectedSectionIds}
|
||
onToggleSectionSelect={toggleSectionSelect}
|
||
onShowSectionOrders={handleShowSectionOrders}
|
||
pinnedSectionIds={pinnedSectionIds}
|
||
/>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="search" className="space-y-4">
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white">内容搜索</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 flex-1"
|
||
placeholder="搜索标题或内容..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
/>
|
||
<Button
|
||
onClick={handleSearch}
|
||
disabled={isSearching || !searchQuery.trim()}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{isSearching ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||
</Button>
|
||
</div>
|
||
{searchResults.length > 0 && (
|
||
<div className="space-y-2 mt-4">
|
||
<p className="text-gray-400 text-sm">找到 {searchResults.length} 个结果</p>
|
||
{searchResults.map((result) => (
|
||
<div
|
||
key={result.id}
|
||
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors"
|
||
onClick={() =>
|
||
handleReadSection({
|
||
id: result.id,
|
||
title: result.title,
|
||
price: result.price ?? 1,
|
||
filePath: '',
|
||
})
|
||
}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[#38bdac] font-mono text-xs">{result.id}</span>
|
||
<span className="text-white">{result.title}</span>
|
||
{pinnedSectionIds.includes(result.id) && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
|
||
</div>
|
||
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
|
||
{result.matchType === 'title' ? '标题匹配' : '内容匹配'}
|
||
</Badge>
|
||
</div>
|
||
{result.snippet && (
|
||
<p className="text-gray-500 text-xs mt-2 line-clamp-2">{result.snippet}</p>
|
||
)}
|
||
{(result.partTitle || result.chapterTitle) && (
|
||
<p className="text-gray-600 text-xs mt-1">
|
||
{result.partTitle} · {result.chapterTitle}
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="ranking" className="space-y-4">
|
||
{/* 内容显示规则 */}
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||
<Settings2 className="w-4 h-4 text-[#38bdac]" />
|
||
内容显示规则
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
<div className="flex items-center gap-2">
|
||
<Label className="text-gray-400 text-sm whitespace-nowrap">未付费预览比例</Label>
|
||
<Input
|
||
type="number"
|
||
min="1"
|
||
max="100"
|
||
className="bg-[#0a1628] border-gray-700 text-white w-20"
|
||
value={previewPercent}
|
||
onChange={(e) => setPreviewPercent(Math.max(1, Math.min(100, Number(e.target.value) || 20)))}
|
||
disabled={previewPercentLoading}
|
||
/>
|
||
<span className="text-gray-500 text-sm">%</span>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSavePreviewPercent}
|
||
disabled={previewPercentSaving}
|
||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||
>
|
||
{previewPercentSaving ? '保存中...' : '保存'}
|
||
</Button>
|
||
<span className="text-xs text-gray-500">小程序未付费用户默认显示文章前 {previewPercent}% 内容</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 排行榜 */}
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||
<Trophy className="w-4 h-4 text-amber-400" />
|
||
内容排行榜
|
||
<span className="text-xs text-gray-500 font-normal ml-2">按热度排行 · 共 {rankedSections.length} 节</span>
|
||
</CardTitle>
|
||
<div className="flex items-center gap-1 text-sm">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={rankingPage <= 1}
|
||
onClick={() => setRankingPage((p) => Math.max(1, p - 1))}
|
||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</Button>
|
||
<span className="text-gray-400 min-w-[60px] text-center">{rankingPage} / {rankingTotalPages}</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={rankingPage >= rankingTotalPages}
|
||
onClick={() => setRankingPage((p) => Math.min(rankingTotalPages, p + 1))}
|
||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||
>
|
||
<ChevronRightIcon className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-0">
|
||
{/* 表头 */}
|
||
<div className="grid grid-cols-[40px_40px_1fr_80px_80px_80px_60px] gap-2 px-3 py-2 text-xs text-gray-500 border-b border-gray-700/50">
|
||
<span>排名</span>
|
||
<span>置顶</span>
|
||
<span>标题</span>
|
||
<span className="text-right">点击量</span>
|
||
<span className="text-right">付款数</span>
|
||
<span className="text-right">热度</span>
|
||
<span className="text-right">编辑</span>
|
||
</div>
|
||
{rankingPageSections.map((s, idx) => {
|
||
const globalRank = (rankingPage - 1) * RANKING_PAGE_SIZE + idx + 1
|
||
const isPinned = pinnedSectionIds.includes(s.id)
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
className={`grid grid-cols-[40px_40px_1fr_80px_80px_80px_60px] gap-2 px-3 py-2.5 items-center border-b border-gray-700/30 hover:bg-[#162840] transition-colors ${isPinned ? 'bg-amber-500/5' : ''}`}
|
||
>
|
||
<span className={`text-sm font-bold ${globalRank <= 3 ? 'text-amber-400' : 'text-gray-500'}`}>
|
||
{globalRank <= 3 ? ['🥇', '🥈', '🥉'][globalRank - 1] : `#${globalRank}`}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className={`h-6 w-6 p-0 ${isPinned ? 'text-amber-400' : 'text-gray-600 hover:text-amber-400'}`}
|
||
onClick={() => handleTogglePin(s.id)}
|
||
disabled={pinnedLoading}
|
||
title={isPinned ? '取消置顶' : '强制置顶(精选推荐/首页最新更新)'}
|
||
>
|
||
{isPinned ? <Star className="w-3.5 h-3.5 fill-current" /> : <Pin className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
<div className="min-w-0">
|
||
<span className="text-white text-sm truncate block">{s.title}</span>
|
||
<span className="text-gray-600 text-xs">{s.partTitle} · {s.chapterTitle}</span>
|
||
</div>
|
||
<span className="text-right text-sm text-blue-400 font-mono">{s.clickCount ?? 0}</span>
|
||
<span className="text-right text-sm text-green-400 font-mono">{s.payCount ?? 0}</span>
|
||
<span className="text-right text-sm text-amber-400 font-mono">{(s.hotScore ?? 0).toFixed(1)}</span>
|
||
<div className="text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-gray-500 hover:text-[#38bdac] h-6 px-1"
|
||
onClick={() => handleReadSection({ id: s.id, title: s.title, price: s.price, filePath: '' })}
|
||
title="编辑文章"
|
||
>
|
||
<Edit3 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{rankingPageSections.length === 0 && (
|
||
<div className="py-8 text-center text-gray-500">暂无数据</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</TabsContent>
|
||
|
||
<TabsContent value="link-ai" className="space-y-4">
|
||
{/* AI列表(@人物) */}
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||
<span className="text-[#38bdac] text-lg font-bold">@</span>
|
||
AI列表 — 链接人与事(编辑器内输入 @ 可链接)
|
||
</CardTitle>
|
||
<p className="text-xs text-gray-500 mt-1">在文章中 @人物,小程序端点击可跳转存客宝流量池或详情页</p>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex gap-2 items-end flex-wrap">
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">名称 *</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 卡若" value={newPerson.name} onChange={e => setNewPerson({ ...newPerson, name: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">人物ID(可选)</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="自动生成" value={newPerson.personId} onChange={e => setNewPerson({ ...newPerson, personId: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">标签(身份/角色)</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="如 超级个体" value={newPerson.label} onChange={e => setNewPerson({ ...newPerson, label: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">存客宝密钥(留空用默认)</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-48" placeholder="xxxx-xxxx-xxxx-xxxx" value={newPerson.ckbApiKey} onChange={e => setNewPerson({ ...newPerson, ckbApiKey: e.target.value })} />
|
||
</div>
|
||
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-8" onClick={async () => {
|
||
if (!newPerson.name) { toast.error('名称必填'); return }
|
||
const payload = { ...newPerson }
|
||
if (!payload.personId) payload.personId = newPerson.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)
|
||
await post('/api/db/persons', payload)
|
||
setNewPerson({ personId: '', name: '', label: '', ckbApiKey: '' })
|
||
loadPersons()
|
||
}}>
|
||
<Plus className="w-3 h-3 mr-1" />添加
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||
{persons.map(p => (
|
||
<div key={p.id} className="bg-[#0a1628] rounded px-3 py-2 space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<span className="text-[#38bdac] font-bold text-base">@{p.name}</span>
|
||
<span className="text-gray-600 text-xs font-mono">{p.id}</span>
|
||
{p.label && <Badge variant="secondary" className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">{p.label}</Badge>}
|
||
{p.ckbApiKey
|
||
? <Badge variant="secondary" className="bg-green-500/20 text-green-300 border-green-500/30 text-[10px]">密钥 ✓</Badge>
|
||
: <Badge variant="secondary" className="bg-gray-500/20 text-gray-500 border-gray-500/30 text-[10px]">用默认密钥</Badge>
|
||
}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-[#38bdac] h-6 px-2" title="编辑密钥" onClick={() => {
|
||
setEditingPersonKey(p.id)
|
||
setEditingPersonKeyValue(p.ckbApiKey || '')
|
||
}}>
|
||
<Pencil className="w-3 h-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
|
||
await del(`/api/db/persons?personId=${p.id}`)
|
||
loadPersons()
|
||
}}>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{editingPersonKey === p.id && (
|
||
<div className="flex items-center gap-2 pt-0.5">
|
||
<Input
|
||
className="bg-[#0d1f35] border-gray-600 text-white h-7 text-xs flex-1"
|
||
placeholder="输入存客宝密钥,留空则用默认"
|
||
value={editingPersonKeyValue}
|
||
onChange={e => setEditingPersonKeyValue(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Escape') setEditingPersonKey(null)
|
||
}}
|
||
autoFocus
|
||
/>
|
||
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-7 px-2" onClick={async () => {
|
||
await post('/api/db/persons', { personId: p.id, name: p.name, label: p.label, ckbApiKey: editingPersonKeyValue })
|
||
setEditingPersonKey(null)
|
||
loadPersons()
|
||
}}>
|
||
<Check className="w-3 h-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="text-gray-400 h-7 px-2" onClick={() => setEditingPersonKey(null)}>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{persons.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">暂无AI人物,添加后可在编辑器中 @链接</div>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* #链接标签管理 */}
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||
<Hash className="w-4 h-4 text-amber-400" />
|
||
链接标签 — 链接事与物(编辑器内 #标签 可跳转链接/小程序/存客宝)
|
||
</CardTitle>
|
||
<p className="text-xs text-gray-500 mt-1">小程序端点击 #标签 可直接跳转对应链接,进入流量池</p>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex gap-2 items-end flex-wrap">
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">标签ID</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-24" placeholder="如 team01" value={newLinkTag.tagId} onChange={e => setNewLinkTag({ ...newLinkTag, tagId: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">显示文字</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 神仙团队" value={newLinkTag.label} onChange={e => setNewLinkTag({ ...newLinkTag, label: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">类型</Label>
|
||
<Select value={newLinkTag.type} onValueChange={v => setNewLinkTag({ ...newLinkTag, type: v as 'url' | 'miniprogram' | 'ckb' })}>
|
||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8 w-24">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="url">网页链接</SelectItem>
|
||
<SelectItem value="miniprogram">小程序</SelectItem>
|
||
<SelectItem value="ckb">存客宝</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">
|
||
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : '小程序AppID'}
|
||
</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-44" placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : 'wx...'} value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId} onChange={e => {
|
||
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
|
||
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
|
||
}} />
|
||
</div>
|
||
{newLinkTag.type === 'miniprogram' && (
|
||
<div className="space-y-1">
|
||
<Label className="text-gray-400 text-xs">页面路径</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
|
||
</div>
|
||
)}
|
||
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
|
||
if (!newLinkTag.tagId || !newLinkTag.label) { toast.error('标签ID和显示文字必填'); return }
|
||
const payload = { ...newLinkTag }
|
||
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
|
||
await post('/api/db/link-tags', payload)
|
||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||
loadLinkTags()
|
||
}}>
|
||
<Plus className="w-3 h-3 mr-1" />添加
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||
{linkTags.map(t => (
|
||
<div key={t.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<span className="text-amber-400 font-bold text-base">#{t.label}</span>
|
||
<Badge variant="secondary" className={`text-[10px] ${t.type === 'ckb' ? 'bg-green-500/20 text-green-300 border-green-500/30' : 'bg-gray-700 text-gray-300'}`}>
|
||
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
|
||
</Badge>
|
||
<a href={t.url} target="_blank" rel="noreferrer" className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1">
|
||
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
|
||
</a>
|
||
</div>
|
||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
|
||
await del(`/api/db/link-tags?tagId=${t.id}`)
|
||
loadLinkTags()
|
||
}}>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{linkTags.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">暂无链接标签,添加后可在编辑器中使用 #标签 跳转</div>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 存客宝绑定配置 */}
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||
<Settings2 className="w-4 h-4 text-green-400" />
|
||
存客宝绑定
|
||
</CardTitle>
|
||
<p className="text-xs text-gray-500 mt-1">配置存客宝 API 后,文章中 @人物 或 #标签 点击可自动进入存客宝流量池</p>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-400 text-xs">存客宝 API 地址</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8" placeholder="https://ckbapi.quwanzhi.com" defaultValue="https://ckbapi.quwanzhi.com" readOnly />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-400 text-xs">绑定计划</Label>
|
||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8" placeholder="创业实验-内容引流" defaultValue="创业实验-内容引流" readOnly />
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500">具体存客宝场景配置与接口测试请前往 <button className="text-[#38bdac] hover:underline" onClick={() => window.open('/match', '_blank')}>找伙伴 → 存客宝工作台</button></p>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
)
|
||
}
|