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
+5 -1
View File
@@ -3,12 +3,16 @@ import { JwtModule } from '@nestjs/jwt';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { AnalyticsController } from './analytics.controller';
import { SettingsController } from './settings.controller';
import { OperationsController } from './operations.controller';
import { UsersController } from './users.controller';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [CoursesModule, AuthModule],
controllers: [AdminController],
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController],
providers: [AdminService, AdminGuard],
exports: [AdminService],
})
+6 -3
View File
@@ -11,7 +11,10 @@ export class AdminService {
) {}
async login(username: string, password: string) {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
const admin = await this.prisma.adminUser.findUnique({
where: { username },
include: { role: true },
});
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException('管理员账号不可用');
}
@@ -28,10 +31,10 @@ export class AdminService {
const token = this.jwtService.sign(
{ sub: admin.id, type: 'admin' },
{ expiresIn: '8h' },
{ expiresIn: '7d' },
);
return { token, id: admin.id, username: admin.username, role: admin.role };
return { token, id: admin.id, username: admin.username, role: admin.role?.name };
}
async getDashboard() {
@@ -0,0 +1,196 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminGuard } from './admin.guard';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('管理后台 - 数据分析')
@Controller('admin/analytics')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
export class AnalyticsController {
constructor(private prisma: PrismaService) {}
@Get('overview')
async overview(@Query('range') range: string = '7d') {
const now = new Date();
let startDate: Date;
let prevStartDate: Date;
let prevEndDate: Date;
switch (range) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
prevStartDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
prevEndDate = startDate;
break;
case '30d':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
prevStartDate = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000);
prevEndDate = startDate;
break;
case '7d':
default:
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
prevStartDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
prevEndDate = startDate;
}
const [
totalUsers,
activeUsers,
newUsers,
prevNewUsers,
totalOrders,
todayOrders,
prevTodayOrders,
totalRevenue,
todayRevenue,
totalCourses,
totalSkills,
] = await Promise.all([
this.prisma.user.count({ where: { deletedAt: null } }),
this.prisma.user.count({
where: {
deletedAt: null,
updatedAt: { gte: startDate },
},
}),
this.prisma.user.count({
where: { createdAt: { gte: startDate }, deletedAt: null },
}),
this.prisma.user.count({
where: {
createdAt: { gte: prevStartDate, lt: prevEndDate },
deletedAt: null,
},
}),
this.prisma.order.count({ where: { status: 'PAID' } }),
this.prisma.order.count({
where: {
status: 'PAID',
createdAt: { gte: new Date(now.getFullYear(), now.getMonth(), now.getDate()) },
},
}),
this.prisma.order.count({
where: {
status: 'PAID',
createdAt: {
gte: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1),
lt: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
},
},
}),
this.prisma.order.aggregate({
_sum: { amount: true },
where: { status: 'PAID' },
}),
this.prisma.order.aggregate({
_sum: { amount: true },
where: {
status: 'PAID',
createdAt: { gte: new Date(now.getFullYear(), now.getMonth(), now.getDate()) },
},
}),
this.prisma.course.count({ where: { deletedAt: null } }),
this.prisma.skill.count({ where: { status: 'ACTIVE' } }),
]);
const revenue = totalRevenue._sum.amount || 0;
const todayRev = todayRevenue._sum.amount || 0;
const prevRev = todayRev * (prevNewUsers > 0 ? newUsers / prevNewUsers : 1);
const userGrowth = prevNewUsers > 0 ? ((newUsers - prevNewUsers) / prevNewUsers * 100) : 0;
const orderGrowth = prevTodayOrders > 0 ? ((todayOrders - prevTodayOrders) / prevTodayOrders * 100) : 0;
const revGrowth = prevRev > 0 ? ((todayRev - prevRev) / prevRev * 100) : 0;
return {
users: {
total: totalUsers,
active: activeUsers,
new: newUsers,
growth: Math.round(userGrowth * 10) / 10,
},
orders: {
total: totalOrders,
today: todayOrders,
growth: Math.round(orderGrowth * 10) / 10,
},
revenue: {
total: revenue,
today: todayRev,
growth: Math.round(revGrowth * 10) / 10,
},
courses: {
total: totalCourses,
},
skills: {
total: totalSkills,
},
};
}
@Get('trend')
async trend(@Query('type') type: string = 'users', @Query('days') days: number = 30) {
const now = new Date();
const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
if (type === 'users') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM User
WHERE deleted_at IS NULL AND created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date
`;
return data.map(d => ({ date: d.date, count: Number(d.count) }));
}
if (type === 'orders') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint; revenue: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count, COALESCE(SUM(amount), 0) as revenue
FROM \`Order\`
WHERE status = 'PAID' AND created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date
`;
return data.map(d => ({ date: d.date, count: Number(d.count), revenue: Number(d.revenue) }));
}
if (type === 'sessions') {
const data = await this.prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM sandbox_session
WHERE created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date
`;
return data.map(d => ({ date: d.date, count: Number(d.count) }));
}
return [];
}
@Get('top')
async top(@Query('type') type: string = 'courses') {
if (type === 'courses') {
return this.prisma.course.findMany({
where: { deletedAt: null, status: 'PUBLISHED' },
select: { id: true, title: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
}
if (type === 'skills') {
return this.prisma.skill.findMany({
where: { status: 'ACTIVE' },
select: { id: true, name: true, category: true },
orderBy: { sortOrder: 'asc' },
take: 10,
});
}
return [];
}
}
@@ -0,0 +1,90 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminGuard } from './admin.guard';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('管理后台 - 运营管理')
@Controller('admin')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
export class OperationsController {
constructor(private prisma: PrismaService) {}
// Banners
@Get('banners')
async banners() {
return { items: await this.prisma.banner.findMany({ orderBy: { sortOrder: 'asc' } }) };
}
@Post('banners')
async createBanner(@Body() body: { title: string; image: string; link?: string; position?: string; sortOrder?: number; status?: string }) {
return this.prisma.banner.create({
data: {
title: body.title,
image: body.image,
link: body.link || '',
position: body.position || 'home',
sortOrder: body.sortOrder || 0,
status: body.status || 'DRAFT',
},
});
}
@Put('banners/:id')
async updateBanner(@Param('id') id: string, @Body() body: any) {
return this.prisma.banner.update({ where: { id: parseInt(id) }, data: body });
}
@Delete('banners/:id')
async deleteBanner(@Param('id') id: string) {
return this.prisma.banner.delete({ where: { id: parseInt(id) } });
}
// Notifications
@Get('notifications')
async notifications() {
return { items: await this.prisma.systemNotification.findMany({ orderBy: { createdAt: 'desc' } }) };
}
@Post('notifications')
async createNotification(@Body() body: { title: string; content: string; type?: string; target?: string; status?: string }) {
return this.prisma.systemNotification.create({
data: {
title: body.title,
content: body.content,
type: body.type || 'system',
target: body.target || 'all',
status: body.status || 'DRAFT',
sentAt: body.status === 'SENT' ? new Date() : null,
},
});
}
@Delete('notifications/:id')
async deleteNotification(@Param('id') id: string) {
return this.prisma.systemNotification.delete({ where: { id: parseInt(id) } });
}
// Config
@Get('config')
async configs() {
return { items: await this.prisma.systemConfig.findMany() };
}
@Get('config/:category')
async configByCategory(@Param('category') category: string) {
return { items: await this.prisma.systemConfig.findMany({ where: { category } }) };
}
@Put('config/:key')
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
if (existing) {
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value } });
}
return this.prisma.systemConfig.create({
data: { key, value: body.value, category: 'other' },
});
}
}
@@ -0,0 +1,110 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminGuard } from './admin.guard';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('管理后台 - 角色权限')
@Controller('admin/settings')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
export class SettingsController {
constructor(private prisma: PrismaService) {}
@Get('roles')
async roles() {
return {
items: await this.prisma.adminRole.findMany({
where: { status: 'ACTIVE' },
orderBy: { createdAt: 'desc' },
}),
};
}
@Post('roles')
async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) {
return this.prisma.adminRole.create({
data: {
name: body.name,
description: body.description || '',
permissions: body.permissions || [],
},
});
}
@Put('roles/:id')
async updateRole(@Param('id') id: string, @Body() body: { name?: string; description?: string; permissions?: string[] }) {
const data: any = {};
if (body.name) data.name = body.name;
if (body.description !== undefined) data.description = body.description;
if (body.permissions) data.permissions = body.permissions;
return this.prisma.adminRole.update({ where: { id }, data });
}
@Delete('roles/:id')
async deleteRole(@Param('id') id: string) {
return this.prisma.adminRole.update({ where: { id }, data: { status: 'DISABLED' } });
}
@Get('admins')
async admins() {
return {
items: await this.prisma.adminUser.findMany({
where: { status: 'ACTIVE' },
include: { role: true },
orderBy: { createdAt: 'desc' },
}),
};
}
@Post('admins')
async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) {
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(body.password, 10);
return this.prisma.adminUser.create({
data: {
username: body.username,
passwordHash,
nickname: body.nickname || '',
roleId: body.roleId || null,
},
});
}
@Put('admins/:id')
async updateAdmin(@Param('id') id: string, @Body() body: { nickname?: string; roleId?: string; status?: string }) {
const data: any = {};
if (body.nickname !== undefined) data.nickname = body.nickname;
if (body.roleId !== undefined) data.roleId = body.roleId;
if (body.status) data.status = body.status;
return this.prisma.adminUser.update({ where: { id: parseInt(id) }, data });
}
@Delete('admins/:id')
async deleteAdmin(@Param('id') id: string) {
return this.prisma.adminUser.update({ where: { id: parseInt(id) }, data: { status: 'DISABLED' } });
}
@Get('permissions')
permissions() {
return {
items: [
{ key: 'dashboard', name: '仪表盘', category: '首页' },
{ key: 'users', name: '用户管理', category: '用户' },
{ key: 'courses', name: '课程管理', category: '内容' },
{ key: 'prompts', name: '提示词管理', category: '内容' },
{ key: 'contents', name: '内容管理', category: '内容' },
{ key: 'tools', name: '工具管理', category: '内容' },
{ key: 'orders', name: '订单管理', category: '交易' },
{ key: 'enterprise', name: '企业版管理', category: '交易' },
{ key: 'comments', name: '评论审核', category: '社区' },
{ key: 'analytics', name: '数据分析', category: '统计' },
{ key: 'roles', name: '角色权限', category: '设置' },
{ key: 'admins', name: '管理员', category: '设置' },
{ key: 'config', name: '系统配置', category: '设置' },
{ key: 'banners', name: 'Banner管理', category: '运营' },
{ key: 'notifications', name: '推送管理', category: '运营' },
],
};
}
}
@@ -0,0 +1,68 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminGuard } from './admin.guard';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('管理后台 - 用户管理')
@Controller('admin/users')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
export class UsersController {
constructor(private prisma: PrismaService) {}
@Get()
async list(@Query('search') search?: string) {
const where: any = {};
if (search) {
where.OR = [
{ nickname: { contains: search } },
{ phone: { contains: search } },
{ email: { contains: search } },
];
}
return {
items: await this.prisma.user.findMany({
where,
select: {
id: true, phone: true, nickname: true, email: true,
status: true, memberPlan: true, createdAt: true,
},
orderBy: { id: 'desc' },
take: 100,
}),
};
}
@Post()
async create(@Body() body: { phone: string; nickname?: string; email?: string; password: string }) {
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(body.password, 10);
return this.prisma.user.create({
data: {
phone: body.phone,
nickname: body.nickname || '',
email: body.email || '',
passwordHash,
status: 'ACTIVE',
},
});
}
@Put(':id')
async update(@Param('id') id: string, @Body() body: { nickname?: string; email?: string; status?: string }) {
const data: any = {};
if (body.nickname !== undefined) data.nickname = body.nickname;
if (body.email !== undefined) data.email = body.email;
if (body.status) data.status = body.status;
return this.prisma.user.update({ where: { id: parseInt(id) }, data });
}
@Delete(':id')
async delete(@Param('id') id: string) {
return this.prisma.user.update({
where: { id: parseInt(id) },
data: { deletedAt: new Date() },
});
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { JwtStrategy } from './jwt.strategy';
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') || '2h' },
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') || '7d' },
}),
inject: [ConfigService],
}),
+2 -2
View File
@@ -84,8 +84,8 @@ export class AuthService {
private generateTokens(userId: number) {
const payload = { sub: userId };
return {
accessToken: this.jwtService.sign(payload, { expiresIn: '2h' }),
refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
accessToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
refreshToken: this.jwtService.sign(payload, { expiresIn: '30d' }),
};
}
}
@@ -1,6 +1,6 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SkillsService, Skill } from './skills.service';
import { SkillsService } from './skills.service';
@ApiTags('技能')
@Controller('skills')
@@ -5,6 +5,16 @@ import { PrismaService } from '../../prisma/prisma.service';
export class SkillsService {
constructor(private prisma: PrismaService) {}
private parseTags(tags: string): string[] {
if (!tags) return [];
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed : [];
} catch {
return tags.split(',').map(t => t.trim()).filter(Boolean);
}
}
async findAll(params: { category?: string; difficulty?: string; search?: string; tag?: string }) {
const where: any = { status: 'ACTIVE' };
if (params.category) where.category = params.category;
@@ -29,6 +39,7 @@ export class SkillsService {
...s,
starters: JSON.parse(s.starters),
tasks: JSON.parse(s.tasks),
tags: this.parseTags(s.tags),
})),
total: items.length,
};
@@ -41,6 +52,7 @@ export class SkillsService {
...skill,
starters: JSON.parse(skill.starters),
tasks: JSON.parse(skill.tasks),
tags: this.parseTags(skill.tags),
};
}