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:
卡若
2026-01-21 15:49:12 +08:00
parent 1ee25e3dab
commit b60edb3d47
197 changed files with 34430 additions and 7345 deletions

View File

@@ -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": "内容..."}]&#10;&#10;或直接粘贴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" />

View 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>
)
}

View 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>
)
}

View File

@@ -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
View 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>
)
}

View File

@@ -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>}

View File

@@ -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>

View File

@@ -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>