feat: 完善后台管理+搜索功能+分销系统

主要更新:
- 后台菜单精简(9项→6项)
- 新增搜索功能(敏感信息过滤)
- 分销绑定和提现系统完善
- 数据库初始化API(自动修复表结构)
- 用户管理:显示绑定关系详情
- 小程序:上下章导航优化、匹配页面重构
- 修复hydration和数据类型问题
This commit is contained in:
卡若
2026-01-25 19:37:59 +08:00
parent 65d2831a45
commit 4dd2f9f4a7
49 changed files with 5921 additions and 636 deletions

View File

@@ -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 = `![${file.name}](${data.data.url})`
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>