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
+235
View File
@@ -0,0 +1,235 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { TrendingUp, TrendingDown, Users, ShoppingCart, DollarSign, BookOpen, MessageCircle } from 'lucide-react';
interface OverviewData {
users: { total: number; active: number; new: number; growth: number };
orders: { total: number; today: number; growth: number };
revenue: { total: number; today: number; growth: number };
courses: { total: number };
skills: { total: number };
}
interface TrendData {
date: string;
count?: number;
revenue?: number;
}
export default function AnalyticsPage() {
const router = useRouter();
const [range, setRange] = useState('7d');
const [overview, setOverview] = useState<OverviewData | null>(null);
const [trend, setTrend] = useState<TrendData[]>([]);
const [trendType, setTrendType] = useState('users');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, [range, trendType]);
async function loadData() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const headers = { Authorization: `Bearer ${token}` };
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const [ovRes, trRes] = await Promise.all([
fetch(`${base}/api/v1/admin/analytics/overview?range=${range}`, { headers }),
fetch(`${base}/api/v1/admin/analytics/trend?type=${trendType}&days=${range === 'today' ? 7 : range === '7d' ? 30 : 90}`, { headers }),
]);
if (ovRes.ok) setOverview(await ovRes.json());
if (trRes.ok) setTrend(await trRes.json());
} catch {}
setLoading(false);
}
function formatNumber(n: number) {
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toString();
}
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
if (loading && !overview) {
return (
<div className="p-6 space-y-4">
<div className="h-8 w-32 bg-muted animate-pulse rounded" />
<div className="grid grid-cols-4 gap-4">
{[1,2,3,4].map(i => <div key={i} className="h-24 bg-muted animate-pulse rounded-xl" />)}
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<select
value={range}
onChange={e => setRange(e.target.value)}
className="px-3 py-2 border border-border rounded-lg bg-background text-foreground"
>
<option value="today"></option>
<option value="7d">7</option>
<option value="30d">30</option>
</select>
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<StatCard
icon={<Users className="w-5 h-5" />}
label="总用户"
value={formatNumber(overview?.users.total || 0)}
growth={overview?.users.growth}
color="text-blue-600"
/>
<StatCard
icon={<Users className="w-5 h-5" />}
label="活跃用户"
value={formatNumber(overview?.users.active || 0)}
color="text-green-600"
/>
<StatCard
icon={<ShoppingCart className="w-5 h-5" />}
label="总订单"
value={formatNumber(overview?.orders.total || 0)}
color="text-purple-600"
/>
<StatCard
icon={<DollarSign className="w-5 h-5" />}
label="总收入"
value={formatNumber(overview?.revenue.total || 0)}
growth={overview?.revenue.growth}
color="text-orange-600"
prefix="¥"
/>
<StatCard
icon={<BookOpen className="w-5 h-5" />}
label="课程数"
value={formatNumber(overview?.courses.total || 0)}
color="text-indigo-600"
/>
<StatCard
icon={<MessageCircle className="w-5 h-5" />}
label="技能数"
value={formatNumber(overview?.skills.total || 0)}
color="text-pink-600"
/>
</div>
{/* 趋势图 */}
<div className="bg-card border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground"></h2>
<div className="flex gap-2">
{['users', 'orders', 'sessions'].map(t => (
<button
key={t}
onClick={() => setTrendType(t)}
className={`px-3 py-1 text-sm rounded-lg ${
trendType === t ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'
}`}
>
{t === 'users' ? '用户' : t === 'orders' ? '订单' : '对话'}
</button>
))}
</div>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tickFormatter={formatDate} stroke="var(--muted-foreground)" fontSize={12} />
<YAxis stroke="var(--muted-foreground)" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '8px',
}}
/>
<Area
type="monotone"
dataKey={trendType === 'orders' ? 'revenue' : 'count'}
stroke="#2563eb"
fill="#3b82f620"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* 快捷操作 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<QuickAction
label="用户分析"
desc="查看用户增长趋势"
onClick={() => { setTrendType('users'); setRange('30d'); }}
/>
<QuickAction
label="订单分析"
desc="查看收入趋势"
onClick={() => { setTrendType('orders'); setRange('30d'); }}
/>
<QuickAction
label="对话分析"
desc="查看AI使用情况"
onClick={() => { setTrendType('sessions'); setRange('30d'); }}
/>
<QuickAction
label="导出报告"
desc="下载数据报表"
onClick={() => alert('导出功能开发中')}
/>
</div>
</div>
);
}
function StatCard({ icon, label, value, growth, color, prefix = '' }: any) {
const isUp = growth > 0;
return (
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<span className={color}>{icon}</span>
<span className="text-sm text-muted-foreground">{label}</span>
</div>
<div className="flex items-end justify-between">
<div className="text-2xl font-bold text-foreground">{prefix}{value}</div>
{growth !== undefined && (
<div className={`flex items-center text-xs ${isUp ? 'text-green-600' : 'text-red-600'}`}>
{isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span className="ml-0.5">{Math.abs(growth)}%</span>
</div>
)}
</div>
</div>
);
}
function QuickAction({ label, desc, onClick }: any) {
return (
<button
onClick={onClick}
className="bg-card border border-border rounded-xl p-4 text-left hover:shadow-md hover:-translate-y-0.5 transition-all"
>
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground mt-1">{desc}</div>
</button>
);
}
+77 -26
View File
@@ -6,10 +6,13 @@ import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard, Users, BookOpen, MessageSquare,
FileText, Wrench, ShoppingCart, Building2, MessageCircle,
BarChart3, Settings, Bell, LogOut, ChevronDown, Layout,
} from 'lucide-react';
import { AdminAIAssistant } from '@/components/admin-ai-assistant';
const sidebarLinks = [
{ href: '/admin', label: '仪表盘', icon: LayoutDashboard },
{ href: '/admin/analytics', label: '数据分析', icon: BarChart3 },
{ href: '/admin/users', label: '用户管理', icon: Users },
{ href: '/admin/courses', label: '课程管理', icon: BookOpen },
{ href: '/admin/prompts', label: '提示词管理', icon: MessageSquare },
@@ -18,12 +21,17 @@ const sidebarLinks = [
{ href: '/admin/orders', label: '订单管理', icon: ShoppingCart },
{ href: '/admin/enterprise', label: '企业版管理', icon: Building2 },
{ href: '/admin/comments', label: '评论审核', icon: MessageCircle },
{ href: '/admin/operations/banners', label: 'Banner管理', icon: Layout },
{ href: '/admin/operations/notifications', label: '推送管理', icon: Bell },
{ href: '/admin/settings/roles', label: '角色权限', icon: Settings },
{ href: '/admin/settings/config', label: '系统配置', icon: Settings },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [checked, setChecked] = useState(false);
const [adminInfo, setAdminInfo] = useState<{ username: string; role?: string } | null>(null);
const isLoginPage = pathname === '/admin/login';
@@ -32,42 +40,85 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (!token && !isLoginPage) {
router.replace('/admin/login');
} else {
// 获取管理员信息
try {
const info = localStorage.getItem('adminInfo');
if (info) setAdminInfo(JSON.parse(info));
} catch {}
setChecked(true);
}
}, [isLoginPage, router]);
if (isLoginPage) return <>{children}</>;
if (!checked) return <div className="min-h-[calc(100vh-4rem)]" />;
function handleLogout() {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminInfo');
router.replace('/admin/login');
}
function isActive(href: string) {
if (href === '/admin') return pathname === '/admin';
return pathname.startsWith(href) && href !== '/admin';
}
if (isLoginPage) return <>{children}</>;
if (!checked) return <div className="min-h-screen bg-background" />;
return (
<div className="min-h-[calc(100vh-4rem)] bg-background flex">
<aside className="w-56 border-r border-border bg-card shrink-0 hidden md:block">
<nav className="p-3 space-y-1">
{sidebarLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-colors ${
isActive(link.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Icon className="h-4 w-4" />
<span>{link.label}</span>
</Link>
);
})}
</nav>
</aside>
<main className="flex-1 overflow-auto">{children}</main>
<div className="min-h-screen bg-background">
{/* 顶部导航栏 */}
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-4 sticky top-0 z-50">
<div className="flex items-center gap-4">
<Link href="/admin" className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold">Y</div>
<span className="text-lg font-bold text-foreground"></span>
</Link>
<span className="text-sm text-muted-foreground hidden sm:inline">AI平台管理系统</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-lg">
<div className="w-6 h-6 bg-brand-600 rounded-full flex items-center justify-center text-white text-xs">
{adminInfo?.username?.charAt(0) || 'A'}
</div>
<span className="text-sm text-foreground">{adminInfo?.username || '管理员'}</span>
<span className="text-xs text-muted-foreground">({adminInfo?.role || '超级管理员'})</span>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<LogOut className="w-4 h-4" />
<span>退</span>
</button>
</div>
</header>
<div className="flex">
{/* 左侧菜单 */}
<aside className="w-56 border-r border-border bg-card shrink-0 hidden md:block" style={{ minHeight: 'calc(100vh - 3.5rem)' }}>
<nav className="p-3 space-y-1">
{sidebarLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-colors ${
isActive(link.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Icon className="h-4 w-4" />
<span>{link.label}</span>
</Link>
);
})}
</nav>
</aside>
{/* 主内容区 */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
<AdminAIAssistant />
</div>
);
}
}
+1
View File
@@ -29,6 +29,7 @@ export default function AdminLoginPage() {
const data = await res.json();
if (!res.ok) throw new Error(data.message || '管理员登录失败');
localStorage.setItem('adminToken', data.token);
localStorage.setItem('adminInfo', JSON.stringify({ username: data.username, role: data.role }));
toast.success('管理员登录成功');
router.push('/admin');
} catch (err: any) {
@@ -0,0 +1,123 @@
'use client';
import { useEffect, useState } from 'react';
interface Banner {
id: number;
title: string;
image: string;
link: string;
position: string;
sortOrder: number;
status: string;
}
export default function BannersPage() {
const [banners, setBanners] = useState<Banner[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ title: '', image: '', link: '', position: 'home', sortOrder: 0 });
useEffect(() => { loadBanners(); }, []);
async function loadBanners() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setBanners(data.items || []);
}
} catch {}
setLoading(false);
}
async function createBanner() {
if (!form.title || !form.image) return alert('请填写标题和图片');
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...form, status: 'PUBLISHED' }),
});
setShowForm(false);
setForm({ title: '', image: '', link: '', position: 'home', sortOrder: 0 });
loadBanners();
}
async function deleteBanner(id: number) {
if (!confirm('确定删除?')) return;
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
loadBanners();
}
if (loading) return <div className="p-6">...</div>;
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Banner </h1>
<p className="text-sm text-muted-foreground">广</p>
</div>
<button onClick={() => setShowForm(!showForm)} className="px-4 py-2 bg-brand-600 text-white rounded-lg">
{showForm ? '取消' : '新建Banner'}
</button>
</div>
{showForm && (
<div className="bg-card border border-border rounded-xl p-4 mb-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-muted-foreground"> *</label>
<input type="text" value={form.title} onChange={e => setForm({...form, title: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
<div>
<label className="text-sm text-muted-foreground">URL *</label>
<input type="text" value={form.image} onChange={e => setForm({...form, image: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<input type="text" value={form.link} onChange={e => setForm({...form, link: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<select value={form.position} onChange={e => setForm({...form, position: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground">
<option value="home"></option>
<option value="skills"></option>
<option value="courses"></option>
</select>
</div>
</div>
<button onClick={createBanner} className="px-4 py-2 bg-brand-600 text-white rounded-lg"></button>
</div>
)}
<div className="space-y-3">
{banners.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">Banner</div>
) : banners.map(banner => (
<div key={banner.id} className="bg-card border border-border rounded-xl p-4 flex items-center gap-4">
<img src={banner.image} alt={banner.title} className="w-24 h-16 object-cover rounded" />
<div className="flex-1">
<div className="font-medium text-foreground">{banner.title}</div>
<div className="text-sm text-muted-foreground">{banner.position} · : {banner.sortOrder}</div>
</div>
<button onClick={() => deleteBanner(banner.id)} className="text-red-500 hover:text-red-700"></button>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState } from 'react';
interface Notification {
id: number;
title: string;
content: string;
type: string;
target: string;
status: string;
sentAt: string;
}
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ title: '', content: '', type: 'system', target: 'all' });
useEffect(() => { loadNotifications(); }, []);
async function loadNotifications() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setNotifications(data.items || []);
}
} catch {}
setLoading(false);
}
async function sendNotification() {
if (!form.title || !form.content) return alert('请填写标题和内容');
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...form, status: 'SENT' }),
});
setShowForm(false);
setForm({ title: '', content: '', type: 'system', target: 'all' });
loadNotifications();
}
async function deleteNotification(id: number) {
if (!confirm('确定删除?')) return;
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
loadNotifications();
}
if (loading) return <div className="p-6">...</div>;
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<button onClick={() => setShowForm(!showForm)} className="px-4 py-2 bg-brand-600 text-white rounded-lg">
{showForm ? '取消' : '发送推送'}
</button>
</div>
{showForm && (
<div className="bg-card border border-border rounded-xl p-4 mb-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-muted-foreground"> *</label>
<input type="text" value={form.title} onChange={e => setForm({...form, title: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<select value={form.type} onChange={e => setForm({...form, type: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground">
<option value="system"></option>
<option value="promo"></option>
<option value="update"></option>
</select>
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<select value={form.target} onChange={e => setForm({...form, target: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground">
<option value="all"></option>
<option value="vip"></option>
<option value="new"></option>
</select>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground"> *</label>
<textarea value={form.content} onChange={e => setForm({...form, content: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" rows={4} />
</div>
<button onClick={sendNotification} className="px-4 py-2 bg-brand-600 text-white rounded-lg"></button>
</div>
)}
<div className="space-y-3">
{notifications.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"></div>
) : notifications.map(n => (
<div key={n.id} className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{n.title}</div>
<div className="text-sm text-muted-foreground mt-1">{n.content}</div>
<div className="text-xs text-muted-foreground mt-2">
{n.type} · {n.target} · {n.sentAt ? new Date(n.sentAt).toLocaleString() : '未发送'}
</div>
</div>
<button onClick={() => deleteNotification(n.id)} className="text-red-500 hover:text-red-700"></button>
</div>
</div>
))}
</div>
</div>
);
}
+5
View File
@@ -15,6 +15,7 @@ interface Stats {
}
const links = [
{ href: '/admin/analytics', title: '数据分析', desc: '数据统计与趋势分析', color: 'from-violet-500 to-violet-600' },
{ href: '/admin/users', title: '用户管理', desc: '管理用户、查看分析', color: 'from-blue-500 to-blue-600' },
{ href: '/admin/courses', title: '课程管理', desc: '管理课程内容', color: 'from-green-500 to-green-600' },
{ href: '/admin/prompts', title: '提示词管理', desc: '审核提示词内容', color: 'from-purple-500 to-purple-600' },
@@ -23,6 +24,10 @@ const links = [
{ href: '/admin/orders', title: '订单管理', desc: '查看支付订单', color: 'from-rose-500 to-rose-600' },
{ href: '/admin/enterprise', title: '企业版管理', desc: '管理组织、成员和学习报告', color: 'from-indigo-500 to-indigo-600' },
{ href: '/admin/comments', title: '评论审核', desc: '审核社区评论', color: 'from-pink-500 to-pink-600' },
{ href: '/admin/operations/banners', title: 'Banner管理', desc: '管理首页横幅', color: 'from-emerald-500 to-emerald-600' },
{ href: '/admin/operations/notifications', title: '推送管理', desc: '系统推送通知', color: 'from-red-500 to-red-600' },
{ href: '/admin/settings/roles', title: '角色权限', desc: '管理系统角色和权限', color: 'from-amber-500 to-amber-600' },
{ href: '/admin/settings/config', title: '系统配置', desc: '站点、AI、会员配置', color: 'from-slate-500 to-slate-600' },
];
export default function AdminDashboard() {
@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
interface Config {
key: string;
value: string;
description: string;
category: string;
}
export default function ConfigPage() {
const [configs, setConfigs] = useState<Config[]>([]);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState('site');
const [form, setForm] = useState<Record<string, string>>({});
useEffect(() => { loadConfigs(); }, [category]);
async function loadConfigs() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/config/${category}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
const configMap: Record<string, string> = {};
(data.items || []).forEach((c: Config) => { configMap[c.key] = c.value; });
setConfigs(data.items || []);
setForm(configMap);
}
} catch {}
setLoading(false);
}
async function saveConfig(key: string) {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/config/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ value: form[key] }),
});
alert('保存成功');
}
const categories = [
{ id: 'site', name: '站点设置' },
{ id: 'ai', name: 'AI 配置' },
{ id: 'member', name: '会员设置' },
];
const fields: Record<string, { key: string; label: string; type: string; placeholder: string }[]> = {
site: [
{ key: 'site_name', label: '网站名称', type: 'text', placeholder: '宇之然 AI' },
{ key: 'site_logo', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
{ key: 'icp_number', label: '备案号', type: 'text', placeholder: '京ICP备...' },
{ key: 'contact_email', label: '联系邮箱', type: 'email', placeholder: 'admin@example.com' },
],
ai: [
{ key: 'default_model', label: '默认模型', type: 'text', placeholder: 'general' },
{ key: 'available_models', label: '可用模型(逗号分隔)', type: 'text', placeholder: 'general,deepseek-v4-flash' },
{ key: 'daily_quota_free', label: '免费用户日配额', type: 'number', placeholder: '10' },
],
member: [
{ key: 'price_monthly', label: '月卡价格(元)', type: 'number', placeholder: '29.9' },
{ key: 'price_yearly', label: '年卡价格(元)', type: 'number', placeholder: '199' },
{ key: 'quota_monthly', label: '月卡日配额', type: 'number', placeholder: '100' },
{ key: 'quota_yearly', label: '年卡日配额', type: 'number', placeholder: '200' },
],
};
if (loading) return <div className="p-6">...</div>;
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground">AI</p>
</div>
<div className="flex gap-4 mb-6">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => setCategory(cat.id)}
className={`px-4 py-2 rounded-lg ${category === cat.id ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
>
{cat.name}
</button>
))}
</div>
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
{(fields[category] || []).map(field => (
<div key={field.key} className="grid grid-cols-3 gap-4 items-center">
<label className="text-sm text-muted-foreground">{field.label}</label>
<div className="col-span-2 flex gap-2">
<input
type={field.type}
value={form[field.key] || ''}
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
placeholder={field.placeholder}
className="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground"
/>
<button onClick={() => saveConfig(field.key)} className="px-4 py-2 bg-brand-600 text-white rounded-lg"></button>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,236 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
interface Role {
id: string;
name: string;
description: string;
permissions: string[];
status: string;
}
interface Permission {
key: string;
name: string;
category: string;
}
export default function SettingsRolesPage() {
const router = useRouter();
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'roles' | 'admins'>('roles');
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const headers = { Authorization: `Bearer ${token}` };
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const [rolesRes, permsRes] = await Promise.all([
fetch(`${base}/api/v1/admin/settings/roles`, { headers }),
fetch(`${base}/api/v1/admin/settings/permissions`, { headers }),
]);
if (rolesRes.ok) {
const data = await rolesRes.json();
setRoles(data.items || []);
}
if (permsRes.ok) {
const data = await permsRes.json();
setPermissions(data.items || []);
}
} catch {}
setLoading(false);
}
async function createRole() {
const name = prompt('请输入角色名称:');
if (!name) return;
const desc = prompt('请输入角色描述:') || '';
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ name, description: desc }),
});
loadData();
}
async function deleteRole(id: string) {
if (!confirm('确定要删除这个角色吗?')) return;
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/roles/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
loadData();
}
const categories = [...new Set(permissions.map(p => p.category))];
if (loading) {
return <div className="p-6">...</div>;
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<button
onClick={createRole}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
</button>
</div>
<div className="flex gap-4 mb-6">
<button
onClick={() => setTab('roles')}
className={`px-4 py-2 rounded-lg ${tab === 'roles' ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
>
</button>
<button
onClick={() => setTab('admins')}
className={`px-4 py-2 rounded-lg ${tab === 'admins' ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
>
</button>
</div>
{tab === 'roles' ? (
<div className="space-y-4">
{roles.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"></div>
) : (
roles.map(role => (
<div key={role.id} className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{role.name}</div>
<div className="text-sm text-muted-foreground">{role.description || '暂无描述'}</div>
<div className="flex flex-wrap gap-1 mt-2">
{(role.permissions || []).map((p: string) => (
<span key={p} className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
{permissions.find(perm => perm.key === p)?.name || p}
</span>
))}
</div>
</div>
<button
onClick={() => deleteRole(role.id)}
className="text-red-500 hover:text-red-700 text-sm"
>
</button>
</div>
</div>
))
)}
</div>
) : (
<AdminsList />
)}
</div>
);
}
function AdminsList() {
const [admins, setAdmins] = useState<any[]>([]);
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const headers = { Authorization: `Bearer ${token}` };
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const [adminsRes, rolesRes] = await Promise.all([
fetch(`${base}/api/v1/admin/settings/admins`, { headers }),
fetch(`${base}/api/v1/admin/settings/roles`, { headers }),
]);
if (adminsRes.ok) {
const data = await adminsRes.json();
setAdmins(data.items || []);
}
if (rolesRes.ok) {
const data = await rolesRes.json();
setRoles(data.items || []);
}
} catch {}
setLoading(false);
}
async function createAdmin() {
const username = prompt('请输入管理员用户名:');
if (!username) return;
const password = prompt('请输入密码:');
if (!password) return;
const nickname = prompt('请输入昵称(可选):') || '';
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username, password, nickname }),
});
loadData();
}
if (loading) return <div>...</div>;
return (
<div>
<button
onClick={createAdmin}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 mb-4"
>
</button>
<div className="space-y-3">
{admins.map(admin => (
<div key={admin.id} className="bg-card border border-border rounded-xl p-4 flex items-center justify-between">
<div>
<div className="font-medium text-foreground">{admin.username}</div>
<div className="text-sm text-muted-foreground">{admin.nickname || '暂无昵称'} · {admin.role?.name || '未分配角色'}</div>
</div>
<button
onClick={async () => {
if (!confirm('确定要禁用这个管理员吗?')) return;
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/admins/${admin.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status: 'DISABLED' }),
});
loadData();
}}
className="text-red-500 hover:text-red-700 text-sm"
>
</button>
</div>
))}
</div>
</div>
);
}
+160 -104
View File
@@ -2,33 +2,34 @@
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
interface User {
id: number;
phone: string;
nickname: string;
email?: string;
phone?: string;
email: string;
status: string;
memberPlan: string;
createdAt: string;
}
export default function AdminUsers() {
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ nickname: '', phone: '', email: '', password: '' });
const [editId, setEditId] = useState<number | null>(null);
useEffect(() => {
loadUsers();
}, []);
useEffect(() => { loadUsers(); }, [search]);
async function loadUsers() {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users`, {
const params = search ? `?search=${encodeURIComponent(search)}` : '';
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setUsers(data.items || []);
@@ -37,104 +38,159 @@ export default function AdminUsers() {
setLoading(false);
}
async function toggleStatus(userId: number, currentStatus: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${userId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: currentStatus === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' }),
});
loadUsers();
} catch {}
async function createUser() {
if (!form.phone || !form.password) return alert('手机号和密码必填');
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
setShowForm(false);
setForm({ nickname: '', phone: '', email: '', password: '' });
loadUsers();
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
);
async function updateUser() {
if (!editId) return;
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ nickname: form.nickname, email: form.email }),
});
setEditId(null);
setShowForm(false);
setForm({ nickname: '', phone: '', email: '', password: '' });
loadUsers();
}
async function deleteUser(id: number) {
if (!confirm('确定删除该用户?')) return;
const token = localStorage.getItem('adminToken');
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
loadUsers();
}
async function toggleStatus(id: number, currentStatus: string) {
const token = localStorage.getItem('adminToken');
const newStatus = currentStatus === 'ACTIVE' ? 'BANNED' : 'ACTIVE';
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status: newStatus }),
});
loadUsers();
}
function openEdit(user: User) {
setEditId(user.id);
setForm({ nickname: user.nickname || '', phone: user.phone || '', email: user.email || '', password: '' });
setShowForm(true);
}
if (loading) return <div className="p-6">...</div>;
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map(user => (
<tr key={user.id} className="hover:bg-accent/50">
<td className="px-6 py-4 text-sm text-foreground">{user.id}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{user.nickname || '未设置'}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-muted-foreground">{user.email || user.phone || '-'}</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
user.status === 'ACTIVE' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{user.status}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded-full ${
user.memberPlan === 'YEARLY' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' :
user.memberPlan === 'MONTHLY' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' :
'bg-muted text-muted-foreground'
}`}>
{user.memberPlan || 'NONE'}
</span>
</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<button
onClick={() => toggleStatus(user.id, user.status)}
className={`text-xs px-3 py-1 rounded transition-colors ${
user.status === 'ACTIVE'
? 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
}`}
>
{user.status === 'ACTIVE' ? '禁用' : '启用'}
</button>
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
</div>
)}
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<button onClick={() => { setEditId(null); setForm({ nickname: '', phone: '', email: '', password: '' }); setShowForm(true); }}
className="px-4 py-2 bg-brand-600 text-white rounded-lg">
</button>
</div>
</>
);
}
<div className="mb-4">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder="搜索用户名或手机号..."
className="px-4 py-2 border border-border rounded-lg bg-background text-foreground w-64" />
</div>
{showForm && (
<div className="bg-card border border-border rounded-xl p-4 mb-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-muted-foreground"> {editId ? '(不可修改)' : '*'}</label>
<input type="text" value={form.phone} onChange={e => setForm({...form, phone: e.target.value})}
disabled={!!editId}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground disabled:opacity-50" />
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<input type="text" value={form.nickname} onChange={e => setForm({...form, nickname: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<input type="email" value={form.email} onChange={e => setForm({...form, email: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
{!editId && (
<div>
<label className="text-sm text-muted-foreground"> *</label>
<input type="password" value={form.password} onChange={e => setForm({...form, password: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" />
</div>
)}
</div>
<div className="flex gap-2">
<button onClick={editId ? updateUser : createUser} className="px-4 py-2 bg-brand-600 text-white rounded-lg">
{editId ? '保存修改' : '创建用户'}
</button>
<button onClick={() => { setShowForm(false); setEditId(null); }} className="px-4 py-2 bg-muted text-muted-foreground rounded-lg">
</button>
</div>
</div>
)}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm text-muted-foreground">ID</th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id} className="border-t border-border">
<td className="px-4 py-3 text-sm">{user.id}</td>
<td className="px-4 py-3 text-sm">{user.phone || '-'}</td>
<td className="px-4 py-3 text-sm">{user.nickname || '-'}</td>
<td className="px-4 py-3 text-sm">{user.email || '-'}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full ${user.status === 'ACTIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{user.status === 'ACTIVE' ? '正常' : '禁用'}
</span>
</td>
<td className="px-4 py-3 text-sm">{user.memberPlan || 'FREE'}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{user.createdAt?.slice(0, 10)}</td>
<td className="px-4 py-3">
<button onClick={() => openEdit(user)} className="text-brand-600 hover:underline text-sm mr-3"></button>
<button onClick={() => toggleStatus(user.id, user.status)} className="text-orange-600 hover:underline text-sm mr-3">
{user.status === 'ACTIVE' ? '禁用' : '启用'}
</button>
<button onClick={() => deleteUser(user.id)} className="text-red-500 hover:underline text-sm"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+10 -4
View File
@@ -1,24 +1,30 @@
'use client'
import { usePathname } from 'next/navigation'
import { I18nProvider } from '@/i18n'
import { ThemeProvider } from '@/components/providers/theme-provider'
import { AuthProvider } from '@/lib/auth-context'
import { Toaster } from '@/components/ui/sonner'
import { Header } from '@/components/layout/header'
import { Footer } from '@/components/layout/footer'
import { AIAssistant } from '@/components/ai-assistant'
import type { ReactNode } from 'react'
export function RootLayoutClient({ children }: { children: ReactNode }) {
const pathname = usePathname()
const isAdminPage = pathname?.startsWith('/admin')
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AuthProvider>
<I18nProvider>
<Header />
<main className="flex-1">{children}</main>
<Footer />
{!isAdminPage && <Header />}
<main className={isAdminPage ? 'flex-1' : 'flex-1'}>{children}</main>
{!isAdminPage && <Footer />}
{!isAdminPage && <AIAssistant />}
<Toaster richColors closeButton />
</I18nProvider>
</AuthProvider>
</ThemeProvider>
)
}
}
+3 -2
View File
@@ -147,8 +147,9 @@ function SandboxPage() {
let reply = '';
if (tk) {
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
const systemPrompt = curScene?.systemPrompt || '你是一个智能 AI 助手';
const apiMessages = [
{ role: 'system', content: curScene.systemPrompt },
{ role: 'system', content: systemPrompt },
...messages,
userMsg,
].map(m => ({ role: m.role, content: m.content }));
@@ -433,7 +434,7 @@ function SandboxPage() {
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
{isNewChat && (
<div className="flex flex-wrap gap-2 mb-4">
{currentScene.starters.map((q, i) => (
{currentScene?.starters?.map((q, i) => (
<button key={i} onClick={() => setInput(q)}
className="px-3 py-1.5 text-xs bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground transition-colors">
{q}
@@ -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>
);
}
+5
View File
@@ -195,6 +195,11 @@ const en: Translations = {
saveTagsPlaceholder: 'Separate by commas, e.g. coding,Python,debug',
saving: 'Saving...',
},
assistant: {
title: 'AI Assistant',
greeting: 'Hi! I\'m the Yuzhiran AI assistant. I can help you learn about and use this site. Try asking:',
placeholder: 'Type your question...',
},
}
export default en
+5
View File
@@ -193,6 +193,11 @@ const zh = {
saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试',
saving: '保存中...',
},
assistant: {
title: 'AI 助手',
greeting: '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:',
placeholder: '输入你的问题...',
},
}
export type Translations = typeof zh
+87
View File
@@ -0,0 +1,87 @@
'use client'
import { useRouter } from 'next/navigation'
import type { AssistantAction } from './assistant-context'
let routerInstance: ReturnType<typeof useRouter> | null = null
export function setRouter(router: ReturnType<typeof useRouter>) {
routerInstance = router
}
export async function executeAction(action: AssistantAction): Promise<{ success: boolean; message: string }> {
if (!routerInstance) {
return { success: false, message: 'Router not initialized' }
}
const { action: actionType, description, path, model, prompt, skillId, temperature, top_p, max_tokens } = action
switch (actionType) {
case 'navigate':
if (path) {
routerInstance.push(path)
return { success: true, message: `正在导航到:${description || path}` }
}
return { success: false, message: '导航路径无效' }
case 'setModel':
if (model) {
sessionStorage.setItem('assistant-set-model', model)
routerInstance.push('/sandbox')
setTimeout(() => {
window.dispatchEvent(new CustomEvent('assistant-set-model', { detail: model }))
}, 500)
return { success: true, message: `正在切换模型:${model}` }
}
return { success: false, message: '模型名称无效' }
case 'startChat':
if (prompt) {
sessionStorage.setItem('assistant-start-chat', prompt)
routerInstance.push('/sandbox')
setTimeout(() => {
window.dispatchEvent(new CustomEvent('assistant-start-chat', { detail: prompt }))
}, 500)
return { success: true, message: `正在开始对话:${prompt.slice(0, 20)}...` }
}
return { success: false, message: '对话内容无效' }
case 'openSkill':
if (skillId) {
routerInstance.push(`/skills/${skillId}`)
return { success: true, message: `正在打开技能:${description || skillId}` }
}
return { success: false, message: '技能 ID 无效' }
case 'setParameter':
const params: Record<string, string> = {}
if (temperature !== undefined) params.temperature = String(temperature)
if (top_p !== undefined) params.top_p = String(top_p)
if (max_tokens !== undefined) params.max_tokens = String(max_tokens)
if (Object.keys(params).length > 0) {
sessionStorage.setItem('assistant-set-params', JSON.stringify(params))
routerInstance.push('/sandbox')
setTimeout(() => {
window.dispatchEvent(new CustomEvent('assistant-set-params', { detail: params }))
}, 500)
return { success: true, message: `正在设置参数:${description}` }
}
return { success: false, message: '参数无效' }
default:
return { success: false, message: '未知操作类型' }
}
}
export const MODEL_OPTIONS = [
{ value: 'general', label: '通用模式' },
{ value: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
{ value: 'opencode', label: 'OpenCode Go' },
{ value: 'meituan/longcat-flash-lite', label: '长颈鹿 Flash' },
]
export const SKILL_IDS = [
'general-chat', 'coding', 'writing', 'english', 'data-analysis',
'ppt-design', 'image-prompt', 'career-guide'
]
+112
View File
@@ -0,0 +1,112 @@
export interface AssistantAction {
action: 'navigate' | 'setModel' | 'startChat' | 'openSkill' | 'setParameter'
path?: string
model?: string
prompt?: string
skillId?: string
temperature?: number
top_p?: number
max_tokens?: number
description: string
}
export interface AssistantContext {
page: string
systemPrompt: string
starters: string[]
}
const ACTION_FORMAT = `\n\n【特殊指令】当你可以直接帮助用户完成操作时,请返回 JSON 格式的指令(不要包含其他内容):\n- {"action":"navigate","path":"/sandbox","description":"导航到页面"}\n- {"action":"setModel","model":"deepseek-v4-flash","description":"切换模型"}\n- {"action":"startChat","prompt":"用户问题","description":"开始新对话"}\n- {"action":"openSkill","skillId":"coding","description":"打开技能详情"}\n- {"action":"setParameter","temperature":0.9,"description":"设置参数"}\n只有当用户请求的操作可以自动化时才返回指令,否则只返回文字回答。`
const contexts: Record<string, AssistantContext> = {
'/': {
page: 'home',
systemPrompt: '你是宇之然 AI 学习与实践平台的智能助手。平台提供 AI 沙盒、技能库、提示词工坊、模型百科、课程学习等功能。帮助用户了解平台功能、推荐学习路径。' + ACTION_FORMAT,
starters: ['宇之然 AI 能做什么?', '如何开始学习 AI', '有哪些功能可以使用?', '推荐一个学习路线'],
},
'/sandbox': {
page: 'sandbox',
systemPrompt: '你是宇之然 AI 沙盒的智能助手。AI 沙盒是在线对话实践环境,支持多种模型切换、高级参数调节、历史会话管理。帮助用户了解如何使用沙盒、调试问题。' + ACTION_FORMAT,
starters: ['如何切换模型?', '高级参数怎么调?', '如何查看历史记录?', '对话次数限制是多少?'],
},
'/skills': {
page: 'skills',
systemPrompt: '你是宇之然技能库的智能助手。技能库提供可组合的 AI 学习技能模块,每个技能包含系统提示词、练习任务和 starter 问题。帮助用户选择合适的技能。' + ACTION_FORMAT,
starters: ['有哪些技能可以学习?', '如何选择适合我的技能?', '技能难度怎么区分?', '如何开始练习一个技能?'],
},
'/learning': {
page: 'learning',
systemPrompt: '你是宇之然学习路径的智能助手。平台提供学情分析(知识领域掌握度)和分阶段学习路径(从入门到精通)。帮助用户制定学习计划、分析薄弱环节。' + ACTION_FORMAT,
starters: ['学情分析怎么用?', '学习路径有哪些阶段?', '如何查看薄弱环节?', '推荐学习内容是什么?'],
},
'/my': {
page: 'my',
systemPrompt: '你是宇之然个人中心的智能助手。个人中心管理会员订阅、查看订单、编辑个人信息。帮助用户管理账户和订阅。' + ACTION_FORMAT,
starters: ['如何开通会员?', '会员有哪些权益?', '如何查看订单记录?', '免费和付费有什么区别?'],
},
'/models': {
page: 'models',
systemPrompt: '你是宇之然模型百科的智能助手。模型百科收录主流 AI 模型信息,包括能力对比、适用场景。帮助用户了解不同模型的差异。' + ACTION_FORMAT,
starters: ['有哪些模型可以参考?', '如何选择合适的模型?', '模型的参数代表什么?', '模型能力怎么对比?'],
},
'/prompts': {
page: 'prompts',
systemPrompt: '你是宇之然提示词工坊的智能助手。提示词工坊提供提示词编写、测试、优化的工具,支持变量设置、角色设定、保存到提示词库。帮助用户学习提示词工程。' + ACTION_FORMAT,
starters: ['如何编写一个好的提示词?', '什么是角色设定?', '如何测试提示词效果?', '提示词变量怎么用?'],
},
'/courses': {
page: 'courses',
systemPrompt: '你是宇之然课程专题的智能助手。平台提供 AI 通识、提示词工程、智能体教程等专题课程。帮助用户选择课程、规划学习。' + ACTION_FORMAT,
starters: ['有哪些课程可以学习?', '如何选择适合我的课程?', '课程从哪开始学?', '课程需要什么基础?'],
},
'/community': {
page: 'community',
systemPrompt: '你是宇之然社区的智能助手。社区是用户交流分享的平台,可以发布帖子、评论互动。帮助用户了解社区规则、找到感兴趣的话题。' + ACTION_FORMAT,
starters: ['社区有哪些板块?', '如何发帖?', '如何找到感兴趣的内容?', '社区使用有什么规则?'],
},
'/contents': {
page: 'contents',
systemPrompt: '你是宇之然文章频道的智能助手。文章频道提供 AI 相关技术文章、教程和资讯。帮助用户找到想读的内容。' + ACTION_FORMAT,
starters: ['有哪些类型的文章?', '推荐几篇热门文章', '最近有哪些新文章?', '如何搜索文章?'],
},
'/tools': {
page: 'tools',
systemPrompt: '你是宇之然 AI 工具集的智能助手。工具集收录各类 AI 工具推荐和使用指南。帮助用户找到合适的工具。' + ACTION_FORMAT,
starters: ['有哪些 AI 工具推荐?', '如何选择适合我的工具?', '有哪些免费工具?', '工具怎么分类?'],
},
'/compare': {
page: 'compare',
systemPrompt: '你是宇之然对比实验室的智能助手。对比实验室支持同题对比不同 AI 模型的回答效果。帮助用户设置对比、分析结果。' + ACTION_FORMAT,
starters: ['对比实验室怎么用?', '如何添加对比模型?', '对比结果怎么看?', '支持哪些模型对比?'],
},
'/code': {
page: 'code',
systemPrompt: '你是宇之然代码沙盒的智能助手。代码沙盒是在线代码运行环境,支持 React、图表、3D 等模板,可实时预览效果。帮助用户编写和调试代码。' + ACTION_FORMAT,
starters: ['代码沙盒怎么用?', '支持哪些模板?', '如何查看控制台输出?', '可以运行什么类型的代码?'],
},
}
export function getAssistantContext(pathname: string): AssistantContext {
const sorted = Object.keys(contexts).sort((a, b) => b.length - a.length)
for (const key of sorted) {
if (pathname.startsWith(key)) {
return contexts[key]
}
}
return contexts['/']
}
export function parseActionCommand(text: string): AssistantAction | 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 && ['navigate', 'setModel', 'startChat', 'openSkill', 'setParameter'].includes(parsed.action)) {
return parsed as AssistantAction
}
} catch {
return null
}
return null
}