2025.11.27.17.50

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

View File

@@ -0,0 +1,160 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
LayoutDashboard,
Users,
FileText,
Settings,
LogOut,
Menu,
ShoppingBag,
MessageSquare,
CreditCard,
Image,
Bot
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface AdminLayoutProps {
children: React.ReactNode;
}
const sidebarItems = [
{ icon: LayoutDashboard, label: '仪表盘', href: '/admin' },
{ icon: Users, label: '用户管理', href: '/admin/users' },
{ icon: FileText, label: '资源管理', href: '/admin/articles' },
{ icon: MessageSquare, label: '评论管理', href: '/admin/comments' },
{ icon: ShoppingBag, label: '订单管理', href: '/admin/orders' },
{ icon: Image, label: 'Banner 管理', href: '/admin/banners' },
{ icon: Bot, label: 'AI 助手', href: '/admin/ai' },
{ icon: Settings, label: '系统设置', href: '/admin/settings' },
];
export default function AdminLayout({ children }: AdminLayoutProps) {
const router = useRouter();
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
const handleLogout = async () => {
// 简单的清除 Cookie 逻辑,实际可能需要调用 API
document.cookie = 'token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
router.push('/auth/login');
};
// Close sidebar on route change (mobile only)
React.useEffect(() => {
const handleRouteChange = () => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
}
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, [router]);
return (
<div className="h-screen bg-gray-100 flex overflow-hidden">
{/* Sidebar */}
<aside
className={cn(
"bg-white border-r border-gray-200 shrink-0 w-64 flex flex-col transition-transform duration-300 ease-in-out absolute inset-y-0 left-0 z-50 lg:static lg:translate-x-0",
!isSidebarOpen && "-translate-x-full lg:hidden"
)}
>
<div className="h-16 flex items-center justify-center border-b border-gray-200 shrink-0">
<h1 className="text-xl font-bold text-primary">Aoun Admin</h1>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{sidebarItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
)}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
{/* Operations Section */}
<div className="mt-4 pt-4 border-t border-gray-100">
<h3 className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
</h3>
<Link
href="/admin/plans"
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors",
router.pathname.startsWith('/admin/plans')
? "bg-primary text-primary-foreground"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
)}
>
<CreditCard className="w-5 h-5" />
</Link>
</div>
</nav>
<div className="p-4 border-t border-gray-200 shrink-0">
<Button
variant="ghost"
className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={handleLogout}
>
<LogOut className="w-5 h-5 mr-2" />
退
</Button>
</div>
</aside>
{/* Main Content Wrapper */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Header */}
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 lg:px-8 shrink-0">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
<Menu className="w-6 h-6" />
</Button>
<div className="flex items-center gap-4 ml-auto">
<div className="text-sm text-gray-600">
<span className="font-semibold text-gray-900"></span>
</div>
<div className="w-8 h-8 rounded-full bg-gray-200 overflow-hidden">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Avatar" />
</div>
</div>
</header>
{/* Scrollable Page Content */}
<main className="flex-1 overflow-y-auto p-6 lg:p-8">
{children}
</main>
</div>
{/* Overlay for mobile sidebar */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
</div>
);
}

View File

@@ -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<Category[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
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 (
<div className="flex items-center justify-center h-full min-h-[500px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col bg-background">
{/* Top Bar */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-background shrink-0 z-10">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{mode === 'edit' ? '编辑文章' : '撰写新文章'}
</span>
{watch('发布状态') === 'published' && (
<span className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-100">
<Globe className="w-3 h-3 mr-1" />
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleSubmit(onSubmit)} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{mode === 'edit' ? '更新' : '发布'}
</>
)}
</Button>
</div>
</div>
{/* Main Editor Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Content Editor */}
<div className="flex-1 overflow-y-auto bg-gray-50/50">
<div className="max-w-6xl mx-auto py-8 px-12 min-h-full bg-white shadow-sm border-x border-gray-100">
{/* Title Input */}
<Input
{...register('文章标题', { required: true })}
className="text-4xl font-bold border-none shadow-none bg-transparent px-0 focus-visible:ring-0 placeholder:text-gray-300 h-auto py-4 mb-4"
placeholder="请输入文章标题..."
/>
{/* Tiptap Editor */}
<Controller
name="正文内容"
control={control}
rules={{ required: true }}
render={({ field }) => (
<TiptapEditor
value={field.value}
onChange={field.onChange}
/>
)}
/>
</div>
</div>
{/* Right: Sidebar Settings */}
<div className="w-[400px] border-l bg-white overflow-y-auto p-6 space-y-8 shrink-0">
{/* Publishing */}
<div className="space-y-4">
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
<Settings className="w-4 h-4" />
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Controller
name="发布状态"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="published"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="offline"></SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">URL </Label>
<Input {...register('URL别名')} className="h-8 text-sm" placeholder="slug-url" />
</div>
</div>
</div>
<div className="h-px bg-gray-100" />
{/* Organization */}
<div className="space-y-4">
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
<FileText className="w-4 h-4" />
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Controller
name="分类ID"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat._id} value={cat._id}>{cat.}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> URL</Label>
<div className="flex gap-2">
<Input {...register('封面图')} className="h-8 text-sm" placeholder="https://..." />
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0">
<ImageIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="h-px bg-gray-100" />
{/* Resource Delivery */}
<div className="space-y-4">
<h3 className="font-semibold text-sm flex items-center gap-2 text-gray-900">
<Lock className="w-4 h-4" />
</h3>
<div className="p-4 bg-gray-50 rounded-lg space-y-4 border border-gray-100">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.下载链接')} className="h-8 text-sm bg-white" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.提取码')} className="h-8 text-sm bg-white" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.解压密码')} className="h-8 text-sm bg-white" />
</div>
</div>
</div>
</div>
<div className="h-px bg-gray-100" />
{/* Sales */}
<div className="space-y-4">
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
<DollarSign className="w-4 h-4" />
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Controller
name="支付方式"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="points"></SelectItem>
<SelectItem value="cash"> (CNY)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
({watch('支付方式') === 'points' ? '积分' : '元'})
</Label>
<Input
type="number"
{...register('价格')}
className="h-8 text-sm"
min="0"
step={watch('支付方式') === 'cash' ? "0.01" : "1"}
/>
</div>
</div>
</div>
<div className="h-px bg-gray-100" />
{/* SEO */}
<div className="space-y-4">
<h3 className="font-semibold text-sm text-gray-900">SEO </h3>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Textarea {...register('摘要')} className="text-sm min-h-[80px]" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('SEO关键词')} className="h-8 text-sm" placeholder="逗号分隔" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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) => (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClick}
disabled={disabled}
className={cn("h-8 w-8 p-0", isActive ? "bg-muted text-primary" : "text-muted-foreground hover:text-primary", className)}
>
{children}
</Button>
);
return (
<div className="border-b bg-gray-50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center">
<Button variant="outline" size="sm" className="mr-2 h-8 gap-1 text-purple-600 border-purple-200 hover:bg-purple-50 hover:text-purple-700" onClick={onAiClick}>
<Sparkles className="w-3.5 h-3.5" />
<span className="text-xs font-medium">AI </span>
</Button>
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
<ToggleButton isActive={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToggleButton>
<ToggleButton isActive={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToggleButton>
<ToggleButton isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToggleButton>
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
<ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
<ToggleButton isActive={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 className="h-4 w-4" /></ToggleButton>
<ToggleButton isActive={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}><Heading3 className="h-4 w-4" /></ToggleButton>
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
<ToggleButton isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToggleButton>
<ToggleButton isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToggleButton>
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
<ToggleButton isActive={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToggleButton>
<ToggleButton isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}><Code className="h-4 w-4" /></ToggleButton>
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
<ToggleButton onClick={addLink} isActive={editor.isActive('link')}><LinkIcon className="h-4 w-4" /></ToggleButton>
<ToggleButton onClick={addImage}><ImageIcon className="h-4 w-4" /></ToggleButton>
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
<ToggleButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}><Undo className="h-4 w-4" /></ToggleButton>
<ToggleButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}><Redo className="h-4 w-4" /></ToggleButton>
</div>
);
};
export default function TiptapEditor({ value, onChange, className }: TiptapEditorProps) {
const [isAiGenerating, setIsAiGenerating] = useState(false);
const [assistants, setAssistants] = useState<any[]>([]);
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<HTMLDivElement>(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 (
<div ref={wrapperRef} className={cn("border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative", className)}>
<Toolbar editor={editor} onAiClick={openAiInput} />
{/* Side Tool */}
{sideToolPos.visible && (
<div
className="absolute z-10 transition-all duration-100 ease-out"
style={{ top: sideToolPos.top, left: sideToolPos.left }}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-4 p-0 text-gray-300 hover:text-gray-600 cursor-grab active:cursor-grabbing"
>
<span className="text-lg leading-none"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom" className="w-48">
<DropdownMenuItem onClick={openAiInput}>
<Sparkles className="w-4 h-4 mr-2 text-purple-500" />
<span>AI ...</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const { from } = editor.state.selection;
const context = editor.state.doc.textBetween(Math.max(0, from - 500), from, '\n');
runAiAction('continue', context, false);
}}>
<Wand2 className="w-4 h-4 mr-2 text-blue-500" />
<span>AI </span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Inline AI Input */}
{aiInputOpen && (
<div
className="absolute z-20 w-[600px] max-w-full"
style={{ top: aiInputPos.top, left: Math.max(20, aiInputPos.left) }}
>
<div className="bg-white rounded-xl border border-purple-200 shadow-xl p-2 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center gap-2 mb-2 px-2 pt-1">
<Sparkles className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-gray-700">AI </span>
</div>
<Textarea
autoFocus
placeholder="告诉 AI 你想要写什么..."
className="min-h-[60px] border-0 focus-visible:ring-0 resize-none bg-gray-50/50 text-base"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const target = e.target as HTMLTextAreaElement;
if (target.value.trim()) {
runAiAction('custom', target.value.trim(), false);
}
}
}}
/>
<div className="flex justify-between items-center mt-2 px-1">
<div className="flex gap-1">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs text-gray-500">
<MessageSquare className="w-3 h-3 mr-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => runAiAction('tone:专业', '', false)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('tone:幽默', '', false)}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => setAiInputOpen(false)}></Button>
<Button size="sm" className="bg-purple-600 hover:bg-purple-700 text-white" onClick={(e) => {
const textarea = e.currentTarget.parentElement?.parentElement?.previousElementSibling as HTMLTextAreaElement;
if (textarea && textarea.value.trim()) {
runAiAction('custom', textarea.value.trim(), false);
}
}}>
<Sparkles className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
</div>
</div>
)}
{/* Custom Bubble Menu */}
{bubbleMenuPos.visible && (
<div
className="absolute z-10 flex overflow-hidden rounded-md border bg-white shadow-md p-1 gap-0.5 animate-in fade-in zoom-in-95 duration-200"
style={{
top: bubbleMenuPos.top,
left: bubbleMenuPos.left,
transform: 'translate(-50%, -100%)'
}}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs gap-1 text-purple-600 hover:text-purple-700 hover:bg-purple-50">
<Sparkles className="w-3.5 h-3.5" /> AI
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => runAiAction('rewrite', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('blogify', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
<PenLine className="w-3.5 h-3.5 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('summarize', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), false)}>
<Minimize2 className="w-3.5 h-3.5 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('expand', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
<Maximize2 className="w-3.5 h-3.5 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('fix', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
<Check className="w-3.5 h-3.5 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px h-4 bg-gray-200 mx-1 self-center" />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
<MessageSquare className="w-3 h-3 mr-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => runAiAction('tone:专业', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('tone:幽默', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('tone:亲切', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
<Languages className="w-3 h-3 mr-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => runAiAction('translate:英语', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('translate:中文', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => runAiAction('translate:日语', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<EditorContent editor={editor} className={cn(isAiGenerating && "opacity-50 pointer-events-none")} />
</div>
);
}

View File

@@ -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 (
<MainLayout
seo={{
title: title ? `${title} - AI 工具箱` : 'AI 工具箱',
description: description || '提供多种实用的 AI 辅助工具,提升您的工作效率。',
keywords: 'AI工具, 提示词优化, 智能翻译, 效率工具'
}}
showFooter={false}
>
<div className="bg-gray-50 min-h-screen pt-8 pb-8">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Sidebar */}
<div className="lg:col-span-3">
<div className="sticky top-24">
<ToolsSidebar />
</div>
</div>
{/* Right Content */}
<div className="lg:col-span-9">
{children}
</div>
</div>
</div>
</div>
</MainLayout>
);
}

