feat: admin back-office system + AI assistant with action commands

- Admin analytics (recharts charts, overview stats, trend analysis, time range selector)
- Admin permissions (AdminRole/AdminUser models, role CRUD, permission catalog)
- Admin system config (site/AI/member category tabs, per-key save)
- Admin operations (Banner CRUD, push notification send/delete)
- Admin user management (table, search, create/edit, ban/delete)
- Admin layout (custom top bar with branding + sidebar, admin AI assistant)
- Admin login (adminToken localStorage, adminInfo display)
- Admin AI assistant (purple '运营助手', context-aware prompts, action commands)
- Public AI assistant Phase B (action commands: navigate, setModel, startChat, openSkill, setParameter)
- Assistant context mapping + action executor
- Skills API fix: tags parsing fallback (JSON.parse → split)
- Sandbox crash fix: curScene fallback system prompt
- JWT token expiration: access 2h→7d, refresh 7d→30d, admin 8h→7d
- i18n: assistant-related translations (zh/en)
- PM2 ecosystem config for process management
- AI assistant login prompt fix: admin uses getAdminToken(), public uses apiFetch with token refresh
This commit is contained in:
yuzhiran-dev
2026-05-20 10:38:58 +08:00
parent dd9240e5cf
commit 728edc59ef
33 changed files with 3014 additions and 153 deletions
@@ -0,0 +1,308 @@
'use client';
import { useState, useRef, useEffect, FormEvent } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { MessageCircle, X, Send, Minus, Sparkles, FileText, BookOpen, Image, Settings } from 'lucide-react';
import { useT } from '@/i18n';
import { getAdminToken } from '@/lib/auth';
import { toast } from 'sonner';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface AdminContext {
page: string;
systemPrompt: string;
starters: string[];
}
const adminContexts: Record<string, AdminContext> = {
'/admin': {
page: 'dashboard',
systemPrompt: '你是宇之然AI管理后台的智能助手。帮助管理员完成日常运营工作,包括:数据分析、用户管理、内容审核、订单处理等。',
starters: ['查看今日数据', '最近有哪些新用户', '待处理订单数量', '系统运行状态'],
},
'/admin/users': {
page: 'users',
systemPrompt: '你是用户管理助手。可以帮助:查看用户列表、搜索用户、修改用户状态、查看用户详情。',
starters: ['列出最近注册的用户', '查找某个用户', '批量启用/禁用用户', '查看用户详情'],
},
'/admin/courses': {
page: 'courses',
systemPrompt: '你是课程管理助手。可以帮助:创建新课程、编辑课程信息、上下架课程、管理课程章节。',
starters: ['创建新课程', '课程列表', '下架某个课程', '添加课程章节'],
},
'/admin/contents': {
page: 'contents',
systemPrompt: '你是内容管理助手。可以帮助:创建文章、编辑内容、设置分类、发布/下架。',
starters: ['创建新文章', '内容列表', '编辑某篇文章', '设置文章分类'],
},
'/admin/prompts': {
page: 'prompts',
systemPrompt: '你是提示词管理助手。可以帮助:创建提示词、审核提示词、设置分类、推荐优质提示词。',
starters: ['创建新提示词', '待审核列表', '热门提示词', '添加提示词标签'],
},
'/admin/orders': {
page: 'orders',
systemPrompt: '你是订单管理助手。可以帮助:查看订单列表、订单详情、退款处理、收入统计。',
starters: ['今日订单', '待处理订单', '收入统计', '订单详情'],
},
'/admin/analytics': {
page: 'analytics',
systemPrompt: '你是数据分析助手。可以帮助:解读数据指标、分析趋势、生成报表建议。',
starters: ['用户增长趋势', '收入分析', '热门内容', '数据摘要'],
},
'/admin/operations': {
page: 'operations',
systemPrompt: '你是运营助手。可以帮助:创建Banner、发送推送通知、管理活动。',
starters: ['创建Banner', '发送系统通知', '查看推送记录', '运营数据'],
},
'/admin/settings': {
page: 'settings',
systemPrompt: '你是系统设置助手。可以帮助:修改系统配置、查看配置项、批量设置。',
starters: ['查看AI配置', '修改会员价格', '站点设置', '配置说明'],
},
};
const ACTION_FORMAT = `\n\n【快捷指令】当需要执行操作时,可以返回 JSON 指令:\n- {"action":"navigate","path":"/admin/courses","description":"跳转到课程管理"}\n- {"action":"search","keyword":"xxx","target":"users","description":"搜索用户"}\n- {"action":"create","type":"course","data":{"title":"课程名"},"description":"创建课程"}\n只有确实需要跳转或执行操作时才返回指令。`;
function getAdminContext(pathname: string): AdminContext {
const sorted = Object.keys(adminContexts).sort((a, b) => b.length - a.length);
for (const key of sorted) {
if (pathname.startsWith(key)) {
const ctx = { ...adminContexts[key] };
ctx.systemPrompt = ctx.systemPrompt + ACTION_FORMAT;
return ctx;
}
}
const defaultCtx = { ...adminContexts['/admin'] };
defaultCtx.systemPrompt = defaultCtx.systemPrompt + ACTION_FORMAT;
return defaultCtx;
}
function parseActionCommand(text: string): any | null {
const jsonMatch = text.match(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/);
if (!jsonMatch) return null;
try {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.action) return parsed;
} catch {}
return null;
}
export function AdminAIAssistant() {
const t = useT();
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
const [minimized, setMinimized] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [started, setStarted] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open && !started) {
const ctx = getAdminContext(pathname);
setMessages([{ role: 'assistant', content: ctx.systemPrompt }]);
setStarted(true);
}
}, [open, pathname, started]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
async function handleSend(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const userMsg: Message = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
setInput('');
setSending(true);
try {
const tk = getAdminToken();
let reply = '';
if (tk) {
const ctx = getAdminContext(pathname);
const apiMessages = [
{ role: 'system', content: ctx.systemPrompt },
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: text },
];
const res = await fetch(`${API_BASE}/sandbox/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
body: JSON.stringify({
conversationId: crypto.randomUUID(),
model: 'general',
messages: apiMessages,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
reply = data.reply;
} else {
await new Promise(r => setTimeout(r, 400));
reply = '请先登录管理账号';
}
const action = parseActionCommand(reply);
const hasAction = !!action;
if (hasAction) {
const cleanReply = reply.replace(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/, '').trim();
const finalReply = cleanReply || '收到指令,正在处理...';
setMessages(prev => [...prev, { role: 'assistant', content: finalReply }]);
if (action.action === 'navigate' && action.path) {
router.push(action.path);
toast.success(`正在跳转:${action.description || action.path}`);
} else {
toast.info(action.description || '收到操作指令');
}
} else {
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
}
} catch (e: any) {
setMessages(prev => [...prev, { role: 'assistant', content: `出错:${e.message}` }]);
} finally {
setSending(false);
}
}
function handleStarter(starter: string) {
setInput(starter);
setTimeout(() => inputRef.current?.focus(), 0);
}
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-purple-600 text-white rounded-full shadow-lg hover:bg-purple-700 hover:shadow-xl hover:scale-105 transition-all"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
</span>
<span className="text-sm font-medium"></span>
</button>
);
}
const ctx = getAdminContext(pathname);
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<div
className={`bg-card border border-border rounded-2xl shadow-2xl overflow-hidden transition-all duration-300 ${
minimized ? 'h-14 w-72' : 'w-80 sm:w-96'
}`}
style={{ maxHeight: 'min(500px, 80vh)' }}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-purple-500/10">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-purple-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">AI</div>
<span className="text-sm font-semibold text-foreground"></span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
<Minus className="w-4 h-4" />
</button>
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
<X className="w-4 h-4" />
</button>
</div>
</div>
{!minimized && (
<>
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '320px' }}>
{messages.length === 1 && messages[0].role === 'assistant' && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-3">
</p>
<div className="flex flex-wrap gap-1.5">
{ctx.starters.map((q, i) => (
<button key={i} onClick={() => handleStarter(q)}
className="text-xs px-2.5 py-1.5 bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground">
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex items-start gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 bg-purple-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">AI</div>
)}
<div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm ${
msg.role === 'user' ? 'bg-purple-600 text-white rounded-tr-none' : 'bg-muted text-foreground rounded-tl-none'
}`}>
{msg.content}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"></div>
)}
</div>
))}
{sending && (
<div className="flex items-start gap-2">
<div className="w-6 h-6 bg-purple-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0">AI</div>
<div className="bg-muted rounded-2xl rounded-tl-none px-3 py-2">
<span className="inline-flex gap-1">
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" />
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-border p-3">
<form onSubmit={handleSend} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="输入问题或指令..."
disabled={sending}
className="flex-1 px-3 py-2 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
/>
<button type="submit" disabled={sending || !input.trim()}
className="p-2 bg-purple-600 text-white rounded-xl hover:bg-purple-700 disabled:opacity-50">
<Send className="w-4 h-4" />
</button>
</form>
</div>
</>
)}
</div>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
'use client';
import { useState, useRef, useEffect, FormEvent } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { MessageCircle, X, Send, Minus, Sparkles } from 'lucide-react';
import { getAssistantContext, parseActionCommand, type AssistantContext, type AssistantAction } from '@/lib/assistant-context';
import { executeAction, setRouter } from '@/lib/assistant-actions';
import { useT } from '@/i18n';
import { getToken, apiFetch } from '@/lib/auth';
import { DEFAULT_MODEL } from '@/lib/models';
import { toast } from 'sonner';
interface Message {
role: 'user' | 'assistant';
content: string;
hasAction?: boolean;
actionExecuted?: boolean;
}
export function AIAssistant() {
const t = useT();
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
const [minimized, setMinimized] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [context, setContext] = useState<AssistantContext>(getAssistantContext(pathname));
const [started, setStarted] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setRouter(router);
}, [router]);
useEffect(() => {
setContext(getAssistantContext(pathname));
}, [pathname]);
useEffect(() => {
if (open && !started) {
const ctx = getAssistantContext(pathname);
setMessages([{ role: 'assistant', content: ctx.systemPrompt }]);
setStarted(true);
}
}, [open, pathname, started]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
async function handleSend(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const userMsg: Message = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
setInput('');
setSending(true);
try {
const tk = getToken();
let reply = '';
if (tk) {
const ctx = getAssistantContext(pathname);
const apiMessages = [
{ role: 'system', content: ctx.systemPrompt },
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: text },
];
const res = await apiFetch('/sandbox/chat', {
method: 'POST',
body: JSON.stringify({
conversationId: crypto.randomUUID(),
model: DEFAULT_MODEL,
messages: apiMessages,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
reply = data.reply;
} else {
await new Promise(r => setTimeout(r, 400));
reply = '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。';
}
const action = parseActionCommand(reply);
const hasAction = !!action;
if (hasAction) {
const cleanReply = reply.replace(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/, '').trim();
const finalReply = cleanReply || '收到你的请求,正在处理...';
setMessages(prev => [...prev, { role: 'assistant', content: finalReply, hasAction: true }]);
const result = await executeAction(action);
if (result.success) {
toast.success(result.message, { icon: <Sparkles className="w-4 h-4" /> });
setMessages(prev => prev.map((m, i) =>
i === prev.length - 1 ? { ...m, actionExecuted: true } : m
));
} else {
toast.error(result.message);
}
} else {
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
}
} catch (e: any) {
setMessages(prev => [...prev, { role: 'assistant', content: `出错啦:${e.message}` }]);
} finally {
setSending(false);
}
}
function handleStarter(starter: string) {
setInput(starter);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}
if (!open) {
return (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3">
<div className="relative group">
<div className="absolute -top-10 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-muted text-muted-foreground text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
{t.assistant?.title || 'AI 助手'}
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-muted rotate-45" />
</div>
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-brand-600 text-white rounded-full shadow-lg hover:bg-brand-700 hover:shadow-xl hover:scale-105 transition-all"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-white"></span>
</span>
<span className="text-sm font-medium">{t.assistant?.title || 'AI 助手'}</span>
</button>
</div>
</div>
);
}
const ctx = getAssistantContext(pathname);
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<div
className={`bg-card border border-border rounded-2xl shadow-2xl overflow-hidden transition-all duration-300 ${
minimized ? 'h-14 w-72' : 'w-80 sm:w-96'
}`}
style={{ maxHeight: 'min(600px, 80vh)' }}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-brand-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">Y</div>
<span className="text-sm font-semibold text-foreground">{t.assistant?.title || 'AI 助手'}</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
<Minus className="w-4 h-4" />
</button>
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{!minimized && (
<>
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '360px' }}>
{messages.length === 1 && messages[0].role === 'assistant' && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t.assistant?.greeting || '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:'}
</p>
<div className="flex flex-wrap gap-1.5">
{ctx.starters.map((q, i) => (
<button
key={i}
onClick={() => handleStarter(q)}
className="text-xs px-2.5 py-1.5 bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => {
const showActionIndicator = msg.hasAction && msg.actionExecuted && i === messages.length - 1;
return (
<div key={i} className={`flex items-start gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
)}
<div
className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap relative ${
msg.role === 'user'
? 'bg-brand-600 text-white rounded-tr-none'
: 'bg-muted text-foreground rounded-tl-none'
}`}
>
{i === messages.length - 1 && msg.role === 'assistant' && started && messages.length > 1
? msg.content
: msg.role === 'assistant' && i === 0
? null
: msg.content}
{showActionIndicator && (
<span className="absolute -top-2 -right-2 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
<Sparkles className="w-3 h-3 text-white" />
</span>
)}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"></div>
)}
</div>
);
})}
{sending && (
<div className="flex items-start gap-2">
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
<div className="bg-muted rounded-2xl rounded-tl-none px-3 py-2">
<span className="inline-flex gap-1">
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" />
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-border p-3">
<form onSubmit={handleSend} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder={t.assistant?.placeholder || '输入你的问题...'}
disabled={sending}
className="flex-1 px-3 py-2 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
<button
type="submit"
disabled={sending || !input.trim()}
className="p-2 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50"
>
<Send className="w-4 h-4" />
</button>
</form>
</div>
</>
)}
</div>
</div>
);
}