+ {/* Sidebar */}
+
+
+ {/* Main Content Wrapper */}
+
+ {/* Header */}
+
+
+
+
+ 欢迎回来,管理员
+
+
+

+
+
+
+
+ {/* Scrollable Page Content */}
+
+ {children}
+
+
+
+ {/* Overlay for mobile sidebar */}
+ {isSidebarOpen && (
+
setIsSidebarOpen(false)}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/admin/ArticleEditor.tsx b/src/components/admin/ArticleEditor.tsx
new file mode 100644
index 0000000..f20b098
--- /dev/null
+++ b/src/components/admin/ArticleEditor.tsx
@@ -0,0 +1,377 @@
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings } from 'lucide-react';
+import { useForm, Controller } from 'react-hook-form';
+import TiptapEditor from './TiptapEditor';
+import { toast } from 'sonner';
+
+interface Category {
+ _id: string;
+ 分类名称: string;
+}
+
+interface Tag {
+ _id: string;
+ 标签名称: string;
+}
+
+interface ArticleEditorProps {
+ mode: 'create' | 'edit';
+ articleId?: string;
+}
+
+export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(mode === 'edit');
+ const [saving, setSaving] = useState(false);
+ const [categories, setCategories] = useState
([]);
+ const [tags, setTags] = useState([]);
+
+
+ const { register, handleSubmit, control, reset, setValue, watch } = useForm({
+ defaultValues: {
+ 文章标题: '',
+ URL别名: '',
+ 封面图: '',
+ 摘要: '',
+ 正文内容: '',
+ SEO关键词: '',
+ SEO描述: '',
+ 分类ID: '',
+ 标签ID列表: [] as string[],
+ 价格: 0,
+ 支付方式: 'points',
+ 资源属性: {
+ 下载链接: '',
+ 提取码: '',
+ 解压密码: '',
+ 隐藏内容: ''
+ },
+ 发布状态: 'published'
+ }
+ });
+
+ useEffect(() => {
+ fetchDependencies();
+ if (mode === 'edit' && articleId) {
+ fetchArticle();
+ }
+ }, [mode, articleId]);
+
+ const fetchDependencies = async () => {
+ try {
+ const [catRes, tagRes] = await Promise.all([
+ fetch('/api/admin/categories'),
+ fetch('/api/admin/tags')
+ ]);
+ if (catRes.ok) setCategories(await catRes.json());
+ if (tagRes.ok) setTags(await tagRes.json());
+ } catch (error) {
+ console.error('Failed to fetch dependencies', error);
+ toast.error('加载分类标签失败');
+ }
+ };
+
+ const fetchArticle = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/admin/articles/${articleId}`);
+ const data = await res.json();
+ if (res.ok) {
+ const formData = {
+ ...data,
+ 分类ID: data.分类ID?._id || '',
+ 标签ID列表: data.标签ID列表?.map((t: any) => t._id) || [],
+ SEO关键词: data.SEO关键词?.join(',') || ''
+ };
+ reset(formData);
+
+ } else {
+ toast.error('加载文章失败');
+ }
+ } catch (error) {
+ console.error('Failed to fetch article', error);
+ toast.error('加载文章出错');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const onSubmit = async (data: any) => {
+
+
+ setSaving(true);
+ try {
+ const payload = {
+ ...data,
+ SEO关键词: data.SEO关键词.split(/[,,]/).map((k: string) => k.trim()).filter(Boolean),
+ 价格: Number(data.价格)
+ };
+
+ const url = mode === 'edit' ? `/api/admin/articles/${articleId}` : '/api/admin/articles';
+ const method = mode === 'edit' ? 'PUT' : 'POST';
+
+ const res = await fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (res.ok) {
+ toast.success('保存成功');
+ router.push('/admin/articles');
+ } else {
+ const err = await res.json();
+ toast.error(`保存失败: ${err.message || '未知错误'}`);
+ }
+ } catch (error) {
+ console.error('Failed to save article', error);
+ toast.error('保存出错');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Top Bar */}
+
+
+
+
+
+ {mode === 'edit' ? '编辑文章' : '撰写新文章'}
+
+ {watch('发布状态') === 'published' && (
+
+ 已发布
+
+ )}
+
+
+
+
+
+
+
+ {/* Main Editor Area */}
+
+ {/* Left: Content Editor */}
+
+
+ {/* Title Input */}
+
+
+ {/* Tiptap Editor */}
+ (
+
+ )}
+ />
+
+
+
+ {/* Right: Sidebar Settings */}
+
+ {/* Publishing */}
+
+
+ 发布设置
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* Organization */}
+
+
+ 分类与标签
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+ {/* Resource Delivery */}
+
+
+
+
+ {/* Sales */}
+
+
+ 售卖策略
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* SEO */}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/TiptapEditor.tsx b/src/components/admin/TiptapEditor.tsx
new file mode 100644
index 0000000..0d799e2
--- /dev/null
+++ b/src/components/admin/TiptapEditor.tsx
@@ -0,0 +1,539 @@
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import { Markdown as TiptapMarkdown } from 'tiptap-markdown';
+import Link from '@tiptap/extension-link';
+import Image from '@tiptap/extension-image';
+import Placeholder from '@tiptap/extension-placeholder';
+import { Button } from '@/components/ui/button';
+import {
+ Bold,
+ Italic,
+ Strikethrough,
+ List,
+ ListOrdered,
+ Heading1,
+ Heading2,
+ Heading3,
+ Quote,
+ Code,
+ Link as LinkIcon,
+ Image as ImageIcon,
+ Undo,
+ Redo,
+ Sparkles,
+ Loader2,
+ Check,
+ X,
+ RefreshCw,
+ Wand2,
+ FileText,
+ Minimize2,
+ Maximize2,
+ Languages,
+ MessageSquare,
+ PenLine,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useEffect, useState, useRef } from 'react';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Textarea } from '@/components/ui/textarea';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { unified } from 'unified';
+import remarkParse from 'remark-parse';
+import remarkRehype from 'remark-rehype';
+import rehypeStringify from 'rehype-stringify';
+import { toast } from 'sonner';
+
+interface TiptapEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ className?: string;
+}
+
+const Toolbar = ({ editor, onAiClick }: { editor: any, onAiClick: () => void }) => {
+ // Removed unused state and handleAiGenerate logic
+ if (!editor) return null;
+
+ const addLink = () => {
+ const url = window.prompt('URL');
+ if (url) {
+ editor.chain().focus().setLink({ href: url }).run();
+ }
+ };
+
+ const addImage = () => {
+ const url = window.prompt('Image URL');
+ if (url) {
+ editor.chain().focus().setImage({ src: url }).run();
+ }
+ };
+
+ const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
+
+ );
+
+ return (
+
+
+
+
editor.chain().focus().toggleBold().run()}>
+
editor.chain().focus().toggleItalic().run()}>
+
editor.chain().focus().toggleStrike().run()}>
+
+
editor.chain().focus().toggleHeading({ level: 1 }).run()}>
+
editor.chain().focus().toggleHeading({ level: 2 }).run()}>
+
editor.chain().focus().toggleHeading({ level: 3 }).run()}>
+
+
editor.chain().focus().toggleBulletList().run()}>
+
editor.chain().focus().toggleOrderedList().run()}>
+
+
editor.chain().focus().toggleBlockquote().run()}>
+
editor.chain().focus().toggleCodeBlock().run()}>
+
+
+
+
+
editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
+
editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
+
+ );
+};
+
+export default function TiptapEditor({ value, onChange, className }: TiptapEditorProps) {
+ const [isAiGenerating, setIsAiGenerating] = useState(false);
+ const [assistants, setAssistants] = useState([]);
+ const [aiInputOpen, setAiInputOpen] = useState(false);
+ const [aiInputPos, setAiInputPos] = useState({ top: 0, left: 0 });
+ const [sideToolPos, setSideToolPos] = useState({ top: 0, left: 0, visible: false });
+ const [bubbleMenuPos, setBubbleMenuPos] = useState({ top: 0, left: 0, visible: false });
+ const wrapperRef = useRef(null);
+
+ useEffect(() => {
+ fetch('/api/admin/settings').then(res => res.json()).then(data => {
+ if (data.AI配置列表) {
+ setAssistants(data.AI配置列表.filter((a: any) => a.是否启用));
+ }
+ }).catch(console.error);
+ }, []);
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ extensions: [
+ StarterKit.configure({ heading: { levels: [1, 2, 3] } }) as any,
+ TiptapMarkdown,
+ Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-primary underline' } }),
+ Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full' } }),
+ Placeholder.configure({ placeholder: '开始撰写精彩内容...' }),
+ ],
+ content: value,
+ editorProps: {
+ attributes: {
+ class: 'prose prose-lg max-w-none focus:outline-none min-h-[500px] p-8 pl-16', // Added padding-left for side tool
+ },
+ },
+ onUpdate: ({ editor }) => {
+ const markdown = (editor.storage as any).markdown.getMarkdown();
+ onChange(markdown);
+ updateSideTool(editor);
+ updateBubbleMenu(editor);
+ },
+ onSelectionUpdate: ({ editor }) => {
+ updateSideTool(editor);
+ updateBubbleMenu(editor);
+ },
+ onTransaction: ({ editor }) => {
+ updateSideTool(editor);
+ updateBubbleMenu(editor);
+ }
+ });
+
+ const updateSideTool = (editor: any) => {
+ if (!editor || !wrapperRef.current) return;
+ const { selection } = editor.state;
+ const { $anchor } = selection;
+ const dom = editor.view.domAtPos($anchor.pos).node as HTMLElement;
+
+ // Find the block element
+ let block = dom;
+ if (block.nodeType === 3) { // Text node
+ block = block.parentElement as HTMLElement;
+ }
+ // Traverse up to find the direct child of prose
+ while (block && block.parentElement && !block.parentElement.classList.contains('ProseMirror')) {
+ block = block.parentElement as HTMLElement;
+ }
+
+ if (block && block.getBoundingClientRect) {
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+ const blockRect = block.getBoundingClientRect();
+ const top = blockRect.top - wrapperRect.top;
+ setSideToolPos({ top, left: 10, visible: true });
+ } else {
+ setSideToolPos(prev => ({ ...prev, visible: false }));
+ }
+ };
+
+ const updateBubbleMenu = (editor: any) => {
+ if (!editor || !wrapperRef.current) return;
+ const { selection } = editor.state;
+
+ if (selection.empty) {
+ setBubbleMenuPos(prev => ({ ...prev, visible: false }));
+ return;
+ }
+
+ const { from, to } = selection;
+ const startCoords = editor.view.coordsAtPos(from);
+ const endCoords = editor.view.coordsAtPos(to);
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+
+ const left = (startCoords.left + endCoords.right) / 2 - wrapperRect.left;
+ const top = startCoords.top - wrapperRect.top - 10; // 10px offset above
+
+ setBubbleMenuPos({ top, left, visible: true });
+ };
+
+ useEffect(() => {
+ if (editor && value && (editor.storage as any).markdown.getMarkdown() !== value) {
+ if (editor.getText() === '' && value) {
+ editor.commands.setContent(value);
+ }
+ }
+ }, [value, editor]);
+
+ const runAiAction = async (action: string, contextText: string, replace: boolean = false) => {
+ if (!assistants.length) {
+ toast.error('请先配置 AI 助手');
+ return;
+ }
+
+ // Close input immediately to prevent blocking
+ setAiInputOpen(false);
+ setIsAiGenerating(true);
+
+ let prompt = '';
+ // Updated base prompt for Chinese output
+ const basePrompt = (task: string, context: string) => `你是一个专业的 AI 写作助手。任务:${task}。上下文:${context}。重要:请直接输出结果,不要包含任何解释性文字。如果上下文太短或无意义,请礼貌地拒绝。请使用中文(简体)回复。`;
+
+ switch (action) {
+ case 'rewrite': prompt = basePrompt('请重写以下文本,使其更加流畅优美', contextText); break;
+ case 'blogify': prompt = basePrompt('将以下文本改写为吸引人的博客风格', contextText); break;
+ case 'summarize': prompt = basePrompt('请总结以下文本的核心内容', contextText); break;
+ case 'expand': prompt = basePrompt('请扩写以下文本,补充更多细节', contextText); break;
+ case 'shorten': prompt = basePrompt('请精简以下文本,保留核心信息', contextText); break;
+ case 'fix': prompt = basePrompt('请纠正以下文本中的错别字和语法错误', contextText); break;
+ case 'continue': prompt = basePrompt('请根据以下上下文继续写作', contextText); break;
+ case 'custom': prompt = basePrompt('请按照用户指令执行', contextText); break;
+ default: prompt = contextText;
+ }
+
+ if (action.startsWith('tone:')) {
+ const tone = action.split(':')[1];
+ prompt = basePrompt(`请用${tone}的语气重写以下文本`, contextText);
+ }
+ if (action.startsWith('translate:')) {
+ const lang = action.split(':')[1];
+ prompt = basePrompt(`请将以下文本翻译成${lang}`, contextText);
+ }
+
+ try {
+ if (replace) {
+ editor?.chain().focus().deleteSelection().run();
+ } else if (action === 'continue') {
+ editor?.chain().focus().run();
+ }
+
+ const res = await fetch('/api/ai/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ prompt,
+ assistantId: assistants[0]._id,
+ }),
+ });
+
+ if (!res.ok) throw new Error('Generation failed');
+
+ const startPos = editor?.state.selection.from || 0;
+ let fullText = '';
+
+ const contentType = res.headers.get('content-type');
+ if (contentType && contentType.includes('text/event-stream')) {
+ const reader = res.body?.getReader();
+ const decoder = new TextDecoder();
+ if (reader) {
+ let done = false;
+ while (!done) {
+ const { value, done: doneReading } = await reader.read();
+ done = doneReading;
+ const chunkValue = decoder.decode(value, { stream: !done });
+ const lines = chunkValue.split('\n');
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const dataStr = line.slice(6);
+ if (dataStr === '[DONE]') continue;
+ try {
+ const data = JSON.parse(dataStr);
+ const content = data.choices?.[0]?.delta?.content || '';
+ if (content && editor) {
+ editor.commands.insertContent(content);
+ fullText += content;
+ // Auto-scroll to keep cursor in view
+ editor.commands.scrollIntoView();
+ }
+ } catch (e) { }
+ }
+ }
+ }
+ }
+ } else {
+ const data = await res.json();
+ if (editor) {
+ editor.commands.insertContent(data.text);
+ fullText = data.text;
+ editor.commands.scrollIntoView();
+ }
+ }
+
+ // Parse Markdown to HTML and replace the raw text
+ if (fullText && editor) {
+ try {
+ const file = await unified()
+ .use(remarkParse)
+ .use(remarkRehype)
+ .use(rehypeStringify)
+ .process(fullText);
+ const htmlContent = String(file);
+
+ const endPos = editor.state.selection.to;
+ // Ensure we are replacing the correct range
+ if (endPos > startPos) {
+ editor.chain()
+ .setTextSelection({ from: startPos, to: endPos })
+ .insertContent(htmlContent)
+ .setTextSelection(startPos + htmlContent.length) // Move cursor to end
+ .run();
+ }
+ } catch (e) {
+ console.error('Markdown parsing failed', e);
+ }
+ }
+
+ } catch (error) {
+ console.error(error);
+ toast.error('AI 生成失败');
+ } finally {
+ setIsAiGenerating(false);
+ if (editor) {
+ editor.setEditable(true);
+ }
+ // Input is already closed at the start
+ }
+ };
+
+ const openAiInput = () => {
+ if (!editor || !wrapperRef.current) return;
+ const { selection } = editor.state;
+ const { $anchor } = selection;
+ const coords = editor.view.coordsAtPos($anchor.pos);
+ const wrapperRect = wrapperRef.current.getBoundingClientRect();
+
+ setAiInputPos({
+ top: coords.bottom - wrapperRect.top + 10,
+ left: coords.left - wrapperRect.left,
+ });
+ setAiInputOpen(true);
+ };
+
+ if (!editor) return null;
+
+ return (
+
+
+
+ {/* Side Tool */}
+ {sideToolPos.visible && (
+
+
+
+
+
+
+
+
+ AI 写作...
+
+ {
+ const { from } = editor.state.selection;
+ const context = editor.state.doc.textBetween(Math.max(0, from - 500), from, '\n');
+ runAiAction('continue', context, false);
+ }}>
+
+ AI 续写
+
+
+
+
+ )}
+
+ {/* Inline AI Input */}
+ {aiInputOpen && (
+
+ )}
+
+ {/* Custom Bubble Menu */}
+ {bubbleMenuPos.visible && (
+
+
+
+
+
+
+ runAiAction('rewrite', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
+ 改写优化
+
+ runAiAction('blogify', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
+ 博客风格
+
+ runAiAction('summarize', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), false)}>
+ 内容总结
+
+ runAiAction('expand', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
+ 内容扩写
+
+ runAiAction('fix', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
+ 纠错润色
+
+
+
+
+
+
+
+
+
+
+
+ runAiAction('tone:专业', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>专业
+ runAiAction('tone:幽默', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>幽默
+ runAiAction('tone:亲切', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>亲切
+
+
+
+
+
+
+
+
+ runAiAction('translate:英语', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>英语
+ runAiAction('translate:中文', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>中文
+ runAiAction('translate:日语', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>日语
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/aitools/AIToolsLayout.tsx b/src/components/aitools/AIToolsLayout.tsx
new file mode 100644
index 0000000..dc29696
--- /dev/null
+++ b/src/components/aitools/AIToolsLayout.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import MainLayout from '@/components/layouts/MainLayout';
+import ToolsSidebar from './ToolsSidebar';
+
+interface AIToolsLayoutProps {
+ children: React.ReactNode;
+ title?: string;
+ description?: string;
+}
+
+export default function AIToolsLayout({ children, title, description }: AIToolsLayoutProps) {
+ return (
+
+
+
+
+ {/* Left Sidebar */}
+
+
+ {/* Right Content */}
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/components/aitools/ToolsSidebar.tsx b/src/components/aitools/ToolsSidebar.tsx
new file mode 100644
index 0000000..1d2fc2a
--- /dev/null
+++ b/src/components/aitools/ToolsSidebar.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { Sparkles, Languages, Wand2, ChevronRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const tools = [
+ {
+ id: 'prompt-optimizer',
+ name: '提示词优化师',
+ description: '优化指令,提升生成质量',
+ icon: Sparkles,
+ href: '/aitools/prompt-optimizer',
+ color: 'text-purple-600',
+ bgColor: 'bg-purple-100',
+ },
+ {
+ id: 'translator',
+ name: '智能翻译助手',
+ description: '多语言精准互译',
+ icon: Languages,
+ href: '/aitools/translator',
+ color: 'text-blue-600',
+ bgColor: 'bg-blue-100',
+ },
+ {
+ id: 'more',
+ name: '更多工具',
+ description: '敬请期待...',
+ icon: Wand2,
+ href: '#',
+ color: 'text-gray-400',
+ bgColor: 'bg-gray-100',
+ disabled: true
+ }
+];
+
+export default function ToolsSidebar() {
+ const router = useRouter();
+
+ return (
+
+
+
+
+ {tools.map((tool) => {
+ const isActive = router.pathname === tool.href;
+ return (
+
tool.disabled && e.preventDefault()}
+ >
+
+
+
+
+
+
+ {tool.name}
+
+
+ {tool.description}
+
+
+
+ {isActive && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/article/CommentSection.tsx b/src/components/article/CommentSection.tsx
new file mode 100644
index 0000000..b0baa1b
--- /dev/null
+++ b/src/components/article/CommentSection.tsx
@@ -0,0 +1,161 @@
+import React, { useState, useEffect } from 'react';
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { formatDistanceToNow } from 'date-fns';
+import { zhCN } from 'date-fns/locale';
+import { Loader2, MessageSquare, Send } from 'lucide-react';
+import { toast } from 'sonner';
+
+interface Comment {
+ _id: string;
+ 用户ID: {
+ _id: string;
+ 用户名: string;
+ 头像: string;
+ };
+ 评论内容: string;
+ createdAt: string;
+ 父评论ID?: string;
+}
+
+interface CommentSectionProps {
+ articleId: string;
+ isLoggedIn: boolean;
+}
+
+export default function CommentSection({ articleId, isLoggedIn }: CommentSectionProps) {
+ const [comments, setComments] = useState([]);
+ const [content, setContent] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ fetchComments();
+ }, [articleId]);
+
+ const fetchComments = async () => {
+ try {
+ const res = await fetch(`/api/comments?articleId=${articleId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setComments(data.comments);
+ }
+ } catch (error) {
+ console.error('Failed to fetch comments', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!content.trim()) return;
+ if (!isLoggedIn) {
+ toast.error('请先登录后再评论');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const res = await fetch('/api/comments', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ articleId, content }),
+ });
+
+ if (res.ok) {
+ toast.success('评论发表成功');
+ setContent('');
+ fetchComments(); // Refresh comments
+ } else {
+ const data = await res.json();
+ toast.error(data.message || '评论发表失败');
+ }
+ } catch (error) {
+ toast.error('网络错误,请稍后重试');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ 评论 ({comments.length})
+
+
+ {/* Comment Form */}
+
+ {isLoggedIn ? (
+
+ ) : (
+
+
登录后参与评论讨论
+
+
+ )}
+
+
+ {/* Comments List */}
+
+ {loading ? (
+
+
+
+ ) : comments.length === 0 ? (
+
+ 暂无评论,快来抢沙发吧!
+
+ ) : (
+ comments.map((comment) => (
+
+
+
+ {comment.用户ID?.用户名?.charAt(0)}
+
+
+
+
+ {comment.用户ID?.用户名 || '未知用户'}
+
+
+ {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true, locale: zhCN })}
+
+
+
+ {comment.评论内容}
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/components/home/ArticleCard.tsx b/src/components/home/ArticleCard.tsx
new file mode 100644
index 0000000..0adbda5
--- /dev/null
+++ b/src/components/home/ArticleCard.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { Clock, ArrowRight } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+
+interface ArticleCardProps {
+ article: {
+ _id: string;
+ 文章标题: string;
+ 摘要: string;
+ 封面图: string;
+ 分类ID: { 分类名称: string };
+ createdAt: string;
+ 价格: number;
+ };
+}
+
+export default function ArticleCard({ article }: ArticleCardProps) {
+ const isFree = article.价格 === 0;
+
+ return (
+
+ {/* Image */}
+
+ {article.封面图 ? (
+
+ ) : (
+
No Image
+ )}
+
+
+ {article.分类ID?.分类名称 || '未分类'}
+
+
+
+
+ {/* Content */}
+
+
+ {new Date(article.createdAt).toLocaleDateString()}
+ •
+
+ 12 min read
+
+
+
+
+
+ {article.文章标题}
+
+
+
+
+ {article.摘要}
+
+
+
+ {isFree ? (
+
FREE
+ ) : (
+
¥{article.价格}
+ )}
+
+
+ 阅读全文
+
+
+
+
+ );
+}
diff --git a/src/components/home/HeroBanner.tsx b/src/components/home/HeroBanner.tsx
new file mode 100644
index 0000000..6c47237
--- /dev/null
+++ b/src/components/home/HeroBanner.tsx
@@ -0,0 +1,152 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel";
+import { cn } from "@/lib/utils";
+
+interface Banner {
+ 标题: string;
+ 描述: string;
+ 图片地址: string;
+ 按钮文本: string;
+ 按钮链接: string;
+}
+
+interface HeroBannerProps {
+ banners: Banner[];
+}
+
+export default function HeroBanner({ banners }: HeroBannerProps) {
+ const [api, setApi] = useState();
+ const [current, setCurrent] = useState(0);
+ const [count, setCount] = useState(0);
+ const [isPaused, setIsPaused] = useState(false);
+
+ useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ setCount(api.scrollSnapList().length);
+ setCurrent(api.selectedScrollSnap());
+
+ api.on("select", () => {
+ setCurrent(api.selectedScrollSnap());
+ });
+ }, [api]);
+
+ // Handle auto-play via progress bar animation end
+ const handleAnimationEnd = useCallback(() => {
+ if (api) {
+ api.scrollNext();
+ }
+ }, [api]);
+
+ if (!banners || banners.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ setIsPaused(true)}
+ onMouseLeave={() => setIsPaused(false)}
+ >
+
+
+ {banners.map((banner, index) => (
+
+
+ {/* Background Image */}
+
+
{/* Overlay */}
+
{/* Gradient Overlay */}
+
+
+ {/* Content */}
+
+
+
+ {banner.标题}
+
+
+ {banner.描述}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {banners.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Premium Progress Indicators */}
+ {banners.length > 1 && (
+
+
+ {Array.from({ length: count }).map((_, index) => {
+ const isActive = current === index;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/home/Sidebar.tsx b/src/components/home/Sidebar.tsx
new file mode 100644
index 0000000..ed28d70
--- /dev/null
+++ b/src/components/home/Sidebar.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import Link from 'next/link';
+
+interface SidebarProps {
+ tags: any[];
+}
+
+export default function Sidebar({ tags }: SidebarProps) {
+ return (
+
+ {/* Hot Tags */}
+
+
+
+ 热门标签
+
+
+ {tags.map((tag) => (
+
+
+ {tag.标签名称}
+
+
+ ))}
+
+
+
+ {/* Recommended (Static for now) */}
+
+
+
+ 推荐阅读
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
0{i}
+
+
+ 2025年前端架构演进:从 Micro-Frontends 到 Islands Architecture
+
+
12 min read
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/layouts/MainLayout.tsx b/src/components/layouts/MainLayout.tsx
new file mode 100644
index 0000000..eec9cbb
--- /dev/null
+++ b/src/components/layouts/MainLayout.tsx
@@ -0,0 +1,314 @@
+import React from 'react';
+import Link from 'next/link';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import { Button } from '@/components/ui/button';
+import { Search, User, LogOut, LayoutDashboard, Settings } from 'lucide-react';
+import { Toaster } from "@/components/ui/sonner";
+import { useAuth } from '@/hooks/useAuth';
+import { useConfig } from '@/contexts/ConfigContext';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+
+interface SEOProps {
+ title?: string;
+ description?: string;
+ keywords?: string;
+}
+
+interface MainLayoutProps {
+ children: React.ReactNode;
+ transparentHeader?: boolean;
+ seo?: SEOProps;
+ showFooter?: boolean;
+}
+
+function AuthButtons() {
+ const { user, loading, logout } = useAuth();
+
+ if (loading) {
+ return ;
+ }
+
+ if (user) {
+ return (
+
+
+
+
+
+
+
+
{user.用户名}
+
+ {user.邮箱}
+
+
+
+
+
+
+
+ 个人中心
+
+
+ {user.角色 === 'admin' && (
+
+
+
+ 管理后台
+
+
+ )}
+
+
+
+ 退出登录
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function MainLayout({ children, transparentHeader = false, seo, showFooter = true }: MainLayoutProps) {
+ const { config } = useConfig();
+ const router = useRouter();
+ const [isScrolled, setIsScrolled] = React.useState(false);
+
+ React.useEffect(() => {
+ const handleScroll = () => {
+ setIsScrolled(window.scrollY > 50);
+ };
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const isTransparent = transparentHeader && !isScrolled;
+
+ // Construct title: "Page Title - Site Title" or just "Site Title"
+ const siteTitle = config?.网站标题 || 'AounApp';
+ const pageTitle = seo?.title ? `${seo.title} - ${siteTitle}` : siteTitle;
+ const pageDescription = seo?.description || config?.全局SEO描述 || config?.网站副标题 || 'AounApp - 个人资源站';
+ const pageKeywords = seo?.keywords || config?.全局SEO关键词 || '';
+
+ return (
+
+
+
{pageTitle}
+
+
+
+
+ {/* Header */}
+
+
+ {/* Main Content */}
+
+ {children}
+
+
+ {/* Footer */}
+ {showFooter && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..7c0eb5f
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..44ca81a
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps { }
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..a0a4586
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..e83f12d
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..b83975e
--- /dev/null
+++ b/src/components/ui/carousel.tsx
@@ -0,0 +1,263 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: CarouselApi
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & CarouselProps
+>(
+ (
+ {
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return
+ }
+
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return
+ }
+
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) {
+ return
+ }
+
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api.off("reInit", onSelect)
+ api.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+Carousel.displayName = "Carousel"
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselContent.displayName = "CarouselContent"
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselItem.displayName = "CarouselItem"
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselPrevious.displayName = "CarouselPrevious"
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselNext.displayName = "CarouselNext"
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..46426e5
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,120 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d13f31
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx
new file mode 100644
index 0000000..03cfa7b
--- /dev/null
+++ b/src/components/ui/form.tsx
@@ -0,0 +1,176 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ ControllerProps,
+ FieldPath,
+ FieldValues,
+ FormProvider,
+ useFormContext,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message) : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..da40b87
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..ef090dc
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..8731f8c
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..c582f80
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,157 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..6864060
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..71b75f2
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner } from "sonner"
+
+type ToasterProps = React.ComponentProps
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ )
+}
+
+export { Toaster }
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000..5b1186a
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..f441195
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..d6cb359
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,53 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..87c2072
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes { }
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
new file mode 100644
index 0000000..81fc751
--- /dev/null
+++ b/src/contexts/ConfigContext.tsx
@@ -0,0 +1,58 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+
+interface SiteConfig {
+ 网站标题?: string;
+ 网站副标题?: string;
+ Logo地址?: string;
+ Favicon?: string;
+ 备案号?: string;
+ 全局SEO关键词?: string;
+ 全局SEO描述?: string;
+ 底部版权信息?: string;
+ 第三方统计代码?: string;
+}
+
+interface ConfigContextType {
+ config: SiteConfig | null;
+ loading: boolean;
+ refreshConfig: () => Promise;
+}
+
+const ConfigContext = createContext(undefined);
+
+export function ConfigProvider({ children }: { children: React.ReactNode }) {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const fetchConfig = async () => {
+ try {
+ const res = await fetch('/api/public/config');
+ if (res.ok) {
+ const data = await res.json();
+ setConfig(data.site);
+ }
+ } catch (error) {
+ console.error('Failed to fetch site config:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchConfig();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useConfig() {
+ const context = useContext(ConfigContext);
+ if (context === undefined) {
+ throw new Error('useConfig must be used within a ConfigProvider');
+ }
+ return context;
+}
diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx
new file mode 100644
index 0000000..6906317
--- /dev/null
+++ b/src/hooks/useAuth.tsx
@@ -0,0 +1,80 @@
+import { useState, useEffect, createContext, useContext } from 'react';
+import { useRouter } from 'next/router';
+
+interface User {
+ _id: string;
+ 用户名: string;
+ 邮箱: string;
+ 头像: string;
+ 角色: string;
+ 钱包?: {
+ 当前积分: number;
+ 历史总消费: number;
+ };
+ 会员信息?: {
+ 当前等级ID?: {
+ _id: string;
+ 套餐名称: string;
+ };
+ 过期时间?: string;
+ };
+}
+
+interface AuthContextType {
+ user: User | null;
+ loading: boolean;
+ logout: () => Promise;
+ refreshUser: () => Promise;
+}
+
+const AuthContext = createContext({
+ user: null,
+ loading: true,
+ logout: async () => { },
+ refreshUser: async () => { },
+});
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const router = useRouter();
+
+ const fetchUser = async () => {
+ try {
+ const res = await fetch('/api/auth/me');
+ if (res.ok) {
+ const data = await res.json();
+ setUser(data.user);
+ } else {
+ setUser(null);
+ }
+ } catch (error) {
+ console.error('Failed to fetch user', error);
+ setUser(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchUser();
+ }, []);
+
+ const logout = async () => {
+ try {
+ await fetch('/api/auth/logout', { method: 'POST' });
+ setUser(null);
+ router.push('/auth/login');
+ } catch (error) {
+ console.error('Logout failed', error);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useAuth = () => useContext(AuthContext);
diff --git a/src/lib/alipay.ts b/src/lib/alipay.ts
new file mode 100644
index 0000000..969bb37
--- /dev/null
+++ b/src/lib/alipay.ts
@@ -0,0 +1,138 @@
+import crypto from 'crypto';
+import { SystemConfig } from '@/models';
+import dbConnect from './dbConnect';
+
+interface AlipayConfig {
+ appId: string;
+ privateKey: string;
+ alipayPublicKey: string;
+ gateway: string;
+ notifyUrl: string;
+ returnUrl?: string;
+}
+
+export class AlipayService {
+ private config: AlipayConfig | null = null;
+
+ async init() {
+ if (this.config) return;
+
+ await dbConnect();
+ const systemConfig = await SystemConfig.findOne({ 配置标识: 'default' }).select('+支付宝设置.AppID +支付宝设置.应用私钥 +支付宝设置.公钥 +支付宝设置.网关地址 +支付宝设置.回调URL');
+
+ if (!systemConfig || !systemConfig.支付宝设置) {
+ throw new Error('Alipay configuration not found');
+ }
+
+ const { AppID, 应用私钥, 公钥, 网关地址, 回调URL } = systemConfig.支付宝设置;
+
+ this.config = {
+ appId: AppID,
+ privateKey: this.convertBase64ToPem(应用私钥, 'PRIVATE KEY'),
+ alipayPublicKey: this.convertBase64ToPem(公钥, 'PUBLIC KEY'),
+ gateway: 网关地址 || 'https://openapi.alipay.com/gateway.do',
+ notifyUrl: 回调URL
+ };
+ }
+
+ /**
+ * 将 Base64 编码的密钥转换为 PEM 格式
+ */
+ private convertBase64ToPem(base64Key: string, keyType: 'PRIVATE KEY' | 'PUBLIC KEY'): string {
+ // 如果已经是 PEM 格式(包含 BEGIN),直接返回
+ if (base64Key.includes('-----BEGIN')) {
+ return base64Key;
+ }
+
+ // 移除所有空白字符
+ const cleanKey = base64Key.replace(/\s/g, '');
+
+ // 每 64 个字符插入一个换行符
+ const formattedKey = cleanKey.match(/.{1,64}/g)?.join('\n') || cleanKey;
+
+ return `-----BEGIN ${keyType}-----\n${formattedKey}\n-----END ${keyType}-----`;
+ }
+
+ /**
+ * Generate Alipay Page Pay URL
+ */
+ async generatePagePayUrl(order: { outTradeNo: string; totalAmount: string; subject: string; body?: string; returnUrl?: string }) {
+ await this.init();
+ if (!this.config) throw new Error('Alipay not initialized');
+
+ const params: Record = {
+ app_id: this.config.appId,
+ method: 'alipay.trade.page.pay',
+ format: 'JSON',
+ charset: 'utf-8',
+ sign_type: 'RSA2',
+ timestamp: new Date().toISOString().replace('T', ' ').split('.')[0],
+ version: '1.0',
+ notify_url: `${this.config.notifyUrl}/api/payment/notify`,
+ return_url: order.returnUrl || `${this.config.notifyUrl}/api/payment/return`, // 优先使用传入的跳转地址
+ biz_content: JSON.stringify({
+ out_trade_no: order.outTradeNo,
+ product_code: 'FAST_INSTANT_TRADE_PAY',
+ total_amount: order.totalAmount,
+ subject: order.subject,
+ body: order.body,
+ }),
+ };
+
+ params.sign = this.sign(params);
+
+ const query = Object.keys(params)
+ .map((key) => `${key}=${encodeURIComponent(params[key])}`)
+ .join('&');
+
+ return `${this.config.gateway}?${query}`;
+ }
+
+ /**
+ * Verify Alipay Signature
+ */
+ async verifySignature(params: Record): Promise {
+ await this.init();
+ if (!this.config) throw new Error('Alipay not initialized');
+
+ const { sign, sign_type, ...rest } = params;
+ if (!sign) return false;
+
+ // Sort and stringify params
+ const content = Object.keys(rest)
+ .sort()
+ .map((key) => {
+ const value = rest[key];
+ return value !== '' && value !== undefined && value !== null ? `${key}=${value}` : null;
+ })
+ .filter(Boolean)
+ .join('&');
+
+ const verify = crypto.createVerify('RSA-SHA256');
+ verify.update(content);
+
+ return verify.verify(this.config.alipayPublicKey, sign, 'base64');
+ }
+
+ /**
+ * Internal Sign Method
+ */
+ private sign(params: Record): string {
+ if (!this.config) throw new Error('Alipay not initialized');
+
+ const content = Object.keys(params)
+ .sort()
+ .map((key) => {
+ const value = params[key];
+ return value !== '' && value !== undefined && value !== null ? `${key}=${value}` : null;
+ })
+ .filter(Boolean)
+ .join('&');
+
+ const sign = crypto.createSign('RSA-SHA256');
+ sign.update(content);
+ return sign.sign(this.config.privateKey, 'base64');
+ }
+}
+
+export const alipayService = new AlipayService();
diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts
new file mode 100644
index 0000000..5ecf4d5
--- /dev/null
+++ b/src/lib/api-handler.ts
@@ -0,0 +1,43 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import { ZodError } from 'zod';
+
+type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
+
+interface ApiHandlerConfig {
+ [key: string]: (req: NextApiRequest, res: NextApiResponse) => Promise | void;
+}
+
+export class AppError extends Error {
+ statusCode: number;
+ constructor(message: string, statusCode = 500) {
+ super(message);
+ this.statusCode = statusCode;
+ }
+}
+
+export function createApiHandler(handlers: ApiHandlerConfig) {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ const method = req.method as HttpMethod;
+ const handler = handlers[method];
+
+ if (!handler) {
+ return res.status(405).json({ message: `Method ${method} Not Allowed` });
+ }
+
+ try {
+ await handler(req, res);
+ } catch (error: any) {
+ console.error(`API Error [${method} ${req.url}]:`, error);
+
+ if (error instanceof AppError) {
+ return res.status(error.statusCode).json({ message: error.message });
+ }
+
+ if (error instanceof ZodError) {
+ return res.status(400).json({ message: 'Validation Error', errors: error.issues });
+ }
+
+ return res.status(500).json({ message: error.message || 'Internal Server Error' });
+ }
+ };
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..d55c217
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,53 @@
+import jwt from 'jsonwebtoken';
+import { NextApiRequest, NextApiResponse } from 'next';
+
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET) {
+ throw new Error('Please define the JWT_SECRET environment variable inside .env.local');
+}
+
+export interface DecodedToken {
+ userId: string;
+ email: string;
+ role: string;
+ iat: number;
+ exp: number;
+}
+
+export function verifyToken(token: string): DecodedToken | null {
+ try {
+ return jwt.verify(token, JWT_SECRET as string) as unknown as DecodedToken;
+ } catch (error) {
+ return null;
+ }
+}
+
+export function getUserFromCookie(req: NextApiRequest): DecodedToken | null {
+ const token = req.cookies.token;
+ if (!token) return null;
+ return verifyToken(token);
+}
+
+export const requireAuth = (handler: (req: NextApiRequest, res: NextApiResponse, user: DecodedToken) => Promise | void) => {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = getUserFromCookie(req);
+
+ if (!user) {
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+
+ return handler(req, res, user);
+ };
+};
+
+export const requireAdmin = (handler: (req: NextApiRequest, res: NextApiResponse, user: DecodedToken) => Promise | void) => {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = getUserFromCookie(req);
+
+ if (!user || user.role !== 'admin') {
+ return res.status(403).json({ message: 'Forbidden: Admin access required' });
+ }
+
+ return handler(req, res, user);
+ };
+};
diff --git a/src/lib/dbConnect.ts b/src/lib/dbConnect.ts
new file mode 100644
index 0000000..41e7fac
--- /dev/null
+++ b/src/lib/dbConnect.ts
@@ -0,0 +1,130 @@
+import mongoose from 'mongoose';
+
+// 获取环境变量
+const MONGODB_URI = process.env.MONGODB_URI;
+
+if (!MONGODB_URI) {
+ throw new Error(
+ '请在 .env.local 文件中定义 MONGODB_URI 环境变量'
+ );
+}
+
+/**
+ * 全局类型定义,防止 TypeScript 报错
+ * 使用 var 声明以挂载到 globalThis 上
+ */
+interface MongooseCache {
+ conn: mongoose.Connection | null;
+ promise: Promise | null;
+}
+
+declare global {
+ var mongoose: MongooseCache;
+}
+
+// 初始化全局缓存,防止热重载导致连接数爆炸
+let cached = global.mongoose;
+
+if (!cached) {
+ cached = global.mongoose = { conn: null, promise: null };
+}
+
+/**
+ * 数据库连接配置
+ * 针对生产环境和开发环境进行了微调
+ */
+const connectionOptions: mongoose.ConnectOptions = {
+ bufferCommands: false, // 禁用缓冲,确保立即失败而不是等待
+ maxPoolSize: process.env.NODE_ENV === 'production' ? 10 : 5, // 生产环境连接池稍大
+ minPoolSize: 2, // 保持少量活跃连接
+ serverSelectionTimeoutMS: 10000, // 寻找服务器超时 (10秒)
+ socketTimeoutMS: 45000, // Socket 操作超时 (45秒)
+ connectTimeoutMS: 10000, // 初始连接超时 (10秒)
+ family: 4, // 强制使用 IPv4,解决某些环境下 localhost 解析慢的问题
+};
+
+/**
+ * 建立或获取缓存的 MongoDB 连接
+ * 这是核心函数,既可以在普通函数中调用,也可以被 HOF 使用
+ */
+async function dbConnect(): Promise {
+ // 1. 检查是否有活跃的连接 (readyState 1 表示 connected)
+ if (cached.conn && cached.conn.readyState === 1) {
+ return cached.conn;
+ }
+
+ // 2. 如果正在连接中,复用当前的 Promise,防止并发请求导致多次连接
+ if (cached.promise) {
+ try {
+ cached.conn = await cached.promise;
+ // 再次检查连接状态,确保 Promise 解析后的连接是可用的
+ if (cached.conn.readyState === 1) {
+ return cached.conn;
+ }
+ } catch (error) {
+ cached.promise = null; // 出错则清除 Promise,允许重试
+ throw error;
+ }
+ }
+
+ // 3. 建立新连接
+ cached.promise = mongoose.connect(MONGODB_URI!, connectionOptions)
+ .then((mongooseInstance) => {
+ const connection = mongooseInstance.connection;
+
+ console.log('✅ [MongoDB] 新连接已建立');
+
+ // 绑定一次性监听器,防止内存泄漏
+ // 注意:Serverless 环境下,这些监听器可能不会长期存在,仅用于当前实例生命周期
+ connection.on('error', (err) => {
+ console.error('🔴 [MongoDB] 连接发生错误:', err);
+ cached.conn = null;
+ cached.promise = null;
+ });
+
+ connection.on('disconnected', () => {
+ console.warn('🟡 [MongoDB] 连接断开');
+ cached.conn = null;
+ cached.promise = null;
+ });
+
+ return connection;
+ })
+ .catch((error) => {
+ console.error('❌ [MongoDB] 连接建立失败:', error);
+ cached.promise = null;
+ throw error;
+ });
+
+ try {
+ cached.conn = await cached.promise;
+ } catch (e) {
+ cached.promise = null;
+ throw e;
+ }
+
+ return cached.conn;
+}
+
+/**
+ * 辅助函数:获取当前连接状态(用于健康检查 API)
+ */
+export function getConnectionStatus() {
+ const connection = mongoose.connection;
+ const states = {
+ 0: 'disconnected',
+ 1: 'connected',
+ 2: 'connecting',
+ 3: 'disconnecting',
+ 99: 'uninitialized',
+ };
+ const readyState = connection ? connection.readyState : 99;
+
+ return {
+ status: states[readyState as keyof typeof states] || 'unknown',
+ readyState,
+ dbName: connection && connection.name ? connection.name : 'unknown'
+ };
+}
+
+export default dbConnect;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..03aaa4b
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/lib/withDatabase.ts b/src/lib/withDatabase.ts
new file mode 100644
index 0000000..f68ea22
--- /dev/null
+++ b/src/lib/withDatabase.ts
@@ -0,0 +1,30 @@
+import { NextApiRequest, NextApiResponse, NextApiHandler } from 'next';
+import dbConnect from './dbConnect';
+
+/**
+ * 高阶函数 (HOF):自动处理数据库连接
+ * 用法: export default withDatabase(async (req, res) => { ... });
+ */
+const withDatabase = (handler: NextApiHandler) => {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ try {
+ // 在执行 handler 之前确保数据库已连接
+ await dbConnect();
+
+ // 数据库连接成功后,执行原本的 API 逻辑
+ return await handler(req, res);
+ } catch (error) {
+ console.error('❌ [API Error] 数据库连接失败或处理程序出错:', error);
+
+ // 统一错误响应
+ return res.status(500).json({
+ success: false,
+ message: 'Internal Server Error - Database Connection Failed',
+ // 在开发环境下返回详细错误信息
+ error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
+ });
+ }
+ };
+};
+
+export default withDatabase;
diff --git a/src/models/index.ts b/src/models/index.ts
new file mode 100644
index 0000000..1e6558a
--- /dev/null
+++ b/src/models/index.ts
@@ -0,0 +1,276 @@
+import mongoose, { Schema, Document, Model } from 'mongoose';
+
+/**
+ * ==================================================================
+ * 1. User (用户模型)
+ * ==================================================================
+ * 核心逻辑:
+ * - 用户是系统的基石,主要存储身份信息和资产信息。
+ */
+const UserSchema = new Schema({
+ 用户名: { type: String, required: true, unique: true },
+ 邮箱: { type: String, required: true, unique: true },
+ 电话: { type: String },
+ 密码: { type: String, required: true, select: false }, // select: false 保护密码
+ 头像: { type: String, default: '/images/default_avatar.png' },
+
+ // 角色权限
+ 角色: {
+ type: String,
+ enum: ['user', 'admin', 'editor'],
+ default: 'user'
+ },
+
+ // 钱包/资产
+ 钱包: {
+ 当前积分: { type: Number, default: 0 },
+ 历史总消费: { type: Number, default: 0 }
+ },
+
+ // 会员逻辑
+ 会员信息: {
+ 当前等级ID: { type: Schema.Types.ObjectId, ref: 'MembershipPlan' },
+ 过期时间: { type: Date }
+ },
+
+ // 账号风控
+ 是否被封禁: { type: Boolean, default: false },
+
+}, { timestamps: true });
+
+
+/**
+ * ==================================================================
+ * 2. Article (文章/资源模型)
+ * ==================================================================
+ */
+const ArticleSchema = new Schema({
+ // --- 基础信息 ---
+ 文章标题: { type: String, required: true },
+ URL别名: { type: String, unique: true, index: true },
+ 封面图: { type: String },
+
+ // --- 内容部分 ---
+ 摘要: { type: String },
+ 正文内容: { type: String, required: true },
+
+ // --- SEO 专用优化 ---
+ SEO关键词: { type: [String], default: [] },
+ SEO描述: { type: String },
+
+ // --- 关联信息 ---
+ 作者ID: { type: Schema.Types.ObjectId, ref: 'User' },
+ 分类ID: { type: Schema.Types.ObjectId, ref: 'Category' },
+ 标签ID列表: [{ type: Schema.Types.ObjectId, ref: 'Tag' }],
+
+ // --- 售卖策略 ---
+ 价格: { type: Number, default: 0 },
+ 支付方式: { type: String, enum: ['points', 'cash'], default: 'points' },
+
+ // --- 资源交付 (付费后可见) ---
+ 资源属性: {
+ 下载链接: { type: String },
+ 提取码: { type: String },
+ 解压密码: { type: String },
+ 隐藏内容: { type: String }
+ },
+
+ // --- 数据统计 ---
+ 统计数据: {
+ 阅读数: { type: Number, default: 0 },
+ 点赞数: { type: Number, default: 0 },
+ 评论数: { type: Number, default: 0 },
+ 收藏数: { type: Number, default: 0 },
+ 分享数: { type: Number, default: 0 }
+ },
+
+ // --- 状态控制 ---
+ 发布状态: { type: String, enum: ['draft', 'published', 'offline'], default: 'published' }
+}, { timestamps: true });
+
+// 复合索引优化
+ArticleSchema.index({ createdAt: -1 });
+ArticleSchema.index({ 分类ID: 1, 发布状态: 1 });
+
+
+/**
+ * ==================================================================
+ * 3. SystemConfig (系统全局配置)
+ * ==================================================================
+ * 作用:存储网站配置、支付秘钥等。
+ */
+const SystemConfigSchema = new Schema({
+ // 标识符,确保只取这一条配置
+ 配置标识: { type: String, default: 'default', unique: true },
+
+ // --- 站点基础信息 ---
+ 站点设置: {
+ 网站标题: { type: String, default: '我的个人资源站' },
+ 网站副标题: { type: String },
+ Logo地址: { type: String },
+ Favicon: { type: String },
+ 备案号: { type: String },
+ 全局SEO关键词: { type: String },
+ 全局SEO描述: { type: String },
+ 底部版权信息: { type: String },
+ 第三方统计代码: { type: String }
+ },
+
+ // ---Banner配置(支持多个)---
+ Banner配置: [{
+ 标题: { type: String },
+ 描述: { type: String },
+ 图片地址: { type: String },
+ 按钮文本: { type: String },
+ 按钮链接: { type: String },
+ 状态: { type: String, enum: ['visible', 'hidden'], default: 'visible' }
+ }],
+
+ // --- 支付宝配置 (敏感字段 hidden) ---
+ 支付宝设置: {
+ AppID: { type: String, select: false },
+ 公钥: { type: String, select: false },
+ 应用公钥: { type: String, select: false },
+ 应用私钥: { type: String, select: false }, // 核心机密
+ 回调URL: { type: String },
+ 网关地址: { type: String }
+ },
+
+ // --- 微信支付配置 (敏感字段 hidden) ---
+ 微信支付设置: {
+ WX_APPID: { type: String, select: false },
+ WX_MCHID: { type: String, select: false },
+ WX_PRIVATE_KEY: { type: String, select: false }, // 核心机密
+ WX_API_V3_KEY: { type: String, select: false }, // 核心机密
+ WX_SERIAL_NO: { type: String }, // 证书序列号通常不需要隐藏
+ WX_NOTIFY_URL: { type: String },
+ },
+
+ // --- 阿里云短信配置 (敏感字段 hidden) ---
+ 阿里云短信设置: {
+ AccessKeyID: { type: String, select: false },
+ AccessKeySecret: { type: String, select: false },
+ aliSignName: { type: String },
+ aliTemplateCode: { type: String },
+ },
+
+ // --- 邮箱配置 (敏感字段 hidden) ---
+ 邮箱设置: {
+ MY_MAIL: { type: String },
+ MY_MAIL_PASS: { type: String, select: false }, // 【修复】授权码必须隐藏!
+ },
+ // --- AI助手配置 (数组形式,支持多个) ---
+ AI配置列表: [{
+ 名称: { type: String, required: true }, // AI-Name (如: "写作助手-Gemini")
+ 接口地址: { type: String, required: true }, // API-Endpoint
+ API密钥: { type: String, select: false, required: true }, // API-Key (敏感字段,默认不查询)
+ 模型: { type: String, required: true }, // AI-Model (如: "gemini-1.5-pro", "gpt-4o")
+ 系统提示词: { type: String }, // AI-System-Prompt
+ 流式传输: { type: Boolean, default: false }, // stream: false (默认关闭流式)
+ 是否启用: { type: Boolean, default: true }
+ }],
+
+}, { timestamps: true });
+
+
+/**
+ * ==================================================================
+ * 4. Comment (评论模型)
+ * ==================================================================
+ */
+const CommentSchema = new Schema({
+ 文章ID: { type: Schema.Types.ObjectId, ref: 'Article', required: true, index: true },
+ 用户ID: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+ 评论内容: { type: String, required: true },
+
+ 父评论ID: { type: Schema.Types.ObjectId, ref: 'Comment', default: null },
+ 被回复用户ID: { type: Schema.Types.ObjectId, ref: 'User' },
+
+ 点赞数: { type: Number, default: 0 },
+ 是否置顶: { type: Boolean, default: false },
+ 状态: { type: String, enum: ['visible', 'audit', 'deleted'], default: 'visible' }
+}, { timestamps: true });
+
+
+/**
+ * ==================================================================
+ * 5. Order (订单模型)
+ * ==================================================================
+ * 核心更新:增加了“商品快照”,防止商品删除后订单显示异常。
+ */
+const OrderSchema = new Schema({
+ 订单号: { type: String, required: true, unique: true },
+ 用户ID: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
+
+ 订单类型: {
+ type: String,
+ enum: ['buy_resource', 'buy_membership', 'recharge_points'],
+ required: true
+ },
+
+ // 关联ID (用于逻辑跳转)
+ 商品ID: { type: Schema.Types.ObjectId },
+
+ // 【新增】商品快照:下单时的商品信息备份
+ 商品快照: {
+ 标题: { type: String }, // 如下单时文章叫"React V1",现在叫"React V2",订单里依然显示"V1"
+ 封面: { type: String }
+ },
+
+ 支付金额: { type: Number, required: true },
+ 支付方式: { type: String, enum: ['alipay', 'wechat', 'points', 'balance'] },
+
+ 订单状态: {
+ type: String,
+ enum: ['pending', 'paid'],
+ default: 'pending'
+ },
+ 支付时间: { type: Date }
+}, { timestamps: true });
+
+
+/**
+ * ==================================================================
+ * 6. MembershipPlan (会员套餐)
+ * ==================================================================
+ */
+const MembershipPlanSchema = new Schema({
+ 套餐名称: { type: String, required: true },
+ 有效天数: { type: Number, required: true },
+ 价格: { type: Number, required: true },
+ 描述: { type: String },
+
+ 特权配置: {
+ 每日下载限制: { type: Number, default: 10 },
+ 购买折扣: { type: Number, default: 0.8 }
+ },
+ 是否上架: { type: Boolean, default: true }
+});
+
+
+/**
+ * ==================================================================
+ * 7. Category & Tag (分类/标签)
+ * ==================================================================
+ */
+const CategorySchema = new Schema({
+ 分类名称: { type: String, required: true },
+ 别名: { type: String, required: true, unique: true },
+ 排序权重: { type: Number, default: 0 }
+});
+
+const TagSchema = new Schema({
+ 标签名称: { type: String, required: true, unique: true }
+});
+
+
+// ------------------------- 模型导出 -------------------------
+
+export const User = mongoose.models.User || mongoose.model('User', UserSchema);
+export const SystemConfig = mongoose.models.SystemConfig || mongoose.model('SystemConfig', SystemConfigSchema);
+export const Article = mongoose.models.Article || mongoose.model('Article', ArticleSchema);
+export const Comment = mongoose.models.Comment || mongoose.model('Comment', CommentSchema);
+export const Order = mongoose.models.Order || mongoose.model('Order', OrderSchema);
+export const MembershipPlan = mongoose.models.MembershipPlan || mongoose.model('MembershipPlan', MembershipPlanSchema);
+export const Category = mongoose.models.Category || mongoose.model('Category', CategorySchema);
+export const Tag = mongoose.models.Tag || mongoose.model('Tag', TagSchema);
\ No newline at end of file
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
new file mode 100644
index 0000000..27d0abf
--- /dev/null
+++ b/src/pages/404.tsx
@@ -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 (
+
+
+ {/* Illustration placeholder or large text */}
+
404
+
+
+
页面未找到
+
+ 抱歉,您访问的页面不存在或已被移除。
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
new file mode 100644
index 0000000..ff1fbf8
--- /dev/null
+++ b/src/pages/_app.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
new file mode 100644
index 0000000..c148572
--- /dev/null
+++ b/src/pages/_document.tsx
@@ -0,0 +1,13 @@
+import { Html, Head, Main, NextScript } from "next/document";
+
+export default function Document() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/ai/index.tsx b/src/pages/admin/ai/index.tsx
new file mode 100644
index 0000000..04bd808
--- /dev/null
+++ b/src/pages/admin/ai/index.tsx
@@ -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([]);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingAssistant, setEditingAssistant] = useState(null);
+
+ const { register, control, handleSubmit, reset, setValue, watch } = useForm({
+ 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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
AI 助手管理
+
+ 配置和管理您的 AI 智能体,支持多模型接入。
+
+
+
+
+
+
+ {assistants.map((assistant, index) => (
+
+
+
+
+
+
+
+
+ {assistant.名称}
+
+
+ {assistant.模型}
+
+
+
+
+
+
+
+
+
+
+
+ {assistant.接口地址}
+
+
+ {assistant.系统提示词 || "无系统提示词"}
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Add New Card Button */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/articles/create.tsx b/src/pages/admin/articles/create.tsx
new file mode 100644
index 0000000..3516b7a
--- /dev/null
+++ b/src/pages/admin/articles/create.tsx
@@ -0,0 +1,5 @@
+import ArticleEditor from '@/components/admin/ArticleEditor';
+
+export default function CreateArticlePage() {
+ return ;
+}
diff --git a/src/pages/admin/articles/edit/[id].tsx b/src/pages/admin/articles/edit/[id].tsx
new file mode 100644
index 0000000..fa25356
--- /dev/null
+++ b/src/pages/admin/articles/edit/[id].tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/pages/admin/articles/index.tsx b/src/pages/admin/articles/index.tsx
new file mode 100644
index 0000000..44a20bd
--- /dev/null
+++ b/src/pages/admin/articles/index.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [deleteId, setDeleteId] = useState(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 已发布;
+ case 'draft':
+ return 草稿;
+ case 'offline':
+ return 已下架;
+ default:
+ return {status};
+ }
+ };
+
+ return (
+
+
+
+
+
资源管理
+
+ 管理网站的所有文章和资源内容。
+
+
+
+
+
+
+
+
+
+
+ {
+ setSearchTerm(e.target.value);
+ setPage(1); // 重置到第一页
+ }}
+ />
+
+
+
+
+
+
+
+ 标题
+ 分类
+ 作者
+ 状态
+ 数据 (阅/赞)
+ 发布时间
+ 操作
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : articles.length === 0 ? (
+
+
+ 暂无数据
+
+
+ ) : (
+ articles.map((article) => (
+
+
+ {article.文章标题}
+
+ {article.分类ID?.分类名称 || '-'}
+ {article.作者ID?.username || 'Unknown'}
+ {getStatusBadge(article.发布状态)}
+
+ {article.统计数据.阅读数} / {article.统计数据.点赞数}
+
+ {new Date(article.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+
+ 第 {page} 页 / 共 {totalPages} 页
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ );
+}
diff --git a/src/pages/admin/banners/index.tsx b/src/pages/admin/banners/index.tsx
new file mode 100644
index 0000000..aafb8c2
--- /dev/null
+++ b/src/pages/admin/banners/index.tsx
@@ -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({
+ 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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Banner 管理
+
+ 配置首页轮播图或横幅内容。
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx
new file mode 100644
index 0000000..0ac8763
--- /dev/null
+++ b/src/pages/admin/index.tsx
@@ -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 (
+
+
+
+
仪表盘
+
+ 查看系统概览和关键指标。
+
+
+
+
+
+
+ 总用户数
+
+
+
+ {stats.totalUsers}
+
+ +20.1% 较上月
+
+
+
+
+
+ 文章总数
+
+
+
+ {stats.totalArticles}
+
+ +15 篇 本周新增
+
+
+
+
+
+ 总订单数
+
+
+
+ {stats.totalOrders}
+
+ +19% 较上月
+
+
+
+
+
+ 总收入
+
+
+
+ ¥{stats.totalRevenue.toFixed(2)}
+
+ +10.5% 较上月
+
+
+
+
+
+
+
+
+ 近期概览
+
+
+
+ 图表组件待集成 (Recharts)
+
+
+
+
+
+ 最新动态
+
+
+
+ {/* 模拟动态数据 */}
+
+
+
+
+
+
+
新用户注册
+
+ zhangsan@example.com
+
+
+
刚刚
+
+
+
+
+
+
+
新订单支付成功
+
+ ¥299.00 - 年度会员
+
+
+
5分钟前
+
+
+
+
+
+
+
+ );
+}
+
+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
+ }
+ },
+ };
+};
diff --git a/src/pages/admin/orders/index.tsx b/src/pages/admin/orders/index.tsx
new file mode 100644
index 0000000..69af64c
--- /dev/null
+++ b/src/pages/admin/orders/index.tsx
@@ -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([]);
+ 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 已支付;
+ case 'pending':
+ return 待支付;
+ default:
+ return {status};
+ }
+ };
+
+ const getOrderTypeLabel = (type: string) => {
+ const map: Record = {
+ 'buy_membership': '购买会员',
+ 'buy_resource': '购买资源',
+ 'recharge_points': '充值积分'
+ };
+ return map[type] || type;
+ };
+
+ return (
+
+
+
+
订单管理
+
+
+ {/* Filters */}
+
+
+
+
+
+
+
+ {/* Table */}
+
+
+
+
+ 订单号
+ 用户
+ 商品信息
+ 金额
+ 状态
+ 支付方式
+ 创建时间
+
+
+
+ {loading ? (
+
+
+
+
+
+
+
+ ) : orders.length === 0 ? (
+
+
+ 暂无订单数据
+
+
+ ) : (
+ orders.map((order) => (
+
+ {order.订单号}
+
+
+
+

+
+
+ {order.用户ID?.用户名 || '未知用户'}
+ {order.用户ID?.邮箱}
+
+
+
+
+
+ {getOrderTypeLabel(order.订单类型)}
+ {order.商品快照?.标题}
+
+
+
+ ¥{order.支付金额.toFixed(2)}
+
+ {getStatusBadge(order.订单状态)}
+
+ {order.支付方式 === 'alipay' && 支付宝}
+ {order.支付方式 === 'wechat' && 微信支付}
+ {!order.支付方式 && -}
+
+
+ {format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+ 共 {pagination.total} 条记录,当前第 {pagination.page} / {pagination.pages} 页
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/plans/edit/[id].tsx b/src/pages/admin/plans/edit/[id].tsx
new file mode 100644
index 0000000..84cb116
--- /dev/null
+++ b/src/pages/admin/plans/edit/[id].tsx
@@ -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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {isEditMode ? '编辑套餐' : '新建套餐'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 是否在前端展示此套餐
+
+
+
(
+
+ )}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/plans/index.tsx b/src/pages/admin/plans/index.tsx
new file mode 100644
index 0000000..5a264da
--- /dev/null
+++ b/src/pages/admin/plans/index.tsx
@@ -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([]);
+ 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 (
+
+
+
+
+
+
+
+ 套餐名称
+ 价格
+ 有效天数
+ 每日下载
+ 折扣
+ 状态
+ 操作
+
+
+
+ {loading ? (
+
+
+
+
+
+
+
+ ) : plans.length === 0 ? (
+
+
+ 暂无套餐
+
+
+ ) : (
+ plans.map((plan) => (
+
+ {plan.套餐名称}
+ ¥{plan.价格}
+ {plan.有效天数} 天
+ {plan.特权配置?.每日下载限制 || 0}
+ {(plan.特权配置?.购买折扣 || 1) * 10} 折
+
+ {plan.是否上架 ? (
+ 已上架
+ ) : (
+ 已下架
+ )}
+
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/settings/index.tsx b/src/pages/admin/settings/index.tsx
new file mode 100644
index 0000000..1d79fdb
--- /dev/null
+++ b/src/pages/admin/settings/index.tsx
@@ -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({
+ 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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
系统设置
+
+ 配置网站的基本信息、支付接口和第三方服务。
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/users/index.tsx b/src/pages/admin/users/index.tsx
new file mode 100644
index 0000000..ae9a1ca
--- /dev/null
+++ b/src/pages/admin/users/index.tsx
@@ -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([]);
+ 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(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 (
+
+
+
+
+
用户管理
+
+ 管理系统用户,查看详情、修改角色或封禁账号。
+
+
+ {/*
*/}
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+
+
+ 头像
+ 用户名
+ 邮箱
+ 角色
+ 状态
+ 注册时间
+ 操作
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : users.length === 0 ? (
+
+
+ 暂无用户
+
+
+ ) : (
+ users.map((user) => (
+
+
+
+
+ {user.用户名.slice(0, 2).toUpperCase()}
+
+
+ {user.用户名}
+ {user.邮箱}
+
+
+ {user.角色}
+
+
+
+ {user.是否被封禁 ? (
+ 已封禁
+ ) : (
+ 正常
+ )}
+
+ {new Date(user.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+ 操作
+ handleEditClick(user)}>
+ 编辑用户
+
+
+ handleDeleteUser(user._id)}
+ >
+ 删除用户
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* 分页 */}
+
+
+ 共 {totalUsers} 条记录,当前第 {page} / {totalPages} 页
+
+
+
+
+
+ {/* 编辑弹窗 */}
+
+
+
+ );
+}
diff --git a/src/pages/aitools/index.tsx b/src/pages/aitools/index.tsx
new file mode 100644
index 0000000..fadcb50
--- /dev/null
+++ b/src/pages/aitools/index.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import AIToolsLayout from '@/components/aitools/AIToolsLayout';
+import { Sparkles } from 'lucide-react';
+
+export default function AIToolsIndex() {
+ return (
+
+
+
+
+
+
欢迎使用 AI 工具箱
+
+ 请在左侧选择一个工具开始使用。
+ 我们将持续更新更多实用的 AI 辅助工具,敬请期待。
+
+
+
+ );
+}
diff --git a/src/pages/aitools/prompt-optimizer/index.tsx b/src/pages/aitools/prompt-optimizer/index.tsx
new file mode 100644
index 0000000..084bbcd
--- /dev/null
+++ b/src/pages/aitools/prompt-optimizer/index.tsx
@@ -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 (
+
+
+ {/* Input Section */}
+
+
+
+
+
+ {/* Output Section */}
+
+ {/* Decorative Background */}
+
+
+
+
+ {output && (
+
+ )}
+
+
+
+ {output ? (
+
+ {output}
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/aitools/translator/index.tsx b/src/pages/aitools/translator/index.tsx
new file mode 100644
index 0000000..9eb1608
--- /dev/null
+++ b/src/pages/aitools/translator/index.tsx
@@ -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 (
+
+
+ {/* Input Section */}
+
+
+
+
+
+ {/* Output Section */}
+
+ {/* Decorative Background */}
+
+
+
+
+
+
+ 目标语言:
+
+
+
+ {output && (
+
+ )}
+
+
+
+ {output ? (
+
+ {output}
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/api/admin/ai/models.ts b/src/pages/api/admin/ai/models.ts
new file mode 100644
index 0000000..cd52510
--- /dev/null
+++ b/src/pages/api/admin/ai/models.ts
@@ -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));
diff --git a/src/pages/api/admin/articles/[id].ts b/src/pages/api/admin/articles/[id].ts
new file mode 100644
index 0000000..a325396
--- /dev/null
+++ b/src/pages/api/admin/articles/[id].ts
@@ -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));
diff --git a/src/pages/api/admin/articles/index.ts b/src/pages/api/admin/articles/index.ts
new file mode 100644
index 0000000..1daf567
--- /dev/null
+++ b/src/pages/api/admin/articles/index.ts
@@ -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));
diff --git a/src/pages/api/admin/categories/index.ts b/src/pages/api/admin/categories/index.ts
new file mode 100644
index 0000000..059a791
--- /dev/null
+++ b/src/pages/api/admin/categories/index.ts
@@ -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));
diff --git a/src/pages/api/admin/orders/index.ts b/src/pages/api/admin/orders/index.ts
new file mode 100644
index 0000000..8e6d1cd
--- /dev/null
+++ b/src/pages/api/admin/orders/index.ts
@@ -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);
diff --git a/src/pages/api/admin/plans/[id].ts b/src/pages/api/admin/plans/[id].ts
new file mode 100644
index 0000000..e68258e
--- /dev/null
+++ b/src/pages/api/admin/plans/[id].ts
@@ -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);
diff --git a/src/pages/api/admin/plans/index.ts b/src/pages/api/admin/plans/index.ts
new file mode 100644
index 0000000..48c1eb3
--- /dev/null
+++ b/src/pages/api/admin/plans/index.ts
@@ -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);
diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts
new file mode 100644
index 0000000..00eff14
--- /dev/null
+++ b/src/pages/api/admin/settings.ts
@@ -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));
diff --git a/src/pages/api/admin/tags/index.ts b/src/pages/api/admin/tags/index.ts
new file mode 100644
index 0000000..a272046
--- /dev/null
+++ b/src/pages/api/admin/tags/index.ts
@@ -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));
diff --git a/src/pages/api/admin/users/[id].ts b/src/pages/api/admin/users/[id].ts
new file mode 100644
index 0000000..5d616b7
--- /dev/null
+++ b/src/pages/api/admin/users/[id].ts
@@ -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));
diff --git a/src/pages/api/admin/users/index.ts b/src/pages/api/admin/users/index.ts
new file mode 100644
index 0000000..bde0678
--- /dev/null
+++ b/src/pages/api/admin/users/index.ts
@@ -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));
diff --git a/src/pages/api/ai/generate.ts b/src/pages/api/ai/generate.ts
new file mode 100644
index 0000000..c7a0e12
--- /dev/null
+++ b/src/pages/api/ai/generate.ts
@@ -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));
diff --git a/src/pages/api/articles/[id].ts b/src/pages/api/articles/[id].ts
new file mode 100644
index 0000000..8b99962
--- /dev/null
+++ b/src/pages/api/articles/[id].ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/articles/index.ts b/src/pages/api/articles/index.ts
new file mode 100644
index 0000000..960dcc6
--- /dev/null
+++ b/src/pages/api/articles/index.ts
@@ -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);
diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts
new file mode 100644
index 0000000..c6d21bf
--- /dev/null
+++ b/src/pages/api/auth/login.ts
@@ -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);
diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts
new file mode 100644
index 0000000..a452e45
--- /dev/null
+++ b/src/pages/api/auth/logout.ts
@@ -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' });
+}
diff --git a/src/pages/api/auth/me.ts b/src/pages/api/auth/me.ts
new file mode 100644
index 0000000..dfae774
--- /dev/null
+++ b/src/pages/api/auth/me.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/auth/register.ts b/src/pages/api/auth/register.ts
new file mode 100644
index 0000000..fdbae8a
--- /dev/null
+++ b/src/pages/api/auth/register.ts
@@ -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);
diff --git a/src/pages/api/categories/index.ts b/src/pages/api/categories/index.ts
new file mode 100644
index 0000000..83234ea
--- /dev/null
+++ b/src/pages/api/categories/index.ts
@@ -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);
diff --git a/src/pages/api/comments/index.ts b/src/pages/api/comments/index.ts
new file mode 100644
index 0000000..f036cfb
--- /dev/null
+++ b/src/pages/api/comments/index.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts
new file mode 100644
index 0000000..ea77e8f
--- /dev/null
+++ b/src/pages/api/hello.ts
@@ -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,
+) {
+ res.status(200).json({ name: "John Doe" });
+}
diff --git a/src/pages/api/orders/create.ts b/src/pages/api/orders/create.ts
new file mode 100644
index 0000000..c98c84d
--- /dev/null
+++ b/src/pages/api/orders/create.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/payment/notify.ts b/src/pages/api/payment/notify.ts
new file mode 100644
index 0000000..a3810f7
--- /dev/null
+++ b/src/pages/api/payment/notify.ts
@@ -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 {
+ 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 = {};
+
+ 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');
+ }
+}
diff --git a/src/pages/api/payment/return.ts b/src/pages/api/payment/return.ts
new file mode 100644
index 0000000..6ee6691
--- /dev/null
+++ b/src/pages/api/payment/return.ts
@@ -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;
+
+ 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');
+ }
+}
diff --git a/src/pages/api/plans/index.ts b/src/pages/api/plans/index.ts
new file mode 100644
index 0000000..7e71bcf
--- /dev/null
+++ b/src/pages/api/plans/index.ts
@@ -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);
diff --git a/src/pages/api/public/config.ts b/src/pages/api/public/config.ts
new file mode 100644
index 0000000..1971d59
--- /dev/null
+++ b/src/pages/api/public/config.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/tags/index.ts b/src/pages/api/tags/index.ts
new file mode 100644
index 0000000..52ae0a1
--- /dev/null
+++ b/src/pages/api/tags/index.ts
@@ -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);
diff --git a/src/pages/api/user/orders.ts b/src/pages/api/user/orders.ts
new file mode 100644
index 0000000..fffefcc
--- /dev/null
+++ b/src/pages/api/user/orders.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/user/orders/index.ts b/src/pages/api/user/orders/index.ts
new file mode 100644
index 0000000..984a68a
--- /dev/null
+++ b/src/pages/api/user/orders/index.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/api/user/profile.ts b/src/pages/api/user/profile.ts
new file mode 100644
index 0000000..c014a15
--- /dev/null
+++ b/src/pages/api/user/profile.ts
@@ -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' });
+ }
+}
diff --git a/src/pages/article/[id].tsx b/src/pages/article/[id].tsx
new file mode 100644
index 0000000..60b2ad5
--- /dev/null
+++ b/src/pages/article/[id].tsx
@@ -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(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 (
+
+
+
+ );
+ }
+
+ if (!article) return null;
+
+ return (
+ t.标签名称).join(',')
+ }}
+ >
+
+ {/* Article Header */}
+
+
+
+ {article.分类ID?.分类名称 || '未分类'}
+
+ {article.标签ID列表?.map((tag, index) => (
+
+ {tag.标签名称}
+
+ ))}
+
+
+
+ {article.文章标题}
+
+
+
+
+
+ {article.作者ID?.用户名 || '匿名'}
+
+
+
+ {format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
+
+
+ 阅读 {article.统计数据?.阅读数 || 0}
+
+
+
+
+ {/* Cover Image */}
+ {article.封面图 && (
+
+

+
+ )}
+
+ {/* Article Content */}
+
+
+
+
+ {/* Resource Download / Paywall Section */}
+
+ {hasAccess ? (
+
+
+
+ 资源下载
+
+ {article.资源属性 ? (
+
+
+ {article.资源属性.提取码 && (
+
+ 提取码
+
+ {article.资源属性.提取码}
+
+
+ )}
+ {article.资源属性.解压密码 && (
+
+ 解压密码
+
+ {article.资源属性.解压密码}
+
+
+ )}
+
+ ) : (
+
此资源暂无下载信息
+ )}
+
+ ) : (
+
+
+
+
+
+ 此内容需要付费查看
+
+
+ 购买此资源或开通会员,即可解锁全文及下载链接,享受更多优质内容。
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Comments Section */}
+
+
+
+ );
+}
diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx
new file mode 100644
index 0000000..6b0921f
--- /dev/null
+++ b/src/pages/auth/login.tsx
@@ -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>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ email: "",
+ password: "",
+ },
+ });
+
+ async function onSubmit(values: z.infer) {
+ 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 (
+
+ );
+}
diff --git a/src/pages/auth/register.tsx b/src/pages/auth/register.tsx
new file mode 100644
index 0000000..c3a3cd2
--- /dev/null
+++ b/src/pages/auth/register.tsx
@@ -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>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ username: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+ },
+ });
+
+ async function onSubmit(values: z.infer) {
+ 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 (
+
+ );
+}
diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx
new file mode 100644
index 0000000..083f8af
--- /dev/null
+++ b/src/pages/dashboard/index.tsx
@@ -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([]);
+ 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 (
+
+
+
+
+
+ );
+ }
+
+ const isVip = user.会员信息?.过期时间 && new Date(user.会员信息.过期时间) > new Date();
+ const resourceOrders = orders.filter(o => o.订单类型 === 'buy_resource' && o.订单状态 === 'paid');
+
+ return (
+
+
+
+ {/* Sidebar / User Info Card */}
+
+
+
+
+
+
+ {user.用户名?.charAt(0).toUpperCase()}
+
+ {isVip && (
+
+
+
+ )}
+
+ {user.用户名}
+ {user.邮箱}
+
+
+
+
会员状态
+
+
+ {isVip ? user.会员信息?.当前等级ID?.套餐名称 || '尊贵会员' : '普通用户'}
+
+ {isVip ? (
+
+ 生效中
+
+ ) : (
+
+ )}
+
+ {isVip && (
+
+ 到期时间: {format(new Date(user.会员信息!.过期时间!), 'yyyy-MM-dd')}
+
+ )}
+
+
+
+
+
+
+
+ {/* Main Content Area */}
+
+
+
+ 概览
+ 我的订单
+ 已购文章
+ 个人设置
+
+
+
+
+
+
+ 总消费
+
+
+ ¥{user.钱包?.历史总消费 || 0}
+
+
+
+
+ 当前积分
+
+
+ {user.钱包?.当前积分 || 0}
+
+
+
+
+
+
+
+
+ 订单记录
+ 您最近的交易记录
+
+
+ {loadingOrders ? (
+
+
+
+ ) : orders.length === 0 ? (
+ 暂无订单记录
+ ) : (
+
+ {orders.map((order) => (
+
+
+
+
+
+
+
+ {order.商品快照?.标题 || (order.订单类型 === 'buy_membership' ? '购买会员' : '购买资源')}
+
+
+ {format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+
+
+
+
+
¥{order.支付金额}
+
+ {order.订单状态 === 'paid' ? '已支付' : '待支付'}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ 我的资源
+ 您已购买或拥有权限的文章
+
+
+ {resourceOrders.length === 0 ? (
+
+ ) : (
+
+ {resourceOrders.map((order) => (
+
+
+
+
+
+
+
+ {order.商品快照?.标题 || '未知资源'}
+
+
+ 购买时间: {format(new Date(order.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ 个人设置
+ 管理您的个人资料和账户安全
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..f0d404b
--- /dev/null
+++ b/src/pages/index.tsx
@@ -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([]);
+ const [categories, setCategories] = useState([]);
+ const [tags, setTags] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [activeCategory, setActiveCategory] = useState('all');
+ const [banners, setBanners] = useState([]);
+
+ 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 (
+
+ {/* Hero Section - Full Width */}
+
+
+
+
+
+
+ {/* Main Content (Articles) */}
+
+ {/* Category Filter */}
+
+
+
+ {categories.map(cat => (
+
+ ))}
+
+
+ 共 {articles.length} 篇文章
+
+
+
+ {/* Article Grid */}
+ {loading ? (
+
+
+
+ ) : (
+
+ {articles.map(article => (
+
+ ))}
+
+ )}
+
+ {!loading && articles.length === 0 && (
+
+ 暂无文章
+
+ )}
+
+
+ {/* Sidebar */}
+
+
+
+
+ );
+}
diff --git a/src/pages/membership.tsx b/src/pages/membership.tsx
new file mode 100644
index 0000000..8aa6f35
--- /dev/null
+++ b/src/pages/membership.tsx
@@ -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([]);
+ 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 (
+
+
+
+
+
+
+
升级您的会员计划
+
+ 解锁全站所有深度技术专栏、系统设计源码及私密社群访问权限。
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {plans.map((plan) => (
+
+ {plan.推荐 && (
+
+ 推荐
+
+ )}
+
+
{plan.套餐名称}
+
+ ¥{plan.价格}
+ /{plan.有效天数}天
+
+
{plan.描述}
+
+
+
+
+ 每日下载限制: {plan.特权配置?.每日下载限制}次
+
+
+
+ 购买折扣: {plan.特权配置?.购买折扣 ? `${(plan.特权配置.购买折扣 * 10).toFixed(1)}折` : '无折扣'}
+
+
+
+ 付费专栏免费读
+
+
+
+ 专属技术交流群
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/payment/failure.tsx b/src/pages/payment/failure.tsx
new file mode 100644
index 0000000..dff3a0c
--- /dev/null
+++ b/src/pages/payment/failure.tsx
@@ -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 (
+
+
+
+
+
+
+ 支付失败
+
+
+
+ {getErrorMessage()}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/payment/success.tsx b/src/pages/payment/success.tsx
new file mode 100644
index 0000000..27437a2
--- /dev/null
+++ b/src/pages/payment/success.tsx
@@ -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 (
+
+
+
+
+
+
+ 支付成功!
+
+
+
+ 恭喜您成功开通会员,现在可以享受所有会员特权了!
+
+
+
+
+
+ {countdown} 秒后自动跳转到会员中心...
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..bbfd0f5
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,119 @@
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+
+@theme {
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+}
+
+:root {
+ --background: 0 0% 100%;
+ --foreground: 240 10% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 240 10% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 240 10% 3.9%;
+ --primary: 240 5.9% 10%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 240 4.8% 95.9%;
+ --secondary-foreground: 240 5.9% 10%;
+ --muted: 240 4.8% 95.9%;
+ --muted-foreground: 240 3.8% 46.1%;
+ --accent: 240 4.8% 95.9%;
+ --accent-foreground: 240 5.9% 10%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 5.9% 90%;
+ --input: 240 5.9% 90%;
+ --ring: 240 5.9% 10%;
+ --radius: 0.5rem;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+}
+
+.dark {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 5.9% 10%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 4.9% 83.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+}
+
+@theme inline {
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+ --color-card: hsl(var(--card));
+ --color-card-foreground: hsl(var(--card-foreground));
+ --color-popover: hsl(var(--popover));
+ --color-popover-foreground: hsl(var(--popover-foreground));
+ --color-primary: hsl(var(--primary));
+ --color-primary-foreground: hsl(var(--primary-foreground));
+ --color-secondary: hsl(var(--secondary));
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+ --color-accent: hsl(var(--accent));
+ --color-accent-foreground: hsl(var(--accent-foreground));
+ --color-destructive: hsl(var(--destructive));
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
+ --color-border: hsl(var(--border));
+ --color-input: hsl(var(--input));
+ --color-ring: hsl(var(--ring));
+ --color-chart-1: hsl(var(--chart-1));
+ --color-chart-2: hsl(var(--chart-2));
+ --color-chart-3: hsl(var(--chart-3));
+ --color-chart-4: hsl(var(--chart-4));
+ --color-chart-5: hsl(var(--chart-5));
+ --radius-lg: var(--radius);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-sm: calc(var(--radius) - 4px);
+}
+
+/*
+ The default border color has changed to `currentColor` in Tailwind CSS v4,
+ so we've added these compatibility styles to make sure everything still
+ looks the same as it did with Tailwind CSS v3.
+
+ If we ever want to remove these styles, we need to add an explicit border
+ color utility to any element that depends on these defaults.
+*/
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentColor);
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..10ba618
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}