View File

@@ -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 (
<div className="space-y-4">
<div className="mb-6 px-2">
<h2 className="text-lg font-bold text-gray-900"></h2>
<p className="text-xs text-gray-500">使</p>
</div>
<div className="space-y-3">
{tools.map((tool) => {
const isActive = router.pathname === tool.href;
return (
<Link
key={tool.id}
href={tool.href}
className={cn(
"group flex items-center gap-4 p-4 rounded-xl border transition-all duration-200",
tool.disabled ? "opacity-60 cursor-not-allowed" : "hover:shadow-md cursor-pointer",
isActive
? "bg-white border-primary/50 shadow-md ring-1 ring-primary/20"
: "bg-white border-gray-100 hover:border-gray-200"
)}
onClick={(e) => tool.disabled && e.preventDefault()}
>
<div className={cn("w-10 h-10 rounded-lg flex items-center justify-center shrink-0", tool.bgColor, tool.color)}>
<tool.icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className={cn("font-medium truncate", isActive ? "text-primary" : "text-gray-900")}>
{tool.name}
</h3>
<p className="text-xs text-gray-500 truncate">
{tool.description}
</p>
</div>
{isActive && (
<ChevronRight className="w-4 h-4 text-primary shrink-0" />
)}
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -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<Comment[]>([]);
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 (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h3 className="text-lg font-bold flex items-center gap-2 mb-6">
<MessageSquare className="w-5 h-5 text-primary" />
({comments.length})
</h3>
{/* Comment Form */}
<div className="mb-8">
{isLoggedIn ? (
<form onSubmit={handleSubmit} className="space-y-4">
<Textarea
placeholder="写下你的评论..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[100px]"
/>
<div className="flex justify-end">
<Button type="submit" disabled={submitting || !content.trim()}>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</form>
) : (
<div className="bg-gray-50 rounded-lg p-6 text-center">
<p className="text-gray-600 mb-4"></p>
<Button variant="outline" onClick={() => window.location.href = '/auth/login'}>
</Button>
</div>
)}
</div>
{/* Comments List */}
<div className="space-y-6">
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 text-gray-500">
</div>
) : (
comments.map((comment) => (
<div key={comment._id} className="flex gap-4">
<Avatar className="w-10 h-10">
<AvatarImage src={comment.ID?.} />
<AvatarFallback>{comment.ID?.?.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-gray-900">
{comment.ID?. || '未知用户'}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true, locale: zhCN })}
</span>
</div>
<p className="text-gray-700 text-sm leading-relaxed">
{comment.}
</p>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="group flex flex-col bg-white rounded-xl overflow-hidden border border-gray-100 hover:shadow-lg transition-all duration-300 h-full">
{/* Image */}
<Link href={`/article/${article._id}`} className="relative aspect-video overflow-hidden bg-gray-100">
{article. ? (
<Image
src={article.}
alt={article.}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">No Image</div>
)}
<div className="absolute top-3 left-3">
<Badge variant="secondary" className="bg-white/90 backdrop-blur-sm text-black font-medium shadow-sm hover:bg-white">
{article.ID?. || '未分类'}
</Badge>
</div>
</Link>
{/* Content */}
<div className="flex-1 p-5 flex flex-col">
<div className="flex items-center gap-2 text-xs text-gray-400 mb-3">
<span>{new Date(article.createdAt).toLocaleDateString()}</span>
<span></span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" /> 12 min read
</span>
</div>
<Link href={`/article/${article._id}`} className="block mb-3">
<h3 className="text-xl font-bold text-gray-900 line-clamp-2 group-hover:text-primary transition-colors">
{article.}
</h3>
</Link>
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1">
{article.}
</p>
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
{isFree ? (
<span className="text-xs font-bold text-green-600 bg-green-50 px-2 py-1 rounded">FREE</span>
) : (
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">¥{article.}</span>
)}
<Link href={`/article/${article._id}`} className="text-sm font-medium text-gray-900 flex items-center gap-1 group-hover:gap-2 transition-all">
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -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<CarouselApi>();
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 (
<div className="relative -mt-16 h-[500px] w-full bg-gray-900 animate-pulse flex items-center justify-center">
<div className="text-white/20 text-xl font-medium">Loading Banner...</div>
</div>
);
}
return (
<div
className="relative -mt-16 group"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<Carousel setApi={setApi} className="w-full" opts={{ loop: true }}>
<CarouselContent>
{banners.map((banner, index) => (
<CarouselItem key={index}>
<div className="relative h-[500px] w-full overflow-hidden">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 hover:scale-105"
style={{ backgroundImage: `url(${banner.})` }}
>
<div className="absolute inset-0 bg-black/40" /> {/* Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" /> {/* Gradient Overlay */}
</div>
{/* Content */}
<div className="container mx-auto px-4 h-full flex items-center relative z-10 pt-20">
<div className="max-w-3xl space-y-6 text-white animate-in fade-in slide-in-from-bottom-4 duration-700">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-tight drop-shadow-lg">
{banner.}
</h1>
<p className="text-gray-100 text-lg md:text-xl leading-relaxed max-w-2xl drop-shadow-md opacity-90">
{banner.}
</p>
<div className="pt-6 flex gap-4">
<Link href={banner. || '#'}>
<Button className="bg-white text-black hover:bg-white/90 font-semibold px-8 h-12 rounded-full text-base border-none shadow-lg hover:shadow-xl transition-all hover:-translate-y-0.5">
{banner. || '查看详情'}
</Button>
</Link>
</div>
</div>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
{banners.length > 1 && (
<>
<CarouselPrevious className="left-8 bg-white/10 hover:bg-white/20 text-white border-white/20 h-12 w-12 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<CarouselNext className="right-8 bg-white/10 hover:bg-white/20 text-white border-white/20 h-12 w-12 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</>
)}
</Carousel>
{/* Premium Progress Indicators */}
{banners.length > 1 && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-1.5 p-1.5 rounded-full bg-black/20 backdrop-blur-md border border-white/10 shadow-lg">
{Array.from({ length: count }).map((_, index) => {
const isActive = current === index;
return (
<button
key={index}
onClick={() => api?.scrollTo(index)}
aria-label={`Go to slide ${index + 1}`}
className={cn(
"relative h-1.5 rounded-full overflow-hidden transition-all duration-500 ease-out focus:outline-none",
isActive
? "w-10 bg-white/20"
: "w-2 bg-white/40 hover:bg-white/60 hover:w-4"
)}
>
{isActive && (
<div
className="absolute top-0 left-0 h-full w-full bg-white rounded-full shadow-[0_0_10px_rgba(255,255,255,0.5)] origin-left"
style={{
animation: `progress 5s linear forwards`,
animationPlayState: isPaused ? 'paused' : 'running',
willChange: 'transform'
}}
onAnimationEnd={handleAnimationEnd}
/>
)}
</button>
);
})}
</div>
</div>
)}
<style jsx global>{`
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
`}</style>
</div>
);
}

View File

@@ -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 (
<div className="space-y-8">
{/* Hot Tags */}
<div>
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-primary rounded-full"></span>
</h3>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Link key={tag._id} href={`/?tag=${tag._id}`}>
<Badge variant="secondary" className="hover:bg-gray-200 cursor-pointer font-normal px-3 py-1">
{tag.}
</Badge>
</Link>
))}
</div>
</div>
{/* Recommended (Static for now) */}
<div>
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-primary rounded-full"></span>
</h3>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="group cursor-pointer">
<div className="flex items-start gap-3">
<span className={`text-2xl font-bold transition-colors ${i === 1 ? 'text-gray-900' :
i === 2 ? 'text-gray-500' :
'text-gray-300'
}`}>0{i}</span>
<div>
<h4 className="text-sm font-medium text-gray-900 group-hover:text-primary transition-colors line-clamp-2">
2025 Micro-Frontends Islands Architecture
</h4>
<p className="text-xs text-gray-400 mt-1">12 min read</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -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 <div className="w-8 h-8 rounded-full bg-gray-100 animate-pulse" />;
}
if (user) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10 border border-gray-200">
<AvatarImage src={user.} alt={user.} />
<AvatarFallback>{user.?.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard" className="cursor-pointer">
<LayoutDashboard className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
{user. === 'admin' && (
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-red-600 cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
<span>退</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div className="flex items-center gap-2">
<Link href="/auth/login">
<Button variant="ghost" size="sm" className="text-white hover:text-white hover:bg-white/20"></Button>
</Link>
<Link href="/auth/register">
<Button size="sm" className="bg-white text-black hover:bg-white/90"></Button>
</Link>
</div>
);
}
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 (
<div className="min-h-screen flex flex-col bg-gray-50">
<Head>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta name="keywords" content={pageKeywords} />
<link rel="icon" href={config?.Favicon || "/favicon.ico"} />
</Head>
{/* Header */}
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${isTransparent
? 'bg-transparent border-transparent py-4'
: 'bg-white/80 backdrop-blur-md border-b py-0 shadow-sm'
}`}
>
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
{/* Logo & Nav */}
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2">
<img src={config?.Logo地址 || "/LOGO.png"} alt={config?. || "AOUN"} className="h-8 w-auto" />
<span className={`font-bold text-xl tracking-tight ${isTransparent ? 'text-white' : 'text-gray-900'}`}>
{config?. || 'AOUN'}
</span>
</Link>
<nav className={`hidden md:flex items-center gap-8 text-sm font-medium ${isTransparent ? 'text-white/90' : 'text-gray-600'}`}>
{[
{ href: '/aitools', label: 'AI工具' },
{ href: '/', label: '专栏文章' },
{ href: '/membership', label: '会员计划' }
].map((link) => {
const isActive = link.href === '/'
? router.pathname === '/'
: router.pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className="relative group py-2"
>
<span className={`transition-colors ${isActive
? (isTransparent ? 'text-white' : 'text-primary')
: (isTransparent ? 'group-hover:text-white' : 'group-hover:text-primary')
}`}>
{link.label}
</span>
<span className={`absolute bottom-0 left-0 h-0.5 transition-all duration-300 ${isActive ? 'w-full' : 'w-0 group-hover:w-full'
} ${isTransparent ? 'bg-white' : 'bg-primary'
}`} />
</Link>
);
})}
</nav>
</div>
{/* Right Actions */}
<div className="flex items-center gap-4">
<div className="relative hidden md:block">
<Search className={`absolute left-2.5 top-2.5 h-4 w-4 ${isTransparent ? 'text-white/60' : 'text-gray-400'}`} />
<input
type="text"
placeholder="搜索..."
className={`h-9 w-64 rounded-full border pl-9 pr-4 text-sm focus:outline-none focus:ring-2 transition-all ${isTransparent
? 'bg-white/10 border-white/20 text-white placeholder:text-white/60 focus:ring-white/30'
: 'bg-gray-50 border-gray-200 text-gray-900 focus:ring-primary/20'
}`}
/>
</div>
<AuthButtons />
</div>
</div>
</header>
{/* Main Content */}
<main className={`flex-1 ${!transparentHeader ? 'pt-16' : ''}`}>
{children}
</main>
{/* Footer */}
{showFooter && (
<footer className="bg-white border-t py-12 mt-12">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Author Profile */}
<div className="lg:col-span-3">
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-gray-50/80 to-transparent border border-gray-100/60 flex flex-col">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-white p-0.5 shadow-sm border border-gray-100 overflow-hidden">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Admin" className="w-full h-full object-cover rounded-lg" />
</div>
<div>
<h3 className="font-bold text-lg text-gray-900 leading-tight"> (RUI)</h3>
<p className="text-[10px] font-bold text-primary uppercase tracking-wider mt-1">AOUN FOUNDER</p>
</div>
</div>
<p className="text-xs text-gray-500 leading-relaxed mb-6 font-medium flex-1">
React
</p>
<div className="flex gap-2 mt-auto">
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-white hover:bg-gray-50 border-gray-200">Bilibili</Button>
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-white hover:bg-gray-50 border-gray-200">WeChat</Button>
</div>
</div>
</div>
{/* Gemini Credit */}
<div className="lg:col-span-3">
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-blue-50/80 to-transparent border border-blue-100/60 flex flex-col">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-white flex items-center justify-center shadow-sm border border-blue-50">
<img src="/Gemini.svg" alt="Gemini" className="w-7 h-7" />
</div>
<div>
<h3 className="font-bold text-lg text-gray-900 leading-tight">
Gemini
</h3>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Powered by</span>
<img src="/Google.svg" alt="Google" className="h-3.5 opacity-60" />
</div>
</div>
</div>
<p className="text-xs text-gray-500 leading-relaxed mb-6 font-medium flex-1">
Google DeepMind Gemini <br />
</p>
<div className="flex items-center gap-2 text-[10px] font-bold text-blue-600 bg-white px-3 py-2 rounded-lg border border-blue-100 shadow-sm w-fit mt-auto">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
100% AI Generated Code
</div>
</div>
</div>
{/* Resources & About */}
<div className="lg:col-span-3">
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-gray-50/80 to-transparent border border-gray-100/60 flex flex-col">
<div className="grid grid-cols-2 gap-4 h-full">
<div className="flex flex-col">
<h4 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-primary rounded-full"></span>
</h4>
<ul className="space-y-1 flex-1">
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span></Link></li>
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span></Link></li>
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span></Link></li>
</ul>
</div>
<div className="flex flex-col">
<h4 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-purple-500 rounded-full"></span>
</h4>
<ul className="space-y-1 flex-1">
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span></Link></li>
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span></Link></li>
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span></Link></li>
</ul>
</div>
</div>
</div>
</div>
{/* Subscribe */}
<div className="lg:col-span-3">
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-primary/5 to-transparent border border-primary/10 flex flex-col">
<h4 className="font-bold text-lg text-gray-900 mb-2"></h4>
<p className="text-xs text-gray-500 mb-6 leading-relaxed flex-1">
</p>
<div className="mt-auto space-y-2">
<input
type="email"
placeholder="请输入您的邮箱"
className="w-full h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
/>
<Button size="sm" className="w-full h-9 bg-primary hover:bg-primary/90 text-white shadow-sm shadow-primary/20">
</Button>
</div>
</div>
</div>
</div>
<div className="border-t mt-12 pt-8 flex flex-col md:flex-row items-center justify-between text-sm text-gray-400">
<p>{config?. || `© ${new Date().getFullYear()} ${config?. || 'AounApp'}. All rights reserved.`}</p>
<p>{config?.}</p>
</div>
</div>
</footer>
)}
<Toaster />
</div>
);
}

View File

@@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -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<typeof useEmblaCarousel>
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<typeof useEmblaCarousel>[0]
api: CarouselApi
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & 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<HTMLDivElement>) => {
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 (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDown={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

176
src/components/ui/form.tsx Normal file
View File

@@ -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<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
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 <FormField>")
}
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<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -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<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -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<void>;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export function ConfigProvider({ children }: { children: React.ReactNode }) {
const [config, setConfig] = useState<SiteConfig | null>(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 (
<ConfigContext.Provider value={{ config, loading, refreshConfig: fetchConfig }}>
{children}
</ConfigContext.Provider>
);
}
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}

80
src/hooks/useAuth.tsx Normal file
View File

@@ -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<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
logout: async () => { },
refreshUser: async () => { },
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(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 (
<AuthContext.Provider value={{ user, loading, logout, refreshUser: fetchUser }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

138
src/lib/alipay.ts Normal file
View File

@@ -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<string, string> = {
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<string, string>): Promise<boolean> {
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, string>): 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();

43
src/lib/api-handler.ts Normal file
View File

@@ -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> | 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' });
}
};
}

53
src/lib/auth.ts Normal file
View File

@@ -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> | 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> | 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);
};
};

130
src/lib/dbConnect.ts Normal file
View File

@@ -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<mongoose.Connection> | 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<mongoose.Connection> {
// 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;

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

30
src/lib/withDatabase.ts Normal file
View File

@@ -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;

276
src/models/index.ts Normal file
View File

@@ -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);

34
src/pages/404.tsx Normal file
View File

@@ -0,0 +1,34 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Home, MoveLeft } from 'lucide-react';
export default function Custom404() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 px-4 text-center">
<div className="space-y-6 max-w-md">
{/* Illustration placeholder or large text */}
<h1 className="text-9xl font-extrabold text-gray-200">404</h1>
<div className="space-y-2">
<h2 className="text-3xl font-bold text-gray-900"></h2>
<p className="text-gray-500">
访
</p>
</div>
<div className="flex items-center justify-center gap-4 pt-4">
<Button variant="outline" onClick={() => window.history.back()}>
<MoveLeft className="mr-2 h-4 w-4" />
</Button>
<Link href="/">
<Button>
<Home className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
);
}

14
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,14 @@
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import { AuthProvider } from '@/hooks/useAuth';
import { ConfigProvider } from '@/contexts/ConfigContext';
export default function App({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<ConfigProvider>
<Component {...pageProps} />
</ConfigProvider>
</AuthProvider>
);
}

13
src/pages/_document.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="zh">
<Head />
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
import React from 'react';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
import { Sparkles } from 'lucide-react';
export default function AIToolsIndex() {
return (
<AIToolsLayout>
<div className="h-full min-h-[500px] bg-white rounded-2xl border border-gray-100 p-8 flex flex-col items-center justify-center text-center">
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mb-6">
<Sparkles className="w-10 h-10 text-primary" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">使 AI </h1>
<p className="text-gray-500 max-w-md">
使<br />
AI
</p>
</div>
</AIToolsLayout>
);
}

View File

@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Sparkles, Copy, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
export default function PromptOptimizer() {
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [loading, setLoading] = useState(false);
const handleOptimize = async () => {
if (!input.trim()) {
toast.error('请输入需要优化的提示词');
return;
}
setLoading(true);
try {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: `作为一名专业的提示词工程师Prompt Engineer请优化以下提示词。
原始提示词:
"${input}"
要求:
1. 使用结构化的格式(如 Role, Context, Task, Constraints
2. 语言精炼,指向明确。
3. 如果原始提示词是中文,请保持中文;如果是英文,请保持英文。
4. 直接输出优化后的提示词,不要包含解释或其他废话。`,
stream: true
}),
});
if (!response.ok) throw new Error('Optimization failed');
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = line.slice(6);
if (data === '[DONE]') continue;
const json = JSON.parse(data);
if (json.content) {
setOutput(prev => prev + json.content);
}
} catch (e) {
console.error('Error parsing SSE', e);
}
}
}
}
}
} catch (error) {
console.error(error);
toast.error('优化失败,请稍后重试');
} finally {
setLoading(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(output);
toast.success('已复制到剪贴板');
};
return (
<AIToolsLayout
title="提示词优化师"
description="将简单的指令转化为结构化、高质量的 AI 提示词。"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-180px)] min-h-[600px]">
{/* Input Section */}
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900"></h2>
</div>
</div>
<Textarea
placeholder="例如:帮我写一篇关于咖啡的文章..."
className="flex-1 resize-none border-gray-200 focus:border-purple-500 focus:ring-purple-500/20 p-4 text-base leading-relaxed"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<div className="mt-4">
<Button
onClick={handleOptimize}
disabled={loading || !input.trim()}
className="w-full bg-purple-600 hover:bg-purple-700 text-white h-10 text-sm font-medium shadow-lg shadow-purple-500/20"
>
{loading ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Output Section */}
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full relative overflow-hidden">
{/* Decorative Background */}
<div className="absolute top-0 right-0 w-64 h-64 bg-linear-to-br from-purple-500/5 to-transparent rounded-bl-full pointer-events-none" />
<div className="flex items-center justify-between mb-4 relative z-10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-600">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900"></h2>
</div>
</div>
{output && (
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-purple-600 border-gray-200 h-8">
<Copy className="w-3.5 h-3.5 mr-1.5" />
</Button>
)}
</div>
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-100 p-4 overflow-y-auto relative z-10">
{output ? (
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap font-mono text-sm">
{output}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<Sparkles className="w-10 h-10 mb-3 opacity-20" />
<p className="text-xs"></p>
</div>
)}
</div>
</div>
</div>
</AIToolsLayout>
);
}

View File

@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Languages, Copy, RefreshCw, ArrowRightLeft } from 'lucide-react';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const languages = [
{ value: 'en', label: '英语 (English)' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ja', label: '日语 (Japanese)' },
{ value: 'ko', label: '韩语 (Korean)' },
{ value: 'fr', label: '法语 (French)' },
{ value: 'de', label: '德语 (German)' },
{ value: 'es', label: '西班牙语 (Spanish)' },
];
export default function Translator() {
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [loading, setLoading] = useState(false);
const [targetLang, setTargetLang] = useState('en');
const handleTranslate = async () => {
if (!input.trim()) {
toast.error('请输入需要翻译的内容');
return;
}
setLoading(true);
setOutput(''); // Clear previous output
try {
const targetLangLabel = languages.find(l => l.value === targetLang)?.label;
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: `作为一名精通多国语言的专业翻译官,请将以下内容翻译成${targetLangLabel}
待翻译内容:
"${input}"
要求:
1. 翻译准确、信达雅。
2. 保持原文的语气和风格。
3. 只输出翻译后的结果,不要包含任何解释或额外说明。`,
stream: true
}),
});
if (!response.ok) throw new Error('Translation failed');
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = line.slice(6);
if (data === '[DONE]') continue;
const json = JSON.parse(data);
if (json.content) {
setOutput(prev => prev + json.content);
}
} catch (e) {
console.error('Error parsing SSE', e);
}
}
}
}
}
} catch (error) {
console.error(error);
toast.error('翻译失败,请稍后重试');
} finally {
setLoading(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(output);
toast.success('已复制到剪贴板');
};
return (
<AIToolsLayout
title="智能翻译助手"
description="精准、地道的多语言互译工具。"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-180px)] min-h-[600px]">
{/* Input Section */}
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center text-blue-600">
<Languages className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900"></h2>
</div>
</div>
</div>
<Textarea
placeholder="请输入需要翻译的内容..."
className="flex-1 resize-none border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 p-4 text-base leading-relaxed"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<div className="mt-4">
<Button
onClick={handleTranslate}
disabled={loading || !input.trim()}
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-10 text-sm font-medium shadow-lg shadow-blue-500/20"
>
{loading ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<ArrowRightLeft className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Output Section */}
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full relative overflow-hidden">
{/* Decorative Background */}
<div className="absolute top-0 right-0 w-64 h-64 bg-linear-to-br from-blue-500/5 to-transparent rounded-bl-full pointer-events-none" />
<div className="flex items-center justify-between mb-4 relative z-10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
<ArrowRightLeft className="w-4 h-4" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-900">:</span>
<Select value={targetLang} onValueChange={setTargetLang}>
<SelectTrigger className="w-[140px] h-8 text-xs border-gray-200 bg-white focus:ring-0 shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map(lang => (
<SelectItem key={lang.value} value={lang.value}>{lang.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{output && (
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-blue-600 border-gray-200 h-8">
<Copy className="w-3.5 h-3.5 mr-1.5" />
</Button>
)}
</div>
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-100 p-4 overflow-y-auto relative z-10">
{output ? (
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap text-base leading-relaxed">
{output}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<Languages className="w-10 h-10 mb-3 opacity-20" />
<p className="text-xs"></p>
</div>
)}
</div>
</div>
</div>
</AIToolsLayout>
);
}

View File

@@ -0,0 +1,71 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { SystemConfig } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
const { endpoint, apiKey, assistantId } = req.body;
if (!endpoint) {
return res.status(400).json({ message: 'Endpoint is required' });
}
try {
let finalApiKey = apiKey;
// 如果没有提供 Key 但提供了 ID尝试从数据库获取
if (!finalApiKey && assistantId) {
const config = await SystemConfig.findOne().select('+AI配置列表.API密钥').lean();
if (config && config.AI配置列表) {
const assistant = config.AI配置列表.find((a: any) => a._id.toString() === assistantId);
if (assistant) {
finalApiKey = assistant.API密钥;
}
}
}
if (!finalApiKey) {
return res.status(400).json({ message: 'API Key is required' });
}
// 处理 Endpoint确保指向 /models
// 假设用户填写的 endpoint 是 base url (e.g. https://api.openai.com/v1)
// 或者完整的 chat url (e.g. https://api.openai.com/v1/chat/completions)
let baseUrl = endpoint.replace(/\/+$/, '');
if (baseUrl.endsWith('/chat/completions')) {
baseUrl = baseUrl.replace('/chat/completions', '');
}
const url = `${baseUrl}/models`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${finalApiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('Fetch Models Error:', errorText);
return res.status(response.status).json({ message: `Provider Error: ${response.statusText}` });
}
const data = await response.json();
// OpenAI 格式返回 { data: [{ id: 'model-name', ... }] }
const models = data.data ? data.data.map((m: any) => m.id) : [];
return res.status(200).json({ models });
} catch (error) {
console.error('Fetch Models Internal Error:', error);
return res.status(500).json({ message: 'Internal Server Error' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,49 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Article } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
if (req.method === 'GET') {
try {
const article = await Article.findById(id)
.populate('分类ID')
.populate('标签ID列表')
.lean();
if (!article) {
return res.status(404).json({ message: '文章不存在' });
}
return res.status(200).json(article);
} catch (error) {
return res.status(500).json({ message: '获取文章详情失败' });
}
} else if (req.method === 'PUT') {
try {
const article = await Article.findByIdAndUpdate(id, req.body, { new: true, runValidators: true });
if (!article) {
return res.status(404).json({ message: '文章不存在' });
}
return res.status(200).json(article);
} catch (error) {
console.error('Update article error:', error);
return res.status(500).json({ message: '更新文章失败' });
}
} else if (req.method === 'DELETE') {
try {
const article = await Article.findByIdAndDelete(id);
if (!article) {
return res.status(404).json({ message: '文章不存在' });
}
return res.status(200).json({ message: '文章已删除' });
} catch (error) {
return res.status(500).json({ message: '删除文章失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,61 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Article, User } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
if (req.method === 'GET') {
try {
const { page = 1, limit = 10, search = '' } = req.query;
const pageNum = parseInt(page as string);
const limitNum = parseInt(limit as string);
const skip = (pageNum - 1) * limitNum;
const query: any = {};
if (search) {
query. = { $regex: search, $options: 'i' };
}
const [articles, total] = await Promise.all([
Article.find(query)
.populate('作者ID', 'username email')
.populate('分类ID', '分类名称')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limitNum)
.lean(),
Article.countDocuments(query)
]);
return res.status(200).json({
articles,
pagination: {
total,
page: pageNum,
limit: limitNum,
pages: Math.ceil(total / limitNum)
}
});
} catch (error) {
console.error('Fetch articles error:', error);
return res.status(500).json({ message: '获取文章列表失败' });
}
} else if (req.method === 'POST') {
try {
const data = req.body;
// 自动设置作者为当前管理员
data.ID = user.userId;
const article = await Article.create(data);
return res.status(201).json(article);
} catch (error) {
console.error('Create article error:', error);
return res.status(500).json({ message: '创建文章失败', error: (error as Error).message });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Category } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const categories = await Category.find().sort({ : -1, createdAt: -1 }).lean();
return res.status(200).json(categories);
} catch (error) {
console.error('Fetch categories error:', error);
return res.status(500).json({ message: '获取分类失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,65 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order, User } from '@/models';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
await dbConnect();
const { page = 1, limit = 10, status, search } = req.query;
const skip = (Number(page) - 1) * Number(limit);
const query: any = {};
if (status && status !== 'all') {
query. = status;
}
if (search) {
// Find users matching the search term first
const users = await User.find({
$or: [
{ : { $regex: search, $options: 'i' } },
{ : { $regex: search, $options: 'i' } }
]
}).select('_id');
const userIds = users.map(u => u._id);
query.$or = [
{ : { $regex: search, $options: 'i' } },
{ ID: { $in: userIds } }
];
}
const [orders, total] = await Promise.all([
Order.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(Number(limit))
.populate('用户ID', '用户名 邮箱 头像'),
Order.countDocuments(query)
]);
res.status(200).json({
orders,
pagination: {
total,
page: Number(page),
limit: Number(limit),
pages: Math.ceil(total / Number(limit))
}
});
} catch (error: any) {
console.error('Fetch orders error:', error);
res.status(500).json({ message: error.message || 'Internal server error' });
}
}
export default requireAdmin(handler);

View File

@@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { MembershipPlan } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
if (req.method === 'GET') {
try {
const plan = await MembershipPlan.findById(id);
if (!plan) return res.status(404).json({ message: 'Plan not found' });
return res.status(200).json(plan);
} catch (error) {
return res.status(500).json({ message: 'Failed to fetch plan' });
}
} else if (req.method === 'PUT') {
try {
const plan = await MembershipPlan.findByIdAndUpdate(id, req.body, { new: true });
if (!plan) return res.status(404).json({ message: 'Plan not found' });
return res.status(200).json(plan);
} catch (error) {
return res.status(500).json({ message: 'Failed to update plan' });
}
} else if (req.method === 'DELETE') {
try {
const plan = await MembershipPlan.findByIdAndDelete(id);
if (!plan) return res.status(404).json({ message: 'Plan not found' });
return res.status(200).json({ message: 'Plan deleted' });
} catch (error) {
return res.status(500).json({ message: 'Failed to delete plan' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,25 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { MembershipPlan } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const plans = await MembershipPlan.find().sort({ 价格: 1 });
return res.status(200).json(plans);
} catch (error) {
return res.status(500).json({ message: 'Failed to fetch plans' });
}
} else if (req.method === 'POST') {
try {
const plan = await MembershipPlan.create(req.body);
return res.status(201).json(plan);
} catch (error) {
return res.status(500).json({ message: 'Failed to create plan' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,72 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { SystemConfig } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
if (req.method === 'GET') {
try {
// 显式选择所有被隐藏的敏感字段
const config = await SystemConfig.findOne().select(
'+支付宝设置.AppID +支付宝设置.公钥 +支付宝设置.应用公钥 +支付宝设置.应用私钥 ' +
'+微信支付设置.WX_APPID +微信支付设置.WX_MCHID +微信支付设置.WX_PRIVATE_KEY +微信支付设置.WX_API_V3_KEY ' +
'+阿里云短信设置.AccessKeyID +阿里云短信设置.AccessKeySecret ' +
'+邮箱设置.MY_MAIL_PASS'
// 注意AI配置列表.API密钥 默认不查询,保持安全
).lean();
if (!config) {
return res.status(200).json({});
}
return res.status(200).json(config);
} catch (error) {
console.error('Fetch settings error:', error);
return res.status(500).json({ message: '获取系统配置失败' });
}
} else if (req.method === 'PUT') {
try {
const updates = req.body;
// 特殊处理 AI配置列表 的 API密钥
if (updates.AI配置列表 && Array.isArray(updates.AI配置列表)) {
// 获取当前配置(包含敏感字段)
const currentConfig = await SystemConfig.findOne().select('+AI配置列表.API密钥').lean();
if (currentConfig && currentConfig.AI配置列表) {
updates.AI配置列表 = updates.AI配置列表.map((newItem: any) => {
// 如果是新项目没有_id直接返回
if (!newItem._id) return newItem;
// 查找旧项目
const oldItem = currentConfig.AI配置列表.find((item: any) =>
item._id.toString() === newItem._id
);
// 如果找到了旧项目,且新项目的密钥为空,则保留旧密钥
if (oldItem && !newItem.API密钥) {
return { ...newItem, API密钥: oldItem.API密钥 };
}
return newItem;
});
}
}
const config = await SystemConfig.findOneAndUpdate(
{},
{ $set: updates },
{ new: true, upsert: true, setDefaultsOnInsert: true }
);
return res.status(200).json({ message: '配置已更新', config });
} catch (error) {
console.error('Update settings error:', error);
return res.status(500).json({ message: '更新系统配置失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Tag } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const tags = await Tag.find().sort({ createdAt: -1 }).lean();
return res.status(200).json(tags);
} catch (error) {
console.error('Fetch tags error:', error);
return res.status(500).json({ message: '获取标签失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,56 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { User } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, adminUser: any) {
const { id } = req.query;
if (req.method === 'PUT') {
// 更新用户
const { role, isBanned } = req.body;
try {
const updatedUser = await User.findByIdAndUpdate(
id,
{
角色: role,
是否被封禁: isBanned
},
{ new: true }
).select('-密码');
if (!updatedUser) {
return res.status(404).json({ message: '用户不存在' });
}
return res.status(200).json({ message: '用户更新成功', user: updatedUser });
} catch (error) {
console.error('Update user error:', error);
return res.status(500).json({ message: '更新用户失败' });
}
} else if (req.method === 'DELETE') {
// 删除用户
try {
// 防止自杀
if (id === adminUser.userId) {
return res.status(400).json({ message: '无法删除自己' });
}
const deletedUser = await User.findByIdAndDelete(id);
if (!deletedUser) {
return res.status(404).json({ message: '用户不存在' });
}
return res.status(200).json({ message: '用户删除成功' });
} catch (error) {
console.error('Delete user error:', error);
return res.status(500).json({ message: '删除用户失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,46 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { User } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
const { page = 1, limit = 10, search = '' } = req.query;
const pageNum = parseInt(page as string, 10);
const limitNum = parseInt(limit as string, 10);
const skip = (pageNum - 1) * limitNum;
const query: any = {};
if (search) {
query.$or = [
{ : { $regex: search, $options: 'i' } },
{ : { $regex: search, $options: 'i' } },
];
}
try {
const [users, total] = await Promise.all([
User.find(query)
.select('-密码') // 不返回密码
.sort({ createdAt: -1 })
.skip(skip)
.limit(limitNum),
User.countDocuments(query),
]);
return res.status(200).json({
users,
total,
page: pageNum,
totalPages: Math.ceil(total / limitNum),
});
} catch (error) {
console.error('Fetch users error:', error);
return res.status(500).json({ message: '获取用户列表失败' });
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,135 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { SystemConfig } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
export const config = {
api: {
bodyParser: false, // Disable body parser for streaming
},
};
async function readBody(req: NextApiRequest) {
const chunks = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString('utf8');
}
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
// Manually parse body since bodyParser is disabled
const bodyStr = await readBody(req);
const { prompt, assistantId, systemPrompt } = JSON.parse(bodyStr);
if (!prompt) {
return res.status(400).json({ message: 'Prompt is required' });
}
// 1. 获取系统配置
const config = await SystemConfig.findOne().select('+AI配置列表.API密钥 +AI配置列表.流式传输').lean();
if (!config || !config.AI配置列表 || config.AI配置列表.length === 0) {
return res.status(404).json({ message: 'No AI assistants configured' });
}
// 2. 选择 AI 助手
let assistant;
if (assistantId) {
assistant = config.AI配置列表.find((a: any) => a._id.toString() === assistantId && a.);
} else {
assistant = config.AI配置列表.find((a: any) => a.);
}
if (!assistant) {
return res.status(404).json({ message: 'Selected AI assistant not found or disabled' });
}
// 3. 构建请求
const messages = [];
const sysPrompt = systemPrompt || assistant.;
if (sysPrompt) {
messages.push({ role: 'system', content: sysPrompt });
}
messages.push({ role: 'user', content: prompt });
const apiEndpoint = assistant..replace(/\/+$/, '');
const url = apiEndpoint.endsWith('/chat/completions')
? apiEndpoint
: `${apiEndpoint}/chat/completions`;
const isStream = assistant. === true;
// 4. 发起请求
const aiRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${assistant.API密钥}`
},
body: JSON.stringify({
model: assistant.模型,
messages: messages,
temperature: 0.7,
stream: isStream, // Use the stream setting
})
});
if (!aiRes.ok) {
const errorText = await aiRes.text();
console.error('AI API Error:', errorText);
return res.status(aiRes.status).json({ message: `AI Provider Error: ${aiRes.statusText}` });
}
if (isStream) {
// Handle Streaming Response
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
});
if (!aiRes.body) {
res.end();
return;
}
const reader = aiRes.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Just forward the raw chunk from OpenAI to the client
// The client will handle parsing the "data: {...}" format
res.write(chunk);
}
} catch (streamError) {
console.error('Stream Error:', streamError);
} finally {
res.end();
}
} else {
// Handle Normal Response
const data = await aiRes.json();
const generatedText = data.choices?.[0]?.message?.content || '';
return res.status(200).json({ text: generatedText });
}
} catch (error) {
console.error('AI Generate Error:', error);
if (!res.headersSent) {
return res.status(500).json({ message: 'Internal Server Error' });
}
}
}
export default withDatabase(requireAdmin(handler));

