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:
@@ -337,12 +337,26 @@ model Subscription {
|
|||||||
@@map("subscriptions")
|
@@map("subscriptions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AdminRole {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
permissions Json @default("[]")
|
||||||
|
status String @default("ACTIVE")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
admins AdminUser[]
|
||||||
|
|
||||||
|
@@map("admin_roles")
|
||||||
|
}
|
||||||
|
|
||||||
model AdminUser {
|
model AdminUser {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
nickname String?
|
nickname String?
|
||||||
role String @default("editor")
|
roleId String?
|
||||||
|
role AdminRole? @relation(fields: [roleId], references: [id])
|
||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -529,3 +543,48 @@ model Notification {
|
|||||||
@@index([userId, isRead])
|
@@index([userId, isRead])
|
||||||
@@map("notifications")
|
@@map("notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Banner {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
image String
|
||||||
|
link String?
|
||||||
|
position String @default("home")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
status String @default("DRAFT")
|
||||||
|
startAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("banners")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemNotification {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type String @default("system")
|
||||||
|
target String @default("all")
|
||||||
|
targetIds String?
|
||||||
|
status String @default("DRAFT")
|
||||||
|
scheduledAt DateTime?
|
||||||
|
sentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("system_notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
category String
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
description String?
|
||||||
|
status String @default("ACTIVE")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("system_configs")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { AdminGuard } from './admin.guard';
|
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 { CoursesModule } from '../courses/courses.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoursesModule, AuthModule],
|
imports: [CoursesModule, AuthModule],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController],
|
||||||
providers: [AdminService, AdminGuard],
|
providers: [AdminService, AdminGuard],
|
||||||
exports: [AdminService],
|
exports: [AdminService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export class AdminService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(username: string, password: string) {
|
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') {
|
if (!admin || admin.status !== 'ACTIVE') {
|
||||||
throw new UnauthorizedException('管理员账号不可用');
|
throw new UnauthorizedException('管理员账号不可用');
|
||||||
}
|
}
|
||||||
@@ -28,10 +31,10 @@ export class AdminService {
|
|||||||
|
|
||||||
const token = this.jwtService.sign(
|
const token = this.jwtService.sign(
|
||||||
{ sub: admin.id, type: 'admin' },
|
{ 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() {
|
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() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
secret: config.get('JWT_SECRET'),
|
secret: config.get('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') || '2h' },
|
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') || '7d' },
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export class AuthService {
|
|||||||
private generateTokens(userId: number) {
|
private generateTokens(userId: number) {
|
||||||
const payload = { sub: userId };
|
const payload = { sub: userId };
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload, { expiresIn: '2h' }),
|
accessToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
|
||||||
refreshToken: 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 { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { SkillsService, Skill } from './skills.service';
|
import { SkillsService } from './skills.service';
|
||||||
|
|
||||||
@ApiTags('技能')
|
@ApiTags('技能')
|
||||||
@Controller('skills')
|
@Controller('skills')
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
export class SkillsService {
|
export class SkillsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
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 }) {
|
async findAll(params: { category?: string; difficulty?: string; search?: string; tag?: string }) {
|
||||||
const where: any = { status: 'ACTIVE' };
|
const where: any = { status: 'ACTIVE' };
|
||||||
if (params.category) where.category = params.category;
|
if (params.category) where.category = params.category;
|
||||||
@@ -29,6 +39,7 @@ export class SkillsService {
|
|||||||
...s,
|
...s,
|
||||||
starters: JSON.parse(s.starters),
|
starters: JSON.parse(s.starters),
|
||||||
tasks: JSON.parse(s.tasks),
|
tasks: JSON.parse(s.tasks),
|
||||||
|
tags: this.parseTags(s.tags),
|
||||||
})),
|
})),
|
||||||
total: items.length,
|
total: items.length,
|
||||||
};
|
};
|
||||||
@@ -41,6 +52,7 @@ export class SkillsService {
|
|||||||
...skill,
|
...skill,
|
||||||
starters: JSON.parse(skill.starters),
|
starters: JSON.parse(skill.starters),
|
||||||
tasks: JSON.parse(skill.tasks),
|
tasks: JSON.parse(skill.tasks),
|
||||||
|
tags: this.parseTags(skill.tags),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,215 @@
|
|||||||
|
# 管理后台系统增强方案
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
宇之然 AI 管理后台当前已有基础框架(用户、课程、提示词、内容、工具、订单、企业版、评论管理),缺少以下核心模块:
|
||||||
|
|
||||||
|
1. **数据统计分析/图表**
|
||||||
|
2. **系统设置/配置**
|
||||||
|
3. **运营管理(Banner、推送)**
|
||||||
|
4. **权限管理(角色、菜单)**
|
||||||
|
|
||||||
|
## 实施方案
|
||||||
|
|
||||||
|
### 1. 数据统计分析/图表
|
||||||
|
|
||||||
|
#### 前端实现
|
||||||
|
- 创建 `/admin/analytics` 页面
|
||||||
|
- 使用 `recharts` 或 `chart.js` 渲染图表
|
||||||
|
- 时间范围选择器(今日/本周/本月/自定义)
|
||||||
|
|
||||||
|
#### 后端 API
|
||||||
|
- `GET /api/v1/admin/analytics/overview` - 核心指标汇总
|
||||||
|
- `GET /api/v1/admin/analytics/users` - 用户增长趋势
|
||||||
|
- `GET /api/v1/admin/analytics/revenue` - 收入统计
|
||||||
|
- `GET /api/v1/admin/analytics/activities` - 用户活跃度
|
||||||
|
|
||||||
|
#### 展示指标
|
||||||
|
| 指标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 总用户数 | 累计注册用户 |
|
||||||
|
| 活跃用户 | 7日内活跃 |
|
||||||
|
| 新增用户 | 日/周/月新增 |
|
||||||
|
| 订单收入 | 日/周/月收入 |
|
||||||
|
| 订单数 | 日/周/月订单 |
|
||||||
|
| 转化率 | 付费用户占比 |
|
||||||
|
| 课程学习次数 | 累计学习次数 |
|
||||||
|
| 对话次数 | AI 沙盒对话次数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 权限管理(角色、菜单)
|
||||||
|
|
||||||
|
#### 数据库设计
|
||||||
|
```prisma
|
||||||
|
model AdminRole {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique // 角色名:super_admin, admin, editor, viewer
|
||||||
|
description String?
|
||||||
|
permissions Json // ["users:read", "users:write", "orders:read", ...]
|
||||||
|
status String @default("ACTIVE") // ACTIVE, DISABLED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
admins AdminUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
nickname String?
|
||||||
|
roleId String
|
||||||
|
role AdminRole @relation(fields: [roleId], references: [id])
|
||||||
|
status String @default("ACTIVE")
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 功能清单
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 角色列表 | 查看所有角色 |
|
||||||
|
| 创建角色 | 新建角色,设置权限 |
|
||||||
|
| 编辑角色 | 修改角色名称、权限 |
|
||||||
|
| 删除角色 | 软删除角色 |
|
||||||
|
| 管理员列表 | 查看所有管理员 |
|
||||||
|
| 创建管理员 | 新建管理员,分配角色 |
|
||||||
|
| 编辑管理员 | 修改管理员信息、角色 |
|
||||||
|
| 禁用管理员 | 禁用/启用管理员 |
|
||||||
|
|
||||||
|
#### 后端 API
|
||||||
|
- `GET /api/v1/admin/roles` - 角色列表
|
||||||
|
- `POST /api/v1/admin/roles` - 创建角色
|
||||||
|
- `PUT /api/v1/admin/roles/:id` - 更新角色
|
||||||
|
- `DELETE /api/v1/admin/roles/:id` - 删除角色
|
||||||
|
- `GET /api/v1/admin/admins` - 管理员列表
|
||||||
|
- `POST /api/v1/admin/admins` - 创建管理员
|
||||||
|
- `PUT /api/v1/admin/admins/:id` - 更新管理员
|
||||||
|
- `DELETE /api/v1/admin/admins/:id` - 删除管理员
|
||||||
|
- `GET /api/v1/admin/permissions` - 所有权限项
|
||||||
|
|
||||||
|
#### 前端页面
|
||||||
|
- `/admin/settings/roles` - 角色管理
|
||||||
|
- `/admin/settings/admins` - 管理员列表
|
||||||
|
- `/admin/settings/permissions` - 权限配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 系统设置/配置
|
||||||
|
|
||||||
|
#### 配置项分类
|
||||||
|
| 分类 | 配置项 |
|
||||||
|
|------|--------|
|
||||||
|
| 站点设置 | 网站名称、LOGO、备案号、联系方式 |
|
||||||
|
| AI 配置 | 默认模型、可用模型列表、API 密钥 |
|
||||||
|
| 会员设置 | 免费版配额、会员价格、会员权益 |
|
||||||
|
| 短信/邮件 | 验证码模板、通知模板 |
|
||||||
|
| 其他 | 维护模式、调试模式 |
|
||||||
|
|
||||||
|
#### 数据库设计
|
||||||
|
```prisma
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
category String // site, ai, member, notification, other
|
||||||
|
key String @unique // site_name, ai_models, member_prices...
|
||||||
|
value String // JSON 格式存储
|
||||||
|
description String?
|
||||||
|
status String @default("ACTIVE")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 后端 API
|
||||||
|
- `GET /api/v1/admin/config` - 获取所有配置
|
||||||
|
- `GET /api/v1/admin/config/:category` - 获取分类配置
|
||||||
|
- `PUT /api/v1/admin/config/:key` - 更新配置
|
||||||
|
- `POST /api/v1/admin/config/batch` - 批量更新
|
||||||
|
|
||||||
|
#### 前端页面
|
||||||
|
- `/admin/settings/general` - 通用设置
|
||||||
|
- `/admin/settings/ai` - AI 配置
|
||||||
|
- `/admin/settings/member` - 会员设置
|
||||||
|
- `/admin/settings/notification` - 通知设置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 运营管理
|
||||||
|
|
||||||
|
#### 4.1 Banner 管理
|
||||||
|
```prisma
|
||||||
|
model Banner {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
image String // 图片 URL
|
||||||
|
link String? // 跳转链接
|
||||||
|
position String // home, skills, courses, community...
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
status String @default("DRAFT") // DRAFT, PUBLISHED, DISABLED
|
||||||
|
startAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 推送通知
|
||||||
|
```prisma
|
||||||
|
model SystemNotification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type String // system, promo, update
|
||||||
|
target String // all, specific_users, specific_roles
|
||||||
|
targetIds String? // 用户ID列表或角色ID列表
|
||||||
|
status String @default("DRAFT")
|
||||||
|
scheduledAt DateTime? // 定时发送
|
||||||
|
sentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 后端 API
|
||||||
|
- `GET /api/v1/admin/banners` - Banner 列表
|
||||||
|
- `POST /api/v1/admin/banners` - 创建 Banner
|
||||||
|
- `PUT /api/v1/admin/banners/:id` - 更新 Banner
|
||||||
|
- `DELETE /api/v1/admin/banners/:id` - 删除 Banner
|
||||||
|
- `GET /api/v1/admin/notifications` - 推送列表
|
||||||
|
- `POST /api/v1/admin/notifications` - 发送推送
|
||||||
|
- `DELETE /api/v1/admin/notifications/:id` - 删除推送
|
||||||
|
|
||||||
|
#### 前端页面
|
||||||
|
- `/admin/operations/banners` - Banner 管理
|
||||||
|
- `/admin/operations/notifications` - 推送管理
|
||||||
|
- `/admin/operations/push` - 发送推送
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发优先级
|
||||||
|
|
||||||
|
| 优先级 | 模块 | 预计工作量 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| P0 | 数据统计分析 | 2-3 小时 |
|
||||||
|
| P1 | 权限管理 | 3-4 小时 |
|
||||||
|
| P2 | 系统设置 | 2-3 小时 |
|
||||||
|
| P3 | Banner 管理 | 1-2 小时 |
|
||||||
|
| P4 | 推送通知 | 2-3 小时 |
|
||||||
|
|
||||||
|
## 技术选型
|
||||||
|
|
||||||
|
- 图表库:`recharts` (React 友好)
|
||||||
|
- 表单:复用现有 shadcn/ui
|
||||||
|
- 权限控制:后端 Guard + 前端路由守卫
|
||||||
|
- 配置存储:数据库 + 缓存
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进度追踪
|
||||||
|
|
||||||
|
- [ ] 数据统计分析/图表
|
||||||
|
- [ ] 权限管理(角色、菜单)
|
||||||
|
- [ ] 系统设置/配置
|
||||||
|
- [ ] Banner 管理
|
||||||
|
- [ ] 推送通知
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'backend',
|
||||||
|
cwd: '/home/wlt/ai-learning-platform/backend',
|
||||||
|
script: 'node',
|
||||||
|
args: 'dist/main.js',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_restarts: 10,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'frontend',
|
||||||
|
cwd: '/home/wlt/ai-learning-platform/frontend',
|
||||||
|
script: 'npm',
|
||||||
|
args: 'run dev',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_restarts: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+333
-2
@@ -34,6 +34,7 @@
|
|||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
@@ -2540,6 +2541,40 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
|
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.18",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
|
||||||
@@ -2808,9 +2843,13 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
||||||
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -2933,6 +2972,60 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -2976,6 +3069,11 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||||
@@ -3488,6 +3586,116 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -3509,6 +3717,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||||
|
},
|
||||||
"node_modules/dequal": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
@@ -3590,6 +3803,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.46.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
|
||||||
|
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3609,6 +3827,11 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -3791,6 +4014,15 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/indent-string": {
|
"node_modules/indent-string": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||||
@@ -3801,6 +4033,14 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -4804,10 +5044,31 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -4898,6 +5159,32 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -4912,6 +5199,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -4922,6 +5222,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -5225,6 +5530,11 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -5484,6 +5794,27 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.11",
|
"version": "8.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
LayoutDashboard, Users, BookOpen, MessageSquare,
|
LayoutDashboard, Users, BookOpen, MessageSquare,
|
||||||
FileText, Wrench, ShoppingCart, Building2, MessageCircle,
|
FileText, Wrench, ShoppingCart, Building2, MessageCircle,
|
||||||
|
BarChart3, Settings, Bell, LogOut, ChevronDown, Layout,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { AdminAIAssistant } from '@/components/admin-ai-assistant';
|
||||||
|
|
||||||
const sidebarLinks = [
|
const sidebarLinks = [
|
||||||
{ href: '/admin', label: '仪表盘', icon: LayoutDashboard },
|
{ href: '/admin', label: '仪表盘', icon: LayoutDashboard },
|
||||||
|
{ href: '/admin/analytics', label: '数据分析', icon: BarChart3 },
|
||||||
{ href: '/admin/users', label: '用户管理', icon: Users },
|
{ href: '/admin/users', label: '用户管理', icon: Users },
|
||||||
{ href: '/admin/courses', label: '课程管理', icon: BookOpen },
|
{ href: '/admin/courses', label: '课程管理', icon: BookOpen },
|
||||||
{ href: '/admin/prompts', label: '提示词管理', icon: MessageSquare },
|
{ href: '/admin/prompts', label: '提示词管理', icon: MessageSquare },
|
||||||
@@ -18,12 +21,17 @@ const sidebarLinks = [
|
|||||||
{ href: '/admin/orders', label: '订单管理', icon: ShoppingCart },
|
{ href: '/admin/orders', label: '订单管理', icon: ShoppingCart },
|
||||||
{ href: '/admin/enterprise', label: '企业版管理', icon: Building2 },
|
{ href: '/admin/enterprise', label: '企业版管理', icon: Building2 },
|
||||||
{ href: '/admin/comments', label: '评论审核', icon: MessageCircle },
|
{ 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 }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
|
const [adminInfo, setAdminInfo] = useState<{ username: string; role?: string } | null>(null);
|
||||||
|
|
||||||
const isLoginPage = pathname === '/admin/login';
|
const isLoginPage = pathname === '/admin/login';
|
||||||
|
|
||||||
@@ -32,21 +40,61 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
if (!token && !isLoginPage) {
|
if (!token && !isLoginPage) {
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
} else {
|
} else {
|
||||||
|
// 获取管理员信息
|
||||||
|
try {
|
||||||
|
const info = localStorage.getItem('adminInfo');
|
||||||
|
if (info) setAdminInfo(JSON.parse(info));
|
||||||
|
} catch {}
|
||||||
setChecked(true);
|
setChecked(true);
|
||||||
}
|
}
|
||||||
}, [isLoginPage, router]);
|
}, [isLoginPage, router]);
|
||||||
|
|
||||||
if (isLoginPage) return <>{children}</>;
|
function handleLogout() {
|
||||||
if (!checked) return <div className="min-h-[calc(100vh-4rem)]" />;
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('adminInfo');
|
||||||
|
router.replace('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
if (href === '/admin') return pathname === '/admin';
|
if (href === '/admin') return pathname === '/admin';
|
||||||
return pathname.startsWith(href) && href !== '/admin';
|
return pathname.startsWith(href) && href !== '/admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoginPage) return <>{children}</>;
|
||||||
|
if (!checked) return <div className="min-h-screen bg-background" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-4rem)] bg-background flex">
|
<div className="min-h-screen bg-background">
|
||||||
<aside className="w-56 border-r border-border bg-card shrink-0 hidden md:block">
|
{/* 顶部导航栏 */}
|
||||||
|
<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">
|
<nav className="p-3 space-y-1">
|
||||||
{sidebarLinks.map((link) => {
|
{sidebarLinks.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
@@ -67,7 +115,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
{/* 主内容区 */}
|
||||||
<main className="flex-1 overflow-auto">{children}</main>
|
<main className="flex-1 overflow-auto">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
<AdminAIAssistant />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ export default function AdminLoginPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.message || '管理员登录失败');
|
if (!res.ok) throw new Error(data.message || '管理员登录失败');
|
||||||
localStorage.setItem('adminToken', data.token);
|
localStorage.setItem('adminToken', data.token);
|
||||||
|
localStorage.setItem('adminInfo', JSON.stringify({ username: data.username, role: data.role }));
|
||||||
toast.success('管理员登录成功');
|
toast.success('管理员登录成功');
|
||||||
router.push('/admin');
|
router.push('/admin');
|
||||||
} catch (err: any) {
|
} 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 = [
|
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/users', title: '用户管理', desc: '管理用户、查看分析', color: 'from-blue-500 to-blue-600' },
|
||||||
{ href: '/admin/courses', title: '课程管理', desc: '管理课程内容', color: 'from-green-500 to-green-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' },
|
{ 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/orders', title: '订单管理', desc: '查看支付订单', color: 'from-rose-500 to-rose-600' },
|
||||||
{ href: '/admin/enterprise', title: '企业版管理', desc: '管理组织、成员和学习报告', color: 'from-indigo-500 to-indigo-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/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() {
|
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 { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
phone: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
email?: string;
|
email: string;
|
||||||
phone?: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
memberPlan: string;
|
memberPlan: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => { loadUsers(); }, [search]);
|
||||||
loadUsers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
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}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUsers(data.items || []);
|
setUsers(data.items || []);
|
||||||
@@ -37,104 +38,159 @@ export default function AdminUsers() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleStatus(userId: number, currentStatus: string) {
|
async function createUser() {
|
||||||
try {
|
if (!form.phone || !form.password) return alert('手机号和密码必填');
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${userId}/status`, {
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users`, {
|
||||||
method: 'PUT',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(form),
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ status: currentStatus === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' }),
|
|
||||||
});
|
});
|
||||||
|
setShowForm(false);
|
||||||
|
setForm({ nickname: '', phone: '', email: '', password: '' });
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return (
|
async function updateUser() {
|
||||||
<div className="space-y-4 p-6">
|
if (!editId) return;
|
||||||
<Skeleton className="h-8 w-48" />
|
const token = localStorage.getItem('adminToken');
|
||||||
<Skeleton className="h-10 w-full" />
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${editId}`, {
|
||||||
<Skeleton className="h-10 w-full" />
|
method: 'PUT',
|
||||||
<Skeleton className="h-10 w-3/4" />
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
</div>
|
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 (
|
return (
|
||||||
<>
|
<div className="p-6">
|
||||||
<div className="border-b border-border bg-card px-4 py-4">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">用户管理</h1>
|
<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>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="mb-4">
|
||||||
<div className="bg-card rounded-xl border border-border overflow-hidden">
|
<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">
|
<table className="w-full">
|
||||||
<thead className="bg-muted/50 border-b border-border">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">ID</th>
|
<th className="px-4 py-3 text-left text-sm text-muted-foreground">ID</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">昵称</th>
|
<th className="px-4 py-3 text-left text-sm text-muted-foreground">手机号</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">联系方式</th>
|
<th className="px-4 py-3 text-left text-sm text-muted-foreground">昵称</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">状态</th>
|
<th className="px-4 py-3 text-left text-sm text-muted-foreground">邮箱</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">会员</th>
|
<th className="px-4 py-3 text-left text-sm text-muted-foreground">状态</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">注册时间</th>
|
<th className="px-4 py-3 text-left text-sm text-muted-foreground">会员</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">操作</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody>
|
||||||
{users.map(user => (
|
{users.map(user => (
|
||||||
<tr key={user.id} className="hover:bg-accent/50">
|
<tr key={user.id} className="border-t border-border">
|
||||||
<td className="px-6 py-4 text-sm text-foreground">{user.id}</td>
|
<td className="px-4 py-3 text-sm">{user.id}</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3 text-sm">{user.phone || '-'}</td>
|
||||||
<div className="text-sm font-medium text-foreground">{user.nickname || '未设置'}</div>
|
<td className="px-4 py-3 text-sm">{user.nickname || '-'}</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-sm">{user.email || '-'}</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="text-sm text-muted-foreground">{user.email || user.phone || '-'}</div>
|
<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'}`}>
|
||||||
</td>
|
{user.status === 'ACTIVE' ? '正常' : '禁用'}
|
||||||
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3 text-sm">{user.memberPlan || 'FREE'}</td>
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
<td className="px-4 py-3 text-sm text-muted-foreground">{user.createdAt?.slice(0, 10)}</td>
|
||||||
user.memberPlan === 'YEARLY' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' :
|
<td className="px-4 py-3">
|
||||||
user.memberPlan === 'MONTHLY' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' :
|
<button onClick={() => openEdit(user)} className="text-brand-600 hover:underline text-sm mr-3">编辑</button>
|
||||||
'bg-muted text-muted-foreground'
|
<button onClick={() => toggleStatus(user.id, user.status)} className="text-orange-600 hover:underline text-sm mr-3">
|
||||||
}`}>
|
|
||||||
{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' ? '禁用' : '启用'}
|
{user.status === 'ACTIVE' ? '禁用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => deleteUser(user.id)} className="text-red-500 hover:underline text-sm">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{users.length === 0 && (
|
|
||||||
<div className="text-center py-20 text-muted-foreground">
|
|
||||||
暂无用户数据
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import { I18nProvider } from '@/i18n'
|
import { I18nProvider } from '@/i18n'
|
||||||
import { ThemeProvider } from '@/components/providers/theme-provider'
|
import { ThemeProvider } from '@/components/providers/theme-provider'
|
||||||
import { AuthProvider } from '@/lib/auth-context'
|
import { AuthProvider } from '@/lib/auth-context'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { Header } from '@/components/layout/header'
|
import { Header } from '@/components/layout/header'
|
||||||
import { Footer } from '@/components/layout/footer'
|
import { Footer } from '@/components/layout/footer'
|
||||||
|
import { AIAssistant } from '@/components/ai-assistant'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export function RootLayoutClient({ children }: { children: ReactNode }) {
|
export function RootLayoutClient({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const isAdminPage = pathname?.startsWith('/admin')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<Header />
|
{!isAdminPage && <Header />}
|
||||||
<main className="flex-1">{children}</main>
|
<main className={isAdminPage ? 'flex-1' : 'flex-1'}>{children}</main>
|
||||||
<Footer />
|
{!isAdminPage && <Footer />}
|
||||||
|
{!isAdminPage && <AIAssistant />}
|
||||||
<Toaster richColors closeButton />
|
<Toaster richColors closeButton />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -147,8 +147,9 @@ function SandboxPage() {
|
|||||||
let reply = '';
|
let reply = '';
|
||||||
if (tk) {
|
if (tk) {
|
||||||
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
|
const curScene = SCENES.find(s => s.id === scene) || SCENES[0];
|
||||||
|
const systemPrompt = curScene?.systemPrompt || '你是一个智能 AI 助手';
|
||||||
const apiMessages = [
|
const apiMessages = [
|
||||||
{ role: 'system', content: curScene.systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
...messages,
|
...messages,
|
||||||
userMsg,
|
userMsg,
|
||||||
].map(m => ({ role: m.role, content: m.content }));
|
].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">
|
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{isNewChat && (
|
{isNewChat && (
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<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)}
|
<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">
|
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}
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -195,6 +195,11 @@ const en: Translations = {
|
|||||||
saveTagsPlaceholder: 'Separate by commas, e.g. coding,Python,debug',
|
saveTagsPlaceholder: 'Separate by commas, e.g. coding,Python,debug',
|
||||||
saving: 'Saving...',
|
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
|
export default en
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ const zh = {
|
|||||||
saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试',
|
saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
},
|
},
|
||||||
|
assistant: {
|
||||||
|
title: 'AI 助手',
|
||||||
|
greeting: '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:',
|
||||||
|
placeholder: '输入你的问题...',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Translations = typeof zh
|
export type Translations = typeof zh
|
||||||
|
|||||||
@@ -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'
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user