feat: 完善后台管理+搜索功能+分销系统
主要更新: - 后台菜单精简(9项→6项) - 新增搜索功能(敏感信息过滤) - 分销绑定和提现系统完善 - 数据库初始化API(自动修复表结构) - 用户管理:显示绑定关系详情 - 小程序:上下章导航优化、匹配页面重构 - 修复hydration和数据类型问题
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
Upload,
|
||||
Eye,
|
||||
Database,
|
||||
Plus,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
interface EditingSection {
|
||||
@@ -34,6 +38,9 @@ interface EditingSection {
|
||||
price: number
|
||||
content?: string
|
||||
filePath?: string
|
||||
isNew?: boolean
|
||||
partId?: string
|
||||
chapterId?: string
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
@@ -46,9 +53,26 @@ export default function ContentPage() {
|
||||
const [feishuDocUrl, setFeishuDocUrl] = useState("")
|
||||
const [showFeishuModal, setShowFeishuModal] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
|
||||
const [importData, setImportData] = useState("")
|
||||
const [isLoadingContent, setIsLoadingContent] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 新建章节表单
|
||||
const [newSection, setNewSection] = useState({
|
||||
id: "",
|
||||
title: "",
|
||||
price: 1,
|
||||
partId: "part-1",
|
||||
chapterId: "chapter-1",
|
||||
content: "",
|
||||
})
|
||||
|
||||
const togglePart = (partId: string) => {
|
||||
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
|
||||
@@ -69,8 +93,8 @@ export default function ContentPage() {
|
||||
if (data.success) {
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
price: section.price,
|
||||
title: data.section.title || section.title,
|
||||
price: data.section.price || section.price,
|
||||
content: data.section.content || "",
|
||||
filePath: section.filePath,
|
||||
})
|
||||
@@ -103,6 +127,7 @@ export default function ContentPage() {
|
||||
const handleSaveSection = async () => {
|
||||
if (!editingSection) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/book', {
|
||||
method: 'PUT',
|
||||
@@ -126,6 +151,110 @@ export default function ContentPage() {
|
||||
} catch (error) {
|
||||
console.error("Save section error:", error)
|
||||
alert("保存失败")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新章节
|
||||
const handleCreateSection = async () => {
|
||||
if (!newSection.id || !newSection.title) {
|
||||
alert("请填写章节ID和标题")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/book', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: newSection.id,
|
||||
title: newSection.title,
|
||||
price: newSection.price,
|
||||
content: newSection.content,
|
||||
partId: newSection.partId,
|
||||
chapterId: newSection.chapterId,
|
||||
saveToFile: false, // 新建章节暂不保存到文件系统
|
||||
})
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(`章节创建成功: ${newSection.title}`)
|
||||
setShowNewSectionModal(false)
|
||||
setNewSection({ id: "", title: "", price: 1, partId: "part-1", chapterId: "chapter-1", content: "" })
|
||||
} else {
|
||||
alert("创建失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Create section error:", error)
|
||||
alert("创建失败")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploadingImage(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('folder', 'book-images')
|
||||
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
// 插入图片Markdown到内容
|
||||
const imageMarkdown = ``
|
||||
if (editingSection) {
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
content: (editingSection.content || '') + '\n\n' + imageMarkdown
|
||||
})
|
||||
}
|
||||
alert(`图片上传成功: ${data.data.url}`)
|
||||
} else {
|
||||
alert("上传失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Image upload error:", error)
|
||||
alert("上传失败")
|
||||
} finally {
|
||||
setUploadingImage(false)
|
||||
if (imageInputRef.current) {
|
||||
imageInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索内容
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
setSearchResults(data.data.results || [])
|
||||
} else {
|
||||
alert("搜索失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Search error:", error)
|
||||
alert("搜索失败")
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,11 +426,15 @@ export default function ContentPage() {
|
||||
|
||||
setIsInitializing(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/init', { method: 'POST' })
|
||||
const res = await fetch('/api/db/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ adminToken: 'init_db_2025' })
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message)
|
||||
alert(data.data?.message || '初始化成功')
|
||||
} else {
|
||||
alert("初始化失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
@@ -506,6 +639,116 @@ export default function ContentPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 新建章节弹窗 */}
|
||||
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-[#38bdac]" />
|
||||
新建章节
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">章节ID *</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如: 9.15"
|
||||
value={newSection.id}
|
||||
onChange={(e) => setNewSection({ ...newSection, id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">价格 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={newSection.price}
|
||||
onChange={(e) => setNewSection({ ...newSection, price: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">章节标题 *</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="输入章节标题"
|
||||
value={newSection.title}
|
||||
onChange={(e) => setNewSection({ ...newSection, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">所属篇</Label>
|
||||
<Select value={newSection.partId} onValueChange={(v) => setNewSection({ ...newSection, partId: v })}>
|
||||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#0f2137] border-gray-700">
|
||||
{bookData.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={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">
|
||||
{bookData.find(p => p.id === newSection.partId)?.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>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</Label>
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[300px] font-mono text-sm placeholder:text-gray-500"
|
||||
placeholder="输入章节内容..."
|
||||
value={newSection.content}
|
||||
onChange={(e) => setNewSection({ ...newSection, content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNewSectionModal(false)}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSection}
|
||||
disabled={isSaving || !newSection.id || !newSection.title}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建章节
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 章节编辑弹窗 */}
|
||||
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -527,11 +770,12 @@ export default function ContentPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">文件路径</Label>
|
||||
<Label className="text-gray-300">价格 (元)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white text-sm"
|
||||
value={editingSection.filePath || ""}
|
||||
disabled
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={editingSection.price}
|
||||
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,17 +787,43 @@ export default function ContentPage() {
|
||||
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">价格 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white w-32"
|
||||
value={editingSection.price}
|
||||
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
{uploadingImage ? (
|
||||
<RefreshCw className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
上传图片
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoadingContent ? (
|
||||
<div className="bg-[#0a1628] border border-gray-700 rounded-md min-h-[400px] flex items-center justify-center">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
@@ -579,9 +849,22 @@ export default function ContentPage() {
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveSection} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存修改
|
||||
<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>
|
||||
@@ -596,6 +879,13 @@ export default function ContentPage() {
|
||||
<BookOpen 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="hooks"
|
||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
|
||||
@@ -606,6 +896,15 @@ export default function ContentPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chapters" className="space-y-4">
|
||||
{/* 新建章节按钮 */}
|
||||
<Button
|
||||
onClick={() => setShowNewSectionModal(true)}
|
||||
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建章节
|
||||
</Button>
|
||||
|
||||
{bookData.map((part, partIndex) => (
|
||||
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
|
||||
<CardHeader
|
||||
@@ -679,6 +978,66 @@ export default function ContentPage() {
|
||||
))}
|
||||
</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: any) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors"
|
||||
onClick={() => handleReadSection({ id: result.id, title: result.title, price: result.price || 1, filePath: '' })}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[#38bdac] font-mono text-xs mr-2">{result.id}</span>
|
||||
<span className="text-white">{result.title}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
|
||||
{result.matchType === 'title' ? '标题匹配' : '内容匹配'}
|
||||
</Badge>
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="text-gray-500 text-xs mt-2 line-clamp-2">{result.snippet}</p>
|
||||
)}
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
{result.partTitle} · {result.chapterTitle}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hooks" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user