View File

@@ -0,0 +1,122 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Article, User, Order } from '@/models';
import { verifyToken } from '@/lib/auth';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeStringify from 'rehype-stringify';
async function processMarkdown(content: string) {
if (!content) return '';
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypePrettyCode, {
theme: 'github-dark',
keepBackground: true,
})
.use(rehypeStringify)
.process(content);
return String(file);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
await dbConnect();
const { id } = req.query;
// 1. Fetch Article
const article = await Article.findById(id)
.populate('作者ID', '用户名 头像')
.populate('分类ID', '分类名称 别名')
.populate('标签ID列表', '标签名称');
if (!article) {
return res.status(404).json({ message: 'Article not found' });
}
// Increment view count
article.. += 1;
await article.save({ validateBeforeSave: false });
// 2. Check Permissions
let hasAccess = false;
let userId = null;
// Get user from token if exists
const token = req.cookies.token;
if (token) {
try {
const decoded = verifyToken(token);
if (decoded) {
userId = decoded.userId;
// Check if admin or author
if (decoded.role === 'admin' || decoded.userId === article.ID?._id?.toString()) {
hasAccess = true;
}
}
} catch (e) {
// Invalid token, treat as guest
}
}
// If article is free, everyone has access
if (article. === 0) {
hasAccess = true;
}
// If not yet accessible and user is logged in, check purchase/membership
if (!hasAccess && userId) {
const user = await User.findById(userId);
// Check Membership
if (user?.?. && new Date(user..) > new Date()) {
hasAccess = true;
}
// Check if purchased
if (!hasAccess) {
const order = await Order.findOne({
用户ID: userId,
商品ID: article._id.toString(),
: 'paid',
: 'buy_resource'
});
if (order) {
hasAccess = true;
}
}
}
// 3. Prepare Response
const articleData = article.toObject();
// Process Markdown to HTML
const htmlContent = await processMarkdown(articleData.);
articleData. = htmlContent; // Replace content with HTML
if (!hasAccess) {
// Hide sensitive content
delete articleData.;
// Optional: Truncate content for preview
articleData. = await processMarkdown(article..substring(0, 300) + '...');
}
res.status(200).json({
article: articleData,
hasAccess,
isLoggedIn: !!userId
});
} catch (error: any) {
console.error('Fetch article error:', error);
res.status(500).json({ message: error.message || 'Internal server error' });
}
}

