2025.11.28.22.40

This commit is contained in:
RUI
2025-11-28 22:44:54 +08:00
parent 0d73d0c63b
commit 21da21925e
71 changed files with 2924 additions and 1834 deletions

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Sparkles, Wand2, RefreshCw, Minimize2, Maximize2, Check, Send, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { ScrollArea } from '@/components/ui/scroll-area';
interface AIPanelProps {
assistantId: string;
onStream?: (text: string) => void;
contextText?: string;
}
export default function AIPanel({ assistantId, onStream, contextText = '' }: AIPanelProps) {
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const runAiAction = async (customPrompt?: string) => {
if (!assistantId) {
toast.error('请先选择 AI 助手');
return;
}
const finalPrompt = customPrompt || prompt;
if (!finalPrompt.trim()) return;
setIsGenerating(true);
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
assistantId: assistantId,
context: contextText
}),
});
if (!res.ok) throw new Error('Generation failed');
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 (onStream) {
onStream(content);
}
} catch (e) { }
}
}
}
}
} else {
const data = await res.json();
if (onStream) {
onStream(data.text);
}
}
setPrompt(''); // Clear prompt after success
} catch (error) {
console.error(error);
toast.error('AI 生成失败');
} finally {
setIsGenerating(false);
}
};
const quickActions = [
{ label: '润色优化', icon: RefreshCw, action: () => runAiAction(`请润色以下文本,使其更加流畅优美:\n${contextText}`) },
{ label: '内容总结', icon: Minimize2, action: () => runAiAction(`请总结以下文本的核心内容:\n${contextText}`) },
{ label: '扩写内容', icon: Maximize2, action: () => runAiAction(`请扩写以下文本,补充更多细节:\n${contextText}`) },
{ label: '纠错检查', icon: Check, action: () => runAiAction(`请检查并纠正以下文本中的错别字和语法错误:\n${contextText}`) },
{ label: '续写', icon: Wand2, action: () => runAiAction(`请根据以下上下文继续写作:\n${contextText}`) },
];
return (
<div className="h-full flex flex-col bg-background w-full">
<div className="p-4 border-b border-border flex items-center gap-2 bg-muted/40">
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="font-semibold text-sm text-foreground">AI </span>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{/* Quick Actions */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider"></h4>
<div className="grid grid-cols-2 gap-2">
{quickActions.map((action, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="justify-start h-8 text-xs bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={action.action}
disabled={isGenerating || !contextText}
>
{isGenerating && action.label === '生成中...' ? (
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
) : (
<action.icon className="w-3 h-3 mr-2" />
)}
{action.label}
</Button>
))}
</div>
{!contextText && (
<p className="text-[10px] text-muted-foreground text-center py-2">
使
</p>
)}
</div>
{/* Custom Input */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider"></h4>
<div className="relative">
<Textarea
placeholder="输入你的指令..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] text-sm resize-none pr-10 bg-muted/50 border-border focus:bg-background transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
runAiAction();
}
}}
/>
<Button
size="icon"
className="absolute bottom-2 right-2 h-7 w-7 rounded-full bg-purple-600 hover:bg-purple-700 text-white shadow-sm disabled:opacity-50"
onClick={() => runAiAction()}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -57,15 +57,15 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
}, [router]);
return (
<div className="h-screen bg-gray-100 flex overflow-hidden">
<div className="h-screen bg-muted/40 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",
"bg-background border-r border-border 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">
<div className="h-16 flex items-center justify-center border-b border-border 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">
@@ -80,7 +80,7 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
"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"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="w-5 h-5" />
@@ -90,8 +90,8 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
})}
{/* 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">
<div className="mt-4 pt-4 border-t border-border">
<h3 className="px-4 text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
</h3>
<Link
@@ -100,7 +100,7 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
"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"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<CreditCard className="w-5 h-5" />
@@ -108,10 +108,10 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
</Link>
</div>
</nav>
<div className="p-4 border-t border-gray-200 shrink-0">
<div className="p-4 border-t border-border shrink-0">
<Button
variant="ghost"
className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50"
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleLogout}
>
<LogOut className="w-5 h-5 mr-2" />
@@ -123,7 +123,7 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
{/* 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">
<header className="bg-background border-b border-border h-16 flex items-center justify-between px-6 lg:px-8 shrink-0">
<Button
variant="ghost"
size="icon"
@@ -133,10 +133,10 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
<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 className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground"></span>
</div>
<div className="w-8 h-8 rounded-full bg-gray-200 overflow-hidden">
<div className="w-8 h-8 rounded-full bg-muted overflow-hidden">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Avatar" />
</div>
</div>

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react';
import { 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 { MarkdownEditor } from '@/components/markdown/MarkdownEditor';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -11,22 +13,17 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings, Plus, Trash2 } from 'lucide-react';
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, DollarSign, Settings } from 'lucide-react';
import { useForm, Controller, useFieldArray } from 'react-hook-form';
import TiptapEditor from './TiptapEditor';
import { toast } from 'sonner';
import AIPanel from './AIPanel';
interface Category {
_id: string;
分类名称: string;
}
interface Tag {
_id: string;
标签名称: string;
}
interface ArticleEditorProps {
mode: 'create' | 'edit';
articleId?: string;
@@ -37,10 +34,10 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
const [loading, setLoading] = useState(mode === 'edit');
const [saving, setSaving] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [assistants, setAssistants] = useState<any[]>([]);
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('');
const { register, handleSubmit, control, reset, setValue, watch } = useForm({
const { register, handleSubmit, control, reset, setValue, watch, getValues } = useForm({
defaultValues: {
: '',
URL别名: '',
@@ -66,11 +63,13 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
}
});
const { fields: extendedFields, append: appendExtended, remove: removeExtended } = useFieldArray({
useFieldArray({
control,
name: "资源属性.扩展属性"
});
useEffect(() => {
fetchDependencies();
if (mode === 'edit' && articleId) {
@@ -80,15 +79,24 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
const fetchDependencies = async () => {
try {
const [catRes, tagRes] = await Promise.all([
const [catRes, settingsRes] = await Promise.all([
fetch('/api/admin/categories'),
fetch('/api/admin/tags')
fetch('/api/admin/settings')
]);
if (catRes.ok) setCategories(await catRes.json());
if (tagRes.ok) setTags(await tagRes.json());
if (settingsRes.ok) {
const settings = await settingsRes.json();
const aiList = settings.AI配置列表?.filter((a: any) => a.) || [];
setAssistants(aiList);
if (aiList.length > 0) {
setSelectedAssistantId(aiList[0]._id || aiList[0].id);
}
}
} catch (error) {
console.error('Failed to fetch dependencies', error);
toast.error('加载分类标签失败');
toast.error('加载依赖数据失败');
}
};
@@ -118,8 +126,6 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
};
const onSubmit = async (data: any) => {
setSaving(true);
try {
const payload = {
@@ -152,6 +158,13 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
}
};
const [selectedContext, setSelectedContext] = useState('');
const handleEditorChange = (content: string) => {
setValue('正文内容', content);
setSelectedContext(content);
};
if (loading) {
return (
<div className="flex items-center justify-center h-full min-h-[500px]">
@@ -161,26 +174,27 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col bg-background">
<div className="h-[calc(100vh-4rem)] flex flex-col bg-muted/40 dark:bg-muted/10">
{/* 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 justify-between px-6 py-3 border-b bg-background shadow-sm 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 variant="ghost" size="icon" onClick={() => router.back()} className="hover:bg-accent rounded-full">
<ArrowLeft className="h-5 w-5 text-muted-foreground" />
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">
{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">
<span className="flex items-center text-[10px] text-green-600 font-medium">
<Globe className="w-3 h-3 mr-1" />
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleSubmit(onSubmit)} disabled={saving}>
<Button onClick={handleSubmit(onSubmit)} disabled={saving} className="rounded-full px-6 shadow-sm hover:shadow-md transition-all bg-primary hover:bg-primary/90 text-primary-foreground">
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -189,56 +203,99 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
) : (
<>
<Save className="mr-2 h-4 w-4" />
{mode === 'edit' ? '更新' : '发布'}
{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="请输入文章标题..."
/>
{/* Main Layout Area: 3 Columns */}
<div className="flex-1 grid grid-cols-[1fr_300px_350px] overflow-hidden">
{/* Tiptap Editor */}
<Controller
name="正文内容"
control={control}
rules={{ required: true }}
render={({ field }) => (
<TiptapEditor
value={field.value}
onChange={field.onChange}
/>
)}
/>
{/* 1. Content Editor (Left) */}
<div className="flex flex-col min-w-0 overflow-hidden bg-background border-r border-border">
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto py-10 px-8">
{/* 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-muted-foreground h-auto py-4 mb-6 leading-tight"
placeholder="请输入文章标题..."
/>
{/* Markdown Editor */}
<Controller
name="正文内容"
control={control}
rules={{ required: true }}
render={({ field }) => (
<MarkdownEditor
value={field.value}
onChange={(val: string) => {
field.onChange(val);
handleEditorChange(val);
}}
className="min-h-[600px]"
/>
)}
/>
</div>
</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>
{/* 2. AI Assistant (Middle) */}
<div className="flex flex-col border-r border-border bg-background overflow-hidden">
<div className="p-3 border-b border-border bg-muted/20">
<div className="flex items-center gap-2 bg-background px-3 py-1.5 rounded-lg border border-border">
<span className="text-xs font-medium text-muted-foreground shrink-0">:</span>
<select
className="bg-transparent text-xs font-medium text-foreground focus:outline-none cursor-pointer w-full"
value={selectedAssistantId}
onChange={(e) => setSelectedAssistantId(e.target.value)}
>
{assistants.length > 0 ? (
assistants.map(a => (
<option key={a._id || a.id} value={a._id || a.id}>{a.}</option>
))
) : (
<option value=""></option>
)}
</select>
</div>
</div>
<AIPanel
assistantId={selectedAssistantId}
contextText={selectedContext}
onStream={async (text: string) => {
// Append raw markdown text to the editor content
const currentContent = getValues('正文内容') || '';
const newContent = currentContent + text;
setValue('正文内容', newContent);
// Update context for AI awareness (optional, might be too frequent)
// setSelectedContext(newContent);
}}
/>
</div>
{/* 3. Settings (Right) */}
<div className="flex flex-col overflow-y-auto bg-muted/10">
<div className="p-4 space-y-5">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<Settings className="w-4 h-4 text-muted-foreground" />
<span className="font-semibold text-sm text-foreground"></span>
</div>
{/* Publishing Status */}
<div className="space-y-3">
<Label className="text-xs font-medium text-muted-foreground"></Label>
<div className="space-y-3">
<Controller
name="发布状态"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectTrigger className="bg-background border-border h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -249,30 +306,23 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
</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 className="space-y-1">
<Label className="text-[10px] text-muted-foreground">URL </Label>
<Input {...register('URL别名')} className="h-8 text-xs bg-background border-border" placeholder="slug-url" />
</div>
</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>
{/* Classification */}
<div className="space-y-3">
<Label className="text-xs font-medium text-muted-foreground"></Label>
<div className="space-y-3">
<Controller
name="分类ID"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectTrigger className="bg-background border-border h-8 text-xs">
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
@@ -283,93 +333,55 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
</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" />
<Input {...register('封面图')} className="h-8 text-xs bg-background border-border" placeholder="封面图 URL..." />
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0 bg-background border-border">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
</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="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" placeholder="v1.0.0" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.文件大小')} className="h-8 text-sm bg-white" placeholder="100MB" />
</div>
</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 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 className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> ()</Label>
<Textarea {...register('资源属性.隐藏内容')} className="text-sm bg-white min-h-[60px]" placeholder="仅付费用户可见的内容..." />
</div>
{/* Extended Attributes */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground flex justify-between items-center">
<span></span>
<Button type="button" variant="ghost" size="sm" className="h-5 px-2 text-xs" onClick={() => appendExtended({ : '', : '' })}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</Label>
{extendedFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-center">
<Input {...register(`资源属性.扩展属性.${index}.属性名` as const)} className="h-7 text-xs bg-white flex-1" placeholder="属性名" />
<Input {...register(`资源属性.扩展属性.${index}.属性值` as const)} className="h-7 text-xs bg-white flex-1" placeholder="属性值" />
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => removeExtended(index)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
{/* SEO */}
<div className="space-y-3">
<Label className="text-xs font-medium text-muted-foreground">SEO </Label>
<div className="space-y-3">
<Textarea {...register('摘要')} className="text-xs min-h-[60px] bg-background border-border resize-none" placeholder="文章摘要..." />
<Input {...register('SEO关键词')} className="h-8 text-xs bg-background border-border" placeholder="关键词 (逗号分隔)" />
</div>
</div>
</div>
<div className="h-px bg-gray-100" />
{/* Resource Delivery */}
<div className="space-y-3 pt-2 border-t border-border">
<Label className="text-xs font-medium text-blue-600 dark:text-blue-400 flex items-center gap-1.5">
<Lock className="w-3.5 h-3.5" />
</Label>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<Input {...register('资源属性.版本号')} className="h-8 text-xs bg-background border-border" placeholder="版本号" />
<Input {...register('资源属性.文件大小')} className="h-8 text-xs bg-background border-border" placeholder="大小" />
</div>
<Input {...register('资源属性.下载链接')} className="h-8 text-xs bg-background border-border" placeholder="下载链接" />
<div className="grid grid-cols-2 gap-2">
<Input {...register('资源属性.提取码')} className="h-8 text-xs bg-background border-border" placeholder="提取码" />
<Input {...register('资源属性.解压密码')} className="h-8 text-xs bg-background border-border" placeholder="解压密码" />
</div>
<Textarea {...register('资源属性.隐藏内容')} className="text-xs bg-background min-h-[50px] border-border resize-none" placeholder="付费可见内容..." />
</div>
</div>
{/* 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>
{/* Sales Strategy */}
<div className="space-y-3 pt-2 border-t border-border">
<Label className="text-xs font-medium text-green-600 dark:text-green-400 flex items-center gap-1.5">
<DollarSign className="w-3.5 h-3.5" />
</Label>
<div className="space-y-3">
<Controller
name="支付方式"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-8 text-sm">
<SelectTrigger className="h-8 text-xs bg-background border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -380,35 +392,18 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
</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 className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground shrink-0">
({watch('支付方式') === 'points' ? '积分' : '元'})
</Label>
<Input
type="number"
{...register('价格')}
className="h-8 text-xs bg-background border-border"
min="0"
step={watch('支付方式') === 'cash' ? "0.01" : "1"}
/>
</div>
</div>
</div>
</div>

View File

@@ -1,539 +0,0 @@
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

@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import MainLayout from '@/components/layouts/MainLayout';
import ToolsSidebar from './ToolsSidebar';
@@ -18,20 +18,18 @@ export default function AIToolsLayout({ children, title, description }: AIToolsL
}}
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>
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-muted/50">
{/* Left Sidebar - Fixed & Scrollable */}
<div className="w-80 shrink-0 border-r border-border bg-background overflow-y-auto hidden lg:block">
<div className="p-6">
<ToolsSidebar />
</div>
</div>
{/* Right Content */}
<div className="lg:col-span-9">
{children}
</div>
{/* Right Content - Scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="container mx-auto p-6 md:p-8 max-w-5xl">
{children}
</div>
</div>
</div>

View File

@@ -1,39 +1,11 @@
import React from 'react';
import Link from 'next/link';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Sparkles, Languages, Wand2, ChevronRight } from 'lucide-react';
import { 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
}
];
import { AI_TOOLS } from '@/constants/aiTools';
const tools = AI_TOOLS;
export default function ToolsSidebar() {
const router = useRouter();
@@ -41,8 +13,8 @@ export default function ToolsSidebar() {
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>
<h2 className="text-lg font-bold text-foreground"></h2>
<p className="text-xs text-muted-foreground">使</p>
</div>
<div className="space-y-3">
@@ -56,8 +28,8 @@ export default function ToolsSidebar() {
"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"
? "bg-card border-primary/50 shadow-md ring-1 ring-primary/20"
: "bg-card border-border hover:border-border"
)}
onClick={(e) => tool.disabled && e.preventDefault()}
>
@@ -66,10 +38,10 @@ export default function ToolsSidebar() {
</div>
<div className="flex-1 min-w-0">
<h3 className={cn("font-medium truncate", isActive ? "text-primary" : "text-gray-900")}>
<h3 className={cn("font-medium truncate", isActive ? "text-primary" : "text-foreground")}>
{tool.name}
</h3>
<p className="text-xs text-gray-500 truncate">
<p className="text-xs text-muted-foreground truncate">
{tool.description}
</p>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
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";
@@ -80,7 +80,7 @@ export default function CommentSection({ articleId, isLoggedIn }: CommentSection
};
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<div className="bg-card rounded-xl shadow-sm border border-border 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})
@@ -113,8 +113,8 @@ export default function CommentSection({ articleId, isLoggedIn }: CommentSection
</div>
</form>
) : (
<div className="bg-gray-50 rounded-lg p-6 text-center">
<p className="text-gray-600 mb-4"></p>
<div className="bg-muted/50 rounded-lg p-6 text-center">
<p className="text-muted-foreground mb-4"></p>
<Button variant="outline" onClick={() => window.location.href = '/auth/login'}>
</Button>
@@ -126,10 +126,10 @@ export default function CommentSection({ articleId, isLoggedIn }: CommentSection
<div className="space-y-6">
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
@@ -141,14 +141,14 @@ export default function CommentSection({ articleId, isLoggedIn }: CommentSection
</Avatar>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-gray-900">
<span className="font-semibold text-foreground">
{comment.ID?. || '未知用户'}
</span>
<span className="text-xs text-gray-500">
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true, locale: zhCN })}
</span>
</div>
<p className="text-gray-700 text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{comment.}
</p>
</div>

View File

@@ -1,4 +1,3 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Clock, ArrowRight } from 'lucide-react';
@@ -20,9 +19,9 @@ 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">
<div className="group flex flex-col bg-card rounded-xl overflow-hidden border border-border hover:shadow-lg transition-all duration-300 h-full">
{/* Image */}
<Link href={`/article/${article._id}`} className="relative aspect-video overflow-hidden bg-gray-100">
<Link href={`/article/${article._id}`} className="relative aspect-video overflow-hidden bg-muted">
{article. ? (
<Image
src={article.}
@@ -31,10 +30,10 @@ export default function ArticleCard({ article }: ArticleCardProps) {
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="w-full h-full flex items-center justify-center text-muted-foreground">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">
<Badge variant="secondary" className="bg-card text-foreground font-medium shadow-sm border border-border">
{article.ID?. || '未分类'}
</Badge>
</div>
@@ -42,7 +41,7 @@ export default function ArticleCard({ article }: ArticleCardProps) {
{/* Content */}
<div className="flex-1 p-5 flex flex-col">
<div className="flex items-center gap-2 text-xs text-gray-400 mb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
<span>{new Date(article.createdAt).toLocaleDateString()}</span>
<span></span>
<span className="flex items-center gap-1">
@@ -51,23 +50,23 @@ export default function ArticleCard({ article }: ArticleCardProps) {
</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">
<h3 className="text-xl font-bold text-foreground 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">
<p className="text-muted-foreground 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">
<div className="flex items-center justify-between mt-auto pt-4 border-t border-border">
{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">
<Link href={`/article/${article._id}`} className="text-sm font-medium text-foreground flex items-center gap-1 group-hover:gap-2 transition-all">
<ArrowRight className="w-4 h-4" />
</Link>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import Image from 'next/image';
@@ -86,7 +86,7 @@ export default function HeroBanner({ banners }: HeroBannerProps) {
</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">
<Button className="bg-primary-foreground text-primary 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>
@@ -126,7 +126,7 @@ export default function HeroBanner({ banners }: HeroBannerProps) {
>
{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"
className="absolute top-0 left-0 h-full w-full bg-card rounded-full shadow-[0_0_10px_rgba(255,255,255,0.5)] origin-left"
style={{
animation: `progress 5s linear forwards`,
animationPlayState: isPaused ? 'paused' : 'running',

View File

@@ -1,5 +1,3 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
@@ -38,8 +36,8 @@ export default function Sidebar({ tags }: SidebarProps) {
<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'
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">

View File

@@ -3,7 +3,7 @@ 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 { Search, LogOut, LayoutDashboard, Settings } from 'lucide-react';
import { Toaster } from "@/components/ui/sonner";
import { useAuth } from '@/hooks/useAuth';
import { useConfig } from '@/contexts/ConfigContext';
@@ -16,6 +16,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ModeToggle } from "@/components/mode-toggle";
interface SEOProps {
title?: string;
@@ -85,10 +86,10 @@ function AuthButtons() {
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>
<Button variant="ghost" size="sm" className="text-foreground hover:bg-accent hover:text-accent-foreground"></Button>
</Link>
<Link href="/auth/register">
<Button size="sm" className="bg-white text-black hover:bg-white/90"></Button>
<Button size="sm" className="bg-primary text-primary-foreground hover:bg-primary/90"></Button>
</Link>
</div>
);
@@ -116,7 +117,7 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
const pageKeywords = seo?.keywords || config?.SEO关键词 || '';
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<div className="min-h-screen flex flex-col bg-background">
<Head>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
@@ -127,7 +128,7 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
<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'
: 'bg-background/80 backdrop-blur-md border-b border-border py-0 shadow-sm'
}`}
>
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
@@ -135,11 +136,11 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
<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'}`}>
<span className={`font-bold text-xl tracking-tight ${isTransparent ? 'text-white' : 'text-foreground'}`}>
{config?. || 'AOUN'}
</span>
</Link>
<nav className={`hidden md:flex items-center gap-8 text-sm font-medium ${isTransparent ? 'text-white/90' : 'text-gray-600'}`}>
<nav className={`hidden md:flex items-center gap-8 text-sm font-medium ${isTransparent ? 'text-white/90' : 'text-muted-foreground'}`}>
{[
{ href: '/aitools', label: 'AI工具' },
{ href: '/', label: '专栏文章' },
@@ -156,8 +157,8 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
className="relative group py-2"
>
<span className={`transition-colors ${isActive
? (isTransparent ? 'text-white' : 'text-primary')
: (isTransparent ? 'group-hover:text-white' : 'group-hover:text-primary')
? (isTransparent ? 'text-white' : 'text-primary')
: (isTransparent ? 'group-hover:text-white' : 'group-hover:text-primary')
}`}>
{link.label}
</span>
@@ -173,17 +174,19 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
{/* 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'}`} />
<Search className={`absolute left-2.5 top-2.5 h-4 w-4 ${isTransparent ? 'text-white/60' : 'text-muted-foreground'}`} />
<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'
: 'bg-muted/50 border-border text-foreground focus:ring-primary/20'
}`}
/>
</div>
<ModeToggle />
<AuthButtons />
</div>
</div>
@@ -196,52 +199,52 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
{/* Footer */}
{showFooter && (
<footer className="bg-white border-t py-12 mt-12">
<footer className="bg-background border-t border-border 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="h-full p-5 rounded-2xl bg-linear-to-b from-muted/50 to-transparent border border-border 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">
<div className="w-12 h-12 rounded-xl bg-background p-0.5 shadow-sm border border-border 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>
<h3 className="font-bold text-lg text-foreground 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">
<p className="text-xs text-muted-foreground 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>
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-background hover:bg-muted border-border">Bilibili</Button>
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-background hover:bg-muted border-border">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="h-full p-5 rounded-2xl bg-linear-to-b from-blue-50/80 to-transparent border border-blue-100/60 flex flex-col dark:from-blue-900/20 dark:border-blue-800/30">
<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">
<div className="w-12 h-12 rounded-xl bg-background flex items-center justify-center shadow-sm border border-blue-50 dark:border-blue-900">
<img src="/Gemini.svg" alt="Gemini" className="w-7 h-7" />
</div>
<div>
<h3 className="font-bold text-lg text-gray-900 leading-tight">
<h3 className="font-bold text-lg text-foreground 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>
<span className="text-[10px] font-semibold text-muted-foreground 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">
<p className="text-xs text-muted-foreground 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">
<div className="flex items-center gap-2 text-[10px] font-bold text-blue-600 bg-background px-3 py-2 rounded-lg border border-blue-100 shadow-sm w-fit mt-auto dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900">
<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>
@@ -253,28 +256,28 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
{/* 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="h-full p-5 rounded-2xl bg-linear-to-b from-muted/50 to-transparent border border-border 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">
<h4 className="font-bold text-foreground 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>
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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">
<h4 className="font-bold text-foreground 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>
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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>
@@ -284,15 +287,15 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
{/* 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">
<h4 className="font-bold text-lg text-foreground mb-2"></h4>
<p className="text-xs text-muted-foreground 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"
className="w-full h-9 px-3 rounded-lg border border-border bg-background 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">
@@ -301,7 +304,7 @@ export default function MainLayout({ children, transparentHeader = false, seo, s
</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">
<div className="border-t border-border mt-12 pt-8 flex flex-col md:flex-row items-center justify-between text-sm text-muted-foreground">
<p>{config?. || `© ${new Date().getFullYear()} ${config?. || 'AounApp'}. All rights reserved.`}</p>
<p>{config?.}</p>
</div>

View File

@@ -0,0 +1,37 @@
import dynamic from 'next/dynamic';
import { cn } from '@/lib/utils';
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import { useTheme } from 'next-themes';
const MDEditor = dynamic(
() => import("@uiw/react-md-editor"),
{ ssr: false }
);
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
}
export function MarkdownEditor({ value, onChange, className, placeholder }: MarkdownEditorProps) {
const { theme } = useTheme();
return (
<div className={cn("w-full", className)} data-color-mode={theme === 'dark' ? 'dark' : 'light'}>
<MDEditor
value={value}
onChange={(val) => onChange(val || '')}
height={600}
preview="live"
visibleDragbar={false}
className="bg-background! border-border! text-foreground!"
textareaProps={{
placeholder: placeholder || "在此输入 Markdown 内容..."
}}
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}

View File

@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-auto", className)}
{...props}
>
{children}
</div>
))
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }

46
src/constants/aiTools.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Sparkles, Languages, Wand2, MessageSquare } from 'lucide-react';
export const AI_TOOLS = [
{
id: 'chat',
name: 'AI 智能对话',
description: '多模型智能问答助手,支持 GPT-4、Claude 等多种模型,提供流式响应。',
icon: MessageSquare,
href: '/aitools/chat',
color: 'text-green-600',
bgColor: 'bg-green-100',
badge: '热门',
status: 'active'
},
{
id: 'prompt-optimizer',
name: '提示词优化师',
description: '智能优化您的 Prompt 指令,让 AI 更懂你的意图,提升生成质量。',
icon: Sparkles,
href: '/aitools/prompt-optimizer',
color: 'text-purple-600',
bgColor: 'bg-purple-100',
status: 'active'
},
{
id: 'translator',
name: '智能翻译助手',
description: '基于 AI 的多语言精准互译工具,支持上下文理解和专业术语优化。',
icon: Languages,
href: '/aitools/translator',
color: 'text-blue-600',
bgColor: 'bg-blue-100',
status: 'active'
},
{
id: 'more',
name: '更多工具',
description: '更多实用 AI 工具正在开发中,敬请期待...',
icon: Wand2,
href: '#',
color: 'text-gray-400',
bgColor: 'bg-gray-100',
disabled: true,
status: 'coming_soon'
}
];

View File

@@ -0,0 +1,47 @@
"use client"
import { useCallback, useRef } from "react"
// basically Exclude<React.ClassAttributes<T>["ref"], string>
type UserRef<T> =
| ((instance: T | null) => void)
| React.RefObject<T | null>
| null
| undefined
const updateRef = <T>(ref: NonNullable<UserRef<T>>, value: T | null) => {
if (typeof ref === "function") {
ref(value)
} else if (ref && typeof ref === "object" && "current" in ref) {
// Safe assignment without MutableRefObject
;(ref as { current: T | null }).current = value
}
}
export const useComposedRef = <T extends HTMLElement>(
libRef: React.RefObject<T | null>,
userRef: UserRef<T>
) => {
const prevUserRef = useRef<UserRef<T>>(null)
return useCallback(
(instance: T | null) => {
if (libRef && "current" in libRef) {
;(libRef as { current: T | null }).current = instance
}
if (prevUserRef.current) {
updateRef(prevUserRef.current, null)
}
prevUserRef.current = userRef
if (userRef) {
updateRef(userRef, instance)
}
},
[libRef, userRef]
)
}
export default useComposedRef

View File

@@ -0,0 +1,69 @@
import type { Editor } from "@tiptap/react"
import { useWindowSize } from "@/hooks/use-window-size"
import { useBodyRect } from "@/hooks/use-element-rect"
import { useEffect } from "react"
export interface CursorVisibilityOptions {
/**
* The Tiptap editor instance
*/
editor?: Editor | null
/**
* Reference to the toolbar element that may obscure the cursor
*/
overlayHeight?: number
}
/**
* Custom hook that ensures the cursor remains visible when typing in a Tiptap editor.
* Automatically scrolls the window when the cursor would be hidden by the toolbar.
*
* @param options.editor The Tiptap editor instance
* @param options.overlayHeight Toolbar height to account for
* @returns The bounding rect of the body
*/
export function useCursorVisibility({
editor,
overlayHeight = 0,
}: CursorVisibilityOptions) {
const { height: windowHeight } = useWindowSize()
const rect = useBodyRect({
enabled: true,
throttleMs: 100,
useResizeObserver: true,
})
useEffect(() => {
const ensureCursorVisibility = () => {
if (!editor) return
const { state, view } = editor
if (!view.hasFocus()) return
// Get current cursor position coordinates
const { from } = state.selection
const cursorCoords = view.coordsAtPos(from)
if (windowHeight < rect.height && cursorCoords) {
const availableSpace = windowHeight - cursorCoords.top
// If the cursor is hidden behind the overlay or offscreen, scroll it into view
if (availableSpace < overlayHeight) {
const targetCursorY = Math.max(windowHeight / 2, overlayHeight)
const currentScrollY = window.scrollY
const cursorAbsoluteY = cursorCoords.top + currentScrollY
const newScrollY = cursorAbsoluteY - targetCursorY
window.scrollTo({
top: Math.max(0, newScrollY),
behavior: "smooth",
})
}
}
}
ensureCursorVisibility()
}, [editor, overlayHeight, windowHeight, rect.height])
return rect
}

View File

@@ -0,0 +1,166 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useThrottledCallback } from "@/hooks/use-throttled-callback"
export type RectState = Omit<DOMRect, "toJSON">
export interface ElementRectOptions {
/**
* The element to track. Can be an Element, ref, or selector string.
* Defaults to document.body if not provided.
*/
element?: Element | React.RefObject<Element> | string | null
/**
* Whether to enable rect tracking
*/
enabled?: boolean
/**
* Throttle delay in milliseconds for rect updates
*/
throttleMs?: number
/**
* Whether to use ResizeObserver for more accurate tracking
*/
useResizeObserver?: boolean
}
const initialRect: RectState = {
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
}
const isSSR = typeof window === "undefined"
const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined"
/**
* Helper function to check if code is running on client side
*/
const isClientSide = (): boolean => !isSSR
/**
* Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc.
*
* @param options Configuration options for element rect tracking
* @returns The current bounding rectangle of the element
*/
export function useElementRect({
element,
enabled = true,
throttleMs = 100,
useResizeObserver = true,
}: ElementRectOptions = {}): RectState {
const [rect, setRect] = useState<RectState>(initialRect)
const getTargetElement = useCallback((): Element | null => {
if (!enabled || !isClientSide()) return null
if (!element) {
return document.body
}
if (typeof element === "string") {
return document.querySelector(element)
}
if ("current" in element) {
return element.current
}
return element
}, [element, enabled])
const updateRect = useThrottledCallback(
() => {
if (!enabled || !isClientSide()) return
const targetElement = getTargetElement()
if (!targetElement) {
setRect(initialRect)
return
}
const newRect = targetElement.getBoundingClientRect()
setRect({
x: newRect.x,
y: newRect.y,
width: newRect.width,
height: newRect.height,
top: newRect.top,
right: newRect.right,
bottom: newRect.bottom,
left: newRect.left,
})
},
throttleMs,
[enabled, getTargetElement],
{ leading: true, trailing: true }
)
useEffect(() => {
if (!enabled || !isClientSide()) {
setRect(initialRect)
return
}
const targetElement = getTargetElement()
if (!targetElement) return
updateRect()
const cleanup: (() => void)[] = []
if (useResizeObserver && hasResizeObserver) {
const resizeObserver = new ResizeObserver(() => {
window.requestAnimationFrame(updateRect)
})
resizeObserver.observe(targetElement)
cleanup.push(() => resizeObserver.disconnect())
}
const handleUpdate = () => updateRect()
window.addEventListener("scroll", handleUpdate, true)
window.addEventListener("resize", handleUpdate, true)
cleanup.push(() => {
window.removeEventListener("scroll", handleUpdate)
window.removeEventListener("resize", handleUpdate)
})
return () => {
cleanup.forEach((fn) => fn())
setRect(initialRect)
}
}, [enabled, getTargetElement, updateRect, useResizeObserver])
return rect
}
/**
* Convenience hook for tracking document.body rect
*/
export function useBodyRect(
options: Omit<ElementRectOptions, "element"> = {}
): RectState {
return useElementRect({
...options,
element: isClientSide() ? document.body : null,
})
}
/**
* Convenience hook for tracking a ref element's rect
*/
export function useRefRect<T extends Element>(
ref: React.RefObject<T>,
options: Omit<ElementRectOptions, "element"> = {}
): RectState {
return useElementRect({ ...options, element: ref })
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react"
type BreakpointMode = "min" | "max"
/**
* Hook to detect whether the current viewport matches a given breakpoint rule.
* Example:
* useIsBreakpoint("max", 768) // true when width < 768
* useIsBreakpoint("min", 1024) // true when width >= 1024
*/
export function useIsBreakpoint(
mode: BreakpointMode = "max",
breakpoint = 768
) {
const [matches, setMatches] = useState<boolean | undefined>(undefined)
useEffect(() => {
const query =
mode === "min"
? `(min-width: ${breakpoint}px)`
: `(max-width: ${breakpoint - 1}px)`
const mql = window.matchMedia(query)
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
// Set initial value
setMatches(mql.matches)
// Add listener
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [mode, breakpoint])
return !!matches
}

View File

@@ -0,0 +1,194 @@
import type { Editor } from "@tiptap/react"
import { useEffect, useState } from "react"
type Orientation = "horizontal" | "vertical" | "both"
interface MenuNavigationOptions<T> {
/**
* The Tiptap editor instance, if using with a Tiptap editor.
*/
editor?: Editor | null
/**
* Reference to the container element for handling keyboard events.
*/
containerRef?: React.RefObject<HTMLElement | null>
/**
* Search query that affects the selected item.
*/
query?: string
/**
* Array of items to navigate through.
*/
items: T[]
/**
* Callback fired when an item is selected.
*/
onSelect?: (item: T) => void
/**
* Callback fired when the menu should close.
*/
onClose?: () => void
/**
* The navigation orientation of the menu.
* @default "vertical"
*/
orientation?: Orientation
/**
* Whether to automatically select the first item when the menu opens.
* @default true
*/
autoSelectFirstItem?: boolean
}
/**
* Hook that implements keyboard navigation for dropdown menus and command palettes.
*
* Handles arrow keys, tab, home/end, enter for selection, and escape to close.
* Works with both Tiptap editors and regular DOM elements.
*
* @param options - Configuration options for the menu navigation
* @returns Object containing the selected index and a setter function
*/
export function useMenuNavigation<T>({
editor,
containerRef,
query,
items,
onSelect,
onClose,
orientation = "vertical",
autoSelectFirstItem = true,
}: MenuNavigationOptions<T>) {
const [selectedIndex, setSelectedIndex] = useState<number>(
autoSelectFirstItem ? 0 : -1
)
useEffect(() => {
const handleKeyboardNavigation = (event: KeyboardEvent) => {
if (!items.length) return false
const moveNext = () =>
setSelectedIndex((currentIndex) => {
if (currentIndex === -1) return 0
return (currentIndex + 1) % items.length
})
const movePrev = () =>
setSelectedIndex((currentIndex) => {
if (currentIndex === -1) return items.length - 1
return (currentIndex - 1 + items.length) % items.length
})
switch (event.key) {
case "ArrowUp": {
if (orientation === "horizontal") return false
event.preventDefault()
movePrev()
return true
}
case "ArrowDown": {
if (orientation === "horizontal") return false
event.preventDefault()
moveNext()
return true
}
case "ArrowLeft": {
if (orientation === "vertical") return false
event.preventDefault()
movePrev()
return true
}
case "ArrowRight": {
if (orientation === "vertical") return false
event.preventDefault()
moveNext()
return true
}
case "Tab": {
event.preventDefault()
if (event.shiftKey) {
movePrev()
} else {
moveNext()
}
return true
}
case "Home": {
event.preventDefault()
setSelectedIndex(0)
return true
}
case "End": {
event.preventDefault()
setSelectedIndex(items.length - 1)
return true
}
case "Enter": {
if (event.isComposing) return false
event.preventDefault()
if (selectedIndex !== -1 && items[selectedIndex]) {
onSelect?.(items[selectedIndex])
}
return true
}
case "Escape": {
event.preventDefault()
onClose?.()
return true
}
default:
return false
}
}
let targetElement: HTMLElement | null = null
if (editor) {
targetElement = editor.view.dom
} else if (containerRef?.current) {
targetElement = containerRef.current
}
if (targetElement) {
targetElement.addEventListener("keydown", handleKeyboardNavigation, true)
return () => {
targetElement?.removeEventListener(
"keydown",
handleKeyboardNavigation,
true
)
}
}
return undefined
}, [
editor,
containerRef,
items,
selectedIndex,
onSelect,
onClose,
orientation,
])
useEffect(() => {
if (query) {
setSelectedIndex(autoSelectFirstItem ? 0 : -1)
}
}, [query, autoSelectFirstItem])
return {
selectedIndex: items.length ? selectedIndex : undefined,
setSelectedIndex,
}
}

View File

@@ -0,0 +1,75 @@
import type { RefObject } from "react"
import { useEffect, useState } from "react"
type ScrollTarget = RefObject<HTMLElement> | Window | null | undefined
type EventTargetWithScroll = Window | HTMLElement | Document
interface UseScrollingOptions {
debounce?: number
fallbackToDocument?: boolean
}
export function useScrolling(
target?: ScrollTarget,
options: UseScrollingOptions = {}
): boolean {
const { debounce = 150, fallbackToDocument = true } = options
const [isScrolling, setIsScrolling] = useState(false)
useEffect(() => {
// Resolve element or window
const element: EventTargetWithScroll =
target && typeof Window !== "undefined" && target instanceof Window
? target
: ((target as RefObject<HTMLElement>)?.current ?? window)
// Mobile: fallback to document when using window
const eventTarget: EventTargetWithScroll =
fallbackToDocument &&
element === window &&
typeof document !== "undefined"
? document
: element
const on = (
el: EventTargetWithScroll,
event: string,
handler: EventListener
) => el.addEventListener(event, handler, true)
const off = (
el: EventTargetWithScroll,
event: string,
handler: EventListener
) => el.removeEventListener(event, handler)
let timeout: ReturnType<typeof setTimeout>
const supportsScrollEnd = element === window && "onscrollend" in window
const handleScroll: EventListener = () => {
if (!isScrolling) setIsScrolling(true)
if (!supportsScrollEnd) {
clearTimeout(timeout)
timeout = setTimeout(() => setIsScrolling(false), debounce)
}
}
const handleScrollEnd: EventListener = () => setIsScrolling(false)
on(eventTarget, "scroll", handleScroll)
if (supportsScrollEnd) {
on(eventTarget, "scrollend", handleScrollEnd)
}
return () => {
off(eventTarget, "scroll", handleScroll)
if (supportsScrollEnd) {
off(eventTarget, "scrollend", handleScrollEnd)
}
clearTimeout(timeout)
}
}, [target, debounce, fallbackToDocument, isScrolling])
return isScrolling
}

View File

@@ -0,0 +1,48 @@
import throttle from "lodash.throttle"
import { useUnmount } from "@/hooks/use-unmount"
import { useMemo } from "react"
interface ThrottleSettings {
leading?: boolean | undefined
trailing?: boolean | undefined
}
const defaultOptions: ThrottleSettings = {
leading: false,
trailing: true,
}
/**
* A hook that returns a throttled callback function.
*
* @param fn The function to throttle
* @param wait The time in ms to wait before calling the function
* @param dependencies The dependencies to watch for changes
* @param options The throttle options
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useThrottledCallback<T extends (...args: any[]) => any>(
fn: T,
wait = 250,
dependencies: React.DependencyList = [],
options: ThrottleSettings = defaultOptions
): {
(this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T>
cancel: () => void
flush: () => void
} {
const handler = useMemo(
() => throttle<T>(fn, wait, options),
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencies
)
useUnmount(() => {
handler.cancel()
})
return handler
}
export default useThrottledCallback

21
src/hooks/use-unmount.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useRef, useEffect } from "react"
/**
* Hook that executes a callback when the component unmounts.
*
* @param callback Function to be called on component unmount
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useUnmount = (callback: (...args: Array<any>) => any) => {
const ref = useRef(callback)
ref.current = callback
useEffect(
() => () => {
ref.current()
},
[]
)
}
export default useUnmount

View File

@@ -0,0 +1,93 @@
"use client"
import { useEffect, useState } from "react"
import { useThrottledCallback } from "@/hooks/use-throttled-callback"
export interface WindowSizeState {
/**
* The width of the window's visual viewport in pixels.
*/
width: number
/**
* The height of the window's visual viewport in pixels.
*/
height: number
/**
* The distance from the top of the visual viewport to the top of the layout viewport.
* Particularly useful for handling mobile keyboard appearance.
*/
offsetTop: number
/**
* The distance from the left of the visual viewport to the left of the layout viewport.
*/
offsetLeft: number
/**
* The scale factor of the visual viewport.
* This is useful for scaling elements based on the current zoom level.
*/
scale: number
}
/**
* Hook that tracks the window's visual viewport dimensions, position, and provides
* a CSS transform for positioning elements.
*
* Uses the Visual Viewport API to get accurate measurements, especially important
* for mobile devices where virtual keyboards can change the visible area.
* Only updates state when values actually change to optimize performance.
*
* @returns An object containing viewport properties and a CSS transform string
*/
export function useWindowSize(): WindowSizeState {
const [windowSize, setWindowSize] = useState<WindowSizeState>({
width: 0,
height: 0,
offsetTop: 0,
offsetLeft: 0,
scale: 0,
})
const handleViewportChange = useThrottledCallback(() => {
if (typeof window === "undefined") return
const vp = window.visualViewport
if (!vp) return
const {
width = 0,
height = 0,
offsetTop = 0,
offsetLeft = 0,
scale = 0,
} = vp
setWindowSize((prevState) => {
if (
width === prevState.width &&
height === prevState.height &&
offsetTop === prevState.offsetTop &&
offsetLeft === prevState.offsetLeft &&
scale === prevState.scale
) {
return prevState
}
return { width, height, offsetTop, offsetLeft, scale }
})
}, 200)
useEffect(() => {
const visualViewport = window.visualViewport
if (!visualViewport) return
visualViewport.addEventListener("resize", handleViewportChange)
handleViewportChange()
return () => {
visualViewport.removeEventListener("resize", handleViewportChange)
}
}, [handleViewportChange])
return windowSize
}

View File

@@ -18,6 +18,22 @@ interface User {
};
过期时间?: string;
};
AI配置?: {
使: 'system' | 'custom';
: {
名称: string;
: 'chat' | 'text' | 'image' | 'video';
API_Endpoint?: string;
API_Key?: string;
Model?: string;
系统提示词?: string;
}[];
当前助手ID?: string;
?: {
今日调用次数: number;
剩余免费额度: number;
};
};
}
interface AuthContextType {

16
src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,16 @@
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
import rehypeStringify from 'rehype-stringify';
export async function markdownToHtml(markdown: string): Promise<string> {
const file = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify)
.process(markdown);
return String(file);
}

View File

@@ -1,4 +1,4 @@
import mongoose, { Schema, Document, Model } from 'mongoose';
import mongoose, { Schema } from 'mongoose';
/**
* ==================================================================
@@ -33,6 +33,34 @@ const UserSchema = new Schema({
: { type: Date }
},
// 【新增】AI 偏好与配置 (支持混合模式)
AI配置: {
// 模式选择:'system' (使用系统Key扣积分) | 'custom' (使用自己的Key免费)
使: { type: String, enum: ['system', 'custom'], default: 'system' },
// 【升级】用户自定义助手列表 (BYOK模式支持多助手/Agent)
: [{
: { type: String, required: true }, // AI_Name (e.g. "我的私人翻译官")
: { type: String, enum: ['chat', 'text', 'image', 'video'], default: 'chat' }, // Type
// BYOK 配置
API_Endpoint: { type: String },
API_Key: { type: String, select: false },
Model: { type: String },
: { type: String } // System_Prompt
}],
// 选中的助手ID (可选,方便前端记住用户上次用的助手)
ID: { type: Schema.Types.ObjectId },
// 系统 Key 模式下的用量统计 (用于每日限额/防刷)
: {
: { type: Number, default: 0 },
: { type: Date }, // 用于跨天重置
: { type: Number, default: 5 } // 比如每日赠送5次
}
},
// 账号风控
: { type: Boolean, default: false },

View File

@@ -1,13 +1,22 @@
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import { AuthProvider } from '@/hooks/useAuth';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { ThemeProvider } from "@/components/theme-provider"
export default function App({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<ConfigProvider>
<Component {...pageProps} />
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
>
<Component {...pageProps} />
</ThemeProvider>
</ConfigProvider>
</AuthProvider>
);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -6,8 +6,8 @@ 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2, Plus, Bot, Trash2, Edit, Sparkles } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
@@ -28,8 +28,9 @@ export default function AIAdminPage() {
const [assistants, setAssistants] = useState<AIConfig[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingAssistant, setEditingAssistant] = useState<AIConfig | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const { register, control, handleSubmit, reset, setValue, watch } = useForm<AIConfig>({
const { register, control, handleSubmit, reset } = useForm<AIConfig>({
defaultValues: {
: '',
: 'https://generativelanguage.googleapis.com/v1beta/openai',
@@ -62,13 +63,15 @@ export default function AIAdminPage() {
const handleSave = async (data: AIConfig) => {
try {
let newAssistants = [...assistants];
const 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
);
if (editingIndex !== null) {
// Update existing by index
const originalItem = newAssistants[editingIndex];
newAssistants[editingIndex] = {
...data,
_id: originalItem._id // Preserve original ID if it exists
};
} else {
// Add new
newAssistants.push(data);
@@ -76,13 +79,6 @@ export default function AIAdminPage() {
// 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();
@@ -98,9 +94,10 @@ export default function AIAdminPage() {
});
if (resSave.ok) {
toast.success(editingAssistant ? 'AI 助手已更新' : 'AI 助手已创建');
toast.success(editingIndex !== null ? 'AI 助手已更新' : 'AI 助手已创建');
setIsDialogOpen(false);
setEditingAssistant(null);
setEditingIndex(null);
reset();
fetchAssistants();
} else {
@@ -147,6 +144,7 @@ export default function AIAdminPage() {
const openAddDialog = () => {
setEditingAssistant(null);
setEditingIndex(null);
reset({
: 'New Assistant',
: 'https://generativelanguage.googleapis.com/v1beta/openai',
@@ -159,8 +157,9 @@ export default function AIAdminPage() {
setIsDialogOpen(true);
};
const openEditDialog = (assistant: AIConfig) => {
const openEditDialog = (assistant: AIConfig, index: number) => {
setEditingAssistant(assistant);
setEditingIndex(index);
reset(assistant);
setIsDialogOpen(true);
};
@@ -223,7 +222,7 @@ export default function AIAdminPage() {
</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)}>
<Button variant="ghost" size="sm" onClick={() => openEditDialog(assistant, index)}>
<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)}>

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useRouter } from 'next/router';
import ArticleEditor from '@/components/admin/ArticleEditor';
import AdminLayout from '@/components/admin/AdminLayout';

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
@@ -21,7 +20,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Plus, Search, Edit, Trash2, Eye, Loader2 } from 'lucide-react';
import { Plus, Search, Edit, Trash2, Loader2 } from 'lucide-react';
interface Article {
_id: string;
@@ -37,7 +36,6 @@ interface Article {
}
export default function ArticlesPage() {
const router = useRouter();
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
@@ -129,7 +127,7 @@ export default function ArticlesPage() {
</div>
</div>
<div className="rounded-md border bg-white">
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
@@ -177,7 +175,7 @@ export default function ArticlesPage() {
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-600 hover:bg-red-50"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteId(article._id)}
>
<Trash2 className="h-4 w-4" />

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import { 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 { Card, CardContent, 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';

View File

@@ -1,11 +1,10 @@
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';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer } from 'recharts';
// 定义统计数据接口
interface DashboardStats {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import {
Table,
@@ -97,7 +97,7 @@ export default function OrderList() {
</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="flex flex-col sm:flex-row gap-4 bg-card p-4 rounded-lg border border-border shadow-sm">
<div className="w-full sm:w-48">
<Select
value={filters.status}
@@ -115,7 +115,7 @@ export default function OrderList() {
</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" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索订单号、用户名或邮箱..."
className="pl-9"
@@ -128,7 +128,7 @@ export default function OrderList() {
</div>
{/* Table */}
<div className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
@@ -152,7 +152,7 @@ export default function OrderList() {
</TableRow>
) : orders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-gray-500">
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
@@ -162,7 +162,7 @@ export default function OrderList() {
<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">
<div className="w-8 h-8 rounded-full bg-muted overflow-hidden">
<img
src={order.ID?. || '/images/default_avatar.png'}
alt="Avatar"
@@ -171,26 +171,26 @@ export default function OrderList() {
</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>
<span className="text-xs text-muted-foreground">{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>
<span className="text-xs text-muted-foreground">{order.?.}</span>
</div>
</TableCell>
<TableCell className="font-bold text-gray-900">
<TableCell className="font-bold text-foreground">
¥{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>}
{!order. && <span className="text-muted-foreground text-sm">-</span>}
</TableCell>
<TableCell className="text-gray-500 text-sm">
<TableCell className="text-muted-foreground text-sm">
{format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</TableCell>
</TableRow>
@@ -202,7 +202,7 @@ export default function OrderList() {
{/* Pagination */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500">
<div className="text-sm text-muted-foreground">
{pagination.total} {pagination.page} / {pagination.pages}
</div>
<div className="flex gap-2">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import {
@@ -59,7 +59,7 @@ export default function PlansIndex() {
</Link>
</div>
<div className="rounded-md border bg-white">
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -6,9 +6,8 @@ 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';
import { Loader2, Save } from 'lucide-react';
import { useForm, useFieldArray } from 'react-hook-form';
interface SystemSettingsForm {
: {
@@ -73,7 +72,7 @@ export default function SystemSettings() {
}
});
const { fields, append, remove } = useFieldArray({
useFieldArray({
control,
name: "AI配置列表"
});

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';
import AdminLayout from '@/components/admin/AdminLayout';
import {
Table,
@@ -18,7 +17,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
@@ -50,7 +48,6 @@ interface User {
}
export default function UserManagement() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -156,7 +153,7 @@ export default function UserManagement() {
</div>
</div>
<div className="rounded-md border bg-white">
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/hooks/useAuth';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Bot, Send, Loader2, Eraser } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import ReactMarkdown from 'react-markdown';
interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export default function AIChat() {
const { user } = useAuth();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedAssistant, setSelectedAssistant] = useState<string>('system');
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Load initial system message based on selected assistant
useEffect(() => {
if (selectedAssistant === 'system') {
// System default
} else {
// Find custom assistant
const assistant = user?.AI配置?.?.find((a: any) => a. === selectedAssistant);
if (assistant && assistant.) {
// Optionally add a system message to context (not visible)
}
}
}, [selectedAssistant, user]);
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = {
role: 'user',
content: input,
timestamp: Date.now()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMessage].map(m => ({ role: m.role, content: m.content })),
assistantId: selectedAssistant === 'system' ? 'system' : selectedAssistant,
mode: selectedAssistant === 'system' ? 'system' : 'custom',
// Pass custom config if needed, but backend should fetch from user profile for security
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to send message');
}
// Handle streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder();
const assistantMessage: Message = {
role: 'assistant',
content: '',
timestamp: Date.now()
};
setMessages(prev => [...prev, assistantMessage]);
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
assistantMessage.content += chunk;
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = { ...assistantMessage };
return newMessages;
});
}
}
} catch (error: any) {
toast.error(error.message || '发送失败,请重试');
// Remove the failed user message or show error state?
// For now just show toast
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const clearHistory = () => {
setMessages([]);
toast.success('对话历史已清空');
};
// Get available assistants
const assistants = [
{ value: 'system', label: '系统默认助手 (System)' },
...(user?.AI配置?.?.map((a: any) => ({
value: a.名称,
label: `${a.} (Custom)`
})) || [])
];
return (
<AIToolsLayout title="AI 智能对话" description="多模型智能问答助手,支持自定义模型配置">
<div className="h-[calc(100vh-140px)] flex flex-col bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between bg-muted/50/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
<Bot className="w-6 h-6" />
</div>
<div>
<h2 className="font-bold text-foreground">AI </h2>
<p className="text-xs text-muted-foreground">
: {selectedAssistant === 'system' ? '系统托管 (消耗积分)' : '自定义 (免费)'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Select value={selectedAssistant} onValueChange={setSelectedAssistant}>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="选择助手" />
</SelectTrigger>
<SelectContent>
{assistants.map(a => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="icon" onClick={clearHistory} title="清空对话">
<Eraser className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
</div>
{/* Chat Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6 bg-muted/50/30">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
<Bot className="w-16 h-16 opacity-20" />
<p>...</p>
<div className="grid grid-cols-2 gap-2 max-w-md w-full">
{['帮我写一篇关于AI的文章', '解释一下量子纠缠', '如何优化React性能', '翻译这段代码'].map((q, i) => (
<Button
key={i}
variant="outline"
className="text-xs justify-start h-auto py-3 px-4 bg-background hover:bg-green-50 hover:text-green-600 hover:border-green-200"
onClick={() => setInput(q)}
>
{q}
</Button>
))}
</div>
</div>
) : (
messages.map((msg, index) => (
<div
key={index}
className={cn(
"flex gap-3 max-w-3xl",
msg.role === 'user' ? "ml-auto flex-row-reverse" : "mr-auto"
)}
>
<Avatar className={cn("w-8 h-8", msg.role === 'user' ? "bg-primary" : "bg-green-600")}>
{msg.role === 'user' ? (
<AvatarImage src={user?.} />
) : (
<div className="w-full h-full flex items-center justify-center bg-green-100 text-green-600">
<Bot className="w-5 h-5" />
</div>
)}
<AvatarFallback>{msg.role === 'user' ? 'U' : 'AI'}</AvatarFallback>
</Avatar>
<div className={cn(
"rounded-2xl px-4 py-3 text-sm shadow-sm",
msg.role === 'user'
? "bg-primary text-white rounded-tr-none"
: "bg-card border border-border text-foreground rounded-tl-none"
)}>
{msg.role === 'assistant' ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</div>
</div>
))
)}
{isLoading && (
<div className="flex gap-3 mr-auto max-w-3xl">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center text-green-600">
<Bot className="w-5 h-5" />
</div>
<div className="bg-card border border-border rounded-2xl rounded-tl-none px-4 py-3 flex items-center">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground ml-2">...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 bg-card border-t border-border">
<div className="relative max-w-4xl mx-auto">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入您的问题... (Shift+Enter 换行)"
className="pr-12 py-6 rounded-xl bg-muted/50 border-border focus:bg-background transition-colors"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-lg"
onClick={handleSend}
disabled={!input.trim() || isLoading}
>
<Send className="w-4 h-4" />
</Button>
</div>
<div className="text-center mt-2">
<p className="text-[10px] text-muted-foreground">
AI
</p>
</div>
</div>
</div>
</AIToolsLayout>
);
}

View File

@@ -1,16 +1,15 @@
import React from 'react';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
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="h-full min-h-[500px] bg-card rounded-2xl border border-border 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">
<h1 className="text-2xl font-bold text-foreground mb-2">使 AI </h1>
<p className="text-muted-foreground max-w-md">
使<br />
AI
</p>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@@ -85,19 +85,19 @@ export default function PromptOptimizer() {
>
<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="bg-card rounded-2xl border border-border 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>
<h2 className="text-base font-bold text-foreground"></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"
className="flex-1 resize-none border-border focus:border-purple-500 focus:ring-purple-500/20 p-4 text-base leading-relaxed"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
@@ -124,34 +124,34 @@ export default function PromptOptimizer() {
</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">
<div className="bg-card rounded-2xl border border-border 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">
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900"></h2>
<h2 className="text-base font-bold text-foreground"></h2>
</div>
</div>
{output && (
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-purple-600 border-gray-200 h-8">
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-muted-foreground hover:text-purple-600 border-border 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">
<div className="flex-1 bg-muted/50 rounded-xl border border-border 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">
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap font-mono text-sm">
{output}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Sparkles className="w-10 h-10 mb-3 opacity-20" />
<p className="text-xs"></p>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@@ -105,21 +105,21 @@ export default function Translator() {
>
<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="bg-card rounded-2xl border border-border 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>
<h2 className="text-base font-bold text-foreground"></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"
className="flex-1 resize-none border-border focus:border-blue-500 focus:ring-blue-500/20 p-4 text-base leading-relaxed"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
@@ -146,7 +146,7 @@ export default function Translator() {
</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">
<div className="bg-card rounded-2xl border border-border 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" />
@@ -156,9 +156,9 @@ export default function Translator() {
<ArrowRightLeft className="w-4 h-4" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-900">:</span>
<span className="text-sm font-bold text-foreground">:</span>
<Select value={targetLang} onValueChange={setTargetLang}>
<SelectTrigger className="w-[140px] h-8 text-xs border-gray-200 bg-white focus:ring-0 shadow-sm">
<SelectTrigger className="w-[140px] h-8 text-xs border-border bg-white focus:ring-0 shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -170,20 +170,20 @@ export default function Translator() {
</div>
</div>
{output && (
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-blue-600 border-gray-200 h-8">
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-muted-foreground hover:text-blue-600 border-border 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">
<div className="flex-1 bg-muted/50 rounded-xl border border-border 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">
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap text-base leading-relaxed">
{output}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Languages className="w-10 h-10 mb-3 opacity-20" />
<p className="text-xs"></p>
</div>

View File

@@ -3,7 +3,7 @@ import { SystemConfig } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
async function handler(req: NextApiRequest, res: NextApiResponse, _user: any) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}

View File

@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Article, User } from '@/models';
import { Article } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';

View File

@@ -3,7 +3,7 @@ import { SystemConfig } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
async function handler(req: NextApiRequest, res: NextApiResponse, _user: any) {
if (req.method === 'GET') {
try {
// 显式选择所有被隐藏的敏感字段

View File

@@ -3,7 +3,7 @@ import { User } from '@/models';
import withDatabase from '@/lib/withDatabase';
import { requireAdmin } from '@/lib/auth';
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
async function handler(req: NextApiRequest, res: NextApiResponse, _user: any) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}

103
src/pages/api/ai/chat.ts Normal file
View File

@@ -0,0 +1,103 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { verifyToken } from '@/lib/auth';
import dbConnect from '@/lib/dbConnect';
import { User, SystemConfig } from '@/models';
import OpenAI from 'openai';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
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 { messages, assistantId, mode } = req.body;
await dbConnect();
const user = await User.findById(decoded.userId).select('+AI配置.自定义助手列表.API_Key');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
let apiKey = process.env.OPENAI_API_KEY;
let baseURL = process.env.OPENAI_API_BASE_URL || 'https://api.openai.com/v1';
let model = 'gpt-3.5-turbo';
let systemPrompt = 'You are a helpful assistant.';
if (mode === 'custom') {
const assistant = user.AI配置?.?.find((a: any) => a. === assistantId);
if (!assistant) {
return res.status(404).json({ message: 'Custom assistant not found' });
}
if (!assistant.API_Key) {
return res.status(400).json({ message: 'Custom assistant API Key is missing' });
}
apiKey = assistant.API_Key;
baseURL = assistant.API_Endpoint || baseURL;
model = assistant.Model || model;
systemPrompt = assistant. || systemPrompt;
} else {
// System mode: Fetch from SystemConfig
const systemConfig = await SystemConfig.findOne({ : 'default' }).select('+AI配置列表.API密钥');
// Use the first enabled AI config or a specific one if we had a selection mechanism
const defaultAI = systemConfig?.AI配置列表?.find((ai: any) => ai.);
if (defaultAI) {
apiKey = defaultAI.API密钥;
baseURL = defaultAI.;
model = defaultAI.;
systemPrompt = defaultAI. || systemPrompt;
}
if (!apiKey) {
return res.status(500).json({ message: 'System AI not configured (Missing Credentials)' });
}
}
const openai = new OpenAI({
apiKey: apiKey,
baseURL: baseURL,
});
const stream = await openai.chat.completions.create({
model: model,
messages: [
{ role: 'system', content: systemPrompt },
...messages
],
stream: true,
});
// Set headers for SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
res.write(content);
}
}
res.end();
} catch (error: any) {
console.error('Chat API Error:', error);
if (!res.headersSent) {
res.status(500).json({ message: error.message || 'Internal server error' });
}
}
}

View File

@@ -17,7 +17,7 @@ async function readBody(req: NextApiRequest) {
return Buffer.concat(chunks).toString('utf8');
}
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
async function handler(req: NextApiRequest, res: NextApiResponse, _user: any) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}

View File

@@ -1,13 +0,0 @@
// 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

@@ -1,6 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect';
import { Order, MembershipPlan, User } from '@/models';
import { Order, MembershipPlan } from '@/models';
import { alipayService } from '@/lib/alipay';
import { getUserFromCookie } from '@/lib/auth';
@@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await dbConnect();
let orderData: any = {
const orderData: any = {
用户ID: user.userId,
: 'alipay',
: 'pending'

View File

@@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: 'Invalid token' });
}
const { username, avatar, password } = req.body;
const { username, avatar, password, aiConfig } = req.body;
await dbConnect();
@@ -34,6 +34,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
updateData. = await bcrypt.hash(password, 10);
}
// Handle AI Configuration updates
if (aiConfig) {
// Validate AI Config structure if necessary
if (aiConfig.使 && !['system', 'custom'].includes(aiConfig.使)) {
return res.status(400).json({ message: 'Invalid AI mode' });
}
// If updating custom assistants, ensure required fields
if (aiConfig.) {
for (const assistant of aiConfig.) {
if (!assistant.) {
return res.status(400).json({ message: 'Assistant name is required' });
}
}
}
// Handle ObjectId casting for empty strings
if (aiConfig.ID === '') {
aiConfig.ID = null;
}
updateData.AI配置 = aiConfig;
}
const user = await User.findByIdAndUpdate(
decoded.userId,
updateData,

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Image from 'next/image';
import Link from 'next/link';
@@ -8,9 +8,8 @@ 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 { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon, Share2, Crown, Sparkles, FileText, Eye, ShoppingCart, Copy, Check, Box, HardDrive } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon, Share2, Crown, FileText, Eye, ShoppingCart, Copy, Check, Box, HardDrive } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { toast } from 'sonner';
@@ -53,7 +52,7 @@ interface Article {
export default function ArticleDetail() {
const router = useRouter();
const { id } = router.query;
const { user, loading: authLoading } = useAuth();
const { user } = useAuth();
const [article, setArticle] = useState<Article | null>(null);
const [recentArticles, setRecentArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
@@ -193,9 +192,9 @@ export default function ArticleDetail() {
}}
>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Main Content - Left Column (75%) */}
<div className="lg:col-span-3">
<div className="grid grid-cols-1 lg:grid-cols-10 gap-8">
{/* Main Content - Left Column (70%) */}
<div className="lg:col-span-7">
{/* Article Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
@@ -203,18 +202,18 @@ export default function ArticleDetail() {
{article.ID?. || '未分类'}
</Badge>
{article.ID列表?.map((tag, index) => (
<Badge key={index} variant="outline" className="text-gray-500">
<Badge key={index} variant="outline" className="text-muted-foreground">
{tag.}
</Badge>
))}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-6 leading-tight">
{article.}
</h1>
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-8">
<div className="flex items-center gap-6 text-sm text-gray-500">
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-border pb-8">
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4" />
<span>{article.ID?. || '匿名'}</span>
@@ -229,7 +228,7 @@ export default function ArticleDetail() {
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleShare} className="text-gray-500 hover:text-primary">
<Button variant="ghost" size="sm" onClick={handleShare} className="text-muted-foreground hover:text-primary">
<Share2 className="w-4 h-4 mr-2" />
</Button>
@@ -238,7 +237,7 @@ export default function ArticleDetail() {
{/* Cover Image */}
{article. && (
<div className="mb-10 rounded-xl overflow-hidden shadow-lg relative aspect-video bg-gray-100">
<div className="mb-10 rounded-xl overflow-hidden shadow-lg relative aspect-video bg-muted">
<Image
src={article.}
alt={article.}
@@ -251,7 +250,7 @@ export default function ArticleDetail() {
)}
{/* 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">
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-foreground prose-p:text-foreground prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
</article>
@@ -259,45 +258,45 @@ export default function ArticleDetail() {
<CommentSection articleId={article._id} isLoggedIn={!!user} />
</div>
{/* Sidebar - Right Column (25%) */}
<div className="lg:col-span-1 space-y-6">
{/* Sidebar - Right Column (30%) */}
<div className="lg:col-span-3 space-y-6">
{/* Resource Card */}
<Card className="border-primary/20 shadow-md overflow-hidden">
<div className="bg-linear-to-r from-primary/10 to-primary/5 p-4 border-b border-primary/10">
<h3 className="font-bold text-lg flex items-center gap-2 text-gray-900">
<h3 className="font-bold text-lg flex items-center gap-2 text-foreground">
<Download className="w-5 h-5 text-primary" />
</h3>
</div>
<CardContent className="p-6 space-y-6">
<div className="flex items-baseline justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-muted-foreground"></span>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-primary">
{article. > 0 ? `¥${article.}` : '免费'}
</span>
{article. > 0 && <span className="text-xs text-gray-400">/ </span>}
{article. > 0 && <span className="text-xs text-muted-foreground">/ </span>}
</div>
</div>
{/* Resource Attributes (Version, Size, etc.) */}
{article. && (
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600 bg-gray-50 p-3 rounded-lg border border-gray-100">
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded-lg border border-border">
{article.. && (
<div className="flex items-center gap-1.5">
<Box className="w-3.5 h-3.5 text-gray-400" />
<Box className="w-3.5 h-3.5 text-muted-foreground" />
<span>: {article..}</span>
</div>
)}
{article.. && (
<div className="flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5 text-gray-400" />
<HardDrive className="w-3.5 h-3.5 text-muted-foreground" />
<span>: {article..}</span>
</div>
)}
{article..?.map((attr, idx) => (
<div key={idx} className="flex items-center gap-1.5 col-span-2">
<TagIcon className="w-3.5 h-3.5 text-gray-400" />
<TagIcon className="w-3.5 h-3.5 text-muted-foreground" />
<span>{attr.}: {attr.}</span>
</div>
))}
@@ -321,8 +320,8 @@ export default function ArticleDetail() {
</Button>
{article.. && (
<div className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm border border-gray-100">
<span className="text-gray-500 pl-1">: {article..}</span>
<div className="flex items-center justify-between bg-muted/50 p-2 rounded text-sm border border-border">
<span className="text-muted-foreground pl-1">: {article..}</span>
<Button
variant="ghost"
size="icon"
@@ -335,8 +334,8 @@ export default function ArticleDetail() {
)}
{article.. && (
<div className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm border border-gray-100">
<span className="text-gray-500 pl-1">: {article..}</span>
<div className="flex items-center justify-between bg-muted/50 p-2 rounded text-sm border border-border">
<span className="text-muted-foreground pl-1">: {article..}</span>
<Button
variant="ghost"
size="icon"
@@ -383,7 +382,7 @@ export default function ArticleDetail() {
</div>
) : (
<div className="text-center">
<Link href="/membership" className="text-xs text-gray-500 hover:text-primary flex items-center justify-center gap-1">
<Link href="/membership" className="text-xs text-muted-foreground hover:text-primary flex items-center justify-center gap-1">
<Crown className="w-3 h-3 text-yellow-500" />
</Link>
@@ -392,13 +391,13 @@ export default function ArticleDetail() {
</div>
)}
<div className="pt-4 border-t border-gray-100 grid grid-cols-2 gap-4 text-center text-sm text-gray-500">
<div className="pt-4 border-t border-border grid grid-cols-2 gap-4 text-center text-sm text-muted-foreground">
<div>
<div className="text-gray-900 font-medium">{article.?. || 0}</div>
<div className="text-foreground font-medium">{article.?. || 0}</div>
<div className="text-xs mt-1"></div>
</div>
<div>
<div className="text-gray-900 font-medium">{article.?. || 0}</div>
<div className="text-foreground font-medium">{article.?. || 0}</div>
<div className="text-xs mt-1"></div>
</div>
</div>
@@ -416,13 +415,13 @@ export default function ArticleDetail() {
<CardContent className="px-0 pb-2">
<div className="flex flex-col">
{recentArticles.length > 0 ? (
recentArticles.map((item, index) => (
recentArticles.map((item, _index) => (
<Link
key={item._id}
href={`/article/${item._id}`}
className="group flex items-start gap-3 px-6 py-3 hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-0"
className="group flex items-start gap-3 px-6 py-3 hover:bg-accent transition-colors border-b border-border/50 last:border-0"
>
<div className="relative w-16 h-12 shrink-0 rounded overflow-hidden bg-gray-100">
<div className="relative w-16 h-12 shrink-0 rounded overflow-hidden bg-muted">
{item. ? (
<Image
src={item.}
@@ -432,23 +431,23 @@ export default function ArticleDetail() {
sizes="64px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<FileText className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 line-clamp-2 group-hover:text-primary transition-colors">
<h4 className="text-sm font-medium text-foreground line-clamp-2 group-hover:text-primary transition-colors">
{item.}
</h4>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-400">
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>{format(new Date(item.createdAt), 'MM-dd')}</span>
</div>
</div>
</Link>
))
) : (
<div className="px-6 py-4 text-sm text-gray-500 text-center">
<div className="px-6 py-4 text-sm text-muted-foreground text-center">
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
@@ -79,7 +79,7 @@ export default function LoginPage() {
{/* Content */}
<div className="relative z-10">
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="w-8 h-8 rounded-lg bg-white text-black flex items-center justify-center">
<div className="w-8 h-8 rounded-lg bg-primary-foreground text-primary flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
AOUN AI
@@ -91,22 +91,22 @@ export default function LoginPage() {
<br />
<span className="text-transparent bg-clip-text bg-linear-to-r from-blue-400 to-purple-400"></span>
</h1>
<p className="text-lg text-gray-300 leading-relaxed">
<p className="text-lg text-muted-foreground leading-relaxed">
AI
</p>
</div>
<div className="relative z-10 text-sm text-gray-400">
<div className="relative z-10 text-sm text-muted-foreground">
© 2024 AOUN AI. All rights reserved.
</div>
</div>
{/* Right Panel - Login Form */}
<div className="flex items-center justify-center p-8 bg-white">
<div className="flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold tracking-tight text-gray-900"></h2>
<p className="text-gray-500"></p>
<h2 className="text-3xl font-bold tracking-tight text-foreground"></h2>
<p className="text-muted-foreground"></p>
</div>
<Form {...form}>
@@ -119,7 +119,7 @@ export default function LoginPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input placeholder="name@example.com" className="pl-10 h-11" {...field} />
</div>
</FormControl>
@@ -135,7 +135,7 @@ export default function LoginPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl>
@@ -149,7 +149,7 @@ export default function LoginPage() {
<Checkbox id="remember" />
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-500"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-muted-foreground"
>
</label>
@@ -179,10 +179,10 @@ export default function LoginPage() {
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-100" />
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
<span className="bg-background px-2 text-muted-foreground">
</span>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
@@ -85,7 +85,7 @@ export default function RegisterPage() {
{/* Content */}
<div className="relative z-10">
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="w-8 h-8 rounded-lg bg-white text-black flex items-center justify-center">
<div className="w-8 h-8 rounded-lg bg-primary-foreground text-primary flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
AOUN AI
@@ -97,22 +97,22 @@ export default function RegisterPage() {
<br />
<span className="text-transparent bg-clip-text bg-linear-to-r from-purple-400 to-pink-400"></span>
</h1>
<p className="text-lg text-gray-300 leading-relaxed">
<p className="text-lg text-muted-foreground leading-relaxed">
AI GPT-4Claude 3
</p>
</div>
<div className="relative z-10 text-sm text-gray-400">
<div className="relative z-10 text-sm text-muted-foreground">
© 2024 AOUN AI. All rights reserved.
</div>
</div>
{/* Right Panel - Register Form */}
<div className="flex items-center justify-center p-8 bg-white">
<div className="flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold tracking-tight text-gray-900"></h2>
<p className="text-gray-500"></p>
<h2 className="text-3xl font-bold tracking-tight text-foreground"></h2>
<p className="text-muted-foreground"></p>
</div>
<Form {...form}>
@@ -125,7 +125,7 @@ export default function RegisterPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input placeholder="johndoe" className="pl-10 h-11" {...field} />
</div>
</FormControl>
@@ -141,7 +141,7 @@ export default function RegisterPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input placeholder="name@example.com" className="pl-10 h-11" {...field} />
</div>
</FormControl>
@@ -157,7 +157,7 @@ export default function RegisterPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl>
@@ -173,7 +173,7 @@ export default function RegisterPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl>
@@ -202,10 +202,10 @@ export default function RegisterPage() {
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-100" />
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
<span className="bg-background px-2 text-muted-foreground">
</span>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import MainLayout from '@/components/layouts/MainLayout';
import { useAuth } from '@/hooks/useAuth';
@@ -132,10 +132,10 @@ export default function Dashboard() {
<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="bg-muted/50 p-4 rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="flex items-center justify-between">
<span className={`font-bold ${isVip ? 'text-yellow-600' : 'text-gray-700'}`}>
<span className={`font-bold ${isVip ? 'text-yellow-600' : 'text-foreground'}`}>
{isVip ? user.?.ID?. || '尊贵会员' : '普通用户'}
</span>
{isVip ? (
@@ -149,7 +149,7 @@ export default function Dashboard() {
)}
</div>
{isVip && (
<div className="text-xs text-gray-400 mt-2">
<div className="text-xs text-muted-foreground mt-2">
: {format(new Date(user.!.!), 'yyyy-MM-dd')}
</div>
)}
@@ -177,7 +177,7 @@ export default function Dashboard() {
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500"></CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">¥{user.?. || 0}</div>
@@ -185,7 +185,7 @@ export default function Dashboard() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500"></CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{user.?. || 0}</div>
@@ -203,29 +203,29 @@ export default function Dashboard() {
<CardContent>
{loadingOrders ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : orders.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
<div className="text-center py-8 text-muted-foreground"></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 key={order._id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent 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">
<div className="font-medium text-foreground">
{order.?. || (order. === 'buy_membership' ? '购买会员' : '购买资源')}
</div>
<div className="text-xs text-gray-500">
<div className="text-xs text-muted-foreground">
{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>
<div className="font-bold text-foreground">¥{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>
@@ -246,23 +246,23 @@ export default function Dashboard() {
</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" />
<div className="text-center py-8 text-muted-foreground">
<BookOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<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 key={order._id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent 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">
<div className="font-medium text-foreground">
{order.?. || '未知资源'}
</div>
<div className="text-xs text-gray-500">
<div className="text-xs text-muted-foreground">
: {format(new Date(order.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
</div>
</div>
@@ -289,7 +289,7 @@ export default function Dashboard() {
<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" />
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="username"
value={profileForm.username}
@@ -307,12 +307,12 @@ export default function Dashboard() {
onChange={e => setProfileForm({ ...profileForm, avatar: e.target.value })}
placeholder="https://..."
/>
<p className="text-xs text-gray-500"> URL使</p>
<p className="text-xs text-muted-foreground"> 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" />
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import MainLayout from '@/components/layouts/MainLayout';
import HeroBanner from '@/components/home/HeroBanner';
import ArticleCard from '@/components/home/ArticleCard';
@@ -116,7 +116,7 @@ export default function Home() {
{/* 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 justify-between mb-6">
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-2">
<Button
variant={activeCategory === 'all' ? 'default' : 'ghost'}
@@ -138,7 +138,7 @@ export default function Home() {
</Button>
))}
</div>
<span className="text-xs text-gray-400 whitespace-nowrap hidden md:block">
<span className="text-xs text-muted-foreground whitespace-nowrap hidden md:block">
{articles.length}
</span>
</div>
@@ -166,7 +166,7 @@ export default function Home() {
)}
{!loading && articles.length === 0 && (
<div className="text-center py-20 text-gray-400">
<div className="text-center py-20 text-muted-foreground">
</div>
)}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import MainLayout from '@/components/layouts/MainLayout';
import { Button } from '@/components/ui/button';
import { Check, Loader2, Crown } from 'lucide-react';
@@ -65,7 +65,7 @@ export default function Membership() {
<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 className="text-lg text-muted-foreground max-w-2xl mx-auto">
访
</p>
</div>
@@ -79,34 +79,34 @@ export default function Membership() {
) : (
<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">
<div key={plan._id} className="bg-card rounded-2xl shadow-xl overflow-hidden border border-border 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>
<h3 className="text-xl font-bold text-foreground 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>
<span className="text-sm text-muted-foreground">/{plan.}</span>
</div>
<p className="text-sm text-gray-500 mb-6 min-h-[40px]">{plan.}</p>
<p className="text-sm text-muted-foreground 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">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<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">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<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">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="w-4 h-4 text-green-500" />
<span></span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="w-4 h-4 text-green-500" />
<span></span>
</div>

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useRouter } from 'next/router';
import { useRouter } from 'next/router';
import MainLayout from '@/components/layouts/MainLayout';
import { XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -33,11 +32,11 @@ export default function PaymentFailure() {
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
<h1 className="text-3xl font-bold text-foreground mb-4">
</h1>
<p className="text-gray-600 mb-8">
<p className="text-muted-foreground mb-8">
{getErrorMessage()}
</p>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import MainLayout from '@/components/layouts/MainLayout';
import { CheckCircle, Loader2 } from 'lucide-react';
@@ -28,16 +28,16 @@ export default function PaymentSuccess() {
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
<h1 className="text-3xl font-bold text-foreground mb-4">
</h1>
<p className="text-gray-600 mb-8">
<p className="text-muted-foreground 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">
<div className="bg-muted/50 rounded-lg p-6 mb-8">
<div className="flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{countdown} ...</span>
</div>

5
src/pages/simple.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor"
export default function Page() {
return <SimpleEditor />
}

View File

@@ -0,0 +1,91 @@
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes zoomIn {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}
@keyframes zoomOut {
from {
transform: scale(1);
}
to {
transform: scale(0.95);
}
}
@keyframes zoom {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideFromTop {
from {
transform: translateY(-0.5rem);
}
to {
transform: translateY(0);
}
}
@keyframes slideFromRight {
from {
transform: translateX(0.5rem);
}
to {
transform: translateX(0);
}
}
@keyframes slideFromLeft {
from {
transform: translateX(-0.5rem);
}
to {
transform: translateX(0);
}
}
@keyframes slideFromBottom {
from {
transform: translateY(0.5rem);
}
to {
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

296
src/styles/_variables.scss Normal file
View File

@@ -0,0 +1,296 @@
:root {
/******************
Basics
******************/
overflow-wrap: break-word;
text-size-adjust: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/******************
Colors variables
******************/
/* Gray alpha (light mode) */
--tt-gray-light-a-50: rgba(56, 56, 56, 0.04);
--tt-gray-light-a-100: rgba(15, 22, 36, 0.05);
--tt-gray-light-a-200: rgba(37, 39, 45, 0.1);
--tt-gray-light-a-300: rgba(47, 50, 55, 0.2);
--tt-gray-light-a-400: rgba(40, 44, 51, 0.42);
--tt-gray-light-a-500: rgba(52, 55, 60, 0.64);
--tt-gray-light-a-600: rgba(36, 39, 46, 0.78);
--tt-gray-light-a-700: rgba(35, 37, 42, 0.87);
--tt-gray-light-a-800: rgba(30, 32, 36, 0.95);
--tt-gray-light-a-900: rgba(29, 30, 32, 0.98);
/* Gray (light mode) */
--tt-gray-light-50: rgba(250, 250, 250, 1);
--tt-gray-light-100: rgba(244, 244, 245, 1);
--tt-gray-light-200: rgba(234, 234, 235, 1);
--tt-gray-light-300: rgba(213, 214, 215, 1);
--tt-gray-light-400: rgba(166, 167, 171, 1);
--tt-gray-light-500: rgba(125, 127, 130, 1);
--tt-gray-light-600: rgba(83, 86, 90, 1);
--tt-gray-light-700: rgba(64, 65, 69, 1);
--tt-gray-light-800: rgba(44, 45, 48, 1);
--tt-gray-light-900: rgba(34, 35, 37, 1);
/* Gray alpha (dark mode) */
--tt-gray-dark-a-50: rgba(232, 232, 253, 0.05);
--tt-gray-dark-a-100: rgba(231, 231, 243, 0.07);
--tt-gray-dark-a-200: rgba(238, 238, 246, 0.11);
--tt-gray-dark-a-300: rgba(239, 239, 245, 0.22);
--tt-gray-dark-a-400: rgba(244, 244, 255, 0.37);
--tt-gray-dark-a-500: rgba(236, 238, 253, 0.5);
--tt-gray-dark-a-600: rgba(247, 247, 253, 0.64);
--tt-gray-dark-a-700: rgba(251, 251, 254, 0.75);
--tt-gray-dark-a-800: rgba(253, 253, 253, 0.88);
--tt-gray-dark-a-900: rgba(255, 255, 255, 0.96);
/* Gray (dark mode) */
--tt-gray-dark-50: rgba(25, 25, 26, 1);
--tt-gray-dark-100: rgba(32, 32, 34, 1);
--tt-gray-dark-200: rgba(45, 45, 47, 1);
--tt-gray-dark-300: rgba(70, 70, 73, 1);
--tt-gray-dark-400: rgba(99, 99, 105, 1);
--tt-gray-dark-500: rgba(124, 124, 131, 1);
--tt-gray-dark-600: rgba(163, 163, 168, 1);
--tt-gray-dark-700: rgba(192, 192, 195, 1);
--tt-gray-dark-800: rgba(224, 224, 225, 1);
--tt-gray-dark-900: rgba(245, 245, 245, 1);
/* Brand colors */
--tt-brand-color-50: rgba(239, 238, 255, 1);
--tt-brand-color-100: rgba(222, 219, 255, 1);
--tt-brand-color-200: rgba(195, 189, 255, 1);
--tt-brand-color-300: rgba(157, 138, 255, 1);
--tt-brand-color-400: rgba(122, 82, 255, 1);
--tt-brand-color-500: rgba(98, 41, 255, 1);
--tt-brand-color-600: rgba(84, 0, 229, 1);
--tt-brand-color-700: rgba(75, 0, 204, 1);
--tt-brand-color-800: rgba(56, 0, 153, 1);
--tt-brand-color-900: rgba(43, 25, 102, 1);
--tt-brand-color-950: hsla(257, 100%, 9%, 1);
/* Green */
--tt-color-green-inc-5: hsla(129, 100%, 97%, 1);
--tt-color-green-inc-4: hsla(129, 100%, 92%, 1);
--tt-color-green-inc-3: hsla(131, 100%, 86%, 1);
--tt-color-green-inc-2: hsla(133, 98%, 78%, 1);
--tt-color-green-inc-1: hsla(137, 99%, 70%, 1);
--tt-color-green-base: hsla(147, 99%, 50%, 1);
--tt-color-green-dec-1: hsla(147, 97%, 41%, 1);
--tt-color-green-dec-2: hsla(146, 98%, 32%, 1);
--tt-color-green-dec-3: hsla(146, 100%, 24%, 1);
--tt-color-green-dec-4: hsla(144, 100%, 16%, 1);
--tt-color-green-dec-5: hsla(140, 100%, 9%, 1);
/* Yellow */
--tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1);
--tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1);
--tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1);
--tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1);
--tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1);
--tt-color-yellow-base: hsla(52, 100%, 50%, 1);
--tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1);
--tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1);
--tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1);
--tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1);
--tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1);
/* Red */
--tt-color-red-inc-5: hsla(11, 100%, 96%, 1);
--tt-color-red-inc-4: hsla(11, 100%, 88%, 1);
--tt-color-red-inc-3: hsla(10, 100%, 80%, 1);
--tt-color-red-inc-2: hsla(9, 100%, 73%, 1);
--tt-color-red-inc-1: hsla(7, 100%, 64%, 1);
--tt-color-red-base: hsla(7, 100%, 54%, 1);
--tt-color-red-dec-1: hsla(7, 100%, 41%, 1);
--tt-color-red-dec-2: hsla(5, 100%, 32%, 1);
--tt-color-red-dec-3: hsla(4, 100%, 24%, 1);
--tt-color-red-dec-4: hsla(3, 100%, 16%, 1);
--tt-color-red-dec-5: hsla(1, 100%, 9%, 1);
/* Basic colors */
--white: rgba(255, 255, 255, 1);
--black: rgba(14, 14, 17, 1);
--transparent: rgba(255, 255, 255, 0);
/******************
Shadow variables
******************/
/* Shadows Light */
--tt-shadow-elevated-md:
0px 16px 48px 0px rgba(17, 24, 39, 0.04),
0px 12px 24px 0px rgba(17, 24, 39, 0.04),
0px 6px 8px 0px rgba(17, 24, 39, 0.02),
0px 2px 3px 0px rgba(17, 24, 39, 0.02);
/**************************************************
Radius variables
**************************************************/
--tt-radius-xxs: 0.125rem; /* 2px */
--tt-radius-xs: 0.25rem; /* 4px */
--tt-radius-sm: 0.375rem; /* 6px */
--tt-radius-md: 0.5rem; /* 8px */
--tt-radius-lg: 0.75rem; /* 12px */
--tt-radius-xl: 1rem; /* 16px */
/**************************************************
Transition variables
**************************************************/
--tt-transition-duration-short: 0.1s;
--tt-transition-duration-default: 0.2s;
--tt-transition-duration-long: 0.64s;
--tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96);
--tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1);
--tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1);
--tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86);
--tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55);
/******************
Contrast variables
******************/
--tt-accent-contrast: 8%;
--tt-destructive-contrast: 8%;
--tt-foreground-contrast: 8%;
&,
*,
::before,
::after {
box-sizing: border-box;
transition: none var(--tt-transition-duration-default)
var(--tt-transition-easing-default);
}
}
:root {
/**************************************************
Global colors
**************************************************/
/* Global colors - Light mode */
--tt-bg-color: var(--white);
--tt-border-color: var(--tt-gray-light-a-200);
--tt-border-color-tint: var(--tt-gray-light-a-100);
--tt-sidebar-bg-color: var(--tt-gray-light-100);
--tt-scrollbar-color: var(--tt-gray-light-a-200);
--tt-cursor-color: var(--tt-brand-color-500);
--tt-selection-color: rgba(157, 138, 255, 0.2);
--tt-card-bg-color: var(--white);
--tt-card-border-color: var(--tt-gray-light-a-100);
}
/* Global colors - Dark mode */
.dark {
--tt-bg-color: var(--black);
--tt-border-color: var(--tt-gray-dark-a-200);
--tt-border-color-tint: var(--tt-gray-dark-a-100);
--tt-sidebar-bg-color: var(--tt-gray-dark-100);
--tt-scrollbar-color: var(--tt-gray-dark-a-200);
--tt-cursor-color: var(--tt-brand-color-400);
--tt-selection-color: rgba(122, 82, 255, 0.2);
--tt-card-bg-color: var(--tt-gray-dark-50);
--tt-card-border-color: var(--tt-gray-dark-a-50);
--tt-shadow-elevated-md:
0px 16px 48px 0px rgba(0, 0, 0, 0.5), 0px 12px 24px 0px rgba(0, 0, 0, 0.24),
0px 6px 8px 0px rgba(0, 0, 0, 0.22), 0px 2px 3px 0px rgba(0, 0, 0, 0.12);
}
/* Text colors */
:root {
--tt-color-text-gray: hsl(45, 2%, 46%);
--tt-color-text-brown: hsl(19, 31%, 47%);
--tt-color-text-orange: hsl(30, 89%, 45%);
--tt-color-text-yellow: hsl(38, 62%, 49%);
--tt-color-text-green: hsl(148, 32%, 39%);
--tt-color-text-blue: hsl(202, 54%, 43%);
--tt-color-text-purple: hsl(274, 32%, 54%);
--tt-color-text-pink: hsl(328, 49%, 53%);
--tt-color-text-red: hsl(2, 62%, 55%);
--tt-color-text-gray-contrast: hsla(39, 26%, 26%, 0.15);
--tt-color-text-brown-contrast: hsla(18, 43%, 69%, 0.35);
--tt-color-text-orange-contrast: hsla(24, 73%, 55%, 0.27);
--tt-color-text-yellow-contrast: hsla(44, 82%, 59%, 0.39);
--tt-color-text-green-contrast: hsla(126, 29%, 60%, 0.27);
--tt-color-text-blue-contrast: hsla(202, 54%, 59%, 0.27);
--tt-color-text-purple-contrast: hsla(274, 37%, 64%, 0.27);
--tt-color-text-pink-contrast: hsla(331, 60%, 71%, 0.27);
--tt-color-text-red-contrast: hsla(8, 79%, 79%, 0.4);
}
.dark {
--tt-color-text-gray: hsl(0, 0%, 61%);
--tt-color-text-brown: hsl(18, 35%, 58%);
--tt-color-text-orange: hsl(25, 53%, 53%);
--tt-color-text-yellow: hsl(36, 54%, 55%);
--tt-color-text-green: hsl(145, 32%, 47%);
--tt-color-text-blue: hsl(202, 64%, 52%);
--tt-color-text-purple: hsl(270, 55%, 62%);
--tt-color-text-pink: hsl(329, 57%, 58%);
--tt-color-text-red: hsl(1, 69%, 60%);
--tt-color-text-gray-contrast: hsla(0, 0%, 100%, 0.09);
--tt-color-text-brown-contrast: hsla(17, 45%, 50%, 0.25);
--tt-color-text-orange-contrast: hsla(27, 82%, 53%, 0.2);
--tt-color-text-yellow-contrast: hsla(35, 49%, 47%, 0.2);
--tt-color-text-green-contrast: hsla(151, 55%, 39%, 0.2);
--tt-color-text-blue-contrast: hsla(202, 54%, 43%, 0.2);
--tt-color-text-purple-contrast: hsla(271, 56%, 60%, 0.18);
--tt-color-text-pink-contrast: hsla(331, 67%, 58%, 0.22);
--tt-color-text-red-contrast: hsla(0, 67%, 60%, 0.25);
}
/* Highlight colors */
:root {
--tt-color-highlight-yellow: #fef9c3;
--tt-color-highlight-green: #dcfce7;
--tt-color-highlight-blue: #e0f2fe;
--tt-color-highlight-purple: #f3e8ff;
--tt-color-highlight-red: #ffe4e6;
--tt-color-highlight-gray: rgb(248, 248, 247);
--tt-color-highlight-brown: rgb(244, 238, 238);
--tt-color-highlight-orange: rgb(251, 236, 221);
--tt-color-highlight-pink: rgb(252, 241, 246);
--tt-color-highlight-yellow-contrast: #fbe604;
--tt-color-highlight-green-contrast: #c7fad8;
--tt-color-highlight-blue-contrast: #ceeafd;
--tt-color-highlight-purple-contrast: #e4ccff;
--tt-color-highlight-red-contrast: #ffccd0;
--tt-color-highlight-gray-contrast: rgba(84, 72, 49, 0.15);
--tt-color-highlight-brown-contrast: rgba(210, 162, 141, 0.35);
--tt-color-highlight-orange-contrast: rgba(224, 124, 57, 0.27);
--tt-color-highlight-pink-contrast: rgba(225, 136, 179, 0.27);
}
.dark {
--tt-color-highlight-yellow: #6b6524;
--tt-color-highlight-green: #509568;
--tt-color-highlight-blue: #6e92aa;
--tt-color-highlight-purple: #583e74;
--tt-color-highlight-red: #743e42;
--tt-color-highlight-gray: rgb(47, 47, 47);
--tt-color-highlight-brown: rgb(74, 50, 40);
--tt-color-highlight-orange: rgb(92, 59, 35);
--tt-color-highlight-pink: rgb(78, 44, 60);
--tt-color-highlight-yellow-contrast: #58531e;
--tt-color-highlight-green-contrast: #47855d;
--tt-color-highlight-blue-contrast: #5e86a1;
--tt-color-highlight-purple-contrast: #4c3564;
--tt-color-highlight-red-contrast: #643539;
--tt-color-highlight-gray-contrast: rgba(255, 255, 255, 0.094);
--tt-color-highlight-brown-contrast: rgba(184, 101, 69, 0.25);
--tt-color-highlight-orange-contrast: rgba(233, 126, 37, 0.2);
--tt-color-highlight-pink-contrast: rgba(220, 76, 145, 0.22);
}

View File

@@ -6,6 +6,7 @@
--font-mono: var(--font-geist-mono);
}
/* 浅色主题变量 */
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
@@ -27,13 +28,9 @@
--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%;
@@ -54,13 +51,9 @@
--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%;
}
/* Tailwind v4 主题配置 */
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
@@ -81,39 +74,37 @@
--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.
*/
/* 边框颜色兼容 - Tailwind v4 默认使用 currentColor这里改为使用主题边框色 */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
border-color: var(--color-border);
}
/* 为所有元素添加平滑的颜色过渡,避免主题切换时闪烁 */
*,
::before,
::after {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
}
/* 基础样式 */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
::selection {
@apply bg-primary/20 text-foreground;
}
}