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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user