View File

@@ -0,0 +1,65 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Article, Category } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const { page = 1, limit = 9, category, tag, search } = req.query;
const pageNum = parseInt(page as string);
const limitNum = parseInt(limit as string);
const skip = (pageNum - 1) * limitNum;
const query: any = { : 'published' };
if (category) {
// 如果传入的是分类ID直接查询如果是别名先查分类ID
if (category.length === 24) {
query.ID = category;
} else {
const catDoc = await Category.findOne({ 别名: category });
if (catDoc) query.ID = catDoc._id;
}
}
if (tag) {
// 暂不支持按标签别名查假设传入的是ID或暂不处理复杂逻辑
// 实际项目中通常需要关联查询 Tag 表
}
if (search) {
query. = { $regex: search, $options: 'i' };
}
const [articles, total] = await Promise.all([
Article.find(query)
.populate('作者ID', 'username avatar') // 假设 User 模型有 avatar 字段,如果没有需确认
.populate('分类ID', '分类名称 别名')
.populate('标签ID列表', '标签名称')
.select('-正文内容 -资源属性 -SEO关键词 -SEO描述') // 列表页不需要这些大字段
.sort({ createdAt: -1 }) // 最新发布在前
.skip(skip)
.limit(limitNum)
.lean(),
Article.countDocuments(query)
]);
return res.status(200).json({
articles,
pagination: {
total,
page: pageNum,
limit: limitNum,
pages: Math.ceil(total / limitNum)
}
});
} catch (error) {
console.error('Fetch public articles error:', error);
return res.status(500).json({ message: '获取文章列表失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,64 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { User } from '@/models';
import withDatabase from '@/lib/withDatabase';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: '请输入邮箱和密码' });
}
try {
// 查找用户,显式选择密码字段
const user = await User.findOne({ 邮箱: email }).select('+密码');
if (!user) {
return res.status(401).json({ message: '邮箱或密码错误' });
}
// 验证密码
const isPasswordValid = await bcrypt.compare(password, user.);
if (!isPasswordValid) {
return res.status(401).json({ message: '邮箱或密码错误' });
}
// 生成 JWT
const token = jwt.sign(
{ userId: user._id, email: user.邮箱, role: user.角色 },
JWT_SECRET,
{ expiresIn: '7d' }
);
// 设置 Cookie
res.setHeader(
'Set-Cookie',
`token=${token}; Path=/; HttpOnly; Max-Age=${60 * 60 * 24 * 7}; SameSite=Strict; ${process.env.NODE_ENV === 'production' ? 'Secure' : ''}`
);
return res.status(200).json({
message: '登录成功',
user: {
id: user._id,
username: user.用户名,
email: user.邮箱,
role: user.角色,
avatar: user.头像
}
});
} catch (error) {
console.error('Login error:', error);
return res.status(500).json({ message: '登录失败,请稍后重试' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,12 @@
import { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
// Clear the cookie by setting it to expire in the past
res.setHeader('Set-Cookie', 'token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Strict');
res.status(200).json({ message: 'Logged out successfully' });
}

36
src/pages/api/auth/me.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { verifyToken } from '@/lib/auth';
import dbConnect from '@/lib/dbConnect';
import { User } from '@/models';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ message: 'Not authenticated' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ message: 'Invalid token' });
}
await dbConnect();
const user = await User.findById(decoded.userId)
.select('-密码') // Exclude password
.populate('会员信息.当前等级ID', '套餐名称');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ user });
} catch (error) {
console.error('Auth check error:', error);
res.status(500).json({ message: 'Internal server error' });
}
}

