Spaces:
Sleeping
Sleeping
| "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"> | |
| × | |
| </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"> | |
| × | |
| </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> | |
| ); | |
| } | |