trae-bot
Update project
426f2a4
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuthStore, useConfigStore } from "@/lib/store";
import { Plus, LayoutDashboard, ShoppingCart, Settings, BarChart3, Download } from "lucide-react";
import RichTextEditor from "@/components/RichTextEditor";
import * as XLSX from 'xlsx';
interface Course {
id: number;
title: string;
description?: string;
coverImage?: string;
driveLink?: string;
price: number;
category?: string;
viewCount?: number;
likeCount?: number;
starCount?: number;
}
interface Order {
id: number;
orderNo: string;
amount: number;
status: string;
createdAt: string;
course?: Course;
}
export default function AdminDashboard() {
const router = useRouter();
const { user, token } = useAuthStore();
const { uiConfig: globalUiConfig, setUiConfig: setGlobalUiConfig } = useConfigStore();
const [activeTab, setActiveTab] = useState<'courses' | 'orders' | 'settings' | 'statistics'>('courses');
const [courses, setCourses] = useState<Course[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
// Statistics State
const [statistics, setStatistics] = useState({ vipAmount: 0, donationAmount: 0, purchaseAmount: 0 });
const [statisticsDetails, setStatisticsDetails] = useState<any[]>([]);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'vip' | 'donation' | 'purchase'>('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// UI Config Form
const [uiConfig, setUiConfig] = useState({
siteName: '',
logo: '',
footerText: '',
heroBackground: '',
navLinks: [] as { label: string; value: string }[],
banners: [] as { imageUrl: string; linkUrl: string }[],
memberFee: 99,
});
// Course Form
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [coverImage, setCoverImage] = useState('');
const [driveLink, setDriveLink] = useState('');
const [price, setPrice] = useState('');
const [category, setCategory] = useState('');
const [viewCount, setViewCount] = useState<number | string>(0);
const [likeCount, setLikeCount] = useState<number | string>(0);
const [starCount, setStarCount] = useState<number | string>(0);
const [showAddForm, setShowAddForm] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [showSettings, setShowSettings] = useState(true);
const fetchData = useCallback(async () => {
try {
if (activeTab === 'courses') {
const res = await api.get('/api/courses'); // In real app, might want a specific admin endpoint to see inactive ones too
if (res.success) setCourses(res.data);
} else if (activeTab === 'orders') {
const res = await api.get('/api/admin/orders');
if (res.success) setOrders(res.data);
} else if (activeTab === 'settings') {
const res = await api.get('/api/config/ui');
if (res.success) setUiConfig(res.data);
} else if (activeTab === 'statistics') {
let url = '/api/admin/statistics';
const params = new URLSearchParams();
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
if (params.toString()) url += `?${params.toString()}`;
const res = await api.get(url);
if (res.success) setStatistics(res.data);
// Fetch details
let detailsUrl = '/api/admin/statistics/details';
const detailsParams = new URLSearchParams();
detailsParams.append('type', activeStatTab);
if (startDate) detailsParams.append('startDate', startDate);
if (endDate) detailsParams.append('endDate', endDate);
detailsUrl += `?${detailsParams.toString()}`;
const detailsRes = await api.get(detailsUrl);
if (detailsRes.success) setStatisticsDetails(detailsRes.data);
}
} catch (err) {
console.error(err);
}
}, [activeTab, startDate, endDate, activeStatTab]);
const handleSaveUiConfig = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await api.put('/api/config/ui', uiConfig);
if (res.success) {
alert('界面设置已保存,立即生效。');
setUiConfig(res.data);
setGlobalUiConfig(res.data);
}
} catch (err) {
console.error(err);
alert('保存设置失败');
}
};
const handleExportExcel = () => {
if (statisticsDetails.length === 0) {
alert('没有数据可导出');
return;
}
const dataToExport = statisticsDetails.map(item => {
const baseData: any = {
'时间': new Date(item.createdAt).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
}),
'订单号': item.orderNo,
'用户昵称': item.user?.nickname || item.user?.email || '未知用户',
'用户邮箱': item.user?.email || '-',
};
if (activeStatTab === 'purchase') {
baseData['分类'] = item.course?.category || '未分类';
baseData['课程名称'] = item.course?.title || '-';
}
baseData['金额(元)'] = Number(item.amount).toFixed(2);
return baseData;
});
const worksheet = XLSX.utils.json_to_sheet(dataToExport);
const workbook = XLSX.utils.book_new();
// Auto-size columns
const colWidths = Object.keys(dataToExport[0]).map(key => ({ wch: Math.max(key.length * 2, 15) }));
worksheet['!cols'] = colWidths;
const sheetNameMap: Record<string, string> = {
'all': '全部明细',
'vip': '会员费明细',
'donation': '打赏明细',
'purchase': '购买明细'
};
XLSX.utils.book_append_sheet(workbook, worksheet, sheetNameMap[activeStatTab]);
const fileName = `${sheetNameMap[activeStatTab]}_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, fileName);
};
useEffect(() => {
if (!token || user?.role !== 'admin') {
router.push('/login');
return;
}
fetchData();
}, [token, user, fetchData, router]);
const handleSubmitCourse = async (e: React.FormEvent) => {
e.preventDefault();
try {
const payload = {
title,
description,
coverImage,
driveLink,
price: Number(price),
category,
viewCount: Number(viewCount),
likeCount: Number(likeCount),
starCount: Number(starCount)
};
let res;
if (editingCourseId) {
res = await api.put(`/api/admin/courses/${editingCourseId}`, payload);
} else {
res = await api.post('/api/admin/courses', payload);
}
if (res.success) {
alert(editingCourseId ? '课程更新成功' : '课程创建成功');
setShowAddForm(false);
setEditingCourseId(null);
fetchData();
// Reset form
setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0); setLikeCount(0); setStarCount(0);
}
} catch (err) {
console.error(err);
alert(editingCourseId ? '更新课程失败' : '创建课程失败');
}
};
const handleEdit = (course: Course) => {
setEditingCourseId(course.id);
setTitle(course.title);
setDescription(course.description || '');
setCoverImage(course.coverImage || '');
setDriveLink(course.driveLink || '');
setPrice(course.price ? course.price.toString() : '');
setCategory(course.category || '');
setViewCount(course.viewCount || 0);
setLikeCount(course.likeCount || 0);
setStarCount(course.starCount || 0);
setShowAddForm(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('确定要删除该课程吗?删除后无法恢复。')) {
return;
}
try {
const res = await api.delete(`/api/admin/courses/${id}`);
if (res.success) {
alert('课程删除成功');
fetchData();
} else {
alert('删除失败');
}
} catch (err) {
console.error(err);
alert('删除失败');
}
};
return (
<div className="flex flex-col md:flex-row gap-8 min-h-[calc(100vh-12rem)]">
{/* Sidebar */}
<div className="w-full md:w-64 bg-white rounded-2xl shadow-sm border border-gray-100 p-6 self-start">
<div className="flex items-center gap-3 mb-8 px-2">
<div className="w-10 h-10 bg-blue-100 text-blue-600 rounded-xl flex items-center justify-center font-bold text-lg">
A
</div>
<div>
<h3 className="font-semibold text-gray-900">管理后台</h3>
<p className="text-xs text-gray-500">管理您的店铺</p>
</div>
</div>
<nav className="space-y-2">
<button
onClick={() => setActiveTab('courses')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'courses' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
>
<LayoutDashboard className="w-5 h-5" />
课程管理
</button>
<button
onClick={() => setActiveTab('orders')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'orders' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
>
<ShoppingCart className="w-5 h-5" />
订单管理
</button>
<button
onClick={() => setActiveTab('settings')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'settings' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
>
<Settings className="w-5 h-5" />
界面设置
</button>
<button
onClick={() => setActiveTab('statistics')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'statistics' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
>
<BarChart3 className="w-5 h-5" />
数据统计
</button>
</nav>
</div>
{/* Main Content */}
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
{activeTab === 'courses' && (
<div>
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-bold text-gray-900">课程管理</h2>
<button
onClick={() => {
if (showAddForm) {
setShowAddForm(false);
setEditingCourseId(null);
setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0); setLikeCount(0); setStarCount(0);
} else {
setShowAddForm(true);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
{showAddForm ? '取消' : <><Plus className="w-4 h-4" /> 添加课程</>}
</button>
</div>
{showAddForm && (
<form onSubmit={handleSubmitCourse} className="bg-white border border-gray-200 rounded-xl mb-8 flex flex-col md:flex-row overflow-hidden shadow-sm min-h-[600px]">
{/* Main Editor Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top Bar */}
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between bg-white">
<input
type="text"
required
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="输入文章/课程标题..."
className="w-full text-3xl font-bold text-gray-900 border-none outline-none focus:ring-0 placeholder:text-gray-300"
/>
<button type="button" onClick={() => setShowSettings(!showSettings)} className="ml-4 p-2 text-gray-500 hover:bg-gray-100 rounded-lg md:hidden">
<Settings className="w-5 h-5" />
</button>
</div>
{/* Rich Text Editor */}
<div className="flex-1 flex flex-col [&_.quill]:flex-1 [&_.quill]:flex [&_.quill]:flex-col [&_.ql-container]:flex-1 [&_.ql-editor]:min-h-[400px]">
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="从这里开始写正文..."
/>
</div>
</div>
{/* Sidebar Settings */}
<div className={`w-full md:w-80 bg-gray-50 border-l border-gray-200 flex flex-col ${showSettings ? 'block' : 'hidden md:flex'}`}>
<div className="p-4 border-b border-gray-200 font-medium text-gray-900 flex justify-between items-center bg-white">
文章设置
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-6">
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">封面图片</label>
{coverImage ? (
<div className="relative w-full aspect-video rounded-lg overflow-hidden border border-gray-200 mb-2">
<img src={coverImage} alt="Cover" className="w-full h-full object-cover" />
<button type="button" onClick={() => setCoverImage('')} className="absolute top-2 right-2 bg-black/50 text-white rounded-full p-1 hover:bg-black/70">
&times;
</button>
</div>
) : (
<div className="w-full aspect-video bg-white border border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50 cursor-pointer transition-colors relative mb-2">
<Plus className="w-6 h-6 mb-1 text-gray-400" />
<span className="text-sm">上传封面</span>
<input
type="file"
accept="image/*"
className="absolute inset-0 opacity-0 cursor-pointer"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await api.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (res.success) setCoverImage(res.url);
else alert('上传失败');
} catch (err) {
console.error(err);
alert('上传失败');
}
}}
/>
</div>
)}
<input type="text" value={coverImage} onChange={e => setCoverImage(e.target.value)} placeholder="或输入图片URL" className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 bg-white" />
</div>
{/* Price */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">价格 (¥)</label>
<input type="number" required value={price} onChange={e => setPrice(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0.00" />
</div>
{/* View Count */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">浏览人数设置</label>
<input type="number" value={viewCount} onChange={e => setViewCount(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0" />
</div>
{/* Like Count */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">点赞人数设置</label>
<input type="number" value={likeCount} onChange={e => setLikeCount(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0" />
</div>
{/* Star Count */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">收藏人数设置</label>
<input type="number" value={starCount} onChange={e => setStarCount(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0" />
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">分类</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white"
>
<option value="">请选择分类</option>
{globalUiConfig?.navLinks?.map(link => (
<option key={link.value} value={link.label}>{link.label}</option>
))}
</select>
</div>
{/* Drive Link */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">付费资料链接 (网盘)</label>
<textarea required value={driveLink} onChange={e => setDriveLink(e.target.value)} rows={3} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm" placeholder="输入百度网盘、阿里云盘等链接及提取码..."></textarea>
</div>
</div>
<div className="p-4 border-t border-gray-200 bg-white flex gap-3">
<button type="button" onClick={() => setShowAddForm(false)} className="flex-1 px-4 py-2 border border-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors">
取消
</button>
<button type="submit" className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors shadow-sm">
{editingCourseId ? '更新发布' : '立即发布'}
</button>
</div>
</div>
</form>
)}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-200 text-sm text-gray-500">
<th className="pb-3 font-medium">ID</th>
<th className="pb-3 font-medium">课程标题</th>
<th className="pb-3 font-medium">价格</th>
<th className="pb-3 font-medium">浏览人数</th>
<th className="pb-3 font-medium">分类</th>
<th className="pb-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{courses.map(course => (
<tr key={course.id} className="text-sm">
<td className="py-4 text-gray-500">#{course.id}</td>
<td className="py-4 font-medium text-gray-900">{course.title}</td>
<td className="py-4">¥{course.price}</td>
<td className="py-4 text-gray-500">{course.viewCount || 0}</td>
<td className="py-4"><span className="px-2 py-1 bg-gray-100 rounded-md text-xs">{course.category || 'N/A'}</span></td>
<td className="py-4">
<button onClick={() => handleEdit(course)} className="text-blue-600 hover:underline mr-3">编辑</button>
<button onClick={() => handleDelete(course.id)} className="text-red-600 hover:underline">删除</button>
</td>
</tr>
))}
{courses.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">未找到课程</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'orders' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-8">订单管理</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-200 text-sm text-gray-500">
<th className="pb-3 font-medium">订单号</th>
<th className="pb-3 font-medium">课程</th>
<th className="pb-3 font-medium">金额</th>
<th className="pb-3 font-medium">状态</th>
<th className="pb-3 font-medium">日期</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{orders.map(order => (
<tr key={order.id} className="text-sm">
<td className="py-4 text-gray-500">{order.orderNo}</td>
<td className="py-4 font-medium text-gray-900 line-clamp-1 max-w-[200px]">{order.course?.title}</td>
<td className="py-4">¥{order.amount}</td>
<td className="py-4">
<span className={`px-2 py-1 rounded-md text-xs font-medium ${
order.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{order.status.toUpperCase()}
</span>
</td>
<td className="py-4 text-gray-500">{new Date(order.createdAt).toLocaleDateString()}</td>
</tr>
))}
{orders.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">未找到订单</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'settings' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-8">界面设置</h2>
<form onSubmit={handleSaveUiConfig} className="max-w-2xl space-y-6">
<div className="space-y-4 bg-gray-50 p-6 rounded-xl border border-gray-100">
<h3 className="font-semibold text-gray-900">基础信息</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">站点名称</label>
<input
type="text"
value={uiConfig.siteName}
onChange={e => setUiConfig({ ...uiConfig, siteName: e.target.value })}
className="w-full px-4 py-2 rounded-lg border border-gray-200"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Logo URL (可选)</label>
<input
type="text"
value={uiConfig.logo}
onChange={e => setUiConfig({ ...uiConfig, logo: e.target.value })}
className="w-full px-4 py-2 rounded-lg border border-gray-200"
placeholder="输入Logo图片URL,若不填则显示站点名称"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">顶部背景图 URL (可选)</label>
<div className="space-y-2">
{uiConfig.heroBackground && (
<div className="relative w-full max-w-sm aspect-[3/1] rounded-lg overflow-hidden border border-gray-200">
<img src={uiConfig.heroBackground} alt="Hero Background" className="w-full h-full object-cover" />
<button type="button" onClick={() => setUiConfig({ ...uiConfig, heroBackground: '' })} className="absolute top-2 right-2 bg-black/50 text-white rounded-full p-1 hover:bg-black/70">
&times;
</button>
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={uiConfig.heroBackground || ''}
onChange={e => setUiConfig({ ...uiConfig, heroBackground: e.target.value })}
className="flex-1 px-4 py-2 rounded-lg border border-gray-200"
placeholder="输入图片URL,或使用右侧按钮上传"
/>
<label className="cursor-pointer px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors flex items-center">
上传
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await api.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (res.success) setUiConfig({ ...uiConfig, heroBackground: res.url });
else alert('上传失败');
} catch (err) {
console.error(err);
alert('上传失败');
}
}}
/>
</label>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">底部版权文案</label>
<input
type="text"
value={uiConfig.footerText}
onChange={e => setUiConfig({ ...uiConfig, footerText: e.target.value })}
className="w-full px-4 py-2 rounded-lg border border-gray-200"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">会员收费 (¥)</label>
<input
type="number"
value={uiConfig.memberFee ?? 99}
onChange={e => setUiConfig({ ...uiConfig, memberFee: Number(e.target.value) })}
className="w-full px-4 py-2 rounded-lg border border-gray-200"
min="0"
step="0.01"
required
/>
</div>
</div>
<div className="space-y-4 bg-gray-50 p-6 rounded-xl border border-gray-100">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-gray-900">导航栏与分类管理</h3>
<button
type="button"
onClick={() => setUiConfig({
...uiConfig,
navLinks: [...(uiConfig.navLinks || []), { label: '新分类', value: 'new-category' }]
})}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus className="w-4 h-4" /> 添加分类
</button>
</div>
<div className="space-y-3">
{(uiConfig.navLinks || []).map((link, idx) => (
<div key={idx} className="flex gap-3 items-center">
<input
type="text"
value={link.label}
onChange={e => {
const newLinks = [...uiConfig.navLinks];
newLinks[idx].label = e.target.value;
setUiConfig({ ...uiConfig, navLinks: newLinks });
}}
className="flex-1 px-4 py-2 rounded-lg border border-gray-200"
placeholder="显示名称 (如: AI新资讯)"
required
/>
<input
type="text"
value={link.value}
onChange={e => {
const newLinks = [...uiConfig.navLinks];
newLinks[idx].value = e.target.value;
setUiConfig({ ...uiConfig, navLinks: newLinks });
}}
className="flex-1 px-4 py-2 rounded-lg border border-gray-200"
placeholder="路由值 (如: ai-news)"
required
/>
<button
type="button"
onClick={() => {
const newLinks = [...uiConfig.navLinks];
newLinks.splice(idx, 1);
setUiConfig({ ...uiConfig, navLinks: newLinks });
}}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
>
删除
</button>
</div>
))}
</div>
</div>
<div className="space-y-4 bg-gray-50 p-6 rounded-xl border border-gray-100">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-gray-900">广告图管理</h3>
<button
type="button"
onClick={() => setUiConfig({
...uiConfig,
banners: [...(uiConfig.banners || []), { imageUrl: '', linkUrl: '' }]
})}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus className="w-4 h-4" /> 添加广告图
</button>
</div>
<div className="space-y-4">
{(uiConfig.banners || []).map((banner, idx) => (
<div key={idx} className="flex flex-col md:flex-row gap-3 items-start md:items-center bg-white p-4 rounded-lg border border-gray-200">
<div className="flex-1 w-full space-y-3">
<div className="flex gap-2 items-center">
{banner.imageUrl && (
<img src={banner.imageUrl} alt="banner preview" className="w-16 h-10 object-cover rounded" />
)}
<input
type="text"
value={banner.imageUrl}
onChange={e => {
const newBanners = [...(uiConfig.banners || [])];
newBanners[idx].imageUrl = e.target.value;
setUiConfig({ ...uiConfig, banners: newBanners });
}}
className="flex-1 px-3 py-2 rounded-md border border-gray-200 text-sm"
placeholder="图片 URL (或右侧上传)"
required
/>
<label className="cursor-pointer px-3 py-2 bg-gray-100 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-200 transition-colors">
上传
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await api.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (res.success) {
const newBanners = [...(uiConfig.banners || [])];
newBanners[idx].imageUrl = res.url;
setUiConfig({ ...uiConfig, banners: newBanners });
} else alert('上传失败');
} catch (err) {
console.error(err);
alert('上传失败');
}
}}
/>
</label>
</div>
<input
type="text"
value={banner.linkUrl}
onChange={e => {
const newBanners = [...(uiConfig.banners || [])];
newBanners[idx].linkUrl = e.target.value;
setUiConfig({ ...uiConfig, banners: newBanners });
}}
className="w-full px-3 py-2 rounded-md border border-gray-200 text-sm"
placeholder="跳转链接 URL"
/>
</div>
<button
type="button"
onClick={() => {
const newBanners = [...(uiConfig.banners || [])];
newBanners.splice(idx, 1);
setUiConfig({ ...uiConfig, banners: newBanners });
}}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg shrink-0 self-end md:self-auto"
>
删除
</button>
</div>
))}
{(!uiConfig.banners || uiConfig.banners.length === 0) && (
<div className="text-center py-4 text-gray-500 text-sm border-2 border-dashed border-gray-200 rounded-lg">
暂无广告图,点击右上角添加
</div>
)}
</div>
</div>
<div className="pt-4">
<button type="submit" className="px-6 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors">
保存设置
</button>
</div>
</form>
</div>
)}
{activeTab === 'statistics' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-8">数据统计</h2>
<div className="flex flex-wrap gap-4 mb-8 items-end">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">开始日期</label>
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
className="px-4 py-2 rounded-lg border border-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">结束日期</label>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
className="px-4 py-2 rounded-lg border border-gray-200"
/>
</div>
<button
onClick={fetchData}
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
查询
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div
className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'all' ? 'bg-blue-100 border-blue-300 ring-2 ring-blue-500/20' : 'bg-blue-50 border-blue-100 hover:bg-blue-100'}`}
onClick={() => setActiveStatTab('all')}
>
<div className="text-blue-600 text-sm font-medium mb-2">总计收入</div>
<div className="text-3xl font-bold text-gray-900">
¥{(statistics.vipAmount + statistics.donationAmount + statistics.purchaseAmount).toFixed(2)}
</div>
</div>
<div
className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'vip' ? 'bg-yellow-100 border-yellow-300 ring-2 ring-yellow-500/20' : 'bg-yellow-50 border-yellow-100 hover:bg-yellow-100'}`}
onClick={() => setActiveStatTab('vip')}
>
<div className="text-yellow-600 text-sm font-medium mb-2">会员费统计</div>
<div className="text-2xl font-bold text-gray-900">¥{statistics.vipAmount.toFixed(2)}</div>
</div>
<div
className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'donation' ? 'bg-green-100 border-green-300 ring-2 ring-green-500/20' : 'bg-green-50 border-green-100 hover:bg-green-100'}`}
onClick={() => setActiveStatTab('donation')}
>
<div className="text-green-600 text-sm font-medium mb-2">打赏统计</div>
<div className="text-2xl font-bold text-gray-900">¥{statistics.donationAmount.toFixed(2)}</div>
</div>
<div
className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'purchase' ? 'bg-purple-100 border-purple-300 ring-2 ring-purple-500/20' : 'bg-purple-50 border-purple-100 hover:bg-purple-100'}`}
onClick={() => setActiveStatTab('purchase')}
>
<div className="text-purple-600 text-sm font-medium mb-2">购买统计</div>
<div className="text-2xl font-bold text-gray-900">¥{statistics.purchaseAmount.toFixed(2)}</div>
</div>
</div>
<div className="bg-white border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="font-semibold text-gray-900">
{activeStatTab === 'all' && '全部明细'}
{activeStatTab === 'vip' && '会员费明细'}
{activeStatTab === 'donation' && '打赏明细'}
{activeStatTab === 'purchase' && '购买明细'}
</h3>
<button
onClick={handleExportExcel}
disabled={statisticsDetails.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" /> 导出 Excel
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse border border-gray-200">
<thead>
<tr className="bg-gray-50">
<th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">时间</th>
<th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">订单号</th>
<th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">用户</th>
{activeStatTab === 'purchase' && (
<>
<th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">分类</th>
<th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">课程名称</th>
</>
)}
<th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700 text-right">金额</th>
</tr>
</thead>
<tbody className="bg-white">
{statisticsDetails.map((item, idx) => (
<tr key={idx} className="text-sm hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6 text-gray-500 border border-gray-200">
{new Date(item.createdAt).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
})}
</td>
<td className="py-4 px-6 text-gray-500 font-mono text-xs border border-gray-200">{item.orderNo}</td>
<td className="py-4 px-6 border border-gray-200">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-xs shrink-0">
{item.user?.nickname?.charAt(0).toUpperCase() || 'U'}
</div>
<span className="font-medium text-gray-900 truncate max-w-[120px]" title={item.user?.nickname || item.user?.email}>
{item.user?.nickname || item.user?.email || '未知用户'}
</span>
</div>
</td>
{activeStatTab === 'purchase' && (
<>
<td className="py-4 px-6 border border-gray-200">
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-md text-xs font-medium whitespace-nowrap">
{item.course?.category || '未分类'}
</span>
</td>
<td className="py-4 px-6 text-gray-900 font-medium max-w-[200px] truncate border border-gray-200" title={item.course?.title || '-'}>
{item.course?.title || '-'}
</td>
</>
)}
<td className="py-4 px-6 font-semibold text-gray-900 text-right border border-gray-200">
¥{Number(item.amount).toFixed(2)}
</td>
</tr>
))}
{statisticsDetails.length === 0 && (
<tr>
<td colSpan={activeStatTab === 'purchase' ? 6 : 4} className="py-12 text-center text-gray-500 border border-gray-200">
<div className="flex flex-col items-center justify-center">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-3">
<span className="text-2xl text-gray-300">📊</span>
</div>
<p>该时间段内暂无数据</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
);
}