View File

@@ -0,0 +1,49 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import bcrypt from 'bcryptjs';
import { User } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
const { email, password, username } = req.body;
if (!email || !password || !username) {
return res.status(400).json({ message: '请填写所有必填项' });
}
try {
// 检查邮箱是否已存在
const existingUser = await User.findOne({ 邮箱: email });
if (existingUser) {
return res.status(409).json({ message: '该邮箱已被注册' });
}
// 检查用户名是否已存在
const existingUsername = await User.findOne({ 用户名: username });
if (existingUsername) {
return res.status(409).json({ message: '该用户名已被使用' });
}
// 哈希密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建新用户
const newUser = await User.create({
用户名: username,
邮箱: email,
密码: hashedPassword,
: 'user', // 默认为普通用户
: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}`,
});
return res.status(201).json({ message: '注册成功', userId: newUser._id });
} catch (error) {
console.error('Registration error:', error);
return res.status(500).json({ message: '注册失败,请稍后重试' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Category } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const categories = await Category.find().sort({ : -1, createdAt: -1 }).lean();
return res.status(200).json(categories);
} catch (error) {
console.error('Fetch public categories error:', error);
return res.status(500).json({ message: '获取分类失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,59 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Comment } from '@/models';
import { verifyToken } from '@/lib/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await dbConnect();
if (req.method === 'GET') {
try {
const { articleId } = req.query;
if (!articleId) {
return res.status(400).json({ message: 'Article ID required' });
}
const comments = await Comment.find({ 文章ID: articleId, : 'visible' })
.populate('用户ID', '用户名 头像')
.sort({ createdAt: -1 })
.limit(50);
res.status(200).json({ comments });
} catch (error) {
res.status(500).json({ message: 'Failed to fetch comments' });
}
} else if (req.method === 'POST') {
try {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ message: 'Unauthorized' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ message: 'Invalid token' });
}
const { articleId, content } = req.body;
if (!articleId || !content) {
return res.status(400).json({ message: 'Missing fields' });
}
const comment = await Comment.create({
文章ID: articleId,
用户ID: decoded.userId,
评论内容: content,
});
// Populate user info for immediate display
await comment.populate('用户ID', '用户名 头像');
res.status(201).json({ comment });
} catch (error) {
res.status(500).json({ message: 'Failed to post comment' });
}
} else {
res.status(405).json({ message: 'Method not allowed' });
}
}

13
src/pages/api/hello.ts Normal file
View File

@@ -0,0 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
type Data = {
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
res.status(200).json({ name: "John Doe" });
}

View File

@@ -0,0 +1,121 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order, MembershipPlan, User } from '@/models';
import { alipayService } from '@/lib/alipay';
import { getUserFromCookie } from '@/lib/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
// 1. Auth Check
const user = getUserFromCookie(req);
if (!user) {
return res.status(401).json({ message: 'Unauthorized' });
}
const { type, planId, itemId } = req.body;
await dbConnect();
let orderData: any = {
用户ID: user.userId,
: 'alipay',
: 'pending'
};
let subject = '';
let body = '';
let amount = 0;
if (type === 'buy_membership') {
if (!planId) {
return res.status(400).json({ message: 'Plan ID is required' });
}
const plan = await MembershipPlan.findById(planId);
if (!plan) {
return res.status(404).json({ message: 'Plan not found' });
}
orderData. = 'buy_membership';
orderData.ID = plan._id;
orderData. = {
标题: plan.套餐名称,
: '/images/membership-cover.png'
};
amount = plan.;
subject = `购买会员 - ${plan.}`;
body = plan.;
} else if (type === 'buy_resource') {
if (!itemId) {
return res.status(400).json({ message: 'Item ID is required' });
}
// Import Article model dynamically or ensure it's imported at top
const { Article } = await import('@/models');
const article = await Article.findById(itemId);
if (!article) {
return res.status(404).json({ message: 'Article not found' });
}
orderData. = 'buy_resource';
orderData.ID = article._id;
orderData. = {
标题: article.文章标题,
封面: article.封面图 || '/images/article-cover.png'
};
amount = article.;
subject = `购买资源 - ${article.}`;
body = article. || '付费资源';
} else {
return res.status(400).json({ message: 'Invalid order type' });
}
// 3. Create Order
const outTradeNo = `ORDER_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
orderData. = outTradeNo;
orderData. = amount;
const order = await Order.create(orderData) as any;
// 4. Generate Alipay URL
// Determine return URL based on order type
let returnUrl = '';
if (orderData. === 'buy_resource') {
if (req.body.returnUrl) {
// Point to backend return handler, passing the frontend URL as 'target'
// We need the base URL of the API. Assuming it's in config or we can derive it.
// Since we don't have easy access to base URL here without config, let's use the notifyUrl base if available
// or just assume relative path if alipay supports it (it doesn't).
// We'll use the alipayService config which has notifyUrl (usually the domain)
// We need to access alipayService.config, but it's private.
// Let's re-init it to be sure or just use a known env var if possible.
// Actually, alipayService.generatePagePayUrl uses config.notifyUrl to build default return_url.
// We can just pass the FULL URL to generatePagePayUrl.
// Hack: We need the domain. Let's assume req.headers.host
const protocol = req.headers['x-forwarded-proto'] || 'http';
const host = req.headers.host;
const baseUrl = `${protocol}://${host}`;
returnUrl = `${baseUrl}/api/payment/return?target=${encodeURIComponent(req.body.returnUrl)}`;
}
}
const payUrl = await alipayService.generatePagePayUrl({
outTradeNo: order.订单号,
totalAmount: order.支付金额.toFixed(2),
subject: subject.substring(0, 256), // Alipay subject limit
body: body?.substring(0, 128), // Alipay body limit
returnUrl: returnUrl || undefined
});
res.status(200).json({ success: true, payUrl });
} catch (error: any) {
console.error('Order creation error:', error);
res.status(500).json({ message: error.message || 'Internal server error' });
}
}

