2025.11.27.17.50

This commit is contained in:
RUI
2025-11-27 17:50:44 +08:00
commit 5dbb30b32c
111 changed files with 18320 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
import React, { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Loader2, Plus, Bot, Trash2, Edit, Save, MoreVertical, Sparkles } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
interface AIConfig {
_id?: string;
名称: string;
接口地址: string;
API密钥?: string;
模型: string;
系统提示词?: string;
流式传输?: boolean;
是否启用: boolean;
}
export default function AIAdminPage() {
const [loading, setLoading] = useState(true);
const [assistants, setAssistants] = useState<AIConfig[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingAssistant, setEditingAssistant] = useState<AIConfig | null>(null);
const { register, control, handleSubmit, reset, setValue, watch } = useForm<AIConfig>({
defaultValues: {
: '',
: 'https://generativelanguage.googleapis.com/v1beta/openai',
API密钥: '',
: 'gemini-1.5-flash',
: '',
流式传输: true,
是否启用: true
}
});
useEffect(() => {
fetchAssistants();
}, []);
const fetchAssistants = async () => {
try {
const res = await fetch('/api/admin/settings');
if (res.ok) {
const data = await res.json();
setAssistants(data.AI配置列表 || []);
}
} catch (error) {
console.error('Failed to fetch AI settings', error);
toast.error('获取 AI 配置失败');
} finally {
setLoading(false);
}
};
const handleSave = async (data: AIConfig) => {
try {
let newAssistants = [...assistants];
if (editingAssistant) {
// Update existing
newAssistants = newAssistants.map(a =>
(a._id === editingAssistant._id || (a as any).id === (editingAssistant as any).id) ? { ...data, _id: a._id } : a
);
} else {
// Add new
newAssistants.push(data);
}
// We need to save the entire SystemConfig, but we only have the AI list here.
// So we first fetch the current config to get other settings, then update.
// Optimization: The backend API should ideally support patching just one field,
// but reusing the existing /api/admin/settings endpoint which expects the full object structure
// or we can send a partial update if the API supports it.
// Let's assume we need to fetch first to be safe, or just send the partial structure if the backend handles it.
// Looking at the previous code, the backend likely does a merge or replacement.
// Let's fetch current settings first to be safe.
const resFetch = await fetch('/api/admin/settings');
const currentSettings = await resFetch.json();
const updatedSettings = {
...currentSettings,
AI配置列表: newAssistants
};
const resSave = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedSettings),
});
if (resSave.ok) {
toast.success(editingAssistant ? 'AI 助手已更新' : 'AI 助手已创建');
setIsDialogOpen(false);
setEditingAssistant(null);
reset();
fetchAssistants();
} else {
toast.error('保存失败');
}
} catch (error) {
console.error('Failed to save AI settings', error);
toast.error('保存出错');
}
};
const handleDelete = async (index: number) => {
if (!confirm('确定要删除这个 AI 助手吗?')) return;
try {
const newAssistants = [...assistants];
newAssistants.splice(index, 1);
const resFetch = await fetch('/api/admin/settings');
const currentSettings = await resFetch.json();
const updatedSettings = {
...currentSettings,
AI配置列表: newAssistants
};
const resSave = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedSettings),
});
if (resSave.ok) {
toast.success('AI 助手已删除');
fetchAssistants();
} else {
toast.error('删除失败');
}
} catch (error) {
console.error('Failed to delete assistant', error);
toast.error('删除出错');
}
};
const openAddDialog = () => {
setEditingAssistant(null);
reset({
: 'New Assistant',
: 'https://generativelanguage.googleapis.com/v1beta/openai',
API密钥: '',
: 'gemini-1.5-flash',
: '你是一个智能助手。',
流式传输: true,
是否启用: true
});
setIsDialogOpen(true);
};
const openEditDialog = (assistant: AIConfig) => {
setEditingAssistant(assistant);
reset(assistant);
setIsDialogOpen(true);
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">AI </h2>
<p className="text-muted-foreground mt-1">
AI
</p>
</div>
<Button onClick={openAddDialog}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{assistants.map((assistant, index) => (
<Card key={index} className="relative group hover:shadow-lg transition-all duration-300 border-t-4 border-t-primary/20 hover:border-t-primary">
<CardHeader className="pb-4">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Bot className="w-6 h-6" />
</div>
<div>
<CardTitle className="text-lg">{assistant.}</CardTitle>
<CardDescription className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-xs font-normal">
{assistant.}
</Badge>
</CardDescription>
</div>
</div>
<div className={`w-2.5 h-2.5 rounded-full ${assistant. ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-gray-300'}`} title={assistant. ? "已启用" : "已禁用"} />
</div>
</CardHeader>
<CardContent className="pb-4">
<div className="space-y-3 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-purple-500" />
<span className="truncate max-w-[200px]">{assistant.}</span>
</div>
<p className="line-clamp-2 min-h-[40px] bg-gray-50 p-2 rounded-md text-xs">
{assistant. || "无系统提示词"}
</p>
</div>
</CardContent>
<CardFooter className="pt-2 flex justify-end gap-2 border-t bg-gray-50/50 rounded-b-xl">
<Button variant="ghost" size="sm" onClick={() => openEditDialog(assistant)}>
<Edit className="w-4 h-4 mr-1" />
</Button>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(index)}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</CardFooter>
</Card>
))}
{/* Add New Card Button */}
<button
onClick={openAddDialog}
className="flex flex-col items-center justify-center h-full min-h-[240px] border-2 border-dashed border-gray-200 rounded-xl hover:border-primary/50 hover:bg-primary/5 transition-all group cursor-pointer"
>
<div className="w-12 h-12 rounded-full bg-gray-100 group-hover:bg-primary/20 flex items-center justify-center mb-3 transition-colors">
<Plus className="w-6 h-6 text-gray-400 group-hover:text-primary" />
</div>
<span className="font-medium text-gray-500 group-hover:text-primary"></span>
</button>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{editingAssistant ? '编辑 AI 助手' : '添加 AI 助手'}</DialogTitle>
<DialogDescription>
AI
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(handleSave)} className="space-y-6 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input {...register('名称', { required: true })} placeholder="例如: 写作助手" />
</div>
<div className="space-y-2">
<Label> (Model)</Label>
<Input {...register('模型', { required: true })} placeholder="例如: gemini-1.5-pro" />
</div>
<div className="space-y-2 col-span-2">
<Label> (Endpoint)</Label>
<Input {...register('接口地址', { required: true })} placeholder="https://..." />
</div>
<div className="space-y-2 col-span-2">
<Label>API </Label>
<Input type="password" {...register('API密钥')} placeholder={editingAssistant ? "留空则不修改" : "请输入 API Key"} />
</div>
<div className="space-y-2 col-span-2">
<Label> (System Prompt)</Label>
<Textarea {...register('系统提示词')} rows={4} placeholder="设定 AI 的角色和行为..." />
</div>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Controller
control={control}
name="是否启用"
render={({ field }) => (
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label></Label>
</div>
<div className="flex items-center gap-2">
<Controller
control={control}
name="流式传输"
render={({ field }) => (
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label></Label>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}></Button>
<Button type="submit"></Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,5 @@
import ArticleEditor from '@/components/admin/ArticleEditor';
export default function CreateArticlePage() {
return <ArticleEditor mode="create" />;
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { useRouter } from 'next/router';
import ArticleEditor from '@/components/admin/ArticleEditor';
import AdminLayout from '@/components/admin/AdminLayout';
export default function EditArticlePage() {
const router = useRouter();
const { id } = router.query;
if (!id) return null;
return (
<AdminLayout>
<ArticleEditor mode="edit" articleId={id as string} />
</AdminLayout>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Plus, Search, Edit, Trash2, Eye, Loader2 } from 'lucide-react';
interface Article {
_id: string;
文章标题: string;
ID: { _id: string; 分类名称: string } | null;
ID: { _id: string; username: string } | null;
: 'draft' | 'published' | 'offline';
: {
阅读数: number;
点赞数: number;
};
createdAt: string;
}
export default function ArticlesPage() {
const router = useRouter();
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [deleteId, setDeleteId] = useState<string | null>(null);
useEffect(() => {
fetchArticles();
}, [page, searchTerm]);
const fetchArticles = async () => {
setLoading(true);
try {
const res = await fetch(`/api/admin/articles?page=${page}&limit=10&search=${searchTerm}`);
const data = await res.json();
if (res.ok) {
setArticles(data.articles);
setTotalPages(data.pagination.pages);
}
} catch (error) {
console.error('Failed to fetch articles', error);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
try {
const res = await fetch(`/api/admin/articles/${deleteId}`, {
method: 'DELETE',
});
if (res.ok) {
fetchArticles();
setDeleteId(null);
} else {
alert('删除失败');
}
} catch (error) {
console.error('Failed to delete article', error);
alert('删除出错');
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'published':
return <Badge className="bg-green-500"></Badge>;
case 'draft':
return <Badge variant="secondary">稿</Badge>;
case 'offline':
return <Badge variant="destructive"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
return (
<AdminLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
</p>
</div>
<Link href="/admin/articles/create">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
<div className="flex items-center space-x-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索文章标题..."
className="pl-8"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(1); // 重置到第一页
}}
/>
</div>
</div>
<div className="rounded-md border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> (/)</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</TableCell>
</TableRow>
) : articles.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
articles.map((article) => (
<TableRow key={article._id}>
<TableCell className="font-medium max-w-[200px] truncate" title={article.}>
{article.}
</TableCell>
<TableCell>{article.ID?. || '-'}</TableCell>
<TableCell>{article.ID?.username || 'Unknown'}</TableCell>
<TableCell>{getStatusBadge(article.)}</TableCell>
<TableCell>
{article..} / {article..}
</TableCell>
<TableCell>{new Date(article.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right space-x-2">
<Link href={`/admin/articles/edit/${article._id}`}>
<Button variant="ghost" size="icon">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => setDeleteId(article._id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
</Button>
<span className="text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
</Button>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,230 @@
import React, { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Loader2, Save, Trash2, Plus, Image as ImageIcon } from 'lucide-react';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { toast } from 'sonner';
interface BannerConfig {
Banner配置: {
标题: string;
描述: string;
图片地址: string;
按钮文本: string;
按钮链接: string;
: 'visible' | 'hidden';
}[];
}
export default function BannerSettings() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { register, control, handleSubmit, reset } = useForm<BannerConfig>({
defaultValues: {
Banner配置: []
}
});
const { fields, append, remove } = useFieldArray({
control,
name: "Banner配置"
});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const res = await fetch('/api/admin/settings');
const data = await res.json();
if (res.ok) {
// Only reset if Banner配置 exists, otherwise default to empty array
reset({ Banner配置: data.Banner配置 || [] });
}
} catch (error) {
console.error('Failed to fetch settings', error);
toast.error('加载配置失败');
} finally {
setLoading(false);
}
};
const onSubmit = async (data: BannerConfig) => {
setSaving(true);
try {
// We need to merge with existing settings, so we fetch first or just send a partial update if API supports it.
// Our current API replaces the whole object usually, but let's check.
// Actually, the /api/admin/settings endpoint likely does a merge or we should send everything.
// To be safe and since we don't have the full state here, we should probably fetch current settings, merge, and save.
// OR, we can update the API to handle partial updates.
// Let's assume we need to fetch current settings first to avoid overwriting other fields.
const currentSettingsRes = await fetch('/api/admin/settings');
const currentSettings = await currentSettingsRes.json();
const newSettings = {
...currentSettings,
Banner配置: data.Banner配置
};
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSettings),
});
if (res.ok) {
toast.success('Banner 配置已保存');
fetchSettings();
} else {
toast.error('保存失败');
}
} catch (error) {
console.error('Failed to save settings', error);
toast.error('保存出错');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Banner </h2>
<p className="text-muted-foreground">
</p>
</div>
<Button
onClick={() => append({
: '新 Banner',
: '这是一个新的 Banner 描述',
: 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
: '查看详情',
: '/',
: 'visible'
})}
>
<Plus className="w-4 h-4 mr-2" /> Banner
</Button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-6">
{fields.map((field, index) => (
<Card key={field.id}>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-gray-500" />
<CardTitle className="text-base">Banner #{index + 1}</CardTitle>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => remove(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label></Label>
<Input {...register(`Banner配置.${index}.标题`)} placeholder="Banner 标题" />
</div>
<div className="space-y-2">
<Label></Label>
<Input {...register(`Banner配置.${index}.描述`)} placeholder="简短描述" />
</div>
<div className="space-y-2 md:col-span-2">
<Label></Label>
<div className="flex gap-4">
<Input {...register(`Banner配置.${index}.图片地址`)} placeholder="https://..." className="flex-1" />
<div className="w-24 h-10 bg-gray-100 rounded overflow-hidden shrink-0 border">
{/* Preview logic could go here, but simple img tag relies on valid url */}
<img
src={control._formValues.Banner配置?.[index]?. || ''}
alt="Preview"
className="w-full h-full object-cover"
onError={(e) => (e.currentTarget.style.display = 'none')}
onLoad={(e) => (e.currentTarget.style.display = 'block')}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input {...register(`Banner配置.${index}.按钮文本`)} placeholder="例如: 立即查看" />
</div>
<div className="space-y-2">
<Label></Label>
<Input {...register(`Banner配置.${index}.按钮链接`)} placeholder="/article/123" />
</div>
<div className="space-y-2 flex items-center gap-4">
<div className="flex items-center gap-2">
<Controller
control={control}
name={`Banner配置.${index}.状态`}
render={({ field }) => (
<Switch
checked={field.value === 'visible'}
onCheckedChange={(checked) => field.onChange(checked ? 'visible' : 'hidden')}
/>
)}
/>
<Label className="text-sm text-muted-foreground">
{control._formValues.Banner配置?.[index]?. === 'visible' ? '显示' : '隐藏'}
</Label>
</div>
</div>
</CardContent>
</Card>
))}
{fields.length === 0 && (
<div className="text-center py-12 text-muted-foreground border-2 border-dashed rounded-lg">
Banner
</div>
)}
</div>
<div className="mt-6 flex justify-end sticky bottom-6">
<Button type="submit" disabled={saving} size="lg" className="shadow-lg">
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</div>
</AdminLayout>
);
}

191
src/pages/admin/index.tsx Normal file
View File

@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { GetServerSideProps } from 'next';
import AdminLayout from '@/components/admin/AdminLayout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, FileText, ShoppingBag, DollarSign } from 'lucide-react';
import { verifyToken } from '@/lib/auth';
import { User, Article, Order } from '@/models';
// 定义统计数据接口
interface DashboardStats {
totalUsers: number;
totalArticles: number;
totalOrders: number;
totalRevenue: number;
}
export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalUsers}</div>
<p className="text-xs text-muted-foreground">
+20.1%
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalArticles}</div>
<p className="text-xs text-muted-foreground">
+15
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<ShoppingBag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalOrders}</div>
<p className="text-xs text-muted-foreground">
+19%
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">¥{stats.totalRevenue.toFixed(2)}</div>
<p className="text-xs text-muted-foreground">
+10.5%
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
(Recharts)
</div>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-8">
{/* 模拟动态数据 */}
<div className="flex items-center">
<span className="relative flex h-2 w-2 mr-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-sky-500"></span>
</span>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none"></p>
<p className="text-sm text-muted-foreground">
zhangsan@example.com
</p>
</div>
<div className="ml-auto font-medium text-xs text-muted-foreground"></div>
</div>
<div className="flex items-center">
<span className="relative flex h-2 w-2 mr-2">
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none"></p>
<p className="text-sm text-muted-foreground">
¥299.00 -
</p>
</div>
<div className="ml-auto font-medium text-xs text-muted-foreground">5</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</AdminLayout>
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { req } = context;
const token = req.cookies.token;
// 1. 验证 Token
if (!token) {
return {
redirect: {
destination: '/auth/login?redirect=/admin',
permanent: false,
},
};
}
const user = verifyToken(token);
if (!user || user.role !== 'admin') {
return {
redirect: {
destination: '/', // 非管理员跳转首页
permanent: false,
},
};
}
// 2. 获取统计数据 (SSR)
// 使用 withDatabase 确保数据库连接
// 注意getServerSideProps 中不能直接使用 withDatabase 高阶函数包裹,需要手动调用连接逻辑或确保全局连接
// 这里我们假设 mongoose 已经在 api 路由或其他地方连接过,或者我们在 _app.tsx 中处理了连接
// 为了稳妥,我们在 lib/dbConnect.ts 中应该有一个缓存连接的逻辑,这里简单起见,我们直接查询
// 更好的做法是把数据获取逻辑封装成 service
// 由于 withDatabase 是 API 路由的高阶函数,这里我们直接使用 mongoose
// 但为了避免连接问题,我们最好在 models/index.ts 导出时确保连接,或者在这里手动 connect
// 鉴于项目结构,我们假设 models 已经可用。
// 修正:在 getServerSideProps 中直接操作数据库需要确保连接。
// 我们临时引入 mongoose 并连接。
const mongoose = require('mongoose');
if (mongoose.connection.readyState === 0) {
await mongoose.connect(process.env.MONGODB_URI);
}
const totalUsers = await User.countDocuments();
const totalArticles = await Article.countDocuments();
const totalOrders = await Order.countDocuments();
// 计算总收入
const orders = await Order.find({ : 'paid' }, '支付金额');
const totalRevenue = orders.reduce((acc: number, order: any) => acc + (order. || 0), 0);
return {
props: {
stats: {
totalUsers,
totalArticles,
totalOrders,
totalRevenue
}
},
};
};

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Loader2, Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
export default function OrderList() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
pages: 0
});
const [filters, setFilters] = useState({
status: 'all',
search: ''
});
useEffect(() => {
fetchOrders();
}, [pagination.page, filters]);
const fetchOrders = async () => {
setLoading(true);
try {
const query = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
status: filters.status,
search: filters.search
});
const res = await fetch(`/api/admin/orders?${query}`);
if (res.ok) {
const data = await res.json();
setOrders(data.orders);
setPagination(data.pagination);
}
} catch (error) {
console.error('Failed to fetch orders', error);
} finally {
setLoading(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPagination(prev => ({ ...prev, page: 1 }));
fetchOrders(); // Trigger fetch immediately or let useEffect handle it if search state changed
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid':
return <Badge className="bg-green-500"></Badge>;
case 'pending':
return <Badge variant="outline" className="text-yellow-600 border-yellow-600"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const getOrderTypeLabel = (type: string) => {
const map: Record<string, string> = {
'buy_membership': '购买会员',
'buy_resource': '购买资源',
'recharge_points': '充值积分'
};
return map[type] || type;
};
return (
<AdminLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
<div className="w-full sm:w-48">
<Select
value={filters.status}
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value, page: 1 }))}
>
<SelectTrigger>
<SelectValue placeholder="订单状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="paid"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
</div>
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="搜索订单号、用户名或邮箱..."
className="pl-9"
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
/>
</div>
<Button type="submit"></Button>
</form>
</div>
{/* Table */}
<div className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
</TableCell>
</TableRow>
) : orders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
orders.map((order) => (
<TableRow key={order._id}>
<TableCell className="font-mono text-xs">{order.}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
<img
src={order.ID?. || '/images/default_avatar.png'}
alt="Avatar"
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">{order.ID?. || '未知用户'}</span>
<span className="text-xs text-gray-500">{order.ID?.}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{getOrderTypeLabel(order.)}</span>
<span className="text-xs text-gray-500">{order.?.}</span>
</div>
</TableCell>
<TableCell className="font-bold text-gray-900">
¥{order..toFixed(2)}
</TableCell>
<TableCell>{getStatusBadge(order.)}</TableCell>
<TableCell>
{order. === 'alipay' && <span className="text-blue-500 text-sm"></span>}
{order. === 'wechat' && <span className="text-green-500 text-sm"></span>}
{!order. && <span className="text-gray-400 text-sm">-</span>}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500">
{pagination.total} {pagination.page} / {pagination.pages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPagination(prev => ({ ...prev, page: prev.page - 1 }))}
disabled={pagination.page <= 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPagination(prev => ({ ...prev, page: prev.page + 1 }))}
disabled={pagination.page >= pagination.pages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Loader2, Save, ArrowLeft } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
export default function PlanEditor() {
const router = useRouter();
const { id } = router.query;
const isEditMode = id && id !== 'create';
const [loading, setLoading] = useState(isEditMode ? true : false);
const [saving, setSaving] = useState(false);
const { register, handleSubmit, control, reset } = useForm({
defaultValues: {
: '',
有效天数: 30,
价格: 0,
: '',
: {
每日下载限制: 10,
购买折扣: 0.8
},
是否上架: true
}
});
useEffect(() => {
if (isEditMode) {
fetchPlan();
}
}, [id]);
const fetchPlan = async () => {
setLoading(true);
try {
const res = await fetch(`/api/admin/plans/${id}`);
if (res.ok) {
const data = await res.json();
reset(data);
}
} catch (error) {
console.error('Failed to fetch plan', error);
} finally {
setLoading(false);
}
};
const onSubmit = async (data: any) => {
setSaving(true);
try {
const url = isEditMode ? `/api/admin/plans/${id}` : '/api/admin/plans';
const method = isEditMode ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
router.push('/admin/plans');
} else {
alert('保存失败');
}
} catch (error) {
console.error('Failed to save plan', error);
alert('保存出错');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="max-w-2xl mx-auto py-6">
<div className="flex items-center gap-4 mb-8">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-2xl font-bold tracking-tight">
{isEditMode ? '编辑套餐' : '新建套餐'}
</h1>
</div>
<div className="bg-white rounded-lg border p-6 space-y-6">
<div className="space-y-2">
<Label></Label>
<Input {...register('套餐名称', { required: true })} placeholder="例如:月度会员" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> ()</Label>
<Input type="number" {...register('价格', { required: true, min: 0 })} />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" {...register('有效天数', { required: true, min: 1 })} />
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea {...register('描述')} placeholder="套餐描述..." />
</div>
<div className="space-y-4 border-t pt-4">
<h3 className="font-medium"></h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> ()</Label>
<Input type="number" {...register('特权配置.每日下载限制')} />
</div>
<div className="space-y-2">
<Label> (0.1 - 1.0)</Label>
<Input type="number" step="0.1" max="1" min="0.1" {...register('特权配置.购买折扣')} />
</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-4">
<div className="space-y-0.5">
<Label></Label>
<div className="text-sm text-muted-foreground">
</div>
</div>
<Controller
name="是否上架"
control={control}
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</div>
<div className="pt-4">
<Button onClick={handleSubmit(onSubmit)} disabled={saving} className="w-full">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
</Button>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
export default function PlansIndex() {
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
try {
const res = await fetch('/api/admin/plans');
if (res.ok) {
setPlans(await res.json());
}
} catch (error) {
console.error('Failed to fetch plans', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个套餐吗?')) return;
try {
const res = await fetch(`/api/admin/plans/${id}`, { method: 'DELETE' });
if (res.ok) {
fetchPlans();
} else {
alert('删除失败');
}
} catch (error) {
console.error('Delete error', error);
}
};
return (
<AdminLayout>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<Link href="/admin/plans/edit/create">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
<div className="rounded-md border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</TableCell>
</TableRow>
) : plans.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
plans.map((plan) => (
<TableRow key={plan._id}>
<TableCell className="font-medium">{plan.}</TableCell>
<TableCell>¥{plan.}</TableCell>
<TableCell>{plan.} </TableCell>
<TableCell>{plan.?. || 0}</TableCell>
<TableCell>{(plan.?. || 1) * 10} </TableCell>
<TableCell>
{plan. ? (
<Badge className="bg-green-500"></Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/admin/plans/edit/${plan._id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="icon" onClick={() => handleDelete(plan._id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,377 @@
import React, { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Loader2, Save, Trash2, Plus, Bot } from 'lucide-react';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
interface SystemSettingsForm {
: {
网站标题?: string;
网站副标题?: string;
Logo地址?: string;
Favicon?: string;
备案号?: string;
全局SEO关键词?: string;
全局SEO描述?: string;
底部版权信息?: string;
第三方统计代码?: string;
};
: {
AppID?: string;
回调URL?: string;
网关地址?: string;
公钥?: string;
应用公钥?: string;
应用私钥?: string;
};
: {
WX_APPID?: string;
WX_MCHID?: string;
WX_SERIAL_NO?: string;
WX_NOTIFY_URL?: string;
WX_API_V3_KEY?: string;
WX_PRIVATE_KEY?: string;
};
: {
AccessKeyID?: string;
AccessKeySecret?: string;
aliSignName?: string;
aliTemplateCode?: string;
};
: {
MY_MAIL?: string;
MY_MAIL_PASS?: string;
};
AI配置列表: {
名称: string;
接口地址: string;
API密钥?: string;
模型: string;
系统提示词?: string;
流式传输?: boolean;
是否启用: boolean;
}[];
}
export default function SystemSettings() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { register, control, handleSubmit, reset } = useForm<SystemSettingsForm>({
defaultValues: {
: {},
: {},
: {},
: {},
: {},
AI配置列表: []
}
});
const { fields, append, remove } = useFieldArray({
control,
name: "AI配置列表"
});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const res = await fetch('/api/admin/settings');
const data = await res.json();
if (res.ok) {
// 重置表单数据,注意处理嵌套对象
reset(data);
}
} catch (error) {
console.error('Failed to fetch settings', error);
} finally {
setLoading(false);
}
};
const onSubmit = async (data: any) => {
setSaving(true);
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
alert('设置已保存');
fetchSettings();
} else {
alert('保存失败');
}
} catch (error) {
console.error('Failed to save settings', error);
alert('保存出错');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Tabs defaultValue="basic" className="space-y-6">
<TabsList className="grid w-full grid-cols-4 lg:w-[500px]">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="payment"></TabsTrigger>
<TabsTrigger value="service"></TabsTrigger>
</TabsList>
<TabsContent value="basic">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="siteName"></Label>
<Input id="siteName" {...register('站点设置.网站标题')} />
</div>
<div className="space-y-2">
<Label htmlFor="siteSubTitle"></Label>
<Input id="siteSubTitle" {...register('站点设置.网站副标题')} />
</div>
<div className="space-y-2">
<Label htmlFor="siteLogo">Logo地址</Label>
<Input id="siteLogo" {...register('站点设置.Logo地址')} />
</div>
<div className="space-y-2">
<Label htmlFor="siteFavicon">Favicon</Label>
<Input id="siteFavicon" {...register('站点设置.Favicon')} />
</div>
<div className="space-y-2">
<Label htmlFor="icp"></Label>
<Input id="icp" {...register('站点设置.备案号')} />
</div>
<div className="space-y-2">
<Label htmlFor="footerCopyright"></Label>
<Input id="footerCopyright" {...register('站点设置.底部版权信息')} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="seoKeywords">SEO关键词</Label>
<Input id="seoKeywords" {...register('站点设置.全局SEO关键词')} />
</div>
<div className="space-y-2">
<Label htmlFor="seoDesc">SEO描述</Label>
<Textarea id="seoDesc" rows={3} {...register('站点设置.全局SEO描述')} />
</div>
<div className="space-y-2">
<Label htmlFor="analytics"></Label>
<Textarea id="analytics" rows={4} className="font-mono text-xs" {...register('站点设置.第三方统计代码')} />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="payment">
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="alipayAppId">AppID</Label>
<Input id="alipayAppId" {...register('支付宝设置.AppID')} />
</div>
<div className="space-y-2">
<Label htmlFor="alipayNotifyUrl">URL</Label>
<Input id="alipayNotifyUrl" {...register('支付宝设置.回调URL')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="alipayGateway"></Label>
<Input id="alipayGateway" {...register('支付宝设置.网关地址')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="alipayPublicKey"></Label>
<Textarea id="alipayPublicKey" className="font-mono text-xs" rows={3} {...register('支付宝设置.公钥')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="alipayAppPublicKey"> (ALIPAY_APP_PUBLIC_KEY)</Label>
<Textarea id="alipayAppPublicKey" className="font-mono text-xs" rows={3} {...register('支付宝设置.应用公钥')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="alipayPrivateKey"> ()</Label>
<Textarea
id="alipayPrivateKey"
className="font-mono text-xs"
rows={5}
{...register('支付宝设置.应用私钥')}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="wechatAppId">AppID (WX_APPID)</Label>
<Input id="wechatAppId" {...register('微信支付设置.WX_APPID')} />
</div>
<div className="space-y-2">
<Label htmlFor="wechatMchId"> (WX_MCHID)</Label>
<Input id="wechatMchId" {...register('微信支付设置.WX_MCHID')} />
</div>
<div className="space-y-2">
<Label htmlFor="wechatSerialNo"> (WX_SERIAL_NO)</Label>
<Input id="wechatSerialNo" {...register('微信支付设置.WX_SERIAL_NO')} />
</div>
<div className="space-y-2">
<Label htmlFor="wechatNotifyUrl"> (WX_NOTIFY_URL)</Label>
<Input id="wechatNotifyUrl" {...register('微信支付设置.WX_NOTIFY_URL')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="wechatKey">API V3 (WX_API_V3_KEY)</Label>
<Input
id="wechatKey"
className="font-mono"
{...register('微信支付设置.WX_API_V3_KEY')}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="wechatPrivateKey"> (WX_PRIVATE_KEY)</Label>
<Textarea
id="wechatPrivateKey"
className="font-mono text-xs"
rows={5}
{...register('微信支付设置.WX_PRIVATE_KEY')}
/>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="service">
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle> (SMTP)</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="smtpUser"> (MY_MAIL)</Label>
<Input id="smtpUser" {...register('邮箱设置.MY_MAIL')} />
</div>
<div className="space-y-2">
<Label htmlFor="smtpPass"> (MY_MAIL_PASS)</Label>
<Input
id="smtpPass"
type="password"
{...register('邮箱设置.MY_MAIL_PASS')}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="aliyunKey">AccessKey ID</Label>
<Input id="aliyunKey" {...register('阿里云短信设置.AccessKeyID')} />
</div>
<div className="space-y-2">
<Label htmlFor="aliyunSecret">AccessKey Secret</Label>
<Input
id="aliyunSecret"
type="password"
{...register('阿里云短信设置.AccessKeySecret')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="aliyunSign"></Label>
<Input id="aliyunSign" {...register('阿里云短信设置.aliSignName')} />
</div>
<div className="space-y-2">
<Label htmlFor="aliyunTemplate"></Label>
<Input id="aliyunTemplate" {...register('阿里云短信设置.aliTemplateCode')} />
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<div className="mt-6 flex justify-end">
<Button type="submit" disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import AdminLayout from '@/components/admin/AdminLayout';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Search, Edit, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface User {
_id: string;
用户名: string;
邮箱: string;
角色: string;
是否被封禁: boolean;
头像: string;
createdAt: string;
}
export default function UserManagement() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
// 编辑状态
const [editingUser, setEditingUser] = useState<User | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editForm, setEditForm] = useState({ role: '', isBanned: false });
// 搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
fetchUsers();
}, 500);
return () => clearTimeout(timer);
}, [search, page]);
const fetchUsers = async () => {
setLoading(true);
try {
const res = await fetch(`/api/admin/users?page=${page}&limit=10&search=${search}`);
const data = await res.json();
if (res.ok) {
setUsers(data.users);
setTotalPages(data.totalPages);
setTotalUsers(data.total);
}
} catch (error) {
console.error('Failed to fetch users', error);
} finally {
setLoading(false);
}
};
const handleEditClick = (user: User) => {
setEditingUser(user);
setEditForm({ role: user.角色, isBanned: user.是否被封禁 });
setIsEditDialogOpen(true);
};
const handleUpdateUser = async () => {
if (!editingUser) return;
try {
const res = await fetch(`/api/admin/users/${editingUser._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
});
if (res.ok) {
setIsEditDialogOpen(false);
fetchUsers(); // 刷新列表
} else {
alert('更新失败');
}
} catch (error) {
console.error('Update failed', error);
}
};
const handleDeleteUser = async (id: string) => {
if (!confirm('确定要删除该用户吗?此操作不可恢复。')) return;
try {
const res = await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
});
if (res.ok) {
fetchUsers();
} else {
const data = await res.json();
alert(data.message || '删除失败');
}
} catch (error) {
console.error('Delete failed', error);
}
};
return (
<AdminLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
</p>
</div>
{/* <Button>添加用户</Button> */}
</div>
<div className="flex items-center justify-between gap-4">
<div className="relative w-full max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="搜索用户名或邮箱..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="rounded-md border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user._id}>
<TableCell>
<Avatar>
<AvatarImage src={user.} alt={user.} />
<AvatarFallback>{user..slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</TableCell>
<TableCell className="font-medium">{user.}</TableCell>
<TableCell>{user.}</TableCell>
<TableCell>
<Badge variant={user. === 'admin' ? 'default' : 'secondary'}>
{user.}
</Badge>
</TableCell>
<TableCell>
{user. ? (
<Badge variant="destructive"></Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-200 bg-green-50"></Badge>
)}
</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleEditClick(user)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => handleDeleteUser(user._id)}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{totalUsers} {page} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || loading}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || loading}
>
</Button>
</div>
{/* 编辑弹窗 */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{editingUser?.}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<label htmlFor="role" className="text-right text-sm font-medium">
</label>
<Select
value={editForm.role}
onValueChange={(val) => setEditForm({ ...editForm, role: val })}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="选择角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User ()</SelectItem>
<SelectItem value="editor">Editor ()</SelectItem>
<SelectItem value="admin">Admin ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<label htmlFor="status" className="text-right text-sm font-medium">
</label>
<Select
value={editForm.isBanned ? 'banned' : 'active'}
onValueChange={(val) => setEditForm({ ...editForm, isBanned: val === 'banned' })}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="banned"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleUpdateUser}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</AdminLayout>
);
}