2025.11.27.17.50
This commit is contained in:
34
src/pages/404.tsx
Normal file
34
src/pages/404.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Home, MoveLeft } from 'lucide-react';
|
||||
|
||||
export default function Custom404() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 px-4 text-center">
|
||||
<div className="space-y-6 max-w-md">
|
||||
{/* Illustration placeholder or large text */}
|
||||
<h1 className="text-9xl font-extrabold text-gray-200">404</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900">页面未找到</h2>
|
||||
<p className="text-gray-500">
|
||||
抱歉,您访问的页面不存在或已被移除。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 pt-4">
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
<MoveLeft className="mr-2 h-4 w-4" />
|
||||
返回上一页
|
||||
</Button>
|
||||
<Link href="/">
|
||||
<Button>
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
回到首页
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/pages/_app.tsx
Normal file
14
src/pages/_app.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import '@/styles/globals.css';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { AuthProvider } from '@/hooks/useAuth';
|
||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ConfigProvider>
|
||||
<Component {...pageProps} />
|
||||
</ConfigProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
13
src/pages/_document.tsx
Normal file
13
src/pages/_document.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="zh">
|
||||
<Head />
|
||||
<body className="antialiased">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
315
src/pages/admin/ai/index.tsx
Normal file
315
src/pages/admin/ai/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/pages/admin/articles/create.tsx
Normal file
5
src/pages/admin/articles/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ArticleEditor from '@/components/admin/ArticleEditor';
|
||||
|
||||
export default function CreateArticlePage() {
|
||||
return <ArticleEditor mode="create" />;
|
||||
}
|
||||
17
src/pages/admin/articles/edit/[id].tsx
Normal file
17
src/pages/admin/articles/edit/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
src/pages/admin/articles/index.tsx
Normal file
238
src/pages/admin/articles/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
src/pages/admin/banners/index.tsx
Normal file
230
src/pages/admin/banners/index.tsx
Normal 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
191
src/pages/admin/index.tsx
Normal 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
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
232
src/pages/admin/orders/index.tsx
Normal file
232
src/pages/admin/orders/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/pages/admin/plans/edit/[id].tsx
Normal file
167
src/pages/admin/plans/edit/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/pages/admin/plans/index.tsx
Normal file
125
src/pages/admin/plans/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
377
src/pages/admin/settings/index.tsx
Normal file
377
src/pages/admin/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
src/pages/admin/users/index.tsx
Normal file
316
src/pages/admin/users/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/pages/aitools/index.tsx
Normal file
20
src/pages/aitools/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
export default function AIToolsIndex() {
|
||||
return (
|
||||
<AIToolsLayout>
|
||||
<div className="h-full min-h-[500px] bg-white rounded-2xl border border-gray-100 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<Sparkles className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">欢迎使用 AI 工具箱</h1>
|
||||
<p className="text-gray-500 max-w-md">
|
||||
请在左侧选择一个工具开始使用。<br />
|
||||
我们将持续更新更多实用的 AI 辅助工具,敬请期待。
|
||||
</p>
|
||||
</div>
|
||||
</AIToolsLayout>
|
||||
);
|
||||
}
|
||||
164
src/pages/aitools/prompt-optimizer/index.tsx
Normal file
164
src/pages/aitools/prompt-optimizer/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Sparkles, Copy, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function PromptOptimizer() {
|
||||
const [input, setInput] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!input.trim()) {
|
||||
toast.error('请输入需要优化的提示词');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: `作为一名专业的提示词工程师(Prompt Engineer),请优化以下提示词。
|
||||
|
||||
原始提示词:
|
||||
"${input}"
|
||||
|
||||
要求:
|
||||
1. 使用结构化的格式(如 Role, Context, Task, Constraints)。
|
||||
2. 语言精炼,指向明确。
|
||||
3. 如果原始提示词是中文,请保持中文;如果是英文,请保持英文。
|
||||
4. 直接输出优化后的提示词,不要包含解释或其他废话。`,
|
||||
stream: true
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Optimization failed');
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
const json = JSON.parse(data);
|
||||
if (json.content) {
|
||||
setOutput(prev => prev + json.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('优化失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(output);
|
||||
toast.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
return (
|
||||
<AIToolsLayout
|
||||
title="提示词优化师"
|
||||
description="将简单的指令转化为结构化、高质量的 AI 提示词。"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-180px)] min-h-[600px]">
|
||||
{/* Input Section */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900">原始提示词</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="例如:帮我写一篇关于咖啡的文章..."
|
||||
className="flex-1 resize-none border-gray-200 focus:border-purple-500 focus:ring-purple-500/20 p-4 text-base leading-relaxed"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={handleOptimize}
|
||||
disabled={loading || !input.trim()}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700 text-white h-10 text-sm font-medium shadow-lg shadow-purple-500/20"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
正在优化中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
立即优化
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Section */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full relative overflow-hidden">
|
||||
{/* Decorative Background */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-linear-to-br from-purple-500/5 to-transparent rounded-bl-full pointer-events-none" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4 relative z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-600">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900">优化结果</h2>
|
||||
</div>
|
||||
</div>
|
||||
{output && (
|
||||
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-purple-600 border-gray-200 h-8">
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
复制
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-100 p-4 overflow-y-auto relative z-10">
|
||||
{output ? (
|
||||
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap font-mono text-sm">
|
||||
{output}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<Sparkles className="w-10 h-10 mb-3 opacity-20" />
|
||||
<p className="text-xs">优化后的内容将显示在这里</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AIToolsLayout>
|
||||
);
|
||||
}
|
||||
196
src/pages/aitools/translator/index.tsx
Normal file
196
src/pages/aitools/translator/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Languages, Copy, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: '英语 (English)' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
{ value: 'ja', label: '日语 (Japanese)' },
|
||||
{ value: 'ko', label: '韩语 (Korean)' },
|
||||
{ value: 'fr', label: '法语 (French)' },
|
||||
{ value: 'de', label: '德语 (German)' },
|
||||
{ value: 'es', label: '西班牙语 (Spanish)' },
|
||||
];
|
||||
|
||||
export default function Translator() {
|
||||
const [input, setInput] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [targetLang, setTargetLang] = useState('en');
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!input.trim()) {
|
||||
toast.error('请输入需要翻译的内容');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setOutput(''); // Clear previous output
|
||||
try {
|
||||
const targetLangLabel = languages.find(l => l.value === targetLang)?.label;
|
||||
|
||||
const response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: `作为一名精通多国语言的专业翻译官,请将以下内容翻译成${targetLangLabel}。
|
||||
|
||||
待翻译内容:
|
||||
"${input}"
|
||||
|
||||
要求:
|
||||
1. 翻译准确、信达雅。
|
||||
2. 保持原文的语气和风格。
|
||||
3. 只输出翻译后的结果,不要包含任何解释或额外说明。`,
|
||||
stream: true
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Translation failed');
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
const json = JSON.parse(data);
|
||||
if (json.content) {
|
||||
setOutput(prev => prev + json.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('翻译失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(output);
|
||||
toast.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
return (
|
||||
<AIToolsLayout
|
||||
title="智能翻译助手"
|
||||
description="精准、地道的多语言互译工具。"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-180px)] min-h-[600px]">
|
||||
{/* Input Section */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center text-blue-600">
|
||||
<Languages className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900">原文</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="请输入需要翻译的内容..."
|
||||
className="flex-1 resize-none border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 p-4 text-base leading-relaxed"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={handleTranslate}
|
||||
disabled={loading || !input.trim()}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-10 text-sm font-medium shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
正在翻译...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightLeft className="w-4 h-4 mr-2" />
|
||||
开始翻译
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Section */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full relative overflow-hidden">
|
||||
{/* Decorative Background */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-linear-to-br from-blue-500/5 to-transparent rounded-bl-full pointer-events-none" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4 relative z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-gray-900">目标语言:</span>
|
||||
<Select value={targetLang} onValueChange={setTargetLang}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs border-gray-200 bg-white focus:ring-0 shadow-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map(lang => (
|
||||
<SelectItem key={lang.value} value={lang.value}>{lang.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{output && (
|
||||
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-blue-600 border-gray-200 h-8">
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
复制
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-100 p-4 overflow-y-auto relative z-10">
|
||||
{output ? (
|
||||
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap text-base leading-relaxed">
|
||||
{output}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<Languages className="w-10 h-10 mb-3 opacity-20" />
|
||||
<p className="text-xs">翻译结果将显示在这里</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AIToolsLayout>
|
||||
);
|
||||
}
|
||||
71
src/pages/api/admin/ai/models.ts
Normal file
71
src/pages/api/admin/ai/models.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { SystemConfig } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { endpoint, apiKey, assistantId } = req.body;
|
||||
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ message: 'Endpoint is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
let finalApiKey = apiKey;
|
||||
|
||||
// 如果没有提供 Key 但提供了 ID,尝试从数据库获取
|
||||
if (!finalApiKey && assistantId) {
|
||||
const config = await SystemConfig.findOne().select('+AI配置列表.API密钥').lean();
|
||||
if (config && config.AI配置列表) {
|
||||
const assistant = config.AI配置列表.find((a: any) => a._id.toString() === assistantId);
|
||||
if (assistant) {
|
||||
finalApiKey = assistant.API密钥;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalApiKey) {
|
||||
return res.status(400).json({ message: 'API Key is required' });
|
||||
}
|
||||
|
||||
// 处理 Endpoint,确保指向 /models
|
||||
// 假设用户填写的 endpoint 是 base url (e.g. https://api.openai.com/v1)
|
||||
// 或者完整的 chat url (e.g. https://api.openai.com/v1/chat/completions)
|
||||
let baseUrl = endpoint.replace(/\/+$/, '');
|
||||
if (baseUrl.endsWith('/chat/completions')) {
|
||||
baseUrl = baseUrl.replace('/chat/completions', '');
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/models`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${finalApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Fetch Models Error:', errorText);
|
||||
return res.status(response.status).json({ message: `Provider Error: ${response.statusText}` });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// OpenAI 格式返回 { data: [{ id: 'model-name', ... }] }
|
||||
const models = data.data ? data.data.map((m: any) => m.id) : [];
|
||||
|
||||
return res.status(200).json({ models });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch Models Internal Error:', error);
|
||||
return res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
49
src/pages/api/admin/articles/[id].ts
Normal file
49
src/pages/api/admin/articles/[id].ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Article } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { id } = req.query;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const article = await Article.findById(id)
|
||||
.populate('分类ID')
|
||||
.populate('标签ID列表')
|
||||
.lean();
|
||||
|
||||
if (!article) {
|
||||
return res.status(404).json({ message: '文章不存在' });
|
||||
}
|
||||
return res.status(200).json(article);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: '获取文章详情失败' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
try {
|
||||
const article = await Article.findByIdAndUpdate(id, req.body, { new: true, runValidators: true });
|
||||
if (!article) {
|
||||
return res.status(404).json({ message: '文章不存在' });
|
||||
}
|
||||
return res.status(200).json(article);
|
||||
} catch (error) {
|
||||
console.error('Update article error:', error);
|
||||
return res.status(500).json({ message: '更新文章失败' });
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
try {
|
||||
const article = await Article.findByIdAndDelete(id);
|
||||
if (!article) {
|
||||
return res.status(404).json({ message: '文章不存在' });
|
||||
}
|
||||
return res.status(200).json({ message: '文章已删除' });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: '删除文章失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
61
src/pages/api/admin/articles/index.ts
Normal file
61
src/pages/api/admin/articles/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Article, User } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const { page = 1, limit = 10, search = '' } = req.query;
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = parseInt(limit as string);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const query: any = {};
|
||||
if (search) {
|
||||
query.文章标题 = { $regex: search, $options: 'i' };
|
||||
}
|
||||
|
||||
const [articles, total] = await Promise.all([
|
||||
Article.find(query)
|
||||
.populate('作者ID', 'username email')
|
||||
.populate('分类ID', '分类名称')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limitNum)
|
||||
.lean(),
|
||||
Article.countDocuments(query)
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
articles,
|
||||
pagination: {
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
pages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch articles error:', error);
|
||||
return res.status(500).json({ message: '获取文章列表失败' });
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
try {
|
||||
const data = req.body;
|
||||
|
||||
// 自动设置作者为当前管理员
|
||||
data.作者ID = user.userId;
|
||||
|
||||
const article = await Article.create(data);
|
||||
return res.status(201).json(article);
|
||||
} catch (error) {
|
||||
console.error('Create article error:', error);
|
||||
return res.status(500).json({ message: '创建文章失败', error: (error as Error).message });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
20
src/pages/api/admin/categories/index.ts
Normal file
20
src/pages/api/admin/categories/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Category } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const categories = await Category.find().sort({ 排序权重: -1, createdAt: -1 }).lean();
|
||||
return res.status(200).json(categories);
|
||||
} catch (error) {
|
||||
console.error('Fetch categories error:', error);
|
||||
return res.status(500).json({ message: '获取分类失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
65
src/pages/api/admin/orders/index.ts
Normal file
65
src/pages/api/admin/orders/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Order, User } from '@/models';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const { page = 1, limit = 10, status, search } = req.query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
|
||||
const query: any = {};
|
||||
|
||||
if (status && status !== 'all') {
|
||||
query.订单状态 = status;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
// Find users matching the search term first
|
||||
const users = await User.find({
|
||||
$or: [
|
||||
{ 用户名: { $regex: search, $options: 'i' } },
|
||||
{ 邮箱: { $regex: search, $options: 'i' } }
|
||||
]
|
||||
}).select('_id');
|
||||
|
||||
const userIds = users.map(u => u._id);
|
||||
|
||||
query.$or = [
|
||||
{ 订单号: { $regex: search, $options: 'i' } },
|
||||
{ 用户ID: { $in: userIds } }
|
||||
];
|
||||
}
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
Order.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(Number(limit))
|
||||
.populate('用户ID', '用户名 邮箱 头像'),
|
||||
Order.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.status(200).json({
|
||||
orders,
|
||||
pagination: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
pages: Math.ceil(total / Number(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Fetch orders error:', error);
|
||||
res.status(500).json({ message: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
export default requireAdmin(handler);
|
||||
37
src/pages/api/admin/plans/[id].ts
Normal file
37
src/pages/api/admin/plans/[id].ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { MembershipPlan } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { id } = req.query;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const plan = await MembershipPlan.findById(id);
|
||||
if (!plan) return res.status(404).json({ message: 'Plan not found' });
|
||||
return res.status(200).json(plan);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Failed to fetch plan' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
try {
|
||||
const plan = await MembershipPlan.findByIdAndUpdate(id, req.body, { new: true });
|
||||
if (!plan) return res.status(404).json({ message: 'Plan not found' });
|
||||
return res.status(200).json(plan);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Failed to update plan' });
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
try {
|
||||
const plan = await MembershipPlan.findByIdAndDelete(id);
|
||||
if (!plan) return res.status(404).json({ message: 'Plan not found' });
|
||||
return res.status(200).json({ message: 'Plan deleted' });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Failed to delete plan' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
25
src/pages/api/admin/plans/index.ts
Normal file
25
src/pages/api/admin/plans/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { MembershipPlan } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const plans = await MembershipPlan.find().sort({ 价格: 1 });
|
||||
return res.status(200).json(plans);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Failed to fetch plans' });
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
try {
|
||||
const plan = await MembershipPlan.create(req.body);
|
||||
return res.status(201).json(plan);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Failed to create plan' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
72
src/pages/api/admin/settings.ts
Normal file
72
src/pages/api/admin/settings.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { SystemConfig } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
// 显式选择所有被隐藏的敏感字段
|
||||
const config = await SystemConfig.findOne().select(
|
||||
'+支付宝设置.AppID +支付宝设置.公钥 +支付宝设置.应用公钥 +支付宝设置.应用私钥 ' +
|
||||
'+微信支付设置.WX_APPID +微信支付设置.WX_MCHID +微信支付设置.WX_PRIVATE_KEY +微信支付设置.WX_API_V3_KEY ' +
|
||||
'+阿里云短信设置.AccessKeyID +阿里云短信设置.AccessKeySecret ' +
|
||||
'+邮箱设置.MY_MAIL_PASS'
|
||||
// 注意:AI配置列表.API密钥 默认不查询,保持安全
|
||||
).lean();
|
||||
|
||||
if (!config) {
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
return res.status(200).json(config);
|
||||
} catch (error) {
|
||||
console.error('Fetch settings error:', error);
|
||||
return res.status(500).json({ message: '获取系统配置失败' });
|
||||
}
|
||||
} else if (req.method === 'PUT') {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
// 特殊处理 AI配置列表 的 API密钥
|
||||
if (updates.AI配置列表 && Array.isArray(updates.AI配置列表)) {
|
||||
// 获取当前配置(包含敏感字段)
|
||||
const currentConfig = await SystemConfig.findOne().select('+AI配置列表.API密钥').lean();
|
||||
|
||||
if (currentConfig && currentConfig.AI配置列表) {
|
||||
updates.AI配置列表 = updates.AI配置列表.map((newItem: any) => {
|
||||
// 如果是新项目(没有_id),直接返回
|
||||
if (!newItem._id) return newItem;
|
||||
|
||||
// 查找旧项目
|
||||
const oldItem = currentConfig.AI配置列表.find((item: any) =>
|
||||
item._id.toString() === newItem._id
|
||||
);
|
||||
|
||||
// 如果找到了旧项目,且新项目的密钥为空,则保留旧密钥
|
||||
if (oldItem && !newItem.API密钥) {
|
||||
return { ...newItem, API密钥: oldItem.API密钥 };
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config = await SystemConfig.findOneAndUpdate(
|
||||
{},
|
||||
{ $set: updates },
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({ message: '配置已更新', config });
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error);
|
||||
return res.status(500).json({ message: '更新系统配置失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
20
src/pages/api/admin/tags/index.ts
Normal file
20
src/pages/api/admin/tags/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Tag } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const tags = await Tag.find().sort({ createdAt: -1 }).lean();
|
||||
return res.status(200).json(tags);
|
||||
} catch (error) {
|
||||
console.error('Fetch tags error:', error);
|
||||
return res.status(500).json({ message: '获取标签失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
56
src/pages/api/admin/users/[id].ts
Normal file
56
src/pages/api/admin/users/[id].ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { User } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, adminUser: any) {
|
||||
const { id } = req.query;
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
// 更新用户
|
||||
const { role, isBanned } = req.body;
|
||||
|
||||
try {
|
||||
const updatedUser = await User.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
角色: role,
|
||||
是否被封禁: isBanned
|
||||
},
|
||||
{ new: true }
|
||||
).select('-密码');
|
||||
|
||||
if (!updatedUser) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: '用户更新成功', user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('Update user error:', error);
|
||||
return res.status(500).json({ message: '更新用户失败' });
|
||||
}
|
||||
} else if (req.method === 'DELETE') {
|
||||
// 删除用户
|
||||
try {
|
||||
// 防止自杀
|
||||
if (id === adminUser.userId) {
|
||||
return res.status(400).json({ message: '无法删除自己' });
|
||||
}
|
||||
|
||||
const deletedUser = await User.findByIdAndDelete(id);
|
||||
|
||||
if (!deletedUser) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: '用户删除成功' });
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
return res.status(500).json({ message: '删除用户失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
46
src/pages/api/admin/users/index.ts
Normal file
46
src/pages/api/admin/users/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { User } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { page = 1, limit = 10, search = '' } = req.query;
|
||||
const pageNum = parseInt(page as string, 10);
|
||||
const limitNum = parseInt(limit as string, 10);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const query: any = {};
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ 用户名: { $regex: search, $options: 'i' } },
|
||||
{ 邮箱: { $regex: search, $options: 'i' } },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
const [users, total] = await Promise.all([
|
||||
User.find(query)
|
||||
.select('-密码') // 不返回密码
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limitNum),
|
||||
User.countDocuments(query),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
users,
|
||||
total,
|
||||
page: pageNum,
|
||||
totalPages: Math.ceil(total / limitNum),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch users error:', error);
|
||||
return res.status(500).json({ message: '获取用户列表失败' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
135
src/pages/api/ai/generate.ts
Normal file
135
src/pages/api/ai/generate.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { SystemConfig } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false, // Disable body parser for streaming
|
||||
},
|
||||
};
|
||||
|
||||
async function readBody(req: NextApiRequest) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Manually parse body since bodyParser is disabled
|
||||
const bodyStr = await readBody(req);
|
||||
const { prompt, assistantId, systemPrompt } = JSON.parse(bodyStr);
|
||||
|
||||
if (!prompt) {
|
||||
return res.status(400).json({ message: 'Prompt is required' });
|
||||
}
|
||||
|
||||
// 1. 获取系统配置
|
||||
const config = await SystemConfig.findOne().select('+AI配置列表.API密钥 +AI配置列表.流式传输').lean();
|
||||
|
||||
if (!config || !config.AI配置列表 || config.AI配置列表.length === 0) {
|
||||
return res.status(404).json({ message: 'No AI assistants configured' });
|
||||
}
|
||||
|
||||
// 2. 选择 AI 助手
|
||||
let assistant;
|
||||
if (assistantId) {
|
||||
assistant = config.AI配置列表.find((a: any) => a._id.toString() === assistantId && a.是否启用);
|
||||
} else {
|
||||
assistant = config.AI配置列表.find((a: any) => a.是否启用);
|
||||
}
|
||||
|
||||
if (!assistant) {
|
||||
return res.status(404).json({ message: 'Selected AI assistant not found or disabled' });
|
||||
}
|
||||
|
||||
// 3. 构建请求
|
||||
const messages = [];
|
||||
const sysPrompt = systemPrompt || assistant.系统提示词;
|
||||
if (sysPrompt) {
|
||||
messages.push({ role: 'system', content: sysPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const apiEndpoint = assistant.接口地址.replace(/\/+$/, '');
|
||||
const url = apiEndpoint.endsWith('/chat/completions')
|
||||
? apiEndpoint
|
||||
: `${apiEndpoint}/chat/completions`;
|
||||
|
||||
const isStream = assistant.流式传输 === true;
|
||||
|
||||
// 4. 发起请求
|
||||
const aiRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${assistant.API密钥}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: assistant.模型,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
stream: isStream, // Use the stream setting
|
||||
})
|
||||
});
|
||||
|
||||
if (!aiRes.ok) {
|
||||
const errorText = await aiRes.text();
|
||||
console.error('AI API Error:', errorText);
|
||||
return res.status(aiRes.status).json({ message: `AI Provider Error: ${aiRes.statusText}` });
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
// Handle Streaming Response
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
if (!aiRes.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = aiRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
// Just forward the raw chunk from OpenAI to the client
|
||||
// The client will handle parsing the "data: {...}" format
|
||||
res.write(chunk);
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error('Stream Error:', streamError);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
// Handle Normal Response
|
||||
const data = await aiRes.json();
|
||||
const generatedText = data.choices?.[0]?.message?.content || '';
|
||||
return res.status(200).json({ text: generatedText });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI Generate Error:', error);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(requireAdmin(handler));
|
||||
122
src/pages/api/articles/[id].ts
Normal file
122
src/pages/api/articles/[id].ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Article, User, Order } from '@/models';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import rehypePrettyCode from 'rehype-pretty-code';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
|
||||
async function processMarkdown(content: string) {
|
||||
if (!content) return '';
|
||||
const file = await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype)
|
||||
.use(rehypePrettyCode, {
|
||||
theme: 'github-dark',
|
||||
keepBackground: true,
|
||||
})
|
||||
.use(rehypeStringify)
|
||||
.process(content);
|
||||
return String(file);
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
const { id } = req.query;
|
||||
|
||||
// 1. Fetch Article
|
||||
const article = await Article.findById(id)
|
||||
.populate('作者ID', '用户名 头像')
|
||||
.populate('分类ID', '分类名称 别名')
|
||||
.populate('标签ID列表', '标签名称');
|
||||
|
||||
if (!article) {
|
||||
return res.status(404).json({ message: 'Article not found' });
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
article.统计数据.阅读数 += 1;
|
||||
await article.save({ validateBeforeSave: false });
|
||||
|
||||
// 2. Check Permissions
|
||||
let hasAccess = false;
|
||||
let userId = null;
|
||||
|
||||
// Get user from token if exists
|
||||
const token = req.cookies.token;
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = verifyToken(token);
|
||||
if (decoded) {
|
||||
userId = decoded.userId;
|
||||
|
||||
// Check if admin or author
|
||||
if (decoded.role === 'admin' || decoded.userId === article.作者ID?._id?.toString()) {
|
||||
hasAccess = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid token, treat as guest
|
||||
}
|
||||
}
|
||||
|
||||
// If article is free, everyone has access
|
||||
if (article.价格 === 0) {
|
||||
hasAccess = true;
|
||||
}
|
||||
|
||||
// If not yet accessible and user is logged in, check purchase/membership
|
||||
if (!hasAccess && userId) {
|
||||
const user = await User.findById(userId);
|
||||
|
||||
// Check Membership
|
||||
if (user?.会员信息?.过期时间 && new Date(user.会员信息.过期时间) > new Date()) {
|
||||
hasAccess = true;
|
||||
}
|
||||
|
||||
// Check if purchased
|
||||
if (!hasAccess) {
|
||||
const order = await Order.findOne({
|
||||
用户ID: userId,
|
||||
商品ID: article._id.toString(),
|
||||
订单状态: 'paid',
|
||||
订单类型: 'buy_resource'
|
||||
});
|
||||
if (order) {
|
||||
hasAccess = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Prepare Response
|
||||
const articleData = article.toObject();
|
||||
|
||||
// Process Markdown to HTML
|
||||
const htmlContent = await processMarkdown(articleData.正文内容);
|
||||
articleData.正文内容 = htmlContent; // Replace content with HTML
|
||||
|
||||
if (!hasAccess) {
|
||||
// Hide sensitive content
|
||||
delete articleData.资源属性;
|
||||
// Optional: Truncate content for preview
|
||||
articleData.正文内容 = await processMarkdown(article.正文内容.substring(0, 300) + '...');
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
article: articleData,
|
||||
hasAccess,
|
||||
isLoggedIn: !!userId
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Fetch article error:', error);
|
||||
res.status(500).json({ message: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
65
src/pages/api/articles/index.ts
Normal file
65
src/pages/api/articles/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Article, Category } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const { page = 1, limit = 9, category, tag, search } = req.query;
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = parseInt(limit as string);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const query: any = { 发布状态: 'published' };
|
||||
|
||||
if (category) {
|
||||
// 如果传入的是分类ID,直接查询;如果是别名,先查分类ID
|
||||
if (category.length === 24) {
|
||||
query.分类ID = category;
|
||||
} else {
|
||||
const catDoc = await Category.findOne({ 别名: category });
|
||||
if (catDoc) query.分类ID = catDoc._id;
|
||||
}
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
// 暂不支持按标签别名查,假设传入的是ID或暂不处理复杂逻辑
|
||||
// 实际项目中通常需要关联查询 Tag 表
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.文章标题 = { $regex: search, $options: 'i' };
|
||||
}
|
||||
|
||||
const [articles, total] = await Promise.all([
|
||||
Article.find(query)
|
||||
.populate('作者ID', 'username avatar') // 假设 User 模型有 avatar 字段,如果没有需确认
|
||||
.populate('分类ID', '分类名称 别名')
|
||||
.populate('标签ID列表', '标签名称')
|
||||
.select('-正文内容 -资源属性 -SEO关键词 -SEO描述') // 列表页不需要这些大字段
|
||||
.sort({ createdAt: -1 }) // 最新发布在前
|
||||
.skip(skip)
|
||||
.limit(limitNum)
|
||||
.lean(),
|
||||
Article.countDocuments(query)
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
articles,
|
||||
pagination: {
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
pages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch public articles error:', error);
|
||||
return res.status(500).json({ message: '获取文章列表失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
64
src/pages/api/auth/login.ts
Normal file
64
src/pages/api/auth/login.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { User } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: '请输入邮箱和密码' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 查找用户,显式选择密码字段
|
||||
const user = await User.findOne({ 邮箱: email }).select('+密码');
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: '邮箱或密码错误' });
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(password, user.密码);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({ message: '邮箱或密码错误' });
|
||||
}
|
||||
|
||||
// 生成 JWT
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id, email: user.邮箱, role: user.角色 },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// 设置 Cookie
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
`token=${token}; Path=/; HttpOnly; Max-Age=${60 * 60 * 24 * 7}; SameSite=Strict; ${process.env.NODE_ENV === 'production' ? 'Secure' : ''}`
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
message: '登录成功',
|
||||
user: {
|
||||
id: user._id,
|
||||
username: user.用户名,
|
||||
email: user.邮箱,
|
||||
role: user.角色,
|
||||
avatar: user.头像
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ message: '登录失败,请稍后重试' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
12
src/pages/api/auth/logout.ts
Normal file
12
src/pages/api/auth/logout.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Clear the cookie by setting it to expire in the past
|
||||
res.setHeader('Set-Cookie', 'token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Strict');
|
||||
|
||||
res.status(200).json({ message: 'Logged out successfully' });
|
||||
}
|
||||
36
src/pages/api/auth/me.ts
Normal file
36
src/pages/api/auth/me.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { User } from '@/models';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: 'Invalid token' });
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
const user = await User.findById(decoded.userId)
|
||||
.select('-密码') // Exclude password
|
||||
.populate('会员信息.当前等级ID', '套餐名称');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
res.status(200).json({ user });
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
49
src/pages/api/auth/register.ts
Normal file
49
src/pages/api/auth/register.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { User } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { email, password, username } = req.body;
|
||||
|
||||
if (!email || !password || !username) {
|
||||
return res.status(400).json({ message: '请填写所有必填项' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查邮箱是否已存在
|
||||
const existingUser = await User.findOne({ 邮箱: email });
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ message: '该邮箱已被注册' });
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUsername = await User.findOne({ 用户名: username });
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ message: '该用户名已被使用' });
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 创建新用户
|
||||
const newUser = await User.create({
|
||||
用户名: username,
|
||||
邮箱: email,
|
||||
密码: hashedPassword,
|
||||
角色: 'user', // 默认为普通用户
|
||||
头像: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}`,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: '注册成功', userId: newUser._id });
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
return res.status(500).json({ message: '注册失败,请稍后重试' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
19
src/pages/api/categories/index.ts
Normal file
19
src/pages/api/categories/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Category } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const categories = await Category.find().sort({ 排序权重: -1, createdAt: -1 }).lean();
|
||||
return res.status(200).json(categories);
|
||||
} catch (error) {
|
||||
console.error('Fetch public categories error:', error);
|
||||
return res.status(500).json({ message: '获取分类失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
59
src/pages/api/comments/index.ts
Normal file
59
src/pages/api/comments/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Comment } from '@/models';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await dbConnect();
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const { articleId } = req.query;
|
||||
if (!articleId) {
|
||||
return res.status(400).json({ message: 'Article ID required' });
|
||||
}
|
||||
|
||||
const comments = await Comment.find({ 文章ID: articleId, 状态: 'visible' })
|
||||
.populate('用户ID', '用户名 头像')
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50);
|
||||
|
||||
res.status(200).json({ comments });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to fetch comments' });
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
try {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { articleId, content } = req.body;
|
||||
|
||||
if (!articleId || !content) {
|
||||
return res.status(400).json({ message: 'Missing fields' });
|
||||
}
|
||||
|
||||
const comment = await Comment.create({
|
||||
文章ID: articleId,
|
||||
用户ID: decoded.userId,
|
||||
评论内容: content,
|
||||
});
|
||||
|
||||
// Populate user info for immediate display
|
||||
await comment.populate('用户ID', '用户名 头像');
|
||||
|
||||
res.status(201).json({ comment });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to post comment' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
13
src/pages/api/hello.ts
Normal file
13
src/pages/api/hello.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
type Data = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>,
|
||||
) {
|
||||
res.status(200).json({ name: "John Doe" });
|
||||
}
|
||||
121
src/pages/api/orders/create.ts
Normal file
121
src/pages/api/orders/create.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Order, MembershipPlan, User } from '@/models';
|
||||
import { alipayService } from '@/lib/alipay';
|
||||
import { getUserFromCookie } from '@/lib/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Auth Check
|
||||
const user = getUserFromCookie(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { type, planId, itemId } = req.body;
|
||||
|
||||
await dbConnect();
|
||||
|
||||
let orderData: any = {
|
||||
用户ID: user.userId,
|
||||
支付方式: 'alipay',
|
||||
订单状态: 'pending'
|
||||
};
|
||||
let subject = '';
|
||||
let body = '';
|
||||
let amount = 0;
|
||||
|
||||
if (type === 'buy_membership') {
|
||||
if (!planId) {
|
||||
return res.status(400).json({ message: 'Plan ID is required' });
|
||||
}
|
||||
const plan = await MembershipPlan.findById(planId);
|
||||
if (!plan) {
|
||||
return res.status(404).json({ message: 'Plan not found' });
|
||||
}
|
||||
|
||||
orderData.订单类型 = 'buy_membership';
|
||||
orderData.商品ID = plan._id;
|
||||
orderData.商品快照 = {
|
||||
标题: plan.套餐名称,
|
||||
封面: '/images/membership-cover.png'
|
||||
};
|
||||
amount = plan.价格;
|
||||
subject = `购买会员 - ${plan.套餐名称}`;
|
||||
body = plan.描述;
|
||||
|
||||
} else if (type === 'buy_resource') {
|
||||
if (!itemId) {
|
||||
return res.status(400).json({ message: 'Item ID is required' });
|
||||
}
|
||||
// Import Article model dynamically or ensure it's imported at top
|
||||
const { Article } = await import('@/models');
|
||||
const article = await Article.findById(itemId);
|
||||
if (!article) {
|
||||
return res.status(404).json({ message: 'Article not found' });
|
||||
}
|
||||
|
||||
orderData.订单类型 = 'buy_resource';
|
||||
orderData.商品ID = article._id;
|
||||
orderData.商品快照 = {
|
||||
标题: article.文章标题,
|
||||
封面: article.封面图 || '/images/article-cover.png'
|
||||
};
|
||||
amount = article.价格;
|
||||
subject = `购买资源 - ${article.文章标题}`;
|
||||
body = article.摘要 || '付费资源';
|
||||
|
||||
} else {
|
||||
return res.status(400).json({ message: 'Invalid order type' });
|
||||
}
|
||||
|
||||
// 3. Create Order
|
||||
const outTradeNo = `ORDER_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
orderData.订单号 = outTradeNo;
|
||||
orderData.支付金额 = amount;
|
||||
|
||||
const order = await Order.create(orderData) as any;
|
||||
|
||||
// 4. Generate Alipay URL
|
||||
// Determine return URL based on order type
|
||||
let returnUrl = '';
|
||||
if (orderData.订单类型 === 'buy_resource') {
|
||||
if (req.body.returnUrl) {
|
||||
// Point to backend return handler, passing the frontend URL as 'target'
|
||||
// We need the base URL of the API. Assuming it's in config or we can derive it.
|
||||
// Since we don't have easy access to base URL here without config, let's use the notifyUrl base if available
|
||||
// or just assume relative path if alipay supports it (it doesn't).
|
||||
// We'll use the alipayService config which has notifyUrl (usually the domain)
|
||||
// We need to access alipayService.config, but it's private.
|
||||
// Let's re-init it to be sure or just use a known env var if possible.
|
||||
// Actually, alipayService.generatePagePayUrl uses config.notifyUrl to build default return_url.
|
||||
// We can just pass the FULL URL to generatePagePayUrl.
|
||||
|
||||
// Hack: We need the domain. Let's assume req.headers.host
|
||||
const protocol = req.headers['x-forwarded-proto'] || 'http';
|
||||
const host = req.headers.host;
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
returnUrl = `${baseUrl}/api/payment/return?target=${encodeURIComponent(req.body.returnUrl)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const payUrl = await alipayService.generatePagePayUrl({
|
||||
outTradeNo: order.订单号,
|
||||
totalAmount: order.支付金额.toFixed(2),
|
||||
subject: subject.substring(0, 256), // Alipay subject limit
|
||||
body: body?.substring(0, 128), // Alipay body limit
|
||||
returnUrl: returnUrl || undefined
|
||||
});
|
||||
|
||||
res.status(200).json({ success: true, payUrl });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Order creation error:', error);
|
||||
res.status(500).json({ message: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
101
src/pages/api/payment/notify.ts
Normal file
101
src/pages/api/payment/notify.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Order, User, MembershipPlan } from '@/models';
|
||||
import { alipayService } from '@/lib/alipay';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false, // Alipay sends x-www-form-urlencoded
|
||||
},
|
||||
};
|
||||
|
||||
async function getRawBody(req: NextApiRequest): Promise<string> {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).send('Method Not Allowed');
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBody = await getRawBody(req);
|
||||
const params = new URLSearchParams(rawBody);
|
||||
const data: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of params.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
// 1. Verify Signature
|
||||
console.log('Received Alipay Notify:', data);
|
||||
const isValid = await alipayService.verifySignature(data);
|
||||
console.log('Signature Verification Result:', isValid);
|
||||
|
||||
if (!isValid) {
|
||||
console.error('Alipay signature verification failed');
|
||||
return res.send('fail');
|
||||
}
|
||||
|
||||
// 2. Check Trade Status
|
||||
const tradeStatus = data.trade_status;
|
||||
|
||||
// Only process successful payments
|
||||
if (tradeStatus !== 'TRADE_SUCCESS' && tradeStatus !== 'TRADE_FINISHED') {
|
||||
console.log(`Order ${data.out_trade_no} - ignoring status: ${tradeStatus}`);
|
||||
return res.send('success');
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
|
||||
// Find Order
|
||||
const outTradeNo = data.out_trade_no;
|
||||
const order = await Order.findOne({ 订单号: outTradeNo });
|
||||
|
||||
if (!order) {
|
||||
console.error(`Order not found: ${outTradeNo}`);
|
||||
return res.send('fail');
|
||||
}
|
||||
|
||||
if (order.订单状态 === 'paid') {
|
||||
return res.send('success'); // Already processed
|
||||
}
|
||||
|
||||
// Update Order to paid
|
||||
order.订单状态 = 'paid';
|
||||
order.支付时间 = new Date();
|
||||
order.支付方式 = 'alipay';
|
||||
await order.save();
|
||||
|
||||
// Update User Membership
|
||||
if (order.订单类型 === 'buy_membership') {
|
||||
const plan = await MembershipPlan.findById(order.商品ID);
|
||||
if (plan) {
|
||||
const user = await User.findById(order.用户ID);
|
||||
if (user) {
|
||||
const currentExpiry = user.会员信息?.过期时间 ? new Date(user.会员信息.过期时间) : new Date();
|
||||
const now = new Date();
|
||||
const startTime = currentExpiry > now ? currentExpiry : now;
|
||||
const newExpiry = new Date(startTime.getTime() + plan.有效天数 * 24 * 60 * 60 * 1000);
|
||||
|
||||
user.会员信息 = {
|
||||
当前等级ID: plan._id,
|
||||
过期时间: newExpiry
|
||||
};
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Order ${outTradeNo} marked as paid`);
|
||||
return res.send('success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Alipay notify error:', error);
|
||||
res.send('fail');
|
||||
}
|
||||
}
|
||||
94
src/pages/api/payment/return.ts
Normal file
94
src/pages/api/payment/return.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Order, User, MembershipPlan } from '@/models';
|
||||
import { alipayService } from '@/lib/alipay';
|
||||
|
||||
/**
|
||||
* Handle synchronous return from Alipay (return_url)
|
||||
* This is called when user is redirected back after payment
|
||||
*/
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const params = req.query as Record<string, string>;
|
||||
|
||||
console.log('Received Alipay Return:', params);
|
||||
|
||||
// 1. Verify Signature
|
||||
const isValid = await alipayService.verifySignature(params);
|
||||
console.log('Return Signature Verification:', isValid);
|
||||
|
||||
if (!isValid) {
|
||||
console.error('Alipay return signature verification failed');
|
||||
return res.redirect('/payment/failure?error=signature');
|
||||
}
|
||||
|
||||
// 2. Check Trade Status
|
||||
const tradeStatus = params.trade_status;
|
||||
const outTradeNo = params.out_trade_no;
|
||||
|
||||
await dbConnect();
|
||||
|
||||
// 3. Find Order
|
||||
const order = await Order.findOne({ 订单号: outTradeNo });
|
||||
|
||||
if (!order) {
|
||||
console.error(`Order not found: ${outTradeNo}`);
|
||||
return res.redirect('/payment/failure?error=order_not_found');
|
||||
}
|
||||
|
||||
// 4. Handle payment success
|
||||
if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') {
|
||||
// Payment successful - update to paid
|
||||
if (order.订单状态 !== 'paid') {
|
||||
order.订单状态 = 'paid';
|
||||
order.支付时间 = new Date();
|
||||
order.支付方式 = 'alipay';
|
||||
await order.save();
|
||||
|
||||
// Update User Membership
|
||||
if (order.订单类型 === 'buy_membership') {
|
||||
const plan = await MembershipPlan.findById(order.商品ID);
|
||||
if (plan) {
|
||||
const user = await User.findById(order.用户ID);
|
||||
if (user) {
|
||||
const currentExpiry = user.会员信息?.过期时间 ? new Date(user.会员信息.过期时间) : new Date();
|
||||
const now = new Date();
|
||||
const startTime = currentExpiry > now ? currentExpiry : now;
|
||||
const newExpiry = new Date(startTime.getTime() + plan.有效天数 * 24 * 60 * 60 * 1000);
|
||||
|
||||
user.会员信息 = {
|
||||
当前等级ID: plan._id,
|
||||
过期时间: newExpiry
|
||||
};
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Order ${outTradeNo} status updated to paid via return_url`);
|
||||
}
|
||||
|
||||
// Redirect to target if present, otherwise success page
|
||||
const target = params.target as string;
|
||||
if (target) {
|
||||
return res.redirect(target);
|
||||
}
|
||||
return res.redirect('/payment/success');
|
||||
}
|
||||
|
||||
// For any other status
|
||||
const target = params.target as string;
|
||||
if (target) {
|
||||
return res.redirect(target);
|
||||
}
|
||||
return res.redirect('/membership');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Alipay return handler error:', error);
|
||||
res.redirect('/payment/failure?error=server');
|
||||
}
|
||||
}
|
||||
18
src/pages/api/plans/index.ts
Normal file
18
src/pages/api/plans/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { MembershipPlan } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const plans = await MembershipPlan.find({ 是否上架: true }).sort({ 价格: 1 });
|
||||
return res.status(200).json(plans);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Failed to fetch plans' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
32
src/pages/api/public/config.ts
Normal file
32
src/pages/api/public/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { SystemConfig } from '@/models';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const config = await SystemConfig.findOne({ 配置标识: 'default' })
|
||||
.select('Banner配置 站点设置')
|
||||
.lean();
|
||||
|
||||
if (!config) {
|
||||
return res.status(404).json({ message: 'Config not found' });
|
||||
}
|
||||
|
||||
// Filter visible banners
|
||||
const visibleBanners = config.Banner配置?.filter((b: any) => b.状态 === 'visible') || [];
|
||||
|
||||
res.status(200).json({
|
||||
banners: visibleBanners,
|
||||
site: config.站点设置
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch public config error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
20
src/pages/api/tags/index.ts
Normal file
20
src/pages/api/tags/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Tag } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
// 简单返回所有标签,实际可优化为返回热门标签
|
||||
const tags = await Tag.find().limit(20).sort({ createdAt: -1 }).lean();
|
||||
return res.status(200).json(tags);
|
||||
} catch (error) {
|
||||
console.error('Fetch public tags error:', error);
|
||||
return res.status(500).json({ message: '获取标签失败' });
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
export default withDatabase(handler);
|
||||
33
src/pages/api/user/orders.ts
Normal file
33
src/pages/api/user/orders.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Order } from '@/models';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: 'Invalid token' });
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const orders = await Order.find({ 用户ID: decoded.userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(20);
|
||||
|
||||
res.status(200).json({ orders });
|
||||
} catch (error) {
|
||||
console.error('Fetch user orders error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
33
src/pages/api/user/orders/index.ts
Normal file
33
src/pages/api/user/orders/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { Order } from '@/models';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: 'Invalid token' });
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const orders = await Order.find({ 用户ID: decoded.userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
res.status(200).json({ orders });
|
||||
} catch (error) {
|
||||
console.error('Fetch orders error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
52
src/pages/api/user/profile.ts
Normal file
52
src/pages/api/user/profile.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { User } from '@/models';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'PUT') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { username, avatar, password } = req.body;
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const updateData: any = {};
|
||||
if (username) updateData.用户名 = username;
|
||||
if (avatar) updateData.头像 = avatar;
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ message: '密码长度不能少于6位' });
|
||||
}
|
||||
updateData.密码 = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
decoded.userId,
|
||||
updateData,
|
||||
{ new: true, runValidators: true }
|
||||
).select('-密码');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
res.status(200).json({ user, message: '个人资料已更新' });
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
282
src/pages/article/[id].tsx
Normal file
282
src/pages/article/[id].tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import MainLayout from '@/components/layouts/MainLayout';
|
||||
import CommentSection from '@/components/article/CommentSection';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Article {
|
||||
_id: string;
|
||||
文章标题: string;
|
||||
封面图: string;
|
||||
摘要: string;
|
||||
正文内容: string;
|
||||
作者ID: {
|
||||
用户名: string;
|
||||
头像: string;
|
||||
};
|
||||
分类ID: {
|
||||
分类名称: string;
|
||||
};
|
||||
标签ID列表: {
|
||||
标签名称: string;
|
||||
}[];
|
||||
价格: number;
|
||||
支付方式: 'points' | 'cash';
|
||||
资源属性?: {
|
||||
下载链接: string;
|
||||
提取码: string;
|
||||
解压密码: string;
|
||||
隐藏内容: string;
|
||||
};
|
||||
createdAt: string;
|
||||
统计数据: {
|
||||
阅读数: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ArticleDetail() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [article, setArticle] = useState<Article | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasAccess, setHasAccess] = useState(false);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchArticle();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchArticle = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/articles/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setArticle(data.article);
|
||||
setHasAccess(data.hasAccess);
|
||||
} else {
|
||||
toast.error('文章不存在或已被删除');
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch article', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!user) {
|
||||
router.push(`/auth/login?redirect=${encodeURIComponent(router.asPath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!article) return;
|
||||
|
||||
// If price is 0, it should be accessible, but just in case
|
||||
if (article.价格 === 0) return;
|
||||
|
||||
setPurchasing(true);
|
||||
try {
|
||||
const res = await fetch('/api/orders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'buy_resource',
|
||||
itemId: article._id,
|
||||
paymentMethod: 'alipay', // Default to alipay for now
|
||||
returnUrl: window.location.href // Pass current page URL for redirect after payment
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Redirect to Alipay
|
||||
window.location.href = data.payUrl;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.message || '创建订单失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="space-y-4 mb-8">
|
||||
<Skeleton className="h-12 w-3/4" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-xl mb-8" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!article) return null;
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
seo={{
|
||||
title: article.文章标题,
|
||||
description: article.摘要 || article.正文内容.substring(0, 100),
|
||||
keywords: article.标签ID列表?.map(t => t.标签名称).join(',')
|
||||
}}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Badge variant="secondary" className="text-primary bg-primary/10 hover:bg-primary/20">
|
||||
{article.分类ID?.分类名称 || '未分类'}
|
||||
</Badge>
|
||||
{article.标签ID列表?.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-gray-500">
|
||||
{tag.标签名称}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{article.文章标题}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500 border-b border-gray-100 pb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4" />
|
||||
<span>{article.作者ID?.用户名 || '匿名'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}</span>
|
||||
</div>
|
||||
<div>
|
||||
阅读 {article.统计数据?.阅读数 || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover Image */}
|
||||
{article.封面图 && (
|
||||
<div className="mb-10 rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
src={article.封面图}
|
||||
alt={article.文章标题}
|
||||
className="w-full h-auto object-cover max-h-[500px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Article Content */}
|
||||
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
|
||||
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
|
||||
</article>
|
||||
|
||||
{/* Resource Download / Paywall Section */}
|
||||
<div className="bg-gray-50 rounded-xl p-8 mb-12 border border-gray-100">
|
||||
{hasAccess ? (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2 text-green-700">
|
||||
<Download className="w-6 h-6" />
|
||||
资源下载
|
||||
</h3>
|
||||
{article.资源属性 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<span className="text-gray-500 text-sm block mb-1">下载链接</span>
|
||||
<a
|
||||
href={article.资源属性.下载链接}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary font-medium hover:underline break-all"
|
||||
>
|
||||
{article.资源属性.下载链接}
|
||||
</a>
|
||||
</div>
|
||||
{article.资源属性.提取码 && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<span className="text-gray-500 text-sm block mb-1">提取码</span>
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono">
|
||||
{article.资源属性.提取码}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{article.资源属性.解压密码 && (
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<span className="text-gray-500 text-sm block mb-1">解压密码</span>
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono">
|
||||
{article.资源属性.解压密码}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">此资源暂无下载信息</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Lock className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
此内容需要付费查看
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
购买此资源或开通会员,即可解锁全文及下载链接,享受更多优质内容。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="px-8"
|
||||
onClick={handlePurchase}
|
||||
disabled={purchasing}
|
||||
>
|
||||
{purchasing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<span className="mr-2">¥{article.价格}</span>
|
||||
)}
|
||||
立即购买
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => router.push('/membership')}
|
||||
>
|
||||
开通会员 (免费下载)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<CommentSection articleId={article._id} isLoggedIn={!!user} />
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
132
src/pages/auth/login.tsx
Normal file
132
src/pages/auth/login.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: "请输入有效的邮箱地址" }),
|
||||
password: z.string().min(6, { message: "密码至少需要6个字符" }),
|
||||
});
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { refreshUser } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || '登录失败');
|
||||
}
|
||||
|
||||
// 登录成功,刷新用户状态并跳转
|
||||
await refreshUser();
|
||||
const redirect = router.query.redirect as string;
|
||||
router.push(redirect || '/');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">登录账号</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
请输入您的邮箱和密码进行登录
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="name@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="******" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive text-center">{error}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
还没有账号?{' '}
|
||||
<Link href="/auth/register" className="text-primary hover:underline">
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/auth/register.tsx
Normal file
165
src/pages/auth/register.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(2, { message: "用户名至少需要2个字符" }),
|
||||
email: z.string().email({ message: "请输入有效的邮箱地址" }),
|
||||
password: z.string().min(6, { message: "密码至少需要6个字符" }),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "两次输入的密码不一致",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || '注册失败');
|
||||
}
|
||||
|
||||
// 注册成功,跳转到登录页
|
||||
router.push('/auth/login');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">注册账号</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
创建一个新账号以开始使用
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="johndoe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="name@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="******" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>确认密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="******" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive text-center">{error}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
注册
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
已有账号?{' '}
|
||||
<Link href="/auth/login" className="text-primary hover:underline">
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
src/pages/dashboard/index.tsx
Normal file
344
src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import MainLayout from '@/components/layouts/MainLayout';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Crown, ShoppingBag, BookOpen, LogOut, User as UserIcon, Lock, Save } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Order {
|
||||
_id: string;
|
||||
订单号: string;
|
||||
订单类型: string;
|
||||
支付金额: number;
|
||||
订单状态: string;
|
||||
createdAt: string;
|
||||
商品ID?: string;
|
||||
商品快照?: {
|
||||
标题: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading, logout, refreshUser } = useAuth();
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(true);
|
||||
|
||||
// Profile state
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
username: '',
|
||||
avatar: '',
|
||||
password: ''
|
||||
});
|
||||
const [updatingProfile, setUpdatingProfile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchOrders();
|
||||
setProfileForm(prev => ({
|
||||
...prev,
|
||||
username: user.用户名 || '',
|
||||
avatar: user.头像 || ''
|
||||
}));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/user/orders');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOrders(data.orders);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch orders', error);
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setUpdatingProfile(true);
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profileForm)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success('个人资料已更新');
|
||||
setProfileForm(prev => ({ ...prev, password: '' })); // Clear password
|
||||
refreshUser(); // Refresh global user state
|
||||
} else {
|
||||
toast.error(data.message || '更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('请求出错');
|
||||
} finally {
|
||||
setUpdatingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isVip = user.会员信息?.过期时间 && new Date(user.会员信息.过期时间) > new Date();
|
||||
const resourceOrders = orders.filter(o => o.订单类型 === 'buy_resource' && o.订单状态 === 'paid');
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="container mx-auto px-4 pt-24 pb-8">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Sidebar / User Info Card */}
|
||||
<div className="w-full md:w-1/3 lg:w-1/4 space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-24 h-24 mb-4 relative">
|
||||
<Avatar className="w-24 h-24 border-4 border-white shadow-lg">
|
||||
<AvatarImage src={user.头像} />
|
||||
<AvatarFallback className="text-2xl">{user.用户名?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
{isVip && (
|
||||
<div className="absolute -top-2 -right-2 bg-yellow-400 text-white p-1.5 rounded-full shadow-sm">
|
||||
<Crown className="w-5 h-5 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle>{user.用户名}</CardTitle>
|
||||
<CardDescription>{user.邮箱}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="text-sm text-gray-500 mb-1">会员状态</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`font-bold ${isVip ? 'text-yellow-600' : 'text-gray-700'}`}>
|
||||
{isVip ? user.会员信息?.当前等级ID?.套餐名称 || '尊贵会员' : '普通用户'}
|
||||
</span>
|
||||
{isVip ? (
|
||||
<Badge variant="outline" className="text-yellow-600 border-yellow-600">
|
||||
生效中
|
||||
</Badge>
|
||||
) : (
|
||||
<Button variant="link" size="sm" className="h-auto p-0 text-primary" onClick={() => router.push('/membership')}>
|
||||
开通会员
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isVip && (
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
到期时间: {format(new Date(user.会员信息!.过期时间!), 'yyyy-MM-dd')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full text-red-600 hover:text-red-700 hover:bg-red-50" onClick={logout}>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="orders">我的订单</TabsTrigger>
|
||||
<TabsTrigger value="articles">已购文章</TabsTrigger>
|
||||
<TabsTrigger value="settings">个人设置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">总消费</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">¥{user.钱包?.历史总消费 || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">当前积分</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{user.钱包?.当前积分 || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>订单记录</CardTitle>
|
||||
<CardDescription>您最近的交易记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingOrders ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无订单记录</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{orders.map((order) => (
|
||||
<div key={order._id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center text-blue-600">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{order.商品快照?.标题 || (order.订单类型 === 'buy_membership' ? '购买会员' : '购买资源')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-gray-900">¥{order.支付金额}</div>
|
||||
<Badge variant={order.订单状态 === 'paid' ? 'default' : 'outline'} className={order.订单状态 === 'paid' ? 'bg-green-500 hover:bg-green-600' : 'text-yellow-600 border-yellow-600'}>
|
||||
{order.订单状态 === 'paid' ? '已支付' : '待支付'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="articles">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>我的资源</CardTitle>
|
||||
<CardDescription>您已购买或拥有权限的文章</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{resourceOrders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<BookOpen className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>暂无已购资源</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{resourceOrders.map((order) => (
|
||||
<div key={order._id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center text-purple-600">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{order.商品快照?.标题 || '未知资源'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
购买时间: {format(new Date(order.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push(`/article/${order.商品ID || '#'}`)}>
|
||||
查看内容
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>个人设置</CardTitle>
|
||||
<CardDescription>管理您的个人资料和账户安全</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<div className="relative">
|
||||
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="username"
|
||||
value={profileForm.username}
|
||||
onChange={e => setProfileForm({ ...profileForm, username: e.target.value })}
|
||||
className="pl-9"
|
||||
placeholder="您的用户名"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="avatar">头像链接</Label>
|
||||
<Input
|
||||
id="avatar"
|
||||
value={profileForm.avatar}
|
||||
onChange={e => setProfileForm({ ...profileForm, avatar: e.target.value })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500">支持输入图片 URL,或使用默认头像</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">新密码 (选填)</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={profileForm.password}
|
||||
onChange={e => setProfileForm({ ...profileForm, password: e.target.value })}
|
||||
className="pl-9"
|
||||
placeholder="不修改请留空"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={updatingProfile}>
|
||||
{updatingProfile && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存更改
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
133
src/pages/index.tsx
Normal file
133
src/pages/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import MainLayout from '@/components/layouts/MainLayout';
|
||||
import HeroBanner from '@/components/home/HeroBanner';
|
||||
import ArticleCard from '@/components/home/ArticleCard';
|
||||
import Sidebar from '@/components/home/Sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
const [articles, setArticles] = useState<any[]>([]);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [tags, setTags] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [artRes, catRes, tagRes, configRes] = await Promise.all([
|
||||
fetch('/api/articles'),
|
||||
fetch('/api/categories'),
|
||||
fetch('/api/tags'),
|
||||
fetch('/api/public/config')
|
||||
]);
|
||||
|
||||
if (artRes.ok) {
|
||||
const data = await artRes.json();
|
||||
setArticles(data.articles);
|
||||
}
|
||||
if (catRes.ok) setCategories(await catRes.json());
|
||||
if (tagRes.ok) setTags(await tagRes.json());
|
||||
if (configRes.ok) {
|
||||
const data = await configRes.json();
|
||||
setBanners(data.banners);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch home data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryClick = async (catId: string) => {
|
||||
setActiveCategory(catId);
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = catId === 'all' ? '/api/articles' : `/api/articles?category=${catId}`;
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setArticles(data.articles);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Filter error', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout transparentHeader={true}>
|
||||
{/* Hero Section - Full Width */}
|
||||
<div className="w-full mb-6">
|
||||
<HeroBanner banners={banners} />
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 pb-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Main Content (Articles) */}
|
||||
<div className="lg:col-span-9 space-y-0">
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-2">
|
||||
<Button
|
||||
variant={activeCategory === 'all' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => handleCategoryClick('all')}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat._id}
|
||||
variant={activeCategory === cat._id ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-full whitespace-nowrap"
|
||||
onClick={() => handleCategoryClick(cat._id)}
|
||||
>
|
||||
{cat.分类名称}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap hidden md:block">
|
||||
共 {articles.length} 篇文章
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Article Grid */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{articles.map(article => (
|
||||
<ArticleCard key={article._id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && articles.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
暂无文章
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="sticky top-24">
|
||||
<Sidebar tags={tags} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
130
src/pages/membership.tsx
Normal file
130
src/pages/membership.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import MainLayout from '@/components/layouts/MainLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check, Loader2, Crown } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Membership() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/plans');
|
||||
if (res.ok) {
|
||||
setPlans(await res.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plans', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async (planId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/orders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ planId, type: 'buy_membership' }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
throw new Error(data.message || '创建订单失败');
|
||||
}
|
||||
|
||||
if (data.payUrl) {
|
||||
window.location.href = data.payUrl;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Payment error:', error);
|
||||
const message = error.message === '创建订单失败'
|
||||
? '支付系统暂时繁忙,请稍后再试或联系客服。'
|
||||
: error.message;
|
||||
toast.error(`支付发起失败: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="bg-[#0F172A] py-12 text-center text-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="inline-flex items-center justify-center p-3 bg-white/10 rounded-full mb-4">
|
||||
<Crown className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4">升级您的会员计划</h1>
|
||||
<p className="text-lg text-gray-400 max-w-2xl mx-auto">
|
||||
解锁全站所有深度技术专栏、系统设计源码及私密社群访问权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<div key={plan._id} className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 flex flex-col relative hover:-translate-y-1 transition-transform duration-300">
|
||||
{plan.推荐 && (
|
||||
<div className="absolute top-0 right-0 bg-primary text-white text-xs font-bold px-3 py-1 rounded-bl-lg z-10">
|
||||
推荐
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 flex-1">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{plan.套餐名称}</h3>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-3xl font-bold text-primary">¥{plan.价格}</span>
|
||||
<span className="text-sm text-gray-500">/{plan.有效天数}天</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6 min-h-[40px]">{plan.描述}</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<span>每日下载限制: {plan.特权配置?.每日下载限制}次</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<span>购买折扣: {plan.特权配置?.购买折扣 ? `${(plan.特权配置.购买折扣 * 10).toFixed(1)}折` : '无折扣'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<span>付费专栏免费读</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<span>专属技术交流群</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 pt-0 mt-auto">
|
||||
<Button
|
||||
className="w-full bg-linear-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 text-white shadow-lg shadow-primary/20"
|
||||
onClick={() => handlePurchase(plan._id)}
|
||||
>
|
||||
立即开通
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
61
src/pages/payment/failure.tsx
Normal file
61
src/pages/payment/failure.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import MainLayout from '@/components/layouts/MainLayout';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PaymentFailure() {
|
||||
const router = useRouter();
|
||||
const { error } = router.query;
|
||||
|
||||
const getErrorMessage = () => {
|
||||
switch (error) {
|
||||
case 'signature':
|
||||
return '支付验证失败,请联系客服';
|
||||
case 'order_not_found':
|
||||
return '订单不存在,请重新下单';
|
||||
case 'cancelled':
|
||||
return '您已取消支付';
|
||||
case 'server':
|
||||
return '服务器错误,请稍后重试';
|
||||
default:
|
||||
return '支付失败,请重试';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XCircle className="w-12 h-12 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
支付失败
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8">
|
||||
{getErrorMessage()}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push('/membership')}
|
||||
>
|
||||
重新购买
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
63
src/pages/payment/success.tsx
Normal file
63
src/pages/payment/success.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import MainLayout from '@/components/layouts/MainLayout';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PaymentSuccess() {
|
||||
const router = useRouter();
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
// 倒计时自动跳转
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
router.push('/membership');
|
||||
}
|
||||
}, [countdown, router]);
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
支付成功!
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8">
|
||||
恭喜您成功开通会员,现在可以享受所有会员特权了!
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-8">
|
||||
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{countdown} 秒后自动跳转到会员中心...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push('/membership')}
|
||||
>
|
||||
查看会员权益
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user