2025.11.28.22.40
This commit is contained in:
35
package.json
35
package.json
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -21,13 +22,7 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-bubble-menu": "^3.11.0",
|
||||
"@tiptap/extension-floating-menu": "^3.11.0",
|
||||
"@tiptap/extension-image": "^3.11.0",
|
||||
"@tiptap/extension-link": "^3.11.0",
|
||||
"@tiptap/extension-placeholder": "^3.11.0",
|
||||
"@tiptap/react": "^3.11.0",
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"@uiw/react-markdown-preview": "^5.1.5",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -36,15 +31,18 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"mongoose": "^9.0.0",
|
||||
"next": "16.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.9.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-hotkeys-hook": "^5.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.5.0",
|
||||
"recharts": "^3.5.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -52,22 +50,23 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"sharp": "^0.34.5",
|
||||
"shiki": "^3.15.0",
|
||||
"shiki": "^3.17.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"unified": "^11.0.5",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"sass": "^1.94.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
1049
pnpm-lock.yaml
generated
1049
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
50
remaining-errors.txt
Normal file
50
remaining-errors.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
scripts/seed.ts(61,7): error TS6133: 'getRandomItems' is declared but its value is never read.
|
||||
src/components/admin/ArticleEditor.tsx(42,12): error TS6133: 'tags' is declared but its value is never read.
|
||||
src/components/tiptap-templates/simple/simple-editor.tsx(70,1): error TS6133: 'content' is declared but its value is never read.
|
||||
src/models/index.ts(1,28): error TS6133: 'Document' is declared but its value is never read.
|
||||
src/models/index.ts(1,38): error TS6133: 'Model' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(24,38): error TS6133: 'Eye' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(40,11): error TS6133: 'router' is declared but its value is never read.
|
||||
src/pages/admin/banners/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/banners/index.tsx(6,29): error TS6133: 'CardDescription' is declared but its value is never read.
|
||||
src/pages/admin/index.tsx(1,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/admin/index.tsx(8,39): error TS6133: 'CartesianGrid' is declared but its value is never read.
|
||||
src/pages/admin/index.tsx(8,54): error TS6133: 'Tooltip' is declared but its value is never read.
|
||||
src/pages/admin/plans/edit/[id].tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/plans/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(9,1): error TS6133: 'Switch' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,25): error TS6133: 'Trash2' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,33): error TS6133: 'Plus' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,39): error TS6133: 'Bot' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(11,34): error TS6133: 'Controller' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(76,11): error TS6198: All destructured elements are unused.
|
||||
src/pages/admin/users/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/users/index.tsx(21,5): error TS6133: 'DialogTrigger' is declared but its value is never read.
|
||||
src/pages/admin/users/index.tsx(53,11): error TS6133: 'router' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(6,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/aitools/chat/index.tsx(9,21): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(9,44): error TS6133: 'Settings2' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(20,11): error TS6196: 'Assistant' is declared but never used.
|
||||
src/pages/aitools/chat/index.tsx(30,19): error TS6133: 'loading' is declared but its value is never read.
|
||||
src/pages/aitools/index.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/aitools/prompt-optimizer/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/aitools/translator/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/api/admin/ai/models.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/admin/articles/index.ts(2,19): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/api/admin/settings.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/admin/users/index.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/ai/generate.ts(20,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/hello.ts(9,3): error TS6133: 'req' is declared but its value is never read.
|
||||
src/pages/api/orders/create.ts(3,33): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(11,52): error TS6133: 'CardDescription' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(11,69): error TS6133: 'CardFooter' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(12,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/article/[id].tsx(13,94): error TS6133: 'Sparkles' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(56,28): error TS6133: 'authLoading' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(419,67): error TS6133: 'index' is declared but its value is never read.
|
||||
src/pages/membership.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/payment/failure.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/payment/success.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
@@ -58,10 +58,6 @@ if (!MONGODB_URI) {
|
||||
// ------------------------------------------------------------------
|
||||
const getRandomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const getRandomItem = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
|
||||
const getRandomItems = <T>(arr: T[], count: number): T[] => {
|
||||
const shuffled = [...arr].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. 主逻辑
|
||||
|
||||
166
src/components/admin/AIPanel.tsx
Normal file
166
src/components/admin/AIPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
37
src/components/markdown/MarkdownEditor.tsx
Normal file
37
src/components/markdown/MarkdownEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/mode-toggle.tsx
Normal file
22
src/components/mode-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
18
src/components/ui/scroll-area.tsx
Normal file
18
src/components/ui/scroll-area.tsx
Normal 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
46
src/constants/aiTools.ts
Normal 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'
|
||||
}
|
||||
];
|
||||
47
src/hooks/use-composed-ref.ts
Normal file
47
src/hooks/use-composed-ref.ts
Normal 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
|
||||
69
src/hooks/use-cursor-visibility.ts
Normal file
69
src/hooks/use-cursor-visibility.ts
Normal 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
|
||||
}
|
||||
166
src/hooks/use-element-rect.ts
Normal file
166
src/hooks/use-element-rect.ts
Normal 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 })
|
||||
}
|
||||
35
src/hooks/use-is-breakpoint.ts
Normal file
35
src/hooks/use-is-breakpoint.ts
Normal 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
|
||||
}
|
||||
194
src/hooks/use-menu-navigation.ts
Normal file
194
src/hooks/use-menu-navigation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
75
src/hooks/use-scrolling.ts
Normal file
75
src/hooks/use-scrolling.ts
Normal 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
|
||||
}
|
||||
48
src/hooks/use-throttled-callback.ts
Normal file
48
src/hooks/use-throttled-callback.ts
Normal 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
21
src/hooks/use-unmount.ts
Normal 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
|
||||
93
src/hooks/use-window-size.ts
Normal file
93
src/hooks/use-window-size.ts
Normal 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
|
||||
}
|
||||
@@ -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
16
src/lib/markdown.ts
Normal 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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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配置列表"
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
266
src/pages/aitools/chat/index.tsx
Normal file
266
src/pages/aitools/chat/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// 显式选择所有被隐藏的敏感字段
|
||||
|
||||
@@ -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
103
src/pages/api/ai/chat.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-4、Claude 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
5
src/pages/simple.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor"
|
||||
|
||||
export default function Page() {
|
||||
return <SimpleEditor />
|
||||
}
|
||||
91
src/styles/_keyframe-animations.scss
Normal file
91
src/styles/_keyframe-animations.scss
Normal 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
296
src/styles/_variables.scss
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
58
tsc-errors-after-manual.txt
Normal file
58
tsc-errors-after-manual.txt
Normal file
@@ -0,0 +1,58 @@
|
||||
scripts/seed.ts(61,7): error TS6133: 'getRandomItems' is declared but its value is never read.
|
||||
src/components/admin/ArticleEditor.tsx(42,12): error TS6133: 'tags' is declared but its value is never read.
|
||||
src/components/layouts/MainLayout.tsx(6,18): error TS6133: 'User' is declared but its value is never read.
|
||||
src/components/tiptap-templates/simple/simple-editor.tsx(70,1): error TS6133: 'content' is declared but its value is never read.
|
||||
src/models/index.ts(1,28): error TS6133: 'Document' is declared but its value is never read.
|
||||
src/models/index.ts(1,38): error TS6133: 'Model' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(9,93): error TS6133: 'DialogTrigger' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(10,44): error TS6133: 'Save' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(10,50): error TS6133: 'MoreVertical' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(33,53): error TS6133: 'setValue' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(33,63): error TS6133: 'watch' is declared but its value is never read.
|
||||
src/pages/admin/articles/edit/[id].tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(24,38): error TS6133: 'Eye' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(40,11): error TS6133: 'router' is declared but its value is never read.
|
||||
src/pages/admin/banners/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/banners/index.tsx(6,29): error TS6133: 'CardDescription' is declared but its value is never read.
|
||||
src/pages/admin/index.tsx(1,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/admin/index.tsx(8,39): error TS6133: 'CartesianGrid' is declared but its value is never read.
|
||||
src/pages/admin/index.tsx(8,54): error TS6133: 'Tooltip' is declared but its value is never read.
|
||||
src/pages/admin/plans/edit/[id].tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/plans/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(9,1): error TS6133: 'Switch' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,25): error TS6133: 'Trash2' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,33): error TS6133: 'Plus' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,39): error TS6133: 'Bot' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(11,34): error TS6133: 'Controller' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(76,11): error TS6198: All destructured elements are unused.
|
||||
src/pages/admin/users/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/users/index.tsx(21,5): error TS6133: 'DialogTrigger' is declared but its value is never read.
|
||||
src/pages/admin/users/index.tsx(53,11): error TS6133: 'router' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(6,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/aitools/chat/index.tsx(9,21): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(9,44): error TS6133: 'Settings2' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(20,11): error TS6196: 'Assistant' is declared but never used.
|
||||
src/pages/aitools/chat/index.tsx(30,19): error TS6133: 'loading' is declared but its value is never read.
|
||||
src/pages/aitools/index.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/aitools/prompt-optimizer/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/aitools/translator/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/api/admin/ai/models.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/admin/articles/index.ts(2,19): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/api/admin/settings.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/admin/users/index.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/ai/generate.ts(20,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/hello.ts(9,3): error TS6133: 'req' is declared but its value is never read.
|
||||
src/pages/api/orders/create.ts(3,33): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(11,52): error TS6133: 'CardDescription' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(11,69): error TS6133: 'CardFooter' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(12,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/article/[id].tsx(13,94): error TS6133: 'Sparkles' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(56,28): error TS6133: 'authLoading' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(419,67): error TS6133: 'index' is declared but its value is never read.
|
||||
src/pages/membership.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/payment/failure.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/payment/success.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
74
tsc-errors.txt
Normal file
74
tsc-errors.txt
Normal file
@@ -0,0 +1,74 @@
|
||||
scripts/seed.ts(61,7): error TS6133: 'getRandomItems' is declared but its value is never read.
|
||||
src/components/admin/AIPanel.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/components/admin/AIPanel.tsx(4,67): error TS6133: 'MessageSquare' is declared but its value is never read.
|
||||
src/components/admin/AIPanel.tsx(4,82): error TS6133: 'Languages' is declared but its value is never read.
|
||||
src/components/admin/ArticleEditor.tsx(2,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/components/admin/ArticleEditor.tsx(15,69): error TS6133: 'FileText' is declared but its value is never read.
|
||||
src/components/admin/ArticleEditor.tsx(42,12): error TS6133: 'tags' is declared but its value is never read.
|
||||
src/components/admin/TiptapEditor.tsx(24,5): error TS6133: 'Loader2' is declared but its value is never read.
|
||||
src/components/admin/TiptapEditor.tsx(26,5): error TS6133: 'X' is declared but its value is never read.
|
||||
src/components/admin/TiptapEditor.tsx(29,5): error TS6133: 'FileText' is declared but its value is never read.
|
||||
src/components/admin/TiptapEditor.tsx(38,1): error TS6192: All imports in import declaration are unused.
|
||||
src/components/admin/TiptapEditor.tsx(44,1): error TS6192: All imports in import declaration are unused.
|
||||
src/components/aitools/ToolsSidebar.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/components/home/ArticleCard.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/components/home/HeroBanner.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/components/home/Sidebar.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/components/home/Sidebar.tsx(2,1): error TS6133: 'Button' is declared but its value is never read.
|
||||
src/components/layouts/MainLayout.tsx(6,18): error TS6133: 'User' is declared but its value is never read.
|
||||
src/components/tiptap-templates/simple/simple-editor.tsx(70,1): error TS6133: 'content' is declared but its value is never read.
|
||||
src/models/index.ts(1,28): error TS6133: 'Document' is declared but its value is never read.
|
||||
src/models/index.ts(1,38): error TS6133: 'Model' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(9,93): error TS6133: 'DialogTrigger' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(10,44): error TS6133: 'Save' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(10,50): error TS6133: 'MoreVertical' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(33,53): error TS6133: 'setValue' is declared but its value is never read.
|
||||
src/pages/admin/ai/index.tsx(33,63): error TS6133: 'watch' is declared but its value is never read.
|
||||
src/pages/admin/articles/edit/[id].tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(24,38): error TS6133: 'Eye' is declared but its value is never read.
|
||||
src/pages/admin/articles/index.tsx(40,11): error TS6133: 'router' is declared but its value is never read.
|
||||
src/pages/admin/banners/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/banners/index.tsx(6,29): error TS6133: 'CardDescription' is declared but its value is never read.
|
||||
src/pages/admin/index.tsx(1,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/admin/index.tsx(8,39): error TS6133: 'CartesianGrid' is declared but its value is never read.
|
||||
src/pages/admin/index.tsx(8,54): error TS6133: 'Tooltip' is declared but its value is never read.
|
||||
src/pages/admin/plans/edit/[id].tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/plans/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(9,1): error TS6133: 'Switch' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,25): error TS6133: 'Trash2' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,33): error TS6133: 'Plus' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(10,39): error TS6133: 'Bot' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(11,34): error TS6133: 'Controller' is declared but its value is never read.
|
||||
src/pages/admin/settings/index.tsx(76,11): error TS6198: All destructured elements are unused.
|
||||
src/pages/admin/users/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/admin/users/index.tsx(21,5): error TS6133: 'DialogTrigger' is declared but its value is never read.
|
||||
src/pages/admin/users/index.tsx(53,11): error TS6133: 'router' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(6,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/aitools/chat/index.tsx(9,21): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(9,44): error TS6133: 'Settings2' is declared but its value is never read.
|
||||
src/pages/aitools/chat/index.tsx(20,11): error TS6196: 'Assistant' is declared but never used.
|
||||
src/pages/aitools/chat/index.tsx(30,19): error TS6133: 'loading' is declared but its value is never read.
|
||||
src/pages/aitools/index.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/aitools/prompt-optimizer/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/aitools/translator/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/api/admin/ai/models.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/admin/articles/index.ts(2,19): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/api/admin/settings.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/admin/users/index.ts(6,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/ai/generate.ts(20,67): error TS6133: 'user' is declared but its value is never read.
|
||||
src/pages/api/hello.ts(9,3): error TS6133: 'req' is declared but its value is never read.
|
||||
src/pages/api/orders/create.ts(3,33): error TS6133: 'User' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(11,52): error TS6133: 'CardDescription' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(11,69): error TS6133: 'CardFooter' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(12,1): error TS6192: All imports in import declaration are unused.
|
||||
src/pages/article/[id].tsx(13,94): error TS6133: 'Sparkles' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(56,28): error TS6133: 'authLoading' is declared but its value is never read.
|
||||
src/pages/article/[id].tsx(419,67): error TS6133: 'index' is declared but its value is never read.
|
||||
src/pages/index.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/membership.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/payment/failure.tsx(1,1): error TS6133: 'React' is declared but its value is never read.
|
||||
src/pages/payment/success.tsx(1,8): error TS6133: 'React' is declared but its value is never read.
|
||||
@@ -1,10 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true, // 未使用的局部变量
|
||||
"noUnusedParameters": true, // 未使用的参数
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
@@ -14,7 +20,9 @@
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -25,5 +33,7 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user