View File

@@ -0,0 +1,101 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order, User, MembershipPlan } from '@/models';
import { alipayService } from '@/lib/alipay';
export const config = {
api: {
bodyParser: false, // Alipay sends x-www-form-urlencoded
},
};
async function getRawBody(req: NextApiRequest): Promise<string> {
const chunks = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString('utf-8');
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).send('Method Not Allowed');
}
try {
const rawBody = await getRawBody(req);
const params = new URLSearchParams(rawBody);
const data: Record<string, string> = {};
for (const [key, value] of params.entries()) {
data[key] = value;
}
// 1. Verify Signature
console.log('Received Alipay Notify:', data);
const isValid = await alipayService.verifySignature(data);
console.log('Signature Verification Result:', isValid);
if (!isValid) {
console.error('Alipay signature verification failed');
return res.send('fail');
}
// 2. Check Trade Status
const tradeStatus = data.trade_status;
// Only process successful payments
if (tradeStatus !== 'TRADE_SUCCESS' && tradeStatus !== 'TRADE_FINISHED') {
console.log(`Order ${data.out_trade_no} - ignoring status: ${tradeStatus}`);
return res.send('success');
}
await dbConnect();
// Find Order
const outTradeNo = data.out_trade_no;
const order = await Order.findOne({ 订单号: outTradeNo });
if (!order) {
console.error(`Order not found: ${outTradeNo}`);
return res.send('fail');
}
if (order. === 'paid') {
return res.send('success'); // Already processed
}
// Update Order to paid
order. = 'paid';
order. = new Date();
order. = 'alipay';
await order.save();
// Update User Membership
if (order. === 'buy_membership') {
const plan = await MembershipPlan.findById(order.ID);
if (plan) {
const user = await User.findById(order.ID);
if (user) {
const currentExpiry = user.?. ? new Date(user..) : new Date();
const now = new Date();
const startTime = currentExpiry > now ? currentExpiry : now;
const newExpiry = new Date(startTime.getTime() + plan. * 24 * 60 * 60 * 1000);
user. = {
当前等级ID: plan._id,
过期时间: newExpiry
};
await user.save();
}
}
}
console.log(`Order ${outTradeNo} marked as paid`);
return res.send('success');
} catch (error) {
console.error('Alipay notify error:', error);
res.send('fail');
}
}

View File

