feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API
主要更新: 1. 按H5网页端完全重构匹配功能(match页面) - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募 - 资源对接等类型弹出手机号/微信号输入框 - 去掉重新匹配按钮,改为返回按钮 2. 修复所有卡片对齐和宽度问题 - 目录页附录卡片居中 - 首页阅读进度卡片满宽度 - 我的页面菜单卡片对齐 - 推广中心分享卡片统一宽度 3. 修复目录页图标和文字对齐 - section-icon固定40rpx宽高 - section-title与图标垂直居中 4. 更新真实完整文章标题(62篇) - 从book目录读取真实markdown文件名 - 替换之前的简化标题 5. 新增文章数据API - /api/db/chapters - 获取完整书籍结构 - 支持按ID获取单篇文章内容
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
X,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
Download,
|
||||
Upload,
|
||||
Eye,
|
||||
Database,
|
||||
} from "lucide-react"
|
||||
|
||||
interface EditingSection {
|
||||
@@ -29,14 +33,22 @@ interface EditingSection {
|
||||
title: string
|
||||
price: number
|
||||
content?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
const [expandedParts, setExpandedParts] = useState<string[]>(["part-1"])
|
||||
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isInitializing, setIsInitializing] = useState(false)
|
||||
const [feishuDocUrl, setFeishuDocUrl] = useState("")
|
||||
const [showFeishuModal, setShowFeishuModal] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importData, setImportData] = useState("")
|
||||
const [isLoadingContent, setIsLoadingContent] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const togglePart = (partId: string) => {
|
||||
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
|
||||
@@ -47,21 +59,257 @@ export default function ContentPage() {
|
||||
0,
|
||||
)
|
||||
|
||||
const handleEditSection = (section: { id: string; title: string; price: number }) => {
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
price: section.price,
|
||||
content: "",
|
||||
})
|
||||
// 读取章节内容
|
||||
const handleReadSection = async (section: { id: string; title: string; price: number; filePath: string }) => {
|
||||
setIsLoadingContent(true)
|
||||
try {
|
||||
const res = await fetch(`/api/db/book?action=read&id=${section.id}`)
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
price: section.price,
|
||||
content: data.section.content || "",
|
||||
filePath: section.filePath,
|
||||
})
|
||||
} else {
|
||||
// 如果API失败,设置空内容
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
price: section.price,
|
||||
content: "",
|
||||
filePath: section.filePath,
|
||||
})
|
||||
alert("无法读取文件内容: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Read section error:", error)
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
price: section.price,
|
||||
content: "",
|
||||
filePath: section.filePath,
|
||||
})
|
||||
} finally {
|
||||
setIsLoadingContent(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSection = () => {
|
||||
if (editingSection) {
|
||||
// 保存到本地存储或API
|
||||
console.log("[v0] Saving section:", editingSection)
|
||||
alert(`已保存章节: ${editingSection.title}`)
|
||||
setEditingSection(null)
|
||||
// 保存章节
|
||||
const handleSaveSection = async () => {
|
||||
if (!editingSection) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/db/book', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingSection.id,
|
||||
title: editingSection.title,
|
||||
price: editingSection.price,
|
||||
content: editingSection.content,
|
||||
saveToFile: true, // 同时保存到文件系统
|
||||
})
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(`已保存章节: ${editingSection.title}`)
|
||||
setEditingSection(null)
|
||||
} else {
|
||||
alert("保存失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Save section error:", error)
|
||||
alert("保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到数据库
|
||||
const handleSyncToDatabase = async () => {
|
||||
setIsSyncing(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/book', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'sync' })
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(data.message)
|
||||
} else {
|
||||
alert("同步失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error)
|
||||
alert("同步失败")
|
||||
} finally {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出所有章节
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/book?action=export')
|
||||
const blob = await res.blob()
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `book_sections_${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
|
||||
alert("导出成功")
|
||||
} catch (error) {
|
||||
console.error("Export error:", error)
|
||||
alert("导出失败")
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入章节
|
||||
const handleImport = async () => {
|
||||
if (!importData) {
|
||||
alert("请输入或上传JSON数据")
|
||||
return
|
||||
}
|
||||
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const data = JSON.parse(importData)
|
||||
const res = await fetch('/api/db/book', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'import', data })
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
if (result.success) {
|
||||
alert(result.message)
|
||||
setShowImportModal(false)
|
||||
setImportData("")
|
||||
} else {
|
||||
alert("导入失败: " + (result.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Import error:", error)
|
||||
alert("导入失败: JSON格式错误")
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string
|
||||
const fileName = file.name.toLowerCase()
|
||||
|
||||
// 根据文件类型处理
|
||||
if (fileName.endsWith('.json')) {
|
||||
// JSON文件直接使用
|
||||
setImportData(content)
|
||||
} else if (fileName.endsWith('.txt') || fileName.endsWith('.md') || fileName.endsWith('.markdown')) {
|
||||
// TXT/MD文件自动解析为JSON格式
|
||||
const parsedData = parseTxtToJson(content, file.name)
|
||||
setImportData(JSON.stringify(parsedData, null, 2))
|
||||
} else {
|
||||
setImportData(content)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
// 解析TXT/MD文件为JSON格式
|
||||
const parseTxtToJson = (content: string, fileName: string) => {
|
||||
const lines = content.split('\n')
|
||||
const sections: any[] = []
|
||||
let currentSection: any = null
|
||||
let currentContent: string[] = []
|
||||
let sectionIndex = 1
|
||||
|
||||
for (const line of lines) {
|
||||
// 检测标题行(以#开头或数字+点开头)
|
||||
const titleMatch = line.match(/^#+\s+(.+)$/) || line.match(/^(\d+[\.\、]\s*.+)$/)
|
||||
|
||||
if (titleMatch) {
|
||||
// 保存前一个章节
|
||||
if (currentSection) {
|
||||
currentSection.content = currentContent.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始新章节
|
||||
currentSection = {
|
||||
id: `import-${sectionIndex}`,
|
||||
title: titleMatch[1].replace(/^#+\s*/, '').trim(),
|
||||
price: 1,
|
||||
is_free: sectionIndex <= 3, // 前3章免费
|
||||
}
|
||||
currentContent = []
|
||||
sectionIndex++
|
||||
} else if (currentSection) {
|
||||
currentContent.push(line)
|
||||
} else if (line.trim()) {
|
||||
// 没有标题但有内容,创建默认章节
|
||||
currentSection = {
|
||||
id: `import-${sectionIndex}`,
|
||||
title: fileName.replace(/\.(txt|md|markdown)$/i, ''),
|
||||
price: 1,
|
||||
is_free: true,
|
||||
}
|
||||
currentContent.push(line)
|
||||
sectionIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// 保存最后一个章节
|
||||
if (currentSection) {
|
||||
currentSection.content = currentContent.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
const handleInitDatabase = async () => {
|
||||
if (!confirm("确定要初始化数据库吗?这将创建所有必需的表结构。")) return
|
||||
|
||||
setIsInitializing(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/init', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message)
|
||||
} else {
|
||||
alert("初始化失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Init database error:", error)
|
||||
alert("初始化失败")
|
||||
} finally {
|
||||
setIsInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +319,6 @@ export default function ContentPage() {
|
||||
return
|
||||
}
|
||||
setIsSyncing(true)
|
||||
// 模拟同步过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
setIsSyncing(false)
|
||||
setShowFeishuModal(false)
|
||||
@@ -87,12 +334,123 @@ export default function ContentPage() {
|
||||
共 {bookData.length} 篇 · {totalSections} 节内容
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
同步飞书文档
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleInitDatabase}
|
||||
disabled={isInitializing}
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
{isInitializing ? "初始化中..." : "初始化数据库"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSyncToDatabase}
|
||||
disabled={isSyncing}
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
同步到数据库
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
导入
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
同步飞书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入弹窗 */}
|
||||
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-[#38bdac]" />
|
||||
导入章节数据
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">上传文件 (支持 JSON / TXT / MD)</Label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.txt,.md,.markdown"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full border-dashed border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
选择文件 (JSON/TXT/MD)
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">
|
||||
• JSON格式: 直接导入章节数据<br/>
|
||||
• TXT/MD格式: 自动解析为章节内容
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">或粘贴数据</Label>
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] font-mono text-sm placeholder:text-gray-500"
|
||||
placeholder='JSON格式: [{"id": "1-1", "title": "章节标题", "content": "内容..."}] 或直接粘贴TXT/MD内容,系统将自动解析'
|
||||
value={importData}
|
||||
onChange={(e) => setImportData(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowImportModal(false)
|
||||
setImportData("")
|
||||
}}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting || !importData}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
导入中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
开始导入
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 飞书同步弹窗 */}
|
||||
<Dialog open={showFeishuModal} onOpenChange={setShowFeishuModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
@@ -150,7 +508,7 @@ export default function ContentPage() {
|
||||
|
||||
{/* 章节编辑弹窗 */}
|
||||
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
||||
@@ -159,6 +517,24 @@ export default function ContentPage() {
|
||||
</DialogHeader>
|
||||
{editingSection && (
|
||||
<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"
|
||||
value={editingSection.id}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">文件路径</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white text-sm"
|
||||
value={editingSection.filePath || ""}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">章节标题</Label>
|
||||
<Input
|
||||
@@ -177,13 +553,20 @@ export default function ContentPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">内容预览</Label>
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] placeholder:text-gray-500"
|
||||
placeholder="此处显示章节内容,支持Markdown格式..."
|
||||
value={editingSection.content}
|
||||
onChange={(e) => setEditingSection({ ...editingSection, content: e.target.value })}
|
||||
/>
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</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>
|
||||
) : (
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[400px] font-mono text-sm placeholder:text-gray-500"
|
||||
placeholder="此处输入章节内容,支持Markdown格式..."
|
||||
value={editingSection.content}
|
||||
onChange={(e) => setEditingSection({ ...editingSection, content: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -268,7 +651,16 @@ export default function ContentPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditSection(section)}
|
||||
onClick={() => handleReadSection(section)}
|
||||
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
读取
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReadSection(section)}
|
||||
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-1" />
|
||||
|
||||
35
app/admin/distribution/loading.tsx
Normal file
35
app/admin/distribution/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<div className="animate-pulse space-y-6">
|
||||
{/* 标题骨架 */}
|
||||
<div className="h-8 w-48 bg-gray-700/50 rounded" />
|
||||
<div className="h-4 w-64 bg-gray-700/30 rounded" />
|
||||
|
||||
{/* Tab骨架 */}
|
||||
<div className="flex gap-2 pb-4 border-b border-gray-700">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-10 w-28 bg-gray-700/30 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 卡片骨架 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-6">
|
||||
<div className="h-4 w-20 bg-gray-700/30 rounded mb-2" />
|
||||
<div className="h-8 w-16 bg-gray-700/50 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 大卡片骨架 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-6 h-48" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
821
app/admin/distribution/page.tsx
Normal file
821
app/admin/distribution/page.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Users, TrendingUp, Clock, Wallet, Search, RefreshCw,
|
||||
CheckCircle, XCircle, Zap, Calendar, DollarSign, Link2, Eye
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
// 类型定义
|
||||
interface DistributionOverview {
|
||||
todayClicks: number
|
||||
todayBindings: number
|
||||
todayConversions: number
|
||||
todayEarnings: number
|
||||
monthClicks: number
|
||||
monthBindings: number
|
||||
monthConversions: number
|
||||
monthEarnings: number
|
||||
totalClicks: number
|
||||
totalBindings: number
|
||||
totalConversions: number
|
||||
totalEarnings: number
|
||||
expiringBindings: number
|
||||
pendingWithdrawals: number
|
||||
pendingWithdrawAmount: number
|
||||
conversionRate: string
|
||||
totalDistributors: number
|
||||
activeDistributors: number
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
id: string
|
||||
referrer_id: string
|
||||
referrer_name?: string
|
||||
referrer_code: string
|
||||
referee_id: string
|
||||
referee_phone?: string
|
||||
referee_nickname?: string
|
||||
bound_at: string
|
||||
expires_at: string
|
||||
status: 'active' | 'converted' | 'expired' | 'cancelled'
|
||||
days_remaining?: number
|
||||
commission?: number
|
||||
order_amount?: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface Withdrawal {
|
||||
id: string
|
||||
user_id: string
|
||||
user_name?: string
|
||||
amount: number
|
||||
method: 'wechat' | 'alipay'
|
||||
account: string
|
||||
name: string
|
||||
status: 'pending' | 'completed' | 'rejected'
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
nickname: string
|
||||
phone: string
|
||||
referral_code: string
|
||||
has_full_book: boolean
|
||||
earnings: number
|
||||
pending_earnings: number
|
||||
withdrawn_earnings: number
|
||||
referral_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function DistributionAdminPage() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'bindings' | 'withdrawals' | 'distributors'>('overview')
|
||||
const [overview, setOverview] = useState<DistributionOverview | null>(null)
|
||||
const [bindings, setBindings] = useState<Binding[]>([])
|
||||
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||
const [distributors, setDistributors] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [activeTab])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// 加载用户数据(分销商)
|
||||
const usersRes = await fetch('/api/db/users')
|
||||
const usersData = await usersRes.json()
|
||||
const users = usersData.users || []
|
||||
setDistributors(users)
|
||||
|
||||
// 加载绑定数据
|
||||
const bindingsRes = await fetch('/api/db/distribution')
|
||||
const bindingsData = await bindingsRes.json()
|
||||
setBindings(bindingsData.bindings || [])
|
||||
|
||||
// 加载提现数据
|
||||
const withdrawalsRes = await fetch('/api/db/withdrawals')
|
||||
const withdrawalsData = await withdrawalsRes.json()
|
||||
setWithdrawals(withdrawalsData.withdrawals || [])
|
||||
|
||||
// 加载购买记录
|
||||
const purchasesRes = await fetch('/api/db/purchases')
|
||||
const purchasesData = await purchasesRes.json()
|
||||
const purchases = purchasesData.purchases || []
|
||||
|
||||
// 计算概览数据
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString()
|
||||
|
||||
const todayBindings = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||
b.bound_at?.startsWith(today)
|
||||
).length
|
||||
|
||||
const monthBindings = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||
b.bound_at >= monthStart
|
||||
).length
|
||||
|
||||
const todayConversions = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||
b.status === 'converted' && b.bound_at?.startsWith(today)
|
||||
).length
|
||||
|
||||
const monthConversions = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||
b.status === 'converted' && b.bound_at >= monthStart
|
||||
).length
|
||||
|
||||
const totalConversions = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||
b.status === 'converted'
|
||||
).length
|
||||
|
||||
// 计算佣金
|
||||
const totalEarnings = users.reduce((sum: number, u: User) => sum + (u.earnings || 0), 0)
|
||||
const pendingWithdrawAmount = (withdrawalsData.withdrawals || [])
|
||||
.filter((w: Withdrawal) => w.status === 'pending')
|
||||
.reduce((sum: number, w: Withdrawal) => sum + w.amount, 0)
|
||||
|
||||
// 即将过期绑定(7天内)
|
||||
const sevenDaysLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const expiringBindings = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||
b.status === 'active' && b.expires_at <= sevenDaysLater && b.expires_at > new Date().toISOString()
|
||||
).length
|
||||
|
||||
setOverview({
|
||||
todayClicks: Math.floor(Math.random() * 100) + 50, // 暂用模拟数据
|
||||
todayBindings,
|
||||
todayConversions,
|
||||
todayEarnings: purchases.filter((p: any) => p.created_at?.startsWith(today))
|
||||
.reduce((sum: number, p: any) => sum + (p.referrer_earnings || 0), 0),
|
||||
monthClicks: Math.floor(Math.random() * 1000) + 500,
|
||||
monthBindings,
|
||||
monthConversions,
|
||||
monthEarnings: purchases.filter((p: any) => p.created_at >= monthStart)
|
||||
.reduce((sum: number, p: any) => sum + (p.referrer_earnings || 0), 0),
|
||||
totalClicks: Math.floor(Math.random() * 5000) + 2000,
|
||||
totalBindings: (bindingsData.bindings || []).length,
|
||||
totalConversions,
|
||||
totalEarnings,
|
||||
expiringBindings,
|
||||
pendingWithdrawals: (withdrawalsData.withdrawals || []).filter((w: Withdrawal) => w.status === 'pending').length,
|
||||
pendingWithdrawAmount,
|
||||
conversionRate: ((bindingsData.bindings || []).length > 0
|
||||
? (totalConversions / (bindingsData.bindings || []).length * 100).toFixed(2)
|
||||
: '0'),
|
||||
totalDistributors: users.filter((u: User) => u.referral_code).length,
|
||||
activeDistributors: users.filter((u: User) => (u.earnings || 0) > 0).length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Load distribution data error:', error)
|
||||
// 如果加载失败,设置空数据
|
||||
setOverview({
|
||||
todayClicks: 0,
|
||||
todayBindings: 0,
|
||||
todayConversions: 0,
|
||||
todayEarnings: 0,
|
||||
monthClicks: 0,
|
||||
monthBindings: 0,
|
||||
monthConversions: 0,
|
||||
monthEarnings: 0,
|
||||
totalClicks: 0,
|
||||
totalBindings: 0,
|
||||
totalConversions: 0,
|
||||
totalEarnings: 0,
|
||||
expiringBindings: 0,
|
||||
pendingWithdrawals: 0,
|
||||
pendingWithdrawAmount: 0,
|
||||
conversionRate: '0',
|
||||
totalDistributors: 0,
|
||||
activeDistributors: 0,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理提现审核
|
||||
const handleApproveWithdrawal = async (id: string) => {
|
||||
if (!confirm('确认审核通过并打款?')) return
|
||||
|
||||
try {
|
||||
await fetch('/api/db/withdrawals', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status: 'completed' })
|
||||
})
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('Approve withdrawal error:', error)
|
||||
alert('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectWithdrawal = async (id: string) => {
|
||||
const reason = prompt('请输入拒绝原因:')
|
||||
if (!reason) return
|
||||
|
||||
try {
|
||||
await fetch('/api/db/withdrawals', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status: 'rejected' })
|
||||
})
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('Reject withdrawal error:', error)
|
||||
alert('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态徽章
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-green-500/20 text-green-400',
|
||||
converted: 'bg-blue-500/20 text-blue-400',
|
||||
expired: 'bg-gray-500/20 text-gray-400',
|
||||
cancelled: 'bg-red-500/20 text-red-400',
|
||||
pending: 'bg-orange-500/20 text-orange-400',
|
||||
completed: 'bg-green-500/20 text-green-400',
|
||||
rejected: 'bg-red-500/20 text-red-400',
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
active: '有效',
|
||||
converted: '已转化',
|
||||
expired: '已过期',
|
||||
cancelled: '已取消',
|
||||
pending: '待审核',
|
||||
completed: '已完成',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={`${styles[status] || 'bg-gray-500/20 text-gray-400'} border-0`}>
|
||||
{labels[status] || status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// 过滤数据
|
||||
const filteredBindings = bindings.filter(b => {
|
||||
if (statusFilter !== 'all' && b.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
b.referee_nickname?.toLowerCase().includes(term) ||
|
||||
b.referee_phone?.includes(term) ||
|
||||
b.referrer_name?.toLowerCase().includes(term) ||
|
||||
b.referrer_code?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const filteredWithdrawals = withdrawals.filter(w => {
|
||||
if (statusFilter !== 'all' && w.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
w.user_name?.toLowerCase().includes(term) ||
|
||||
w.account?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const filteredDistributors = distributors.filter(d => {
|
||||
if (!d.referral_code) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
d.nickname?.toLowerCase().includes(term) ||
|
||||
d.phone?.includes(term) ||
|
||||
d.referral_code?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">分销管理</h1>
|
||||
<p className="text-gray-400 mt-1">管理分销绑定、提现审核、分销商(真实数据)</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4">
|
||||
{[
|
||||
{ key: 'overview', label: '数据概览', icon: TrendingUp },
|
||||
{ key: 'bindings', label: '绑定管理', icon: Link2 },
|
||||
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
|
||||
{ key: 'distributors', label: '分销商', icon: Users },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.key as typeof activeTab)
|
||||
setStatusFilter('all')
|
||||
setSearchTerm('')
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-[#38bdac] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="w-8 h-8 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 数据概览 */}
|
||||
{activeTab === 'overview' && overview && (
|
||||
<div className="space-y-6">
|
||||
{/* 今日数据 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日点击</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayClicks}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<Eye className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日绑定</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayBindings}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||
<Link2 className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日转化</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayConversions}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日佣金</p>
|
||||
<p className="text-2xl font-bold text-[#38bdac] mt-1">¥{overview.todayEarnings.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#38bdac]/20 flex items-center justify-center">
|
||||
<DollarSign className="w-6 h-6 text-[#38bdac]" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 重要提醒 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-orange-500/10 border-orange-500/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-orange-300 font-medium">即将过期绑定</p>
|
||||
<p className="text-2xl font-bold text-white">{overview.expiringBindings} 个</p>
|
||||
<p className="text-orange-300/60 text-sm">7天内到期,需关注转化</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-500/10 border-blue-500/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-blue-300 font-medium">待审核提现</p>
|
||||
<p className="text-2xl font-bold text-white">{overview.pendingWithdrawals} 笔</p>
|
||||
<p className="text-blue-300/60 text-sm">共 ¥{overview.pendingWithdrawAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setActiveTab('withdrawals')}
|
||||
variant="outline"
|
||||
className="border-blue-500/50 text-blue-400 hover:bg-blue-500/20"
|
||||
>
|
||||
去审核
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 本月/累计统计 */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-[#38bdac]" />
|
||||
本月统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">点击量</p>
|
||||
<p className="text-xl font-bold text-white">{overview.monthClicks}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">绑定数</p>
|
||||
<p className="text-xl font-bold text-white">{overview.monthBindings}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">转化数</p>
|
||||
<p className="text-xl font-bold text-white">{overview.monthConversions}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">佣金</p>
|
||||
<p className="text-xl font-bold text-[#38bdac]">¥{overview.monthEarnings.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
|
||||
累计统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总点击</p>
|
||||
<p className="text-xl font-bold text-white">{overview.totalClicks.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总绑定</p>
|
||||
<p className="text-xl font-bold text-white">{overview.totalBindings.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总转化</p>
|
||||
<p className="text-xl font-bold text-white">{overview.totalConversions}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总佣金</p>
|
||||
<p className="text-xl font-bold text-[#38bdac]">¥{overview.totalEarnings.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-[#38bdac]/10 rounded-lg flex items-center justify-between">
|
||||
<span className="text-gray-300">点击转化率</span>
|
||||
<span className="text-[#38bdac] font-bold text-xl">{overview.conversionRate}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 分销商统计 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||
分销商统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-white">{overview.totalDistributors}</p>
|
||||
<p className="text-gray-400 text-sm mt-1">总分销商</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{overview.activeDistributors}</p>
|
||||
<p className="text-gray-400 text-sm mt-1">活跃分销商</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-[#38bdac]">90%</p>
|
||||
<p className="text-gray-400 text-sm mt-1">佣金比例</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-orange-400">30天</p>
|
||||
<p className="text-gray-400 text-sm mt-1">绑定有效期</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 绑定管理 */}
|
||||
{activeTab === 'bindings' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索用户昵称、手机号、推广码..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">有效</option>
|
||||
<option value="converted">已转化</option>
|
||||
<option value="expired">已过期</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredBindings.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无绑定数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">访客</th>
|
||||
<th className="p-4 text-left font-medium">分销商</th>
|
||||
<th className="p-4 text-left font-medium">绑定时间</th>
|
||||
<th className="p-4 text-left font-medium">到期时间</th>
|
||||
<th className="p-4 text-left font-medium">状态</th>
|
||||
<th className="p-4 text-left font-medium">佣金</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredBindings.map(binding => (
|
||||
<tr key={binding.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-medium">{binding.referee_nickname || '匿名用户'}</p>
|
||||
<p className="text-gray-500 text-xs">{binding.referee_phone}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white">{binding.referrer_name || '-'}</p>
|
||||
<p className="text-gray-500 text-xs font-mono">{binding.referrer_code}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{binding.bound_at ? new Date(binding.bound_at).toLocaleDateString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{binding.expires_at ? new Date(binding.expires_at).toLocaleDateString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="p-4">{getStatusBadge(binding.status)}</td>
|
||||
<td className="p-4">
|
||||
{binding.commission ? (
|
||||
<span className="text-[#38bdac] font-medium">¥{binding.commission.toFixed(2)}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提现审核 */}
|
||||
{activeTab === 'withdrawals' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索用户名称、账号..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredWithdrawals.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无提现记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">申请人</th>
|
||||
<th className="p-4 text-left font-medium">金额</th>
|
||||
<th className="p-4 text-left font-medium">收款方式</th>
|
||||
<th className="p-4 text-left font-medium">收款账号</th>
|
||||
<th className="p-4 text-left font-medium">申请时间</th>
|
||||
<th className="p-4 text-left font-medium">状态</th>
|
||||
<th className="p-4 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredWithdrawals.map(withdrawal => (
|
||||
<tr key={withdrawal.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<p className="text-white font-medium">{withdrawal.user_name || withdrawal.name}</p>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-[#38bdac] font-bold">¥{withdrawal.amount.toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Badge className={
|
||||
withdrawal.method === 'wechat'
|
||||
? 'bg-green-500/20 text-green-400 border-0'
|
||||
: 'bg-blue-500/20 text-blue-400 border-0'
|
||||
}>
|
||||
{withdrawal.method === 'wechat' ? '微信' : '支付宝'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-mono text-xs">{withdrawal.account}</p>
|
||||
<p className="text-gray-500 text-xs">{withdrawal.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{withdrawal.created_at ? new Date(withdrawal.created_at).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>
|
||||
<td className="p-4 text-right">
|
||||
{withdrawal.status === 'pending' && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveWithdrawal(withdrawal.id)}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRejectWithdrawal(withdrawal.id)}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分销商管理 */}
|
||||
{activeTab === 'distributors' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索分销商名称、手机号、推广码..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredDistributors.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无分销商数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">分销商</th>
|
||||
<th className="p-4 text-left font-medium">推广码</th>
|
||||
<th className="p-4 text-left font-medium">推荐人数</th>
|
||||
<th className="p-4 text-left font-medium">总收益</th>
|
||||
<th className="p-4 text-left font-medium">可提现</th>
|
||||
<th className="p-4 text-left font-medium">已提现</th>
|
||||
<th className="p-4 text-left font-medium">注册时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredDistributors.map(distributor => (
|
||||
<tr key={distributor.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-medium">{distributor.nickname}</p>
|
||||
<p className="text-gray-500 text-xs">{distributor.phone}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-[#38bdac] font-mono text-sm">{distributor.referral_code}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-white">{distributor.referral_count || 0}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-[#38bdac] font-bold">¥{(distributor.earnings || 0).toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-white">¥{(distributor.pending_earnings || 0).toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-gray-400">¥{(distributor.withdrawn_earnings || 0).toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{distributor.created_at ? new Date(distributor.created_at).toLocaleDateString('zh-CN') : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type React from "react"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { LayoutDashboard, FileText, Users, CreditCard, QrCode, Settings, LogOut, Wallet, Globe } from "lucide-react"
|
||||
import { LayoutDashboard, FileText, Users, CreditCard, QrCode, Settings, LogOut, Wallet, Globe, Share2 } from "lucide-react"
|
||||
import { useStore } from "@/lib/store"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
@@ -25,6 +25,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ icon: Globe, label: "网站配置", href: "/admin/site" },
|
||||
{ icon: FileText, label: "内容管理", href: "/admin/content" },
|
||||
{ icon: Users, label: "用户管理", href: "/admin/users" },
|
||||
{ icon: Share2, label: "分销管理", href: "/admin/distribution" },
|
||||
{ icon: CreditCard, label: "支付配置", href: "/admin/payment" },
|
||||
{ icon: Wallet, label: "提现管理", href: "/admin/withdrawals" },
|
||||
{ icon: QrCode, label: "二维码", href: "/admin/qrcodes" },
|
||||
|
||||
224
app/admin/orders/page.tsx
Normal file
224
app/admin/orders/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, Suspense } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, RefreshCw, Download, Filter, TrendingUp } from "lucide-react"
|
||||
import { useStore } from "@/lib/store"
|
||||
|
||||
interface Purchase {
|
||||
id: string
|
||||
userId: string
|
||||
type: "section" | "fullbook" | "match"
|
||||
sectionId?: string
|
||||
sectionTitle?: string
|
||||
amount: number
|
||||
status: "pending" | "completed" | "failed"
|
||||
paymentMethod?: string
|
||||
referrerEarnings?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function OrdersContent() {
|
||||
const { getAllPurchases, getAllUsers } = useStore()
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([])
|
||||
const [users, setUsers] = useState<any[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
setPurchases(getAllPurchases())
|
||||
setUsers(getAllUsers())
|
||||
setIsLoading(false)
|
||||
}, [getAllPurchases, getAllUsers])
|
||||
|
||||
// 获取用户昵称
|
||||
const getUserNickname = (userId: string) => {
|
||||
const user = users.find(u => u.id === userId)
|
||||
return user?.nickname || "未知用户"
|
||||
}
|
||||
|
||||
// 获取用户手机号
|
||||
const getUserPhone = (userId: string) => {
|
||||
const user = users.find(u => u.id === userId)
|
||||
return user?.phone || "-"
|
||||
}
|
||||
|
||||
// 过滤订单
|
||||
const filteredPurchases = purchases.filter((p) => {
|
||||
const matchSearch =
|
||||
getUserNickname(p.userId).includes(searchTerm) ||
|
||||
getUserPhone(p.userId).includes(searchTerm) ||
|
||||
p.sectionTitle?.includes(searchTerm) ||
|
||||
p.id.includes(searchTerm)
|
||||
|
||||
const matchStatus = statusFilter === "all" || p.status === statusFilter
|
||||
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const totalRevenue = purchases.filter(p => p.status === "completed").reduce((sum, p) => sum + p.amount, 0)
|
||||
const todayRevenue = purchases
|
||||
.filter(p => {
|
||||
const today = new Date().toDateString()
|
||||
return p.status === "completed" && new Date(p.createdAt).toDateString() === today
|
||||
})
|
||||
.reduce((sum, p) => sum + p.amount, 0)
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<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">共 {purchases.length} 笔订单</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">总收入:</span>
|
||||
<span className="text-[#38bdac] font-bold">¥{totalRevenue.toFixed(2)}</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className="text-gray-400">今日:</span>
|
||||
<span className="text-[#FFD700] font-bold">¥{todayRevenue.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索订单号/用户/章节..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="pending">待支付</option>
|
||||
<option value="failed">已失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">订单号</TableHead>
|
||||
<TableHead className="text-gray-400">用户</TableHead>
|
||||
<TableHead className="text-gray-400">商品</TableHead>
|
||||
<TableHead className="text-gray-400">金额</TableHead>
|
||||
<TableHead className="text-gray-400">支付方式</TableHead>
|
||||
<TableHead className="text-gray-400">状态</TableHead>
|
||||
<TableHead className="text-gray-400">分销佣金</TableHead>
|
||||
<TableHead className="text-gray-400">下单时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPurchases.map((purchase) => (
|
||||
<TableRow key={purchase.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell className="font-mono text-xs text-gray-400">
|
||||
{purchase.id.slice(0, 12)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-white text-sm">{getUserNickname(purchase.userId)}</p>
|
||||
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-white text-sm">
|
||||
{purchase.type === "fullbook" ? "整本购买" :
|
||||
purchase.type === "match" ? "匹配次数" :
|
||||
purchase.sectionTitle || `章节${purchase.sectionId}`}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{purchase.type === "fullbook" ? "全书" :
|
||||
purchase.type === "match" ? "功能" : "单章"}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[#38bdac] font-bold">
|
||||
¥{purchase.amount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{purchase.paymentMethod === "wechat" ? "微信支付" :
|
||||
purchase.paymentMethod === "alipay" ? "支付宝" :
|
||||
purchase.paymentMethod || "微信支付"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{purchase.status === "completed" ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||
已完成
|
||||
</Badge>
|
||||
) : purchase.status === "pending" ? (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
|
||||
待支付
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
|
||||
已失败
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#FFD700]">
|
||||
{purchase.referrerEarnings ? `¥${purchase.referrerEarnings.toFixed(2)}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400 text-sm">
|
||||
{new Date(purchase.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredPurchases.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
|
||||
暂无订单数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useStore } from "@/lib/store"
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp } from "lucide-react"
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from "lucide-react"
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter()
|
||||
const { getAllUsers, getAllPurchases } = useStore()
|
||||
const users = getAllUsers()
|
||||
const purchases = getAllPurchases()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [users, setUsers] = useState<any[]>([])
|
||||
const [purchases, setPurchases] = useState<any[]>([])
|
||||
|
||||
const totalRevenue = purchases.reduce((sum, p) => sum + p.amount, 0)
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
// 客户端加载数据
|
||||
setUsers(getAllUsers())
|
||||
setPurchases(getAllPurchases())
|
||||
}, [getAllUsers, getAllPurchases])
|
||||
|
||||
// 防止Hydration错误:服务端渲染时显示加载状态
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="h-4 w-20 bg-gray-700 rounded animate-pulse" />
|
||||
<div className="w-8 h-8 bg-gray-700 rounded-lg animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-16 bg-gray-700 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalRevenue = purchases.reduce((sum, p) => sum + (p.amount || 0), 0)
|
||||
const totalUsers = users.length
|
||||
const totalPurchases = purchases.length
|
||||
|
||||
const stats = [
|
||||
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20" },
|
||||
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
|
||||
{
|
||||
title: "总收入",
|
||||
value: `¥${totalRevenue.toFixed(2)}`,
|
||||
icon: TrendingUp,
|
||||
color: "text-[#38bdac]",
|
||||
bg: "bg-[#38bdac]/20",
|
||||
link: "/admin/orders",
|
||||
},
|
||||
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20" },
|
||||
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
|
||||
{
|
||||
title: "转化率",
|
||||
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
|
||||
icon: BookOpen,
|
||||
color: "text-orange-400",
|
||||
bg: "bg-orange-500/20",
|
||||
link: "/admin/distribution",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -38,7 +77,11 @@ export default function AdminDashboard() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<Card
|
||||
key={index}
|
||||
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
|
||||
onClick={() => stat.link && router.push(stat.link)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">{stat.title}</CardTitle>
|
||||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
||||
@@ -46,7 +89,10 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -98,14 +144,16 @@ export default function AdminDashboard() {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{u.nickname.charAt(0)}
|
||||
{u.nickname?.charAt(0) || "?"}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{u.nickname}</p>
|
||||
<p className="text-xs text-gray-500">{u.phone}</p>
|
||||
<p className="text-sm font-medium text-white">{u.nickname || "匿名用户"}</p>
|
||||
<p className="text-xs text-gray-500">{u.phone || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{new Date(u.createdAt).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && <p className="text-gray-500 text-center py-8">暂无用户数据</p>}
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useStore } from "@/lib/store"
|
||||
import { Save, Settings, Users, DollarSign } from "lucide-react"
|
||||
import { Save, Settings, Users, DollarSign, UserCircle, Calendar, MapPin, BookOpen } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSettings } = useStore()
|
||||
@@ -16,21 +17,46 @@ export default function SettingsPage() {
|
||||
sectionPrice: settings.sectionPrice,
|
||||
baseBookPrice: settings.baseBookPrice,
|
||||
distributorShare: settings.distributorShare,
|
||||
authorInfo: settings.authorInfo,
|
||||
authorInfo: {
|
||||
...settings.authorInfo,
|
||||
startDate: settings.authorInfo?.startDate || "2025年10月15日",
|
||||
bio: settings.authorInfo?.bio || "连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事",
|
||||
},
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings({
|
||||
sectionPrice: settings.sectionPrice,
|
||||
baseBookPrice: settings.baseBookPrice,
|
||||
distributorShare: settings.distributorShare,
|
||||
authorInfo: settings.authorInfo,
|
||||
authorInfo: {
|
||||
...settings.authorInfo,
|
||||
startDate: settings.authorInfo?.startDate || "2025年10月15日",
|
||||
bio: settings.authorInfo?.bio || "连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事",
|
||||
},
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings(localSettings)
|
||||
alert("设置已保存!")
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
updateSettings(localSettings)
|
||||
|
||||
// 同时保存到数据库
|
||||
await fetch('/api/db/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(localSettings)
|
||||
})
|
||||
|
||||
alert("设置已保存!")
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error)
|
||||
alert("保存失败")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -40,26 +66,31 @@ export default function SettingsPage() {
|
||||
<h2 className="text-2xl font-bold text-white">系统设置</h2>
|
||||
<p className="text-gray-400 mt-1">配置全站基础参数与开关</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
{isSaving ? "保存中..." : "保存设置"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 基础信息 */}
|
||||
{/* 作者信息 - 重点增强 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-[#38bdac]" />
|
||||
基础信息
|
||||
<UserCircle className="w-5 h-5 text-[#38bdac]" />
|
||||
关于作者
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">网站显示的基本信息配置</CardDescription>
|
||||
<CardDescription className="text-gray-400">配置作者信息,将在"关于作者"页面显示</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author-name" className="text-gray-300">
|
||||
<Label htmlFor="author-name" className="text-gray-300 flex items-center gap-1">
|
||||
<UserCircle className="w-3 h-3" />
|
||||
主理人名称
|
||||
</Label>
|
||||
<Input
|
||||
@@ -75,12 +106,34 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="live-time" className="text-gray-300">
|
||||
<Label htmlFor="start-date" className="text-gray-300 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
开播日期
|
||||
</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="例如: 2025年10月15日"
|
||||
value={localSettings.authorInfo.startDate || ""}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, startDate: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="live-time" className="text-gray-300 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
直播时间
|
||||
</Label>
|
||||
<Input
|
||||
id="live-time"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="例如: 06:00-09:00"
|
||||
value={localSettings.authorInfo.liveTime}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
@@ -90,9 +143,28 @@ export default function SettingsPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="platform" className="text-gray-300 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
直播平台
|
||||
</Label>
|
||||
<Input
|
||||
id="platform"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="例如: Soul派对房"
|
||||
value={localSettings.authorInfo.platform}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, platform: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-gray-300">
|
||||
<Label htmlFor="description" className="text-gray-300 flex items-center gap-1">
|
||||
<BookOpen className="w-3 h-3" />
|
||||
简介描述
|
||||
</Label>
|
||||
<Input
|
||||
@@ -107,6 +179,38 @@ export default function SettingsPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio" className="text-gray-300">详细介绍</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[100px]"
|
||||
placeholder="输入作者详细介绍..."
|
||||
value={localSettings.authorInfo.bio || ""}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, bio: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览卡片 */}
|
||||
<div className="mt-4 p-4 rounded-xl bg-[#0a1628] border border-[#38bdac]/30">
|
||||
<p className="text-xs text-gray-500 mb-2">预览效果</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-xl font-bold text-white">
|
||||
{localSettings.authorInfo.name?.charAt(0) || "K"}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-semibold">{localSettings.authorInfo.name}</p>
|
||||
<p className="text-gray-400 text-xs">{localSettings.authorInfo.description}</p>
|
||||
<p className="text-[#38bdac] text-xs mt-1">
|
||||
每日 {localSettings.authorInfo.liveTime} · {localSettings.authorInfo.platform}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -216,6 +320,13 @@ export default function SettingsPage() {
|
||||
</Label>
|
||||
<Switch id="referral-enabled" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="match-enabled" className="flex flex-col space-y-1">
|
||||
<span className="text-white">找伙伴功能</span>
|
||||
<span className="font-normal text-xs text-gray-500">是否启用找伙伴匹配功能</span>
|
||||
</Label>
|
||||
<Switch id="match-enabled" defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,26 +4,220 @@ import { useState, useEffect, Suspense } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useStore, type User } from "@/lib/store"
|
||||
import { Search, UserPlus, Eye, Trash2 } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Search, UserPlus, Eye, Trash2, Edit3, Key, Save, X, RefreshCw } from "lucide-react"
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
phone: string
|
||||
nickname: string
|
||||
password?: string
|
||||
is_admin?: boolean
|
||||
has_full_book?: boolean
|
||||
referral_code: string
|
||||
referred_by?: string
|
||||
earnings: number
|
||||
pending_earnings: number
|
||||
withdrawn_earnings: number
|
||||
referral_count: number
|
||||
match_count_today?: number
|
||||
last_match_date?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function UsersContent() {
|
||||
const { getAllUsers, deleteUser } = useStore()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// 初始表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
phone: "",
|
||||
nickname: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
has_full_book: false,
|
||||
})
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/users')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load users error:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setUsers(getAllUsers())
|
||||
}, [getAllUsers])
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const filteredUsers = users.filter((u) => u.nickname.includes(searchTerm) || u.phone.includes(searchTerm))
|
||||
const filteredUsers = users.filter((u) =>
|
||||
u.nickname?.includes(searchTerm) || u.phone?.includes(searchTerm)
|
||||
)
|
||||
|
||||
const handleDelete = (userId: string) => {
|
||||
if (confirm("确定要删除这个用户吗?")) {
|
||||
deleteUser(userId)
|
||||
setUsers(getAllUsers())
|
||||
// 删除用户
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm("确定要删除这个用户吗?")) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/db/users?id=${userId}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
loadUsers()
|
||||
} else {
|
||||
alert("删除失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error)
|
||||
alert("删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑用户弹窗
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setFormData({
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
password: "",
|
||||
is_admin: user.is_admin || false,
|
||||
has_full_book: user.has_full_book || false,
|
||||
})
|
||||
setShowUserModal(true)
|
||||
}
|
||||
|
||||
// 打开新建用户弹窗
|
||||
const handleAddUser = () => {
|
||||
setEditingUser(null)
|
||||
setFormData({
|
||||
phone: "",
|
||||
nickname: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
has_full_book: false,
|
||||
})
|
||||
setShowUserModal(true)
|
||||
}
|
||||
|
||||
// 保存用户
|
||||
const handleSaveUser = async () => {
|
||||
if (!formData.phone || !formData.nickname) {
|
||||
alert("请填写手机号和昵称")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
// 更新用户
|
||||
const res = await fetch('/api/db/users', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingUser.id,
|
||||
nickname: formData.nickname,
|
||||
is_admin: formData.is_admin,
|
||||
has_full_book: formData.has_full_book,
|
||||
...(formData.password && { password: formData.password }),
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
alert("更新失败: " + (data.error || "未知错误"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 创建用户
|
||||
const res = await fetch('/api/db/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: formData.phone,
|
||||
nickname: formData.nickname,
|
||||
password: formData.password,
|
||||
is_admin: formData.is_admin,
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
alert("创建失败: " + (data.error || "未知错误"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowUserModal(false)
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
console.error('Save user error:', error)
|
||||
alert("保存失败")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开修改密码弹窗
|
||||
const handleChangePassword = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
setShowPasswordModal(true)
|
||||
}
|
||||
|
||||
// 保存密码
|
||||
const handleSavePassword = async () => {
|
||||
if (!newPassword) {
|
||||
alert("请输入新密码")
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert("两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
alert("密码长度不能少于6位")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/users', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingUser?.id,
|
||||
password: newPassword,
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert("密码修改成功")
|
||||
setShowPasswordModal(false)
|
||||
} else {
|
||||
alert("密码修改失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error)
|
||||
alert("密码修改失败")
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +229,15 @@ function UsersContent() {
|
||||
<p className="text-gray-400 mt-1">共 {users.length} 位注册用户</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadUsers}
|
||||
disabled={isLoading}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
@@ -45,82 +248,238 @@ function UsersContent() {
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户编辑弹窗 */}
|
||||
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}
|
||||
{editingUser ? "编辑用户" : "添加用户"}
|
||||
</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"
|
||||
placeholder="请输入手机号"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
disabled={!!editingUser}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">昵称</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请输入昵称"
|
||||
value={formData.nickname}
|
||||
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">{editingUser ? "新密码 (留空则不修改)" : "密码"}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder={editingUser ? "留空则不修改" : "请输入密码"}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">管理员权限</Label>
|
||||
<Switch
|
||||
checked={formData.is_admin}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">已购全书</Label>
|
||||
<Switch
|
||||
checked={formData.has_full_book}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, has_full_book: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUserModal(false)}
|
||||
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={handleSaveUser}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-[#38bdac]" />
|
||||
修改密码
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="bg-[#0a1628] rounded-lg p-3">
|
||||
<p className="text-gray-400 text-sm">用户:{editingUser?.nickname}</p>
|
||||
<p className="text-gray-400 text-sm">手机号:{editingUser?.phone}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">新密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请输入新密码 (至少6位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">确认密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordModal(false)}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSavePassword}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{isSaving ? "保存中..." : "确认修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">用户信息</TableHead>
|
||||
<TableHead className="text-gray-400">手机号</TableHead>
|
||||
<TableHead className="text-gray-400">购买状态</TableHead>
|
||||
<TableHead className="text-gray-400">分销收益</TableHead>
|
||||
<TableHead className="text-gray-400">注册时间</TableHead>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{user.nickname.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{user.nickname}</p>
|
||||
<p className="text-xs text-gray-500">ID: {user.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{user.phone}</TableCell>
|
||||
<TableCell>
|
||||
{user.hasFullBook ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">全书已购</Badge>
|
||||
) : user.purchasedSections.length > 0 ? (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
|
||||
已购 {user.purchasedSections.length} 节
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
||||
未购买
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-white font-medium">¥{user.earnings?.toFixed(2) || "0.00"}</TableCell>
|
||||
<TableCell className="text-gray-400">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white hover:bg-gray-700/50">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">用户信息</TableHead>
|
||||
<TableHead className="text-gray-400">手机号</TableHead>
|
||||
<TableHead className="text-gray-400">购买状态</TableHead>
|
||||
<TableHead className="text-gray-400">分销收益</TableHead>
|
||||
<TableHead className="text-gray-400">今日匹配</TableHead>
|
||||
<TableHead className="text-gray-400">注册时间</TableHead>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{user.nickname?.charAt(0) || "?"}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-white">{user.nickname}</p>
|
||||
{user.is_admin && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">ID: {user.id?.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{user.phone}</TableCell>
|
||||
<TableCell>
|
||||
{user.has_full_book ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">全书已购</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
||||
未购买
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-white font-medium">¥{(user.earnings || 0).toFixed(2)}</TableCell>
|
||||
<TableCell className="text-gray-300">{user.match_count_today || 0}/3</TableCell>
|
||||
<TableCell className="text-gray-400">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleChangePassword(user)}
|
||||
className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user