@@ -0,0 +1,94 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order, User, MembershipPlan } from '@/models';
import { alipayService } from '@/lib/alipay';
/**
* Handle synchronous return from Alipay (return_url)
* This is called when user is redirected back after payment
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const params = req.query as Record<string, string>;
console.log('Received Alipay Return:', params);
// 1. Verify Signature
const isValid = await alipayService.verifySignature(params);
console.log('Return Signature Verification:', isValid);
if (!isValid) {
console.error('Alipay return signature verification failed');
return res.redirect('/payment/failure?error=signature');
}
// 2. Check Trade Status
const tradeStatus = params.trade_status;
const outTradeNo = params.out_trade_no;
await dbConnect();
// 3. Find Order
const order = await Order.findOne({ 订单号: outTradeNo });
if (!order) {
console.error(`Order not found: ${outTradeNo}`);
return res.redirect('/payment/failure?error=order_not_found');
}
// 4. Handle payment success
if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') {
// Payment successful - update to paid
if (order. !== 'paid') {
order. = 'paid';
order. = new Date();
order. = 'alipay';
await order.save();
// Update User Membership
if (order. === 'buy_membership') {
const plan = await MembershipPlan.findById(order.ID);
if (plan) {
const user = await User.findById(order.ID);
if (user) {
const currentExpiry = user.?. ? new Date(user..) : new Date();
const now = new Date();
const startTime = currentExpiry > now ? currentExpiry : now;
const newExpiry = new Date(startTime.getTime() + plan. * 24 * 60 * 60 * 1000);
user. = {
当前等级ID: plan._id,
过期时间: newExpiry
};
await user.save();
}
}
}
console.log(`Order ${outTradeNo} status updated to paid via return_url`);
}
// Redirect to target if present, otherwise success page
const target = params.target as string;
if (target) {
return res.redirect(target);
}
return res.redirect('/payment/success');
}
// For any other status
const target = params.target as string;
if (target) {
return res.redirect(target);
}
return res.redirect('/membership');
} catch (error: any) {
console.error('Alipay return handler error:', error);
res.redirect('/payment/failure?error=server');
}
}

View File

@@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { MembershipPlan } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
const plans = await MembershipPlan.find({ 是否上架: true }).sort({ 价格: 1 });
return res.status(200).json(plans);
} catch (error) {
return res.status(500).json({ message: 'Failed to fetch plans' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,32 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { SystemConfig } from '@/models';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
await dbConnect();
const config = await SystemConfig.findOne({ : 'default' })
.select('Banner配置 站点设置')
.lean();
if (!config) {
return res.status(404).json({ message: 'Config not found' });
}
// Filter visible banners
const visibleBanners = config.Banner配置?.filter((b: any) => b. === 'visible') || [];
res.status(200).json({
banners: visibleBanners,
site: config.站点设置
});
} catch (error) {
console.error('Fetch public config error:', error);
res.status(500).json({ message: 'Internal server error' });
}
}

View File

@@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Tag } from '@/models';
import withDatabase from '@/lib/withDatabase';
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
try {
// 简单返回所有标签,实际可优化为返回热门标签
const tags = await Tag.find().limit(20).sort({ createdAt: -1 }).lean();
return res.status(200).json(tags);
} catch (error) {
console.error('Fetch public tags error:', error);
return res.status(500).json({ message: '获取标签失败' });
}
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
}
export default withDatabase(handler);

View File

@@ -0,0 +1,33 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order } from '@/models';
import { verifyToken } from '@/lib/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ message: 'Unauthorized' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ message: 'Invalid token' });
}
await dbConnect();
const orders = await Order.find({ 用户ID: decoded.userId })
.sort({ createdAt: -1 })
.limit(20);
res.status(200).json({ orders });
} catch (error) {
console.error('Fetch user orders error:', error);
res.status(500).json({ message: 'Internal server error' });
}
}

View File

@@ -0,0 +1,33 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { verifyToken } from '@/lib/auth';
import dbConnect from '@/lib/dbConnect';
import { Order } from '@/models';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ message: 'Not authenticated' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ message: 'Invalid token' });
}
await dbConnect();
const orders = await Order.find({ 用户ID: decoded.userId })
.sort({ createdAt: -1 })
.lean();
res.status(200).json({ orders });
} catch (error) {
console.error('Fetch orders error:', error);
res.status(500).json({ message: 'Internal server error' });
}
}

View File

@@ -0,0 +1,52 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { verifyToken } from '@/lib/auth';
import dbConnect from '@/lib/dbConnect';
import { User } from '@/models';
import bcrypt from 'bcryptjs';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'PUT') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ message: 'Not authenticated' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ message: 'Invalid token' });
}
const { username, avatar, password } = req.body;
await dbConnect();
const updateData: any = {};
if (username) updateData. = username;
if (avatar) updateData. = avatar;
if (password) {
if (password.length < 6) {
return res.status(400).json({ message: '密码长度不能少于6位' });
}
updateData. = await bcrypt.hash(password, 10);
}
const user = await User.findByIdAndUpdate(
decoded.userId,
updateData,
{ new: true, runValidators: true }
).select('-密码');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ user, message: '个人资料已更新' });
} catch (error) {
console.error('Update profile error:', error);
res.status(500).json({ message: 'Internal server error' });
}
}

282
src/pages/article/[id].tsx Normal file
View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '@/hooks/useAuth';
import MainLayout from '@/components/layouts/MainLayout';
import CommentSection from '@/components/article/CommentSection';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { toast } from 'sonner';
interface Article {
_id: string;
文章标题: string;
封面图: string;
摘要: string;
正文内容: string;
ID: {
用户名: string;
头像: string;
};
ID: {
分类名称: string;
};
ID列表: {
标签名称: string;
}[];
价格: number;
: 'points' | 'cash';
?: {
下载链接: string;
提取码: string;
解压密码: string;
隐藏内容: string;
};
createdAt: string;
: {
阅读数: number;
};
}
export default function ArticleDetail() {
const router = useRouter();
const { id } = router.query;
const { user, loading: authLoading } = useAuth();
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(true);
const [hasAccess, setHasAccess] = useState(false);
const [purchasing, setPurchasing] = useState(false);
useEffect(() => {
if (id) {
fetchArticle();
}
}, [id]);
const fetchArticle = async () => {
try {
const res = await fetch(`/api/articles/${id}`);
if (res.ok) {
const data = await res.json();
setArticle(data.article);
setHasAccess(data.hasAccess);
} else {
toast.error('文章不存在或已被删除');
router.push('/');
}
} catch (error) {
console.error('Failed to fetch article', error);
} finally {
setLoading(false);
}
};
const handlePurchase = async () => {
if (!user) {
router.push(`/auth/login?redirect=${encodeURIComponent(router.asPath)}`);
return;
}
if (!article) return;
// If price is 0, it should be accessible, but just in case
if (article. === 0) return;
setPurchasing(true);
try {
const res = await fetch('/api/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'buy_resource',
itemId: article._id,
paymentMethod: 'alipay', // Default to alipay for now
returnUrl: window.location.href // Pass current page URL for redirect after payment
}),
});
if (res.ok) {
const data = await res.json();
// Redirect to Alipay
window.location.href = data.payUrl;
} else {
const data = await res.json();
toast.error(data.message || '创建订单失败');
}
} catch (error) {
toast.error('网络错误,请稍后重试');
} finally {
setPurchasing(false);
}
};
if (loading) {
return (
<MainLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="space-y-4 mb-8">
<Skeleton className="h-12 w-3/4" />
<div className="flex gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<Skeleton className="h-[400px] w-full rounded-xl mb-8" />
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
</MainLayout>
);
}
if (!article) return null;
return (
<MainLayout
seo={{
title: article.文章标题,
description: article.摘要 || article..substring(0, 100),
keywords: article.标签ID列表?.map(t => t.).join(',')
}}
>
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Article Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-primary bg-primary/10 hover:bg-primary/20">
{article.ID?. || '未分类'}
</Badge>
{article.ID列表?.map((tag, index) => (
<Badge key={index} variant="outline" className="text-gray-500">
{tag.}
</Badge>
))}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
{article.}
</h1>
<div className="flex items-center gap-6 text-sm text-gray-500 border-b border-gray-100 pb-8">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4" />
<span>{article.ID?. || '匿名'}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}</span>
</div>
<div>
{article.?. || 0}
</div>
</div>
</div>
{/* Cover Image */}
{article. && (
<div className="mb-10 rounded-xl overflow-hidden shadow-lg">
<img
src={article.}
alt={article.}
className="w-full h-auto object-cover max-h-[500px]"
/>
</div>
)}
{/* Article Content */}
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
</article>
{/* Resource Download / Paywall Section */}
<div className="bg-gray-50 rounded-xl p-8 mb-12 border border-gray-100">
{hasAccess ? (
<div className="space-y-6">
<h3 className="text-xl font-bold flex items-center gap-2 text-green-700">
<Download className="w-6 h-6" />
</h3>
{article. ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<a
href={article..}
target="_blank"
rel="noopener noreferrer"
className="text-primary font-medium hover:underline break-all"
>
{article..}
</a>
</div>
{article.. && (
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono">
{article..}
</code>
</div>
)}
{article.. && (
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono">
{article..}
</code>
</div>
)}
</div>
) : (
<p className="text-gray-500"></p>
)}
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-primary" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
className="px-8"
onClick={handlePurchase}
disabled={purchasing}
>
{purchasing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<span className="mr-2">¥{article.}</span>
)}
</Button>
<Button
size="lg"
variant="outline"
onClick={() => router.push('/membership')}
>
()
</Button>
</div>
</div>
)}
</div>
{/* Comments Section */}
<CommentSection articleId={article._id} isLoggedIn={!!user} />
</div>
</MainLayout>
);
}

132
src/pages/auth/login.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2 } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
const formSchema = z.object({
email: z.string().email({ message: "请输入有效的邮箱地址" }),
password: z.string().min(6, { message: "密码至少需要6个字符" }),
});
export default function LoginPage() {
const router = useRouter();
const { refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || '登录失败');
}
// 登录成功,刷新用户状态并跳转
await refreshUser();
const redirect = router.query.redirect as string;
router.push(redirect || '/');
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center"></CardTitle>
<CardDescription className="text-center">
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<div className="text-sm text-destructive text-center">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
{' '}
<Link href="/auth/register" className="text-primary hover:underline">
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}

165
src/pages/auth/register.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
const formSchema = z.object({
username: z.string().min(2, { message: "用户名至少需要2个字符" }),
email: z.string().email({ message: "请输入有效的邮箱地址" }),
password: z.string().min(6, { message: "密码至少需要6个字符" }),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"],
});
export default function RegisterPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
email: "",
password: "",
confirmPassword: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
setError('');
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
email: values.email,
password: values.password,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || '注册失败');
}
// 注册成功,跳转到登录页
router.push('/auth/login');
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center"></CardTitle>
<CardDescription className="text-center">
使
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<div className="text-sm text-destructive text-center">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
{' '}
<Link href="/auth/login" className="text-primary hover:underline">
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,344 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import MainLayout from '@/components/layouts/MainLayout';
import { useAuth } from '@/hooks/useAuth';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Crown, ShoppingBag, BookOpen, LogOut, User as UserIcon, Lock, Save } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { toast } from 'sonner';
interface Order {
_id: string;
订单号: string;
订单类型: string;
支付金额: number;
订单状态: string;
createdAt: string;
商品ID?: string;
?: {
标题: string;
};
}
export default function Dashboard() {
const { user, loading, logout, refreshUser } = useAuth();
const router = useRouter();
const [orders, setOrders] = useState<Order[]>([]);
const [loadingOrders, setLoadingOrders] = useState(true);
// Profile state
const [profileForm, setProfileForm] = useState({
username: '',
avatar: '',
password: ''
});
const [updatingProfile, setUpdatingProfile] = useState(false);
useEffect(() => {
if (!loading && !user) {
router.push('/auth/login');
}
}, [user, loading, router]);
useEffect(() => {
if (user) {
fetchOrders();
setProfileForm(prev => ({
...prev,
username: user.用户名 || '',
avatar: user.头像 || ''
}));
}
}, [user]);
const fetchOrders = async () => {
try {
const res = await fetch('/api/user/orders');
if (res.ok) {
const data = await res.json();
setOrders(data.orders);
}
} catch (error) {
console.error('Failed to fetch orders', error);
} finally {
setLoadingOrders(false);
}
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
setUpdatingProfile(true);
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profileForm)
});
const data = await res.json();
if (res.ok) {
toast.success('个人资料已更新');
setProfileForm(prev => ({ ...prev, password: '' })); // Clear password
refreshUser(); // Refresh global user state
} else {
toast.error(data.message || '更新失败');
}
} catch (error) {
toast.error('请求出错');
} finally {
setUpdatingProfile(false);
}
};
if (loading || !user) {
return (
<MainLayout>
<div className="min-h-[60vh] flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
</MainLayout>
);
}
const isVip = user.?. && new Date(user..) > new Date();
const resourceOrders = orders.filter(o => o. === 'buy_resource' && o. === 'paid');
return (
<MainLayout>
<div className="container mx-auto px-4 pt-24 pb-8">
<div className="flex flex-col md:flex-row gap-8">
{/* Sidebar / User Info Card */}
<div className="w-full md:w-1/3 lg:w-1/4 space-y-6">
<Card>
<CardHeader className="text-center">
<div className="mx-auto w-24 h-24 mb-4 relative">
<Avatar className="w-24 h-24 border-4 border-white shadow-lg">
<AvatarImage src={user.} />
<AvatarFallback className="text-2xl">{user.?.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
{isVip && (
<div className="absolute -top-2 -right-2 bg-yellow-400 text-white p-1.5 rounded-full shadow-sm">
<Crown className="w-5 h-5 fill-current" />
</div>
)}
</div>
<CardTitle>{user.}</CardTitle>
<CardDescription>{user.}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="flex items-center justify-between">
<span className={`font-bold ${isVip ? 'text-yellow-600' : 'text-gray-700'}`}>
{isVip ? user.?.ID?. || '尊贵会员' : '普通用户'}
</span>
{isVip ? (
<Badge variant="outline" className="text-yellow-600 border-yellow-600">
</Badge>
) : (
<Button variant="link" size="sm" className="h-auto p-0 text-primary" onClick={() => router.push('/membership')}>
</Button>
)}
</div>
{isVip && (
<div className="text-xs text-gray-400 mt-2">
: {format(new Date(user.!.!), 'yyyy-MM-dd')}
</div>
)}
</div>
<Button variant="outline" className="w-full text-red-600 hover:text-red-700 hover:bg-red-50" onClick={logout}>
<LogOut className="w-4 h-4 mr-2" />
退
</Button>
</CardContent>
</Card>
</div>
{/* Main Content Area */}
<div className="flex-1">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="orders"></TabsTrigger>
<TabsTrigger value="articles"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">¥{user.?. || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{user.?. || 0}</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="orders">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{loadingOrders ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : orders.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<div className="space-y-4">
{orders.map((order) => (
<div key={order._id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center text-blue-600">
<ShoppingBag className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900">
{order.?. || (order. === 'buy_membership' ? '购买会员' : '购买资源')}
</div>
<div className="text-xs text-gray-500">
{format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</div>
</div>
</div>
<div className="text-right">
<div className="font-bold text-gray-900">¥{order.}</div>
<Badge variant={order. === 'paid' ? 'default' : 'outline'} className={order. === 'paid' ? 'bg-green-500 hover:bg-green-600' : 'text-yellow-600 border-yellow-600'}>
{order. === 'paid' ? '已支付' : '待支付'}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="articles">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{resourceOrders.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<BookOpen className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="space-y-4">
{resourceOrders.map((order) => (
<div key={order._id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center text-purple-600">
<BookOpen className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900">
{order.?. || '未知资源'}
</div>
<div className="text-xs text-gray-500">
: {format(new Date(order.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => router.push(`/article/${order.ID || '#'}`)}>
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<form onSubmit={handleUpdateProfile}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<div className="relative">
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="username"
value={profileForm.username}
onChange={e => setProfileForm({ ...profileForm, username: e.target.value })}
className="pl-9"
placeholder="您的用户名"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="avatar"></Label>
<Input
id="avatar"
value={profileForm.avatar}
onChange={e => setProfileForm({ ...profileForm, avatar: e.target.value })}
placeholder="https://..."
/>
<p className="text-xs text-gray-500"> URL使</p>
</div>
<div className="space-y-2">
<Label htmlFor="password"> ()</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="password"
type="password"
value={profileForm.password}
onChange={e => setProfileForm({ ...profileForm, password: e.target.value })}
className="pl-9"
placeholder="不修改请留空"
minLength={6}
/>
</div>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={updatingProfile}>
{updatingProfile && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
</Button>
</CardFooter>
</form>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</MainLayout>
);
}

133
src/pages/index.tsx Normal file
View File

@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import MainLayout from '@/components/layouts/MainLayout';
import HeroBanner from '@/components/home/HeroBanner';
import ArticleCard from '@/components/home/ArticleCard';
import Sidebar from '@/components/home/Sidebar';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
export default function Home() {
const [articles, setArticles] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [tags, setTags] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState('all');
const [banners, setBanners] = useState<any[]>([]);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const [artRes, catRes, tagRes, configRes] = await Promise.all([
fetch('/api/articles'),
fetch('/api/categories'),
fetch('/api/tags'),
fetch('/api/public/config')
]);
if (artRes.ok) {
const data = await artRes.json();
setArticles(data.articles);
}
if (catRes.ok) setCategories(await catRes.json());
if (tagRes.ok) setTags(await tagRes.json());
if (configRes.ok) {
const data = await configRes.json();
setBanners(data.banners);
}
} catch (error) {
console.error('Failed to fetch home data', error);
} finally {
setLoading(false);
}
};
const handleCategoryClick = async (catId: string) => {
setActiveCategory(catId);
setLoading(true);
try {
const url = catId === 'all' ? '/api/articles' : `/api/articles?category=${catId}`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
setArticles(data.articles);
}
} catch (error) {
console.error('Filter error', error);
} finally {
setLoading(false);
}
};
return (
<MainLayout transparentHeader={true}>
{/* Hero Section - Full Width */}
<div className="w-full mb-6">
<HeroBanner banners={banners} />
</div>
<div className="container mx-auto px-4 pb-8">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Content (Articles) */}
<div className="lg:col-span-9 space-y-0">
{/* Category Filter */}
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-2">
<Button
variant={activeCategory === 'all' ? 'default' : 'ghost'}
size="sm"
className="rounded-full"
onClick={() => handleCategoryClick('all')}
>
</Button>
{categories.map(cat => (
<Button
key={cat._id}
variant={activeCategory === cat._id ? 'default' : 'ghost'}
size="sm"
className="rounded-full whitespace-nowrap"
onClick={() => handleCategoryClick(cat._id)}
>
{cat.}
</Button>
))}
</div>
<span className="text-xs text-gray-400 whitespace-nowrap hidden md:block">
{articles.length}
</span>
</div>
{/* Article Grid */}
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{articles.map(article => (
<ArticleCard key={article._id} article={article} />
))}
</div>
)}
{!loading && articles.length === 0 && (
<div className="text-center py-20 text-gray-400">
</div>
)}
</div>
{/* Sidebar */}
<div className="lg:col-span-3">
<div className="sticky top-24">
<Sidebar tags={tags} />
</div>
</div>
</div>
</div>
</MainLayout>
);
}

130
src/pages/membership.tsx Normal file
View File

@@ -0,0 +1,130 @@
import React, { useEffect, useState } from 'react';
import MainLayout from '@/components/layouts/MainLayout';
import { Button } from '@/components/ui/button';
import { Check, Loader2, Crown } from 'lucide-react';
import { useRouter } from 'next/router';
import { toast } from 'sonner';
export default function Membership() {
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
try {
const res = await fetch('/api/plans');
if (res.ok) {
setPlans(await res.json());
}
} catch (error) {
console.error('Failed to fetch plans', error);
} finally {
setLoading(false);
}
};
const handlePurchase = async (planId: string) => {
try {
const res = await fetch('/api/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId, type: 'buy_membership' }),
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
router.push('/auth/login');
return;
}
throw new Error(data.message || '创建订单失败');
}
if (data.payUrl) {
window.location.href = data.payUrl;
}
} catch (error: any) {
console.error('Payment error:', error);
const message = error.message === '创建订单失败'
? '支付系统暂时繁忙,请稍后再试或联系客服。'
: error.message;
toast.error(`支付发起失败: ${message}`);
}
};
return (
<MainLayout>
<div className="bg-[#0F172A] py-12 text-center text-white">
<div className="container mx-auto px-4">
<div className="inline-flex items-center justify-center p-3 bg-white/10 rounded-full mb-4">
<Crown className="w-6 h-6 text-yellow-400" />
</div>
<h1 className="text-3xl md:text-4xl font-bold mb-4"></h1>
<p className="text-lg text-gray-400 max-w-2xl mx-auto">
访
</p>
</div>
</div>
<div className="container mx-auto px-4 py-12">
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{plans.map((plan) => (
<div key={plan._id} className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 flex flex-col relative hover:-translate-y-1 transition-transform duration-300">
{plan. && (
<div className="absolute top-0 right-0 bg-primary text-white text-xs font-bold px-3 py-1 rounded-bl-lg z-10">
</div>
)}
<div className="p-6 flex-1">
<h3 className="text-xl font-bold text-gray-900 mb-2">{plan.}</h3>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-3xl font-bold text-primary">¥{plan.}</span>
<span className="text-sm text-gray-500">/{plan.}</span>
</div>
<p className="text-sm text-gray-500 mb-6 min-h-[40px]">{plan.}</p>
<div className="space-y-3 mb-6">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Check className="w-4 h-4 text-green-500" />
<span>: {plan.?.}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Check className="w-4 h-4 text-green-500" />
<span>: {plan.?. ? `${(plan.. * 10).toFixed(1)}` : '无折扣'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Check className="w-4 h-4 text-green-500" />
<span></span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Check className="w-4 h-4 text-green-500" />
<span></span>
</div>
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<Button
className="w-full bg-linear-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 text-white shadow-lg shadow-primary/20"
onClick={() => handlePurchase(plan._id)}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</MainLayout>
);
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useRouter } from 'next/router';
import MainLayout from '@/components/layouts/MainLayout';
import { XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
export default function PaymentFailure() {
const router = useRouter();
const { error } = router.query;
const getErrorMessage = () => {
switch (error) {
case 'signature':
return '支付验证失败,请联系客服';
case 'order_not_found':
return '订单不存在,请重新下单';
case 'cancelled':
return '您已取消支付';
case 'server':
return '服务器错误,请稍后重试';
default:
return '支付失败,请重试';
}
};
return (
<MainLayout>
<div className="min-h-[60vh] flex items-center justify-center px-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center">
<XCircle className="w-12 h-12 text-red-600" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
</h1>
<p className="text-gray-600 mb-8">
{getErrorMessage()}
</p>
<div className="flex gap-4 justify-center">
<Button
variant="outline"
onClick={() => router.push('/')}
>
</Button>
<Button
onClick={() => router.push('/membership')}
>
</Button>
</div>
</div>
</div>
</MainLayout>
);
}

View File

@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import MainLayout from '@/components/layouts/MainLayout';
import { CheckCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
export default function PaymentSuccess() {
const router = useRouter();
const [countdown, setCountdown] = useState(5);
useEffect(() => {
// 倒计时自动跳转
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
} else {
router.push('/membership');
}
}, [countdown, router]);
return (
<MainLayout>
<div className="min-h-[60vh] flex items-center justify-center px-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-12 h-12 text-green-600" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
</h1>
<p className="text-gray-600 mb-8">
</p>
<div className="bg-gray-50 rounded-lg p-6 mb-8">
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{countdown} ...</span>
</div>
</div>
<div className="flex gap-4 justify-center">
<Button
variant="outline"
onClick={() => router.push('/')}
>
</Button>
<Button
onClick={() => router.push('/membership')}
>
</Button>
</div>
</div>
</div>
</MainLayout>
);
}

119
src/styles/globals.css Normal file
View File

@@ -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;
}
}