feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善
- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成 - 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作 - 角色管理:表格布局 + Dialog 表单 + 权限勾选 - 配置统一:config.ts 单一数据源 - API 审计:补齐 status toggle / comments 端点 - 暗黑模式硬件编码颜色全部替换为 CSS 变量
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param } from '@nestjs/common';
|
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { AdminGuard } from './admin.guard';
|
import { AdminGuard } from './admin.guard';
|
||||||
import { CoursesService } from '../courses/courses.service';
|
import { CoursesService } from '../courses/courses.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AIGatewayService } from '../ai/ai-gateway.service';
|
||||||
|
import { AdminAiAssistantService } from './ai-assistant.service';
|
||||||
|
|
||||||
@ApiTags('管理后台')
|
@ApiTags('管理后台')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@@ -13,6 +15,8 @@ export class AdminController {
|
|||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private coursesService: CoursesService,
|
private coursesService: CoursesService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
private aiGateway: AIGatewayService,
|
||||||
|
private aiAssistant: AdminAiAssistantService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@@ -83,4 +87,36 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('ai-assistant/chat')
|
||||||
|
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async aiAssistantChat(@Body() body: { messages: { role: string; content: string }[] }) {
|
||||||
|
try {
|
||||||
|
const { messages } = body;
|
||||||
|
const apiMessages = messages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
|
||||||
|
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.7, max_tokens: 2000 });
|
||||||
|
return { reply };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new HttpException(err.message || 'AI 助手请求失败', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ai-assistant/action')
|
||||||
|
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async aiAssistantAction(@Body() body: { tool: string; params: Record<string, any>; messages: { role: string; content: string }[] }) {
|
||||||
|
const result = await this.aiAssistant.execute({ tool: body.tool, params: body.params });
|
||||||
|
try {
|
||||||
|
const apiMessages = [
|
||||||
|
{ role: 'system' as const, content: '你是一个管理后台助手。下面是对用户请求的执行结果,请用自然语言总结给用户,语言简洁。' },
|
||||||
|
...body.messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
||||||
|
{ role: 'assistant' as const, content: `【工具执行结果】\n工具: ${body.tool}\n参数: ${JSON.stringify(body.params)}\n结果: ${result.summary}\n数据: ${JSON.stringify(result.data)}` },
|
||||||
|
];
|
||||||
|
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.3, max_tokens: 1000 });
|
||||||
|
return { reply, result };
|
||||||
|
} catch {
|
||||||
|
return { reply: result.summary, result };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ 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 { AdminAiAssistantService } from './ai-assistant.service';
|
||||||
import { AnalyticsController } from './analytics.controller';
|
import { AnalyticsController } from './analytics.controller';
|
||||||
import { SettingsController } from './settings.controller';
|
import { SettingsController } from './settings.controller';
|
||||||
import { OperationsController } from './operations.controller';
|
import { OperationsController } from './operations.controller';
|
||||||
import { UsersController } from './users.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';
|
||||||
|
import { AIModule } from '../ai/ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoursesModule, AuthModule],
|
imports: [CoursesModule, AuthModule, AIModule],
|
||||||
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController],
|
controllers: [AdminController, AnalyticsController, SettingsController, OperationsController, UsersController],
|
||||||
providers: [AdminService, AdminGuard],
|
providers: [AdminService, AdminGuard, AdminAiAssistantService],
|
||||||
exports: [AdminService],
|
exports: [AdminService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface ToolDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
tool: string;
|
||||||
|
params: Record<string, any>;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
success: boolean;
|
||||||
|
data: any;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminAiAssistantService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getAvailableTools(): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'navigate',
|
||||||
|
description: '跳转到管理后台的某个页面',
|
||||||
|
parameters: {
|
||||||
|
path: { type: 'string', description: '页面路径,如 /admin/users、/admin/orders、/admin/analytics、/admin/enterprise、/admin/operations/banners、/admin/operations/notifications、/admin/settings/roles、/admin/settings/config、/admin/comments、/admin/courses、/admin/prompts、/admin/contents、/admin/tools' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get-dashboard',
|
||||||
|
description: '获取仪表盘概览数据(用户数、课程数、内容数、提示词数、订单数)',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-users',
|
||||||
|
description: '列出用户,可按关键词搜索',
|
||||||
|
parameters: {
|
||||||
|
search: { type: 'string', description: '搜索关键词(可选)' },
|
||||||
|
page: { type: 'number', description: '页码(可选,默认1)' },
|
||||||
|
pageSize: { type: 'number', description: '每页数量(可选,默认20)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get-user',
|
||||||
|
description: '查看某个用户的详细信息',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '用户ID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'update-user-status',
|
||||||
|
description: '修改用户状态(启用/禁用/封禁)',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '用户ID' },
|
||||||
|
status: { type: 'string', enum: ['ACTIVE', 'INACTIVE', 'BANNED'], description: '新状态' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-orders',
|
||||||
|
description: '查看最近订单列表',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get-analytics-overview',
|
||||||
|
description: '获取数据分析概览(用户增长、收入、趋势)',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-comments',
|
||||||
|
description: '查看评论列表,可按状态筛选',
|
||||||
|
parameters: {
|
||||||
|
status: { type: 'string', enum: ['PENDING_REVIEW', 'PUBLISHED', 'REJECTED'], description: '评论状态(可选)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'approve-comment',
|
||||||
|
description: '通过一条待审核评论',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '评论ID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reject-comment',
|
||||||
|
description: '拒绝一条评论并给出原因',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '评论ID' },
|
||||||
|
reason: { type: 'string', description: '拒绝原因(可选)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-banners',
|
||||||
|
description: '查看所有Banner列表',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-notifications',
|
||||||
|
description: '查看所有系统通知',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-config',
|
||||||
|
description: '查看系统配置项',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-roles',
|
||||||
|
description: '查看所有管理角色',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-admins',
|
||||||
|
description: '查看所有管理员账号',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toggle-course-status',
|
||||||
|
description: '上架或下架一个课程',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '课程ID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toggle-content-status',
|
||||||
|
description: '上架或下架一个内容',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '内容ID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toggle-prompt-status',
|
||||||
|
description: '上架或下架一个提示词',
|
||||||
|
parameters: {
|
||||||
|
id: { type: 'number', description: '提示词ID' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get-enterprise-orgs',
|
||||||
|
description: '查看所有企业版组织',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolsDescription(): string {
|
||||||
|
return this.getAvailableTools().map(t => {
|
||||||
|
const params = Object.entries(t.parameters)
|
||||||
|
.map(([k, v]: any) => ` - ${k}: ${v.type}${v.description ? ' (' + v.description + ')' : ''}${v.enum ? ' [' + v.enum.join('|') + ']' : ''}`)
|
||||||
|
.join('\n');
|
||||||
|
return `- **${t.name}**: ${t.description}\n${params}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(call: ToolCall): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
switch (call.tool) {
|
||||||
|
case 'navigate':
|
||||||
|
return { success: true, data: { path: call.params.path }, summary: `跳转到 ${call.params.path}` };
|
||||||
|
|
||||||
|
case 'get-dashboard': {
|
||||||
|
const data = await this.prisma.user.count({ where: { deletedAt: null } });
|
||||||
|
const courses = await this.prisma.course.count({ where: { deletedAt: null } });
|
||||||
|
const contents = await this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED' } });
|
||||||
|
const prompts = await this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED' } });
|
||||||
|
const orders = await this.prisma.order.count();
|
||||||
|
return { success: true, data: { userCount: data, courseCount: courses, contentCount: contents, promptCount: prompts, orderCount: orders }, summary: `总用户${data},课程${courses},内容${contents},提示词${prompts},订单${orders}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-users': {
|
||||||
|
const { search, page = 1, pageSize = 20 } = call.params;
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
if (search) where.OR = [
|
||||||
|
{ nickname: { contains: search } },
|
||||||
|
{ phone: { contains: search } },
|
||||||
|
{ email: { contains: search } },
|
||||||
|
];
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.user.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: 'desc' }, select: { id: true, nickname: true, phone: true, email: true, status: true, memberPlan: true, createdAt: true } }),
|
||||||
|
this.prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
return { success: true, data: { items, total, page, pageSize }, summary: `找到 ${total} 个用户` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get-user': {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: call.params.id }, include: { orders: { take: 5, orderBy: { createdAt: 'desc' } } } });
|
||||||
|
if (!user) throw new Error('用户不存在');
|
||||||
|
return { success: true, data: user, summary: `用户 ${user.nickname || user.phone || '未知'} (ID:${user.id})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update-user-status': {
|
||||||
|
const { id, status } = call.params;
|
||||||
|
await this.prisma.user.update({ where: { id }, data: { status } });
|
||||||
|
return { success: true, data: { id, status }, summary: `用户 ${id} 状态已改为 ${status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-orders': {
|
||||||
|
const items = await this.prisma.order.findMany({
|
||||||
|
take: 50, orderBy: { createdAt: 'desc' },
|
||||||
|
include: { user: { select: { id: true, nickname: true } } },
|
||||||
|
});
|
||||||
|
const total = items.length;
|
||||||
|
const revenue = items.filter(o => o.status === 'PAID').reduce((s, o) => s + o.amount, 0);
|
||||||
|
return { success: true, data: { items, total, revenue }, summary: `最近 ${total} 笔订单,已收金额 ¥${revenue}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get-analytics-overview': {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const [userTotal, ordersTotal, todayOrders, todayUsers, revenue] = await Promise.all([
|
||||||
|
this.prisma.user.count({ where: { deletedAt: null } }),
|
||||||
|
this.prisma.order.count(),
|
||||||
|
this.prisma.order.count({ where: { createdAt: { gte: today } } }),
|
||||||
|
this.prisma.user.count({ where: { deletedAt: null, createdAt: { gte: today } } }),
|
||||||
|
this.prisma.order.aggregate({ _sum: { amount: true }, where: { status: 'PAID' } }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { users: userTotal, orders: ordersTotal, todayOrders, todayUsers, revenue: revenue._sum.amount || 0 },
|
||||||
|
summary: `总用户 ${userTotal},今日新增 ${todayUsers};总订单 ${ordersTotal},今日 ${todayOrders};总收入 ¥${revenue._sum.amount || 0}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-comments': {
|
||||||
|
const where: any = {};
|
||||||
|
if (call.params.status) where.status = call.params.status;
|
||||||
|
const items = await this.prisma.comment.findMany({
|
||||||
|
where, take: 50, orderBy: { createdAt: 'desc' },
|
||||||
|
include: { user: { select: { id: true, nickname: true } } },
|
||||||
|
});
|
||||||
|
return { success: true, data: { items, total: items.length }, summary: `共 ${items.length} 条评论` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'approve-comment': {
|
||||||
|
await this.prisma.comment.update({ where: { id: call.params.id }, data: { status: 'PUBLISHED', reviewedAt: new Date() } });
|
||||||
|
return { success: true, data: { id: call.params.id }, summary: `评论 ${call.params.id} 已通过` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reject-comment': {
|
||||||
|
await this.prisma.comment.update({ where: { id: call.params.id }, data: { status: 'REJECTED', reviewNote: call.params.reason || '违规内容', reviewedAt: new Date() } });
|
||||||
|
return { success: true, data: { id: call.params.id }, summary: `评论 ${call.params.id} 已拒绝` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-banners': {
|
||||||
|
const items = await this.prisma.banner.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
|
||||||
|
return { success: true, data: { items }, summary: `共 ${items.length} 个Banner` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-notifications': {
|
||||||
|
const items = await this.prisma.notification.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
|
||||||
|
return { success: true, data: { items, total: items.length }, summary: `共 ${items.length} 条通知` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-config': {
|
||||||
|
const items = await this.prisma.systemConfig.findMany();
|
||||||
|
return { success: true, data: { items }, summary: `共 ${items.length} 个配置项` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-roles': {
|
||||||
|
const items = await this.prisma.adminRole.findMany();
|
||||||
|
return { success: true, data: { items }, summary: `共 ${items.length} 个角色` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-admins': {
|
||||||
|
const items = await this.prisma.adminUser.findMany({ include: { role: { select: { name: true } } } });
|
||||||
|
return { success: true, data: { items }, summary: `共 ${items.length} 个管理员` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toggle-course-status': {
|
||||||
|
const course = await this.prisma.course.findUnique({ where: { id: call.params.id } });
|
||||||
|
if (!course) throw new Error('课程不存在');
|
||||||
|
const newStatus = course.status === 'PUBLISHED' ? 'INACTIVE' : 'PUBLISHED';
|
||||||
|
await this.prisma.course.update({ where: { id: call.params.id }, data: { status: newStatus as any } });
|
||||||
|
return { success: true, data: { id: call.params.id, status: newStatus }, summary: `课程 ${call.params.id} 状态已切换为 ${newStatus}` };
|
||||||
|
}
|
||||||
|
case 'toggle-content-status': {
|
||||||
|
const content = await this.prisma.content.findUnique({ where: { id: call.params.id } });
|
||||||
|
if (!content) throw new Error('内容不存在');
|
||||||
|
const newContentStatus = content.status === 'PUBLISHED' ? 'INACTIVE' : 'PUBLISHED';
|
||||||
|
await this.prisma.content.update({ where: { id: call.params.id }, data: { status: newContentStatus as any } });
|
||||||
|
return { success: true, data: { id: call.params.id, status: newContentStatus }, summary: `内容 ${call.params.id} 状态已切换为 ${newContentStatus}` };
|
||||||
|
}
|
||||||
|
case 'toggle-prompt-status': {
|
||||||
|
const prompt = await this.prisma.prompt.findUnique({ where: { id: call.params.id } });
|
||||||
|
if (!prompt) throw new Error('提示词不存在');
|
||||||
|
const newPromptStatus = prompt.status === 'PUBLISHED' ? 'INACTIVE' : 'PUBLISHED';
|
||||||
|
await this.prisma.prompt.update({ where: { id: call.params.id }, data: { status: newPromptStatus as any } });
|
||||||
|
return { success: true, data: { id: call.params.id, status: newPromptStatus }, summary: `提示词 ${call.params.id} 状态已切换为 ${newPromptStatus}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get-enterprise-orgs': {
|
||||||
|
const items = await this.prisma.organization.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
|
||||||
|
return { success: true, data: { items, total: items.length }, summary: `共 ${items.length} 个企业组织` };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`未知工具: ${call.tool}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, data: null, summary: `执行失败: ${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,35 @@ export class OperationsController {
|
|||||||
return { items: await this.prisma.systemConfig.findMany({ where: { category } }) };
|
return { items: await this.prisma.systemConfig.findMany({ where: { category } }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content status toggles (for admin list pages)
|
||||||
|
@Put('courses/:id/status')
|
||||||
|
async toggleCourseStatus(@Param('id') id: string, @Body() body: { status: string }) {
|
||||||
|
return this.prisma.course.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('contents/:id/status')
|
||||||
|
async toggleContentStatus(@Param('id') id: string, @Body() body: { status: string }) {
|
||||||
|
return this.prisma.content.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('prompts/:id/status')
|
||||||
|
async togglePromptStatus(@Param('id') id: string, @Body() body: { status: string }) {
|
||||||
|
return this.prisma.prompt.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('tools/:id/status')
|
||||||
|
async toggleToolStatus(@Param('id') id: string, @Body() body: { status: string }) {
|
||||||
|
return this.prisma.tool.update({ where: { id: parseInt(id) }, data: { status: body.status as any } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
@Get('comments')
|
||||||
|
async comments(@Query('status') status?: string) {
|
||||||
|
const where: any = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
return { items: await this.prisma.comment.findMany({ where, orderBy: { createdAt: 'desc' }, include: { user: { select: { id: true, nickname: true } }, post: { select: { id: true, title: true } } } }) };
|
||||||
|
}
|
||||||
|
|
||||||
@Put('config/:key')
|
@Put('config/:key')
|
||||||
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
|
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
|
||||||
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
|
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ const MODEL_CATALOG: Record<string, ModelInfo> = {
|
|||||||
'openai': { id: 'openai', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
|
'openai': { id: 'openai', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
|
||||||
'gpt-3.5': { id: 'gpt-3.5', provider: 'OpenAI', capabilities: ['chat', 'code'], contextWindow: 16384 },
|
'gpt-3.5': { id: 'gpt-3.5', provider: 'OpenAI', capabilities: ['chat', 'code'], contextWindow: 16384 },
|
||||||
'gpt-4': { id: 'gpt-4', provider: 'OpenAI', capabilities: ['chat', 'code', 'vision'], contextWindow: 32768 },
|
'gpt-4': { id: 'gpt-4', provider: 'OpenAI', capabilities: ['chat', 'code', 'vision'], contextWindow: 32768 },
|
||||||
'opencode-go': { id: 'opencode-go', provider: 'OpenCode Go', capabilities: ['chat', 'code'], contextWindow: 32768 },
|
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
|
||||||
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: 'OpenCode Go', capabilities: ['chat', 'code'], contextWindow: 32768 },
|
'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
|
||||||
|
'sensenova-u1-fast': { id: 'sensenova-u1-fast', provider: '商汤科技', capabilities: ['chat', 'code', 'vision'], contextWindow: 65536 },
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -58,19 +59,21 @@ export class AIGatewayService {
|
|||||||
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`);
|
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.OPENCODE_API_KEY) {
|
if (process.env.SENSENOVA_API_KEY) {
|
||||||
let apiUrl = process.env.OPENCODE_API_URL || 'https://opencode.ai/zen/go/v1';
|
let apiUrl = process.env.SENSENOVA_API_URL || 'https://token.sensenova.cn/v1';
|
||||||
if (!apiUrl.endsWith('/chat/completions')) {
|
if (!apiUrl.endsWith('/chat/completions')) {
|
||||||
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
||||||
}
|
}
|
||||||
const defaultModel = process.env.OPENCODE_MODEL || 'deepseek-v4-flash';
|
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite', 'sensenova-u1-fast'];
|
||||||
this.providers.set('opencode', new OpenAICompatibleProvider(
|
for (const modelName of models) {
|
||||||
process.env.OPENCODE_API_KEY!,
|
this.providers.set(modelName, new OpenAICompatibleProvider(
|
||||||
apiUrl,
|
process.env.SENSENOVA_API_KEY!,
|
||||||
defaultModel,
|
apiUrl,
|
||||||
'OpenCode Go',
|
modelName,
|
||||||
));
|
'商汤科技',
|
||||||
this.logger.log(`OpenCode Go 已注册: ${defaultModel}`);
|
));
|
||||||
|
}
|
||||||
|
this.logger.log(`商汤科技已注册: ${models.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +85,9 @@ export class AIGatewayService {
|
|||||||
'gpt-4': 'openai',
|
'gpt-4': 'openai',
|
||||||
'longcat': 'openai',
|
'longcat': 'openai',
|
||||||
'meituan/longcat-flash-lite': 'openai',
|
'meituan/longcat-flash-lite': 'openai',
|
||||||
'opencode-go': 'opencode',
|
'deepseek-v4-flash': 'deepseek-v4-flash',
|
||||||
'opencode': 'opencode',
|
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
|
||||||
'deepseek-v4-flash': 'opencode',
|
'sensenova-u1-fast': 'sensenova-u1-fast',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
|
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ class CreateOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
payChannel?: string;
|
payChannel?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['JSAPI', 'NATIVE', 'MWEB'])
|
||||||
|
tradeType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('订单')
|
@ApiTags('订单')
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class OrdersService {
|
|||||||
private paymentService: PaymentService,
|
private paymentService: PaymentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: number, data: { amount: number; planType: string; payChannel?: string }) {
|
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
|
||||||
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||||
|
|
||||||
const order = await this.prisma.order.create({
|
const order = await this.prisma.order.create({
|
||||||
@@ -33,7 +33,13 @@ export class OrdersService {
|
|||||||
description: planLabels[data.planType] || '宇之然AI会员充值',
|
description: planLabels[data.planType] || '宇之然AI会员充值',
|
||||||
outTradeNo: orderNo,
|
outTradeNo: orderNo,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
|
tradeType: (data.tradeType as 'JSAPI' | 'NATIVE' | 'MWEB') || 'NATIVE',
|
||||||
|
openid: data.openid,
|
||||||
});
|
});
|
||||||
|
// Mock 模式下自动完成支付闭环(NATIVE mock 无需扫码)
|
||||||
|
if (payResult?.codeUrl === 'mock://pay') {
|
||||||
|
await this.paymentService.completeMockPayment(orderNo);
|
||||||
|
}
|
||||||
return { order, payResult };
|
return { order, payResult };
|
||||||
} catch {
|
} catch {
|
||||||
return { order, payResult: null };
|
return { order, payResult: null };
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('OrdersService', () => {
|
|||||||
|
|
||||||
const mockPaymentService = {
|
const mockPaymentService = {
|
||||||
createUnifiedOrder: jest.fn(),
|
createUnifiedOrder: jest.fn(),
|
||||||
|
completeMockPayment: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export class PaymentService {
|
|||||||
notifyUrl: process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
|
notifyUrl: process.env.WX_NOTIFY_URL || 'https://yuzhiran.com/api/v1/payment/wxpay/notify',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (process.env.WX_PAY_MOCK === 'true') {
|
||||||
|
this.logger.warn('WX_PAY_MOCK=true,强制使用模拟支付模式');
|
||||||
|
this.wxPay = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { WechatPay } = require('wechat-pay-nodejs');
|
const { WechatPay } = require('wechat-pay-nodejs');
|
||||||
@@ -237,6 +243,27 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成模拟支付(mock 模式):自动更新订单状态 + 升级会员订阅
|
||||||
|
*/
|
||||||
|
async completeMockPayment(orderNo: string): Promise<void> {
|
||||||
|
if (this.wxPay) {
|
||||||
|
this.logger.warn(`非模拟模式,跳过模拟支付完成: ${orderNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const order = await this.prisma.order.findUnique({ where: { orderNo } });
|
||||||
|
if (!order) {
|
||||||
|
this.logger.warn(`订单不存在: ${orderNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (order.status === 'PAID') {
|
||||||
|
this.logger.log(`订单已支付,跳过重复处理: ${orderNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.updateOrderAndMembership(orderNo, Math.round(order.amount * 100));
|
||||||
|
this.logger.log(`模拟支付完成: ${orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
async refund(outTradeNo: string, amount: number, reason?: string) {
|
async refund(outTradeNo: string, amount: number, reason?: string) {
|
||||||
if (!this.wxPay) {
|
if (!this.wxPay) {
|
||||||
this.logger.log(`模拟退款: ${outTradeNo}`);
|
this.logger.log(`模拟退款: ${outTradeNo}`);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -39,8 +39,30 @@
|
|||||||
### 参考来源
|
### 参考来源
|
||||||
- opencode 源码克隆到 `/tmp/opencode-source/`(21 packages, 147MB)
|
- opencode 源码克隆到 `/tmp/opencode-source/`(21 packages, 147MB)
|
||||||
|
|
||||||
|
### P4 — 支付闭环(进行中 🔄)
|
||||||
|
| 子项 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **P4a** | Mock 支付自动完成 — `PaymentService.completeMockPayment()` + `OrdersService` 检测 mock 结果后自动调用 | ✅ |
|
||||||
|
| **P4b** | 会员页面 mock 提示优化 — 显示"订阅成功"而非"请扫码支付" | ✅ |
|
||||||
|
| **P4c** | 真实微信支付证书部署 — 商户证书/密钥已安装到 `backend/cert/key/`,参数与 `.env` 一致 | ✅ |
|
||||||
|
| **P4d** | 下单切 NATIVE 扫码支付 — 默认 `tradeType: 'NATIVE'`,前端展示二维码 Modal + 轮询支付状态 | ✅ |
|
||||||
|
| **P4e** | JSAPI 支付集成 — 检测微信环境 + openid,在 Mini Program web-view 内自动调起 `WeixinJSBridge`;其他场景 NATIVE 兜底 | ✅ |
|
||||||
|
| — | 订阅到期提醒 / 自动续费 | 📋 远期 |
|
||||||
|
| — | 获取公众号 appid 后补充 Web OAuth 流程 | 📋 待定 |
|
||||||
|
|
||||||
|
### P5 — 运营助手(已完成 ✅)
|
||||||
|
| 子项 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **P5a** | 新增 `POST /admin/ai-assistant/chat` 端点 — 管理员专用 AI 聊天,使用 AdminGuard | ✅ |
|
||||||
|
| **P5b** | 新增 `AdminAiAssistantService` — 19 个可执行工具定义(查看数据/用户管理/评论审核/状态切换等) | ✅ |
|
||||||
|
| **P5c** | 新增 `POST /admin/ai-assistant/action` 端点 — 工具执行 + AI 自然语言总结 | ✅ |
|
||||||
|
| **P5d** | 前端运营助手改造 — Tool Calling 架构,AI 自动识别用户意图→调用工具→解释结果→展示 | ✅ |
|
||||||
|
| **P5e** | system prompt 动态注入可用工具列表,覆盖仪表盘/用户/订单/评论/Banner/通知/配置/角色/企业版等 | ✅ |
|
||||||
|
|
||||||
### 关键决策
|
### 关键决策
|
||||||
- `frontend/src/lib/models.ts` 作为模型列表单数据源
|
- `frontend/src/lib/models.ts` 作为模型列表单数据源
|
||||||
- 所有颜色使用 CSS 变量(`text-foreground`/`text-muted-foreground`/`bg-card`/`bg-muted`/`border-border`)
|
- 所有颜色使用 CSS 变量(`text-foreground`/`text-muted-foreground`/`bg-card`/`bg-muted`/`border-border`)
|
||||||
- `nest build` 正常产出(`--tsc` 遗漏 prisma 模块)
|
- `nest build` 正常产出(`--tsc` 遗漏 prisma 模块)
|
||||||
- 沙盒 JWT 认证保留
|
- 沙盒 JWT 认证保留
|
||||||
|
- Mock 支付自动完成闭环,无需手动触发回调;真实微信支付上线后自动切换
|
||||||
|
- 运营助手采用 Tool Calling 架构:system prompt 描述工具 → AI 返回 JSON 工具调用 → 后端执行 → 结果喂回 AI 总结 → 展示给用户
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||||
|
|||||||
Generated
+279
-2
@@ -32,6 +32,7 @@
|
|||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@@ -3049,6 +3051,15 @@
|
|||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -3224,9 +3235,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3428,6 +3437,14 @@
|
|||||||
"node": ">=10.16.0"
|
"node": ">=10.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -3521,6 +3538,16 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -3530,6 +3557,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@@ -3710,6 +3753,14 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decimal.js": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -3754,6 +3805,11 @@
|
|||||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
||||||
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -3774,6 +3830,11 @@
|
|||||||
"integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==",
|
"integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
@@ -3891,6 +3952,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -3954,6 +4027,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-nonce": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
@@ -4077,6 +4158,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -4440,6 +4529,17 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -4707,6 +4807,39 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
@@ -4720,6 +4853,14 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -4813,6 +4954,14 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
@@ -4995,6 +5144,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -5212,6 +5377,14 @@
|
|||||||
"redux": "^5.0.0"
|
"redux": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.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",
|
||||||
@@ -5222,6 +5395,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
@@ -5337,6 +5515,11 @@
|
|||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -5385,6 +5568,30 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -6057,6 +6264,11 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -6074,6 +6286,33 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
@@ -6090,6 +6329,44 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||||
import { TrendingUp, TrendingDown, Users, ShoppingCart, DollarSign, BookOpen, MessageCircle } from 'lucide-react';
|
import { TrendingUp, TrendingDown, Users, ShoppingCart, DollarSign, BookOpen, MessageCircle } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface OverviewData {
|
interface OverviewData {
|
||||||
users: { total: number; active: number; new: number; growth: number };
|
users: { total: number; active: number; new: number; growth: number };
|
||||||
@@ -36,11 +37,11 @@ export default function AnalyticsPage() {
|
|||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
|
|
||||||
const [ovRes, trRes] = await Promise.all([
|
const [ovRes, trRes] = await Promise.all([
|
||||||
fetch(`${base}/api/v1/admin/analytics/overview?range=${range}`, { headers }),
|
fetch(`${base}/admin/analytics/overview?range=${range}`, { headers }),
|
||||||
fetch(`${base}/api/v1/admin/analytics/trend?type=${trendType}&days=${range === 'today' ? 7 : range === '7d' ? 30 : 90}`, { headers }),
|
fetch(`${base}/admin/analytics/trend?type=${trendType}&days=${range === 'today' ? 7 : range === '7d' ? 30 : 90}`, { headers }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (ovRes.ok) setOverview(await ovRes.json());
|
if (ovRes.ok) setOverview(await ovRes.json());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface PendingComment {
|
interface PendingComment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -17,7 +18,7 @@ export default function AdminCommentsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
|
const [tab, setTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
const base = API_BASE;
|
||||||
function headers() {
|
function headers() {
|
||||||
const t = localStorage.getItem('adminToken');
|
const t = localStorage.getItem('adminToken');
|
||||||
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
|
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function EditContentPage() {
|
export default function EditContentPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -17,7 +18,7 @@ export default function EditContentPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadContent(); }, [params.id]);
|
useEffect(() => { loadContent(); }, [params.id]);
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
function token() { return localStorage.getItem('adminToken'); }
|
function token() { return localStorage.getItem('adminToken'); }
|
||||||
function headers() {
|
function headers() {
|
||||||
const t = token();
|
const t = token();
|
||||||
@@ -26,7 +27,7 @@ export default function EditContentPage() {
|
|||||||
|
|
||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/contents/${params.id}`, { headers: headers() });
|
const res = await fetch(`${base}/contents/${params.id}`, { headers: headers() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setForm({ title: data.title || '', summary: data.summary || '', content: data.content || '', cover: data.cover || '', contentType: data.contentType || 'article' });
|
setForm({ title: data.title || '', summary: data.summary || '', content: data.content || '', cover: data.cover || '', contentType: data.contentType || 'article' });
|
||||||
@@ -40,7 +41,7 @@ export default function EditContentPage() {
|
|||||||
if (!form.title.trim() || !form.content.trim()) return;
|
if (!form.title.trim() || !form.content.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/contents/${params.id}`, {
|
const res = await fetch(`${base}/contents/${params.id}`, {
|
||||||
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
||||||
});
|
});
|
||||||
if (res.ok) router.push('/admin/contents');
|
if (res.ok) router.push('/admin/contents');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/contents`);
|
||||||
const res = await fetch(`${base}/api/v1/contents`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function NewContentPage() {
|
export default function NewContentPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,7 +19,7 @@ export default function NewContentPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(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/contents`, {
|
const res = await fetch(`${API_BASE}/contents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ export default function AdminContents() {
|
|||||||
async function loadContents() {
|
async function loadContents() {
|
||||||
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/contents?pageSize=50`, {
|
const res = await fetch(`${API_BASE}/contents?pageSize=50`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default function AdminContents() {
|
|||||||
async function toggleStatus(id: number, currentStatus: string) {
|
async function toggleStatus(id: number, currentStatus: string) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/contents/${id}/status`, {
|
await fetch(`${API_BASE}/admin/contents/${id}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function EditCoursePage() {
|
export default function EditCoursePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -17,7 +18,7 @@ export default function EditCoursePage() {
|
|||||||
|
|
||||||
useEffect(() => { loadCourse(); }, [params.id]);
|
useEffect(() => { loadCourse(); }, [params.id]);
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
function token() { return localStorage.getItem('adminToken'); }
|
function token() { return localStorage.getItem('adminToken'); }
|
||||||
function headers() {
|
function headers() {
|
||||||
const t = token();
|
const t = token();
|
||||||
@@ -26,7 +27,7 @@ export default function EditCoursePage() {
|
|||||||
|
|
||||||
async function loadCourse() {
|
async function loadCourse() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/courses/${params.id}`, { headers: headers() });
|
const res = await fetch(`${base}/courses/${params.id}`, { headers: headers() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setForm({ title: data.title || '', description: data.description || '', cover: data.cover || '', isFree: data.isFree ?? true });
|
setForm({ title: data.title || '', description: data.description || '', cover: data.cover || '', isFree: data.isFree ?? true });
|
||||||
@@ -40,7 +41,7 @@ export default function EditCoursePage() {
|
|||||||
if (!form.title.trim()) return;
|
if (!form.title.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/courses/${params.id}`, {
|
const res = await fetch(`${base}/courses/${params.id}`, {
|
||||||
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
||||||
});
|
});
|
||||||
if (res.ok) router.push('/admin/courses');
|
if (res.ok) router.push('/admin/courses');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/courses`);
|
||||||
const res = await fetch(`${base}/api/v1/courses`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function NewCoursePage() {
|
export default function NewCoursePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,7 +19,7 @@ export default function NewCoursePage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(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/courses`, {
|
const res = await fetch(`${API_BASE}/courses`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Course {
|
interface Course {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ export default function AdminCourses() {
|
|||||||
async function loadCourses() {
|
async function loadCourses() {
|
||||||
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/courses?pageSize=50`, {
|
const res = await fetch(`${API_BASE}/courses?pageSize=50`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default function AdminCourses() {
|
|||||||
async function toggleStatus(id: number, currentStatus: string) {
|
async function toggleStatus(id: number, currentStatus: string) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/courses/${id}/status`, {
|
await fetch(`${API_BASE}/admin/courses/${id}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function OrgDetailPage() {
|
export default function OrgDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -27,11 +28,11 @@ export default function OrgDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadOrg(); loadCourses(); }, [orgId]);
|
useEffect(() => { loadOrg(); loadCourses(); }, [orgId]);
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
|
|
||||||
async function loadOrg() {
|
async function loadOrg() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}`, { headers: headers() });
|
const res = await fetch(`${base}/enterprise/organizations/${orgId}`, { headers: headers() });
|
||||||
if (res.ok) setOrg(await res.json());
|
if (res.ok) setOrg(await res.json());
|
||||||
} catch {}
|
} catch {}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -39,7 +40,7 @@ export default function OrgDetailPage() {
|
|||||||
|
|
||||||
async function loadCourses() {
|
async function loadCourses() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/courses`, { headers: headers() });
|
const res = await fetch(`${base}/courses`, { headers: headers() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCourses(data.items || []);
|
setCourses(data.items || []);
|
||||||
@@ -51,7 +52,7 @@ export default function OrgDetailPage() {
|
|||||||
const uid = Number(memberUserId);
|
const uid = Number(memberUserId);
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/members`, {
|
const res = await fetch(`${base}/enterprise/organizations/${orgId}/members`, {
|
||||||
method: 'POST', headers: headers(),
|
method: 'POST', headers: headers(),
|
||||||
body: JSON.stringify({ userId: uid }),
|
body: JSON.stringify({ userId: uid }),
|
||||||
});
|
});
|
||||||
@@ -65,7 +66,7 @@ export default function OrgDetailPage() {
|
|||||||
|
|
||||||
async function removeMember(userId: number) {
|
async function removeMember(userId: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/members/${userId}`, {
|
await fetch(`${base}/enterprise/organizations/${orgId}/members/${userId}`, {
|
||||||
method: 'DELETE', headers: headers(),
|
method: 'DELETE', headers: headers(),
|
||||||
});
|
});
|
||||||
loadOrg();
|
loadOrg();
|
||||||
@@ -78,7 +79,7 @@ export default function OrgDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const body: any = { courseId: cid };
|
const body: any = { courseId: cid };
|
||||||
if (courseDeadline) body.deadline = courseDeadline;
|
if (courseDeadline) body.deadline = courseDeadline;
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/assignments`, {
|
const res = await fetch(`${base}/enterprise/organizations/${orgId}/assignments`, {
|
||||||
method: 'POST', headers: headers(),
|
method: 'POST', headers: headers(),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
@@ -93,7 +94,7 @@ export default function OrgDetailPage() {
|
|||||||
|
|
||||||
async function updateOrg() {
|
async function updateOrg() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}`, {
|
const res = await fetch(`${base}/enterprise/organizations/${orgId}`, {
|
||||||
method: 'PUT', headers: headers(),
|
method: 'PUT', headers: headers(),
|
||||||
body: JSON.stringify(editForm),
|
body: JSON.stringify(editForm),
|
||||||
});
|
});
|
||||||
@@ -106,7 +107,7 @@ export default function OrgDetailPage() {
|
|||||||
|
|
||||||
async function removeAssignment(courseId: number) {
|
async function removeAssignment(courseId: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/assignments/${courseId}`, {
|
await fetch(`${base}/enterprise/organizations/${orgId}/assignments/${courseId}`, {
|
||||||
method: 'DELETE', headers: headers(),
|
method: 'DELETE', headers: headers(),
|
||||||
});
|
});
|
||||||
loadOrg();
|
loadOrg();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/enterprise/organizations`);
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/enterprise/organizations`);
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function OrgReportPage() {
|
export default function OrgReportPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -19,11 +20,11 @@ export default function OrgReportPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadReport(); }, [orgId]);
|
useEffect(() => { loadReport(); }, [orgId]);
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
|
|
||||||
async function loadReport() {
|
async function loadReport() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations/${orgId}/report`, { headers: headers() });
|
const res = await fetch(`${base}/enterprise/organizations/${orgId}/report`, { headers: headers() });
|
||||||
if (res.ok) setReport(await res.json());
|
if (res.ok) setReport(await res.json());
|
||||||
} catch {}
|
} catch {}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
id: number; name: string; description?: string;
|
id: number; name: string; description?: string;
|
||||||
@@ -30,8 +31,8 @@ export default function EnterprisePage() {
|
|||||||
|
|
||||||
async function loadOrgs() {
|
async function loadOrgs() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`, { headers: headers() });
|
const res = await fetch(`${base}/enterprise/organizations`, { headers: headers() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setOrgs(data.items || []);
|
setOrgs(data.items || []);
|
||||||
@@ -43,8 +44,8 @@ export default function EnterprisePage() {
|
|||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
const res = await fetch(`${base}/api/v1/enterprise/organizations`, {
|
const res = await fetch(`${base}/enterprise/organizations`, {
|
||||||
method: 'POST', headers: headers(),
|
method: 'POST', headers: headers(),
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { toast } from 'sonner';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Banner {
|
interface Banner {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ export default function BannersPage() {
|
|||||||
setLoading(true);
|
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/banners`, {
|
const res = await fetch(`${API_BASE}/admin/banners`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -38,7 +39,7 @@ export default function BannersPage() {
|
|||||||
async function createBanner() {
|
async function createBanner() {
|
||||||
if (!form.title || !form.image) return alert('请填写标题和图片');
|
if (!form.title || !form.image) 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/banners`, {
|
await fetch(`${API_BASE}/admin/banners`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ ...form, status: 'PUBLISHED' }),
|
body: JSON.stringify({ ...form, status: 'PUBLISHED' }),
|
||||||
@@ -51,7 +52,7 @@ export default function BannersPage() {
|
|||||||
async function deleteBanner(id: number) {
|
async function deleteBanner(id: number) {
|
||||||
if (!confirm('确定删除?')) return;
|
if (!confirm('确定删除?')) return;
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/banners/${id}`, {
|
await fetch(`${API_BASE}/admin/banners/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ export default function NotificationsPage() {
|
|||||||
setLoading(true);
|
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/notifications`, {
|
const res = await fetch(`${API_BASE}/admin/notifications`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -38,7 +39,7 @@ export default function NotificationsPage() {
|
|||||||
async function sendNotification() {
|
async function sendNotification() {
|
||||||
if (!form.title || !form.content) return alert('请填写标题和内容');
|
if (!form.title || !form.content) 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/notifications`, {
|
await fetch(`${API_BASE}/admin/notifications`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ ...form, status: 'SENT' }),
|
body: JSON.stringify({ ...form, status: 'SENT' }),
|
||||||
@@ -51,7 +52,7 @@ export default function NotificationsPage() {
|
|||||||
async function deleteNotification(id: number) {
|
async function deleteNotification(id: number) {
|
||||||
if (!confirm('确定删除?')) return;
|
if (!confirm('确定删除?')) return;
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/notifications/${id}`, {
|
await fetch(`${API_BASE}/admin/notifications/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,7 +24,7 @@ export default function AdminOrders() {
|
|||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
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/orders`, {
|
const res = await fetch(`${API_BASE}/orders`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default function AdminOrders() {
|
|||||||
if (!confirm('确认要退款吗?')) return;
|
if (!confirm('确认要退款吗?')) return;
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/payment/wxpay/refund`, {
|
await fetch(`${API_BASE}/payment/wxpay/refund`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
@@ -40,7 +41,7 @@ export default function AdminDashboard() {
|
|||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
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/dashboard`, {
|
const res = await fetch(`${API_BASE}/admin/dashboard`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function EditPromptPage() {
|
export default function EditPromptPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -17,7 +18,7 @@ export default function EditPromptPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadPrompt(); }, [params.id]);
|
useEffect(() => { loadPrompt(); }, [params.id]);
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
function token() { return localStorage.getItem('adminToken'); }
|
function token() { return localStorage.getItem('adminToken'); }
|
||||||
function headers() {
|
function headers() {
|
||||||
const t = token();
|
const t = token();
|
||||||
@@ -26,7 +27,7 @@ export default function EditPromptPage() {
|
|||||||
|
|
||||||
async function loadPrompt() {
|
async function loadPrompt() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/prompts/${params.id}`, { headers: headers() });
|
const res = await fetch(`${base}/prompts/${params.id}`, { headers: headers() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setForm({ title: data.title || '', content: data.content || '', description: data.description || '', tags: data.tags || '', model: data.model || '' });
|
setForm({ title: data.title || '', content: data.content || '', description: data.description || '', tags: data.tags || '', model: data.model || '' });
|
||||||
@@ -40,7 +41,7 @@ export default function EditPromptPage() {
|
|||||||
if (!form.title.trim() || !form.content.trim()) return;
|
if (!form.title.trim() || !form.content.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/prompts/${params.id}`, {
|
const res = await fetch(`${base}/prompts/${params.id}`, {
|
||||||
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
method: 'PUT', headers: headers(), body: JSON.stringify(form),
|
||||||
});
|
});
|
||||||
if (res.ok) router.push('/admin/prompts');
|
if (res.ok) router.push('/admin/prompts');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/prompts`);
|
||||||
const res = await fetch(`${base}/api/v1/prompts`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function NewPromptPage() {
|
export default function NewPromptPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,7 +19,7 @@ export default function NewPromptPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(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/prompts`, {
|
const res = await fetch(`${API_BASE}/prompts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Prompt {
|
interface Prompt {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ export default function AdminPrompts() {
|
|||||||
async function loadPrompts() {
|
async function loadPrompts() {
|
||||||
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/prompts?pageSize=50`, {
|
const res = await fetch(`${API_BASE}/prompts?pageSize=50`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default function AdminPrompts() {
|
|||||||
async function toggleStatus(id: number, currentStatus: string) {
|
async function toggleStatus(id: number, currentStatus: string) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/prompts/${id}/status`, {
|
await fetch(`${API_BASE}/admin/prompts/${id}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -21,7 +22,7 @@ export default function ConfigPage() {
|
|||||||
setLoading(true);
|
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/config/${category}`, {
|
const res = await fetch(`${API_BASE}/admin/config/${category}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -37,7 +38,7 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
async function saveConfig(key: string) {
|
async function saveConfig(key: string) {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/config/${key}`, {
|
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ value: form[key] }),
|
body: JSON.stringify({ value: form[key] }),
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import * as Dialog from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +22,20 @@ interface Permission {
|
|||||||
category: string;
|
category: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Admin {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
roleId: string | null;
|
||||||
|
role: { id: string; name: string } | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('adminToken');
|
||||||
|
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsRolesPage() {
|
export default function SettingsRolesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
@@ -24,213 +43,385 @@ export default function SettingsRolesPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState<'roles' | 'admins'>('roles');
|
const [tab, setTab] = useState<'roles' | 'admins'>('roles');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(async () => {
|
||||||
loadData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const headers = getAuthHeaders();
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
|
||||||
|
|
||||||
const [rolesRes, permsRes] = await Promise.all([
|
const [rolesRes, permsRes] = await Promise.all([
|
||||||
fetch(`${base}/api/v1/admin/settings/roles`, { headers }),
|
fetch(`${API_BASE}/admin/settings/roles`, { headers }),
|
||||||
fetch(`${base}/api/v1/admin/settings/permissions`, { headers }),
|
fetch(`${API_BASE}/admin/settings/permissions`, { headers }),
|
||||||
]);
|
]);
|
||||||
|
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
|
||||||
if (rolesRes.ok) {
|
if (permsRes.ok) setPermissions((await permsRes.json()).items || []);
|
||||||
const data = await rolesRes.json();
|
} catch (e) { console.error(e); }
|
||||||
setRoles(data.items || []);
|
|
||||||
}
|
|
||||||
if (permsRes.ok) {
|
|
||||||
const data = await permsRes.json();
|
|
||||||
setPermissions(data.items || []);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
async function createRole() {
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
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))];
|
const categories = [...new Set(permissions.map(p => p.category))];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-6">加载中...</div>;
|
return <div className="p-6 text-center text-muted-foreground">加载中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">角色权限管理</h1>
|
<h1 className="text-3xl font-bold text-foreground">角色权限管理</h1>
|
||||||
<p className="text-sm text-muted-foreground">管理系统角色和权限配置</p>
|
<p className="mt-2 text-muted-foreground">管理系统角色、权限和管理员</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={createRole}
|
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
|
||||||
>
|
|
||||||
新建角色
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6">
|
||||||
<button
|
<Button variant={tab === 'roles' ? 'default' : 'outline'} onClick={() => setTab('roles')}>角色管理</Button>
|
||||||
onClick={() => setTab('roles')}
|
<Button variant={tab === 'admins' ? 'default' : 'outline'} onClick={() => setTab('admins')}>管理员</Button>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{tab === 'roles' ? (
|
{tab === 'roles' ? (
|
||||||
<div className="space-y-4">
|
<RolesTab roles={roles} permissions={permissions} categories={categories} onReload={loadData} />
|
||||||
{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 />
|
<AdminsTab onReload={loadData} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminsList() {
|
function RolesTab({ roles, permissions, categories, onReload }: {
|
||||||
const [admins, setAdmins] = useState<any[]>([]);
|
roles: Role[]; permissions: Permission[]; categories: string[]; onReload: () => void;
|
||||||
const [roles, setRoles] = useState<any[]>([]);
|
}) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [editRole, setEditRole] = useState<Role | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<div className="flex justify-end mb-4">
|
||||||
onClick={createAdmin}
|
<Button onClick={() => { setEditRole(null); setOpen(true); }}>新建角色</Button>
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 mb-4"
|
</div>
|
||||||
>
|
|
||||||
新建管理员
|
<RoleDialog
|
||||||
</button>
|
role={editRole}
|
||||||
<div className="space-y-3">
|
permissions={permissions}
|
||||||
{admins.map(admin => (
|
categories={categories}
|
||||||
<div key={admin.id} className="bg-card border border-border rounded-xl p-4 flex items-center justify-between">
|
open={open}
|
||||||
<div>
|
onOpenChange={setOpen}
|
||||||
<div className="font-medium text-foreground">{admin.username}</div>
|
onSaved={() => { setOpen(false); onReload(); }}
|
||||||
<div className="text-sm text-muted-foreground">{admin.nickname || '暂无昵称'} · {admin.role?.name || '未分配角色'}</div>
|
/>
|
||||||
</div>
|
|
||||||
<button
|
{roles.length === 0 ? (
|
||||||
onClick={async () => {
|
<div className="text-center py-12 text-muted-foreground">暂无角色</div>
|
||||||
if (!confirm('确定要禁用这个管理员吗?')) return;
|
) : (
|
||||||
const token = localStorage.getItem('adminToken');
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/settings/admins/${admin.id}`, {
|
<table className="w-full text-sm">
|
||||||
method: 'PUT',
|
<thead>
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
<tr className="border-b border-border bg-muted/50">
|
||||||
body: JSON.stringify({ status: 'DISABLED' }),
|
<th className="text-left px-4 py-3 font-medium text-foreground">角色名称</th>
|
||||||
});
|
<th className="text-left px-4 py-3 font-medium text-foreground">描述</th>
|
||||||
loadData();
|
<th className="text-left px-4 py-3 font-medium text-foreground">权限</th>
|
||||||
}}
|
<th className="text-right px-4 py-3 font-medium text-foreground">操作</th>
|
||||||
className="text-red-500 hover:text-red-700 text-sm"
|
</tr>
|
||||||
>
|
</thead>
|
||||||
禁用
|
<tbody>
|
||||||
</button>
|
{roles.map(role => (
|
||||||
|
<tr key={role.id} className="border-b border-border hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-3 font-medium text-foreground">{role.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{role.description || '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(role.permissions || []).length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">无权限</span>
|
||||||
|
) : (
|
||||||
|
role.permissions.map(p => (
|
||||||
|
<Badge key={p} variant="secondary" className="text-xs">
|
||||||
|
{permissions.find(perm => perm.key === p)?.name || p}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditRole(role); setOpen(true); }}>编辑</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('确定要删除此角色吗?')) return;
|
||||||
|
await fetch(`${API_BASE}/admin/settings/roles/${role.id}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||||
|
onReload();
|
||||||
|
}}
|
||||||
|
>删除</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleDialog({ role, permissions, categories, open, onOpenChange, onSaved }: {
|
||||||
|
role: Role | null; permissions: Permission[]; categories: string[]; open: boolean; onOpenChange: (v: boolean) => void; onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(role?.name || '');
|
||||||
|
setDescription(role?.description || '');
|
||||||
|
setSelectedPerms(role?.permissions || []);
|
||||||
|
}
|
||||||
|
}, [open, role]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
if (role) {
|
||||||
|
await fetch(`${API_BASE}/admin/settings/roles/${role.id}`, {
|
||||||
|
method: 'PUT', headers,
|
||||||
|
body: JSON.stringify({ name, description, permissions: selectedPerms }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch(`${API_BASE}/admin/settings/roles`, {
|
||||||
|
method: 'POST', headers,
|
||||||
|
body: JSON.stringify({ name, description, permissions: selectedPerms }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.DialogContent>
|
||||||
|
<Dialog.DialogHeader>
|
||||||
|
<Dialog.DialogTitle>{role ? '编辑角色' : '新建角色'}</Dialog.DialogTitle>
|
||||||
|
</Dialog.DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">角色名称</label>
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} placeholder="请输入角色名称" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">描述</label>
|
||||||
|
<textarea
|
||||||
|
className="flex w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm"
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="请输入角色描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">权限设置</label>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div key={cat} className="mb-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">{cat}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{permissions.filter(p => p.category === cat).map(perm => (
|
||||||
|
<label key={perm.key} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerms.includes(perm.key)}
|
||||||
|
onChange={e => {
|
||||||
|
setSelectedPerms(prev =>
|
||||||
|
e.target.checked
|
||||||
|
? [...prev, perm.key]
|
||||||
|
: prev.filter(k => k !== perm.key)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">{perm.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!name.trim() || saving}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.DialogFooter>
|
||||||
|
</Dialog.DialogContent>
|
||||||
|
</Dialog.Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminsTab({ onReload }: { onReload: () => void }) {
|
||||||
|
const [admins, setAdmins] = useState<Admin[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editAdmin, setEditAdmin] = useState<Admin | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const [adminsRes, rolesRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/admin/settings/admins`, { headers }),
|
||||||
|
fetch(`${API_BASE}/admin/settings/roles`, { headers }),
|
||||||
|
]);
|
||||||
|
if (adminsRes.ok) setAdmins((await adminsRes.json()).items || []);
|
||||||
|
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-8 text-muted-foreground">加载中...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<Button onClick={() => { setEditAdmin(null); setOpen(true); }}>新建管理员</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminDialog
|
||||||
|
admin={editAdmin}
|
||||||
|
roles={roles}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onSaved={() => { setOpen(false); loadData(); onReload(); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-foreground">用户名</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-foreground">昵称</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-foreground">角色</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-foreground">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{admins.map(admin => (
|
||||||
|
<tr key={admin.id} className="border-b border-border hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-3 font-medium text-foreground">{admin.username}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{admin.nickname || '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant="secondary" className="text-xs">{admin.role?.name || '未分配'}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditAdmin(admin); setOpen(true); }}>编辑</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('确定要禁用此管理员吗?')) return;
|
||||||
|
await fetch(`${API_BASE}/admin/settings/admins/${admin.id}`, {
|
||||||
|
method: 'PUT', headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ status: 'DISABLED' }),
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
>禁用</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{admins.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="text-center py-8 text-muted-foreground">暂无管理员</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdminDialog({ admin, roles, open, onOpenChange, onSaved }: {
|
||||||
|
admin: Admin | null; roles: Role[]; open: boolean; onOpenChange: (v: boolean) => void; onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [nickname, setNickname] = useState('');
|
||||||
|
const [roleId, setRoleId] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setUsername(admin?.username || '');
|
||||||
|
setPassword('');
|
||||||
|
setNickname(admin?.nickname || '');
|
||||||
|
setRoleId(admin?.roleId || '');
|
||||||
|
}
|
||||||
|
}, [open, admin]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!username.trim()) return;
|
||||||
|
if (!admin && !password.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
if (admin) {
|
||||||
|
await fetch(`${API_BASE}/admin/settings/admins/${admin.id}`, {
|
||||||
|
method: 'PUT', headers,
|
||||||
|
body: JSON.stringify({ nickname, roleId: roleId || null }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch(`${API_BASE}/admin/settings/admins`, {
|
||||||
|
method: 'POST', headers,
|
||||||
|
body: JSON.stringify({ username, password, nickname, roleId: roleId || undefined }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.DialogContent>
|
||||||
|
<Dialog.DialogHeader>
|
||||||
|
<Dialog.DialogTitle>{admin ? '编辑管理员' : '新建管理员'}</Dialog.DialogTitle>
|
||||||
|
</Dialog.DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">用户名</label>
|
||||||
|
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="登录用户名"
|
||||||
|
disabled={!!admin} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">{admin ? '新密码(留空不修改)' : '密码'}</label>
|
||||||
|
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={admin ? '留空则不修改' : '请输入密码'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">昵称</label>
|
||||||
|
<Input value={nickname} onChange={e => setNickname(e.target.value)} placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">角色</label>
|
||||||
|
<select
|
||||||
|
className="flex w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm"
|
||||||
|
value={roleId}
|
||||||
|
onChange={e => setRoleId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">无角色</option>
|
||||||
|
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!username.trim() || (!admin && !password.trim()) || saving}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</Dialog.DialogFooter>
|
||||||
|
</Dialog.DialogContent>
|
||||||
|
</Dialog.Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function EditToolPage() {
|
export default function EditToolPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -17,7 +18,7 @@ export default function EditToolPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadTool(); }, [params.id]);
|
useEffect(() => { loadTool(); }, [params.id]);
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const base = API_BASE;
|
||||||
function token() { return localStorage.getItem('adminToken'); }
|
function token() { return localStorage.getItem('adminToken'); }
|
||||||
function headers() {
|
function headers() {
|
||||||
const t = token();
|
const t = token();
|
||||||
@@ -26,7 +27,7 @@ export default function EditToolPage() {
|
|||||||
|
|
||||||
async function loadTool() {
|
async function loadTool() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/v1/tools`, { headers: headers() });
|
const res = await fetch(`${base}/tools`, { headers: headers() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const tool = (data.items || []).find((t: any) => t.id === Number(params.id));
|
const tool = (data.items || []).find((t: any) => t.id === Number(params.id));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/tools`);
|
||||||
const res = await fetch(`${base}/api/v1/tools`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export default function NewToolPage() {
|
export default function NewToolPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,7 +19,7 @@ export default function NewToolPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(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/tools`, {
|
const res = await fetch(`${API_BASE}/tools`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Tool {
|
interface Tool {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,7 +24,7 @@ export default function AdminTools() {
|
|||||||
async function loadTools() {
|
async function loadTools() {
|
||||||
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/tools?pageSize=50`, {
|
const res = await fetch(`${API_BASE}/tools?pageSize=50`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export default function AdminTools() {
|
|||||||
async function toggleStatus(id: number, currentStatus: string) {
|
async function toggleStatus(id: number, currentStatus: string) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/tools/${id}/status`, {
|
await fetch(`${API_BASE}/admin/tools/${id}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,7 +28,7 @@ export default function UsersPage() {
|
|||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
const params = search ? `?search=${encodeURIComponent(search)}` : '';
|
const params = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users${params}`, {
|
const res = await fetch(`${API_BASE}/admin/users${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -41,7 +42,7 @@ export default function UsersPage() {
|
|||||||
async function createUser() {
|
async function createUser() {
|
||||||
if (!form.phone || !form.password) return alert('手机号和密码必填');
|
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`, {
|
await fetch(`${API_BASE}/admin/users`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
@@ -54,7 +55,7 @@ export default function UsersPage() {
|
|||||||
async function updateUser() {
|
async function updateUser() {
|
||||||
if (!editId) return;
|
if (!editId) return;
|
||||||
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/${editId}`, {
|
await fetch(`${API_BASE}/admin/users/${editId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ nickname: form.nickname, email: form.email }),
|
body: JSON.stringify({ nickname: form.nickname, email: form.email }),
|
||||||
@@ -68,7 +69,7 @@ export default function UsersPage() {
|
|||||||
async function deleteUser(id: number) {
|
async function deleteUser(id: number) {
|
||||||
if (!confirm('确定删除该用户?')) return;
|
if (!confirm('确定删除该用户?')) return;
|
||||||
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/${id}`, {
|
await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
@@ -78,7 +79,7 @@ export default function UsersPage() {
|
|||||||
async function toggleStatus(id: number, currentStatus: string) {
|
async function toggleStatus(id: number, currentStatus: string) {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
const newStatus = currentStatus === 'ACTIVE' ? 'BANNED' : 'ACTIVE';
|
const newStatus = currentStatus === 'ACTIVE' ? 'BANNED' : 'ACTIVE';
|
||||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/admin/users/${id}`, {
|
await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
function AuthForm() {
|
function AuthForm() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -36,8 +37,8 @@ export default function CircleDetail() {
|
|||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
const [circleRes, postsRes] = await Promise.all([
|
const [circleRes, postsRes] = await Promise.all([
|
||||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}`, { headers }),
|
fetch(`${API_BASE}/circles/${circleId}`, { headers }),
|
||||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/posts`, { headers }),
|
fetch(`${API_BASE}/circles/${circleId}/posts`, { headers }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (circleRes.ok) setCircle(await circleRes.json());
|
if (circleRes.ok) setCircle(await circleRes.json());
|
||||||
@@ -47,7 +48,7 @@ export default function CircleDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const memRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/membership`, {
|
const memRes = await fetch(`${API_BASE}/circles/${circleId}/membership`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (memRes.ok) {
|
if (memRes.ok) {
|
||||||
@@ -65,8 +66,8 @@ export default function CircleDetail() {
|
|||||||
|
|
||||||
const method = isMember ? 'POST' : 'POST';
|
const method = isMember ? 'POST' : 'POST';
|
||||||
const url = isMember
|
const url = isMember
|
||||||
? `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/leave`
|
? `${API_BASE}/circles/${circleId}/leave`
|
||||||
: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles/${circleId}/join`;
|
: `${API_BASE}/circles/${circleId}/join`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -85,7 +86,7 @@ export default function CircleDetail() {
|
|||||||
if (!token || !formTitle || !formContent) return;
|
if (!token || !formTitle || !formContent) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/community/posts`, {
|
const res = await fetch(`${API_BASE}/community/posts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: formTitle, content: formContent, circleId }),
|
body: JSON.stringify({ title: formTitle, content: formContent, circleId }),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/circles`);
|
||||||
const res = await fetch(`${base}/api/v1/circles`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Circle {
|
interface Circle {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,7 +22,7 @@ export default function CirclesPage() {
|
|||||||
|
|
||||||
async function loadCircles() {
|
async function loadCircles() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/circles`);
|
const res = await fetch(`${API_BASE}/circles`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCircles(data || []);
|
setCircles(data || []);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/community/posts`);
|
||||||
const res = await fetch(`${base}/api/v1/community/posts`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number; title: string; summary?: string; content?: string; cover?: string;
|
id: number; title: string; summary?: string; content?: string; cover?: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/contents`);
|
||||||
const res = await fetch(`${base}/api/v1/contents`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { FileText, Eye } from 'lucide-react';
|
import { FileText, Eye } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number; title: string; summary: string | null; cover: string | null;
|
id: number; title: string; summary: string | null; cover: string | null;
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: number; title: string; content?: string; sortOrder: number; status: string;
|
id: number; title: string; content?: string; sortOrder: number; status: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/courses`);
|
||||||
const res = await fetch(`${base}/api/v1/courses`);
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) return [{ id: '1' }];
|
if (items.length === 0) return [{ id: '1' }];
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { BookOpen, Users } from 'lucide-react';
|
import { BookOpen, Users } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Course {
|
interface Course {
|
||||||
id: number; title: string; description: string; cover: string | null;
|
id: number; title: string; description: string; cover: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { RootLayoutClient } from './layout-client';
|
import { RootLayoutClient } from './layout-client';
|
||||||
|
import { SITE_URL } from '@/lib/config';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -20,7 +21,7 @@ export const metadata: Metadata = {
|
|||||||
siteName: '宇之然 AI',
|
siteName: '宇之然 AI',
|
||||||
title: '宇之然 AI - AI 工具与知识社区',
|
title: '宇之然 AI - AI 工具与知识社区',
|
||||||
description: '让每个人都能用好 AI',
|
description: '让每个人都能用好 AI',
|
||||||
url: 'https://yuzhiran.com',
|
url: SITE_URL,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface AiModel {
|
interface AiModel {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { apiFetch } from '../../../lib/auth';
|
import { apiFetch } from '../../../lib/auth';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import PaymentModal from '@/components/ui/payment-modal';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
|
import { isWeChatBrowser, getOpenidFromUrl, isMiniProgram } from '@/lib/wechat';
|
||||||
|
|
||||||
|
interface PayResult {
|
||||||
|
prepay_id?: string;
|
||||||
|
nonceStr?: string;
|
||||||
|
timeStamp?: string;
|
||||||
|
package?: string;
|
||||||
|
paySign?: string;
|
||||||
|
signType?: string;
|
||||||
|
codeUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Subscription {
|
interface Subscription {
|
||||||
id: number; plan: string; startDate: string; endDate: string; status: string;
|
id: number; plan: string; startDate: string; endDate: string; status: string;
|
||||||
@@ -29,6 +41,9 @@ export default function MemberPage() {
|
|||||||
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
const [payLoading, setPayLoading] = useState<string | null>(null);
|
||||||
|
const [paymentModal, setPaymentModal] = useState<{
|
||||||
|
open: boolean; orderNo: string; payResult: PayResult; tradeType: 'JSAPI' | 'NATIVE';
|
||||||
|
}>({ open: false, orderNo: '', payResult: {}, tradeType: 'NATIVE' });
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, []);
|
useEffect(() => { loadData(); }, []);
|
||||||
|
|
||||||
@@ -51,22 +66,43 @@ export default function MemberPage() {
|
|||||||
async function handleSubscribe(planType: string) {
|
async function handleSubscribe(planType: string) {
|
||||||
setPayLoading(planType);
|
setPayLoading(planType);
|
||||||
try {
|
try {
|
||||||
|
const inWeChat = isWeChatBrowser();
|
||||||
|
const openid = getOpenidFromUrl();
|
||||||
|
const useJsapi = inWeChat && openid && isMiniProgram();
|
||||||
|
const tradeType = useJsapi ? 'JSAPI' : 'NATIVE';
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
amount: planType === 'MONTHLY' ? 29.9 : 199,
|
||||||
|
planType, payChannel: 'wxpay', tradeType,
|
||||||
|
};
|
||||||
|
if (useJsapi && openid) body.openid = openid;
|
||||||
|
|
||||||
const res = await apiFetch('/orders/create', {
|
const res = await apiFetch('/orders/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
amount: planType === 'MONTHLY' ? 29.9 : 199,
|
|
||||||
planType, payChannel: 'wxpay',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.order && data.payResult) {
|
if (data.order && data.payResult) {
|
||||||
alert('订单创建成功,请扫码支付(模拟模式)');
|
// Mock mode → auto-completed by backend
|
||||||
loadData();
|
if (data.payResult.codeUrl === 'mock://pay') {
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
setPaymentModal({
|
||||||
|
open: true, orderNo: data.order.orderNo,
|
||||||
|
payResult: data.payResult, tradeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
setPayLoading(null);
|
setPayLoading(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePaymentPaid() {
|
||||||
|
setPaymentModal(prev => ({ ...prev, open: false }));
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<Skeleton className="h-8 w-48 mb-2" />
|
<Skeleton className="h-8 w-48 mb-2" />
|
||||||
@@ -161,6 +197,14 @@ export default function MemberPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PaymentModal
|
||||||
|
open={paymentModal.open}
|
||||||
|
orderNo={paymentModal.orderNo}
|
||||||
|
payResult={paymentModal.payResult}
|
||||||
|
tradeType={paymentModal.tradeType}
|
||||||
|
onPaid={handlePaymentPaid}
|
||||||
|
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { MessageSquare, Heart } from 'lucide-react';
|
import { MessageSquare, Heart } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Prompt {
|
interface Prompt {
|
||||||
id: number; title: string; description: string; content: string;
|
id: number; title: string; description: string; content: string;
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { useAuth } from '@/lib/auth-context';
|
|||||||
import { apiFetch, getToken } from '@/lib/auth';
|
import { apiFetch, getToken } from '@/lib/auth';
|
||||||
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
|
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
|
||||||
import { ModelSelector } from '@/components/ui/model-selector';
|
import { ModelSelector } from '@/components/ui/model-selector';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { DEFAULT_MODEL } from '@/lib/models';
|
|||||||
import { ModelSelector } from '@/components/ui/model-selector';
|
import { ModelSelector } from '@/components/ui/model-selector';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
import { CodeBlock } from '@/components/ui/code-block';
|
import { CodeBlock } from '@/components/ui/code-block';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
@@ -22,7 +21,7 @@ interface SessionItem {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
model: string;
|
model: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
createdAt: string;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +364,7 @@ function SandboxPage() {
|
|||||||
<div className="text-xs font-medium text-foreground truncate">{s.title}</div>
|
<div className="text-xs font-medium text-foreground truncate">{s.title}</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<span className="text-[10px] text-muted-foreground">{formatTime(s.updatedAt)} · {s.model}</span>
|
<span className="text-[10px] text-muted-foreground">{formatTime(s.createdAt)} · {s.model}</span>
|
||||||
<button onClick={e => { e.stopPropagation(); deleteSession(s.id); }}
|
<button onClick={e => { e.stopPropagation(); deleteSession(s.id); }}
|
||||||
className="opacity-0 group-hover:opacity-100 text-[10px] text-red-500 hover:text-red-700">
|
className="opacity-0 group-hover:opacity-100 text-[10px] text-red-500 hover:text-red-700">
|
||||||
{t.common.delete}
|
{t.common.delete}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { Suspense, useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface SharedMessage {
|
interface SharedMessage {
|
||||||
role: string;
|
role: string;
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { SearchIcon, FileText, BookOpen, Wrench, MessageSquare } from 'lucide-react';
|
import { SearchIcon, FileText, BookOpen, Wrench, MessageSquare } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
id: number; _type: 'course' | 'prompt' | 'tool' | 'content';
|
id: number; _type: 'course' | 'prompt' | 'tool' | 'content';
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface SkillTask { label: string; prompt: string }
|
interface SkillTask { label: string; prompt: string }
|
||||||
interface Skill {
|
interface Skill {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Skill {
|
interface Skill {
|
||||||
id: string; name: string; description: string; icon: string;
|
id: string; name: string; description: string; icon: string;
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Wrench, ExternalLink, Star } from 'lucide-react';
|
import { Wrench, ExternalLink, Star } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
|
||||||
|
|
||||||
interface Tool {
|
interface Tool {
|
||||||
id: number; name: string; description: string; url: string;
|
id: number; name: string; description: string; url: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/users`, {
|
||||||
const res = await fetch(`${base}/api/v1/users`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
id: number; nickname: string; avatar?: string; bio?: string;
|
id: number; nickname: string; avatar?: string; bio?: string;
|
||||||
@@ -39,10 +40,9 @@ export default function UserProfilePage() {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const [profileRes, postsRes] = await Promise.all([
|
||||||
const [profileRes, postsRes] = await Promise.all([
|
fetch(`${API_BASE}/community/users/${userId}/profile`),
|
||||||
fetch(`${base}/api/v1/community/users/${userId}/profile`),
|
fetch(`${API_BASE}/community/posts?userId=${userId}`),
|
||||||
fetch(`${base}/api/v1/community/posts?userId=${userId}`),
|
|
||||||
]);
|
]);
|
||||||
if (profileRes.ok) setProfile(await profileRes.json());
|
if (profileRes.ok) setProfile(await profileRes.json());
|
||||||
if (postsRes.ok) {
|
if (postsRes.ok) {
|
||||||
@@ -52,7 +52,7 @@ export default function UserProfilePage() {
|
|||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
const followRes = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
|
const followRes = await fetch(`${API_BASE}/community/users/${userId}/follow`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (followRes.ok) {
|
if (followRes.ok) {
|
||||||
@@ -67,8 +67,7 @@ export default function UserProfilePage() {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/community/users/${userId}/follow`, {
|
||||||
const res = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
|
|
||||||
method: isFollowing ? 'DELETE' : 'POST',
|
method: isFollowing ? 'DELETE' : 'POST',
|
||||||
headers: apiHeaders(),
|
headers: apiHeaders(),
|
||||||
});
|
});
|
||||||
@@ -84,8 +83,7 @@ export default function UserProfilePage() {
|
|||||||
|
|
||||||
async function loadFollowers() {
|
async function loadFollowers() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/community/users/${userId}/followers`);
|
||||||
const res = await fetch(`${base}/api/v1/community/users/${userId}/followers`);
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setFollowers(d.items || []);
|
setFollowers(d.items || []);
|
||||||
@@ -95,8 +93,7 @@ export default function UserProfilePage() {
|
|||||||
|
|
||||||
async function loadFollowing() {
|
async function loadFollowing() {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const res = await fetch(`${API_BASE}/community/users/${userId}/following`);
|
||||||
const res = await fetch(`${base}/api/v1/community/users/${userId}/following`);
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setFollowing(d.items || []);
|
setFollowing(d.items || []);
|
||||||
|
|||||||
@@ -2,100 +2,97 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, FormEvent } from 'react';
|
import { useState, useRef, useEffect, FormEvent } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { MessageCircle, X, Send, Minus, Sparkles, FileText, BookOpen, Image, Settings } from 'lucide-react';
|
import { X, Send, Minus } from 'lucide-react';
|
||||||
import { useT } from '@/i18n';
|
|
||||||
import { getAdminToken } from '@/lib/auth';
|
import { getAdminToken } from '@/lib/auth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminContext {
|
const AVAILABLE_TOOLS_DESC = `可用工具列表(需要执行操作时,返回 JSON:{"tool":"工具名","params":{...},"description":"简述"}):
|
||||||
page: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
starters: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminContexts: Record<string, AdminContext> = {
|
- **get-dashboard**: 获取仪表盘概览数据(用户数、课程数、内容数、提示词数、订单数)
|
||||||
'/admin': {
|
- **list-users**: 列出用户,可按关键词搜索(参数: search, page, pageSize)
|
||||||
page: 'dashboard',
|
- **get-user**: 查看用户详情(参数: id)
|
||||||
systemPrompt: '你是宇之然AI管理后台的智能助手。帮助管理员完成日常运营工作,包括:数据分析、用户管理、内容审核、订单处理等。',
|
- **update-user-status**: 修改用户状态(参数: id, status: ACTIVE|INACTIVE|BANNED)
|
||||||
starters: ['查看今日数据', '最近有哪些新用户', '待处理订单数量', '系统运行状态'],
|
- **list-orders**: 查看最近订单列表
|
||||||
},
|
- **get-analytics-overview**: 获取数据分析概览(用户增长、收入、趋势)
|
||||||
'/admin/users': {
|
- **list-comments**: 查看评论(参数: status: PENDING_REVIEW|PUBLISHED|REJECTED)
|
||||||
page: 'users',
|
- **approve-comment**: 通过评论(参数: id)
|
||||||
systemPrompt: '你是用户管理助手。可以帮助:查看用户列表、搜索用户、修改用户状态、查看用户详情。',
|
- **reject-comment**: 拒绝评论(参数: id, reason?)
|
||||||
starters: ['列出最近注册的用户', '查找某个用户', '批量启用/禁用用户', '查看用户详情'],
|
- **list-banners**: 查看所有Banner
|
||||||
},
|
- **list-notifications**: 查看系统通知
|
||||||
'/admin/courses': {
|
- **list-config**: 查看系统配置
|
||||||
page: 'courses',
|
- **list-roles**: 查看管理角色
|
||||||
systemPrompt: '你是课程管理助手。可以帮助:创建新课程、编辑课程信息、上下架课程、管理课程章节。',
|
- **list-admins**: 查看管理员
|
||||||
starters: ['创建新课程', '课程列表', '下架某个课程', '添加课程章节'],
|
- **get-enterprise-orgs**: 查看企业版组织
|
||||||
},
|
- **toggle-course-status**: 切换课程上下架(参数: id)
|
||||||
'/admin/contents': {
|
- **toggle-content-status**: 切换内容上下架(参数: id)
|
||||||
page: 'contents',
|
- **toggle-prompt-status**: 切换提示词上下架(参数: id)
|
||||||
systemPrompt: '你是内容管理助手。可以帮助:创建文章、编辑内容、设置分类、发布/下架。',
|
- **navigate**: 跳转到某个管理页面(参数: path — 如 /admin/users, /admin/orders, /admin/analytics, /admin/enterprise, /admin/operations/banners, /admin/operations/notifications, /admin/settings/roles, /admin/settings/config, /admin/comments, /admin/courses, /admin/prompts, /admin/contents, /admin/tools)
|
||||||
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 SYSTEM_PROMPT = `你是宇之然AI管理后台的智能助手,帮助管理员完成日常运营工作。
|
||||||
const sorted = Object.keys(adminContexts).sort((a, b) => b.length - a.length);
|
你可以:
|
||||||
for (const key of sorted) {
|
1. 回答管理员的问题
|
||||||
if (pathname.startsWith(key)) {
|
2. 调用工具执行操作(如查看数据、管理用户、审核评论等)
|
||||||
const ctx = { ...adminContexts[key] };
|
3. 跳转到各个管理页面
|
||||||
ctx.systemPrompt = ctx.systemPrompt + ACTION_FORMAT;
|
|
||||||
return ctx;
|
${AVAILABLE_TOOLS_DESC}
|
||||||
|
|
||||||
|
注意:每次只需要返回一个 JSON 工具调用,不要包含多余文字。执行结果会自动呈现给用户。`;
|
||||||
|
|
||||||
|
function parseToolCall(text: string): { tool: string; params: Record<string, any>; description?: string } | null {
|
||||||
|
let braceDepth = 0;
|
||||||
|
let start = -1;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === '{') {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
braceDepth++;
|
||||||
|
} else if (text[i] === '}') {
|
||||||
|
braceDepth--;
|
||||||
|
if (braceDepth === 0 && start !== -1) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text.slice(start, i + 1));
|
||||||
|
if (parsed.tool) return parsed;
|
||||||
|
} catch {}
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postChat(messages: { role: string; content: string }[]): Promise<string> {
|
||||||
|
const tk = getAdminToken();
|
||||||
|
if (!tk) return '请先登录管理账号';
|
||||||
|
const res = await fetch(`${API_BASE}/admin/ai-assistant/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}` },
|
||||||
|
body: JSON.stringify({ messages }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message || '请求失败');
|
||||||
|
return data.reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeTool(tool: string, params: Record<string, any>, messages: { role: string; content: string }[]): Promise<{ reply: string }> {
|
||||||
|
const tk = getAdminToken();
|
||||||
|
const res = await fetch(`${API_BASE}/admin/ai-assistant/action`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}` },
|
||||||
|
body: JSON.stringify({ tool, params, messages }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message || '工具执行失败');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminAIAssistant() {
|
export function AdminAIAssistant() {
|
||||||
const t = useT();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -103,18 +100,9 @@ export function AdminAIAssistant() {
|
|||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [started, setStarted] = useState(false);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(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(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@@ -134,51 +122,25 @@ export function AdminAIAssistant() {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tk = getAdminToken();
|
const apiMessages = [
|
||||||
let reply = '';
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
...messages.map(m => ({ role: m.role, content: m.content })),
|
||||||
|
{ role: 'user', content: text },
|
||||||
|
];
|
||||||
|
|
||||||
if (tk) {
|
let reply = await postChat(apiMessages);
|
||||||
const ctx = getAdminContext(pathname);
|
const toolCall = parseToolCall(reply);
|
||||||
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`, {
|
if (toolCall) {
|
||||||
method: 'POST',
|
if (toolCall.tool === 'navigate') {
|
||||||
headers: {
|
const path = toolCall.params.path;
|
||||||
'Content-Type': 'application/json',
|
setMessages(prev => [...prev, { role: 'assistant', content: `正在跳转到 ${path}...` }]);
|
||||||
Authorization: `Bearer ${tk}`,
|
toast.success(toolCall.description || `跳转到 ${path}`);
|
||||||
},
|
router.push(path);
|
||||||
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 {
|
} else {
|
||||||
toast.info(action.description || '收到操作指令');
|
const result = await executeTool(toolCall.tool, toolCall.params, apiMessages);
|
||||||
|
reply = result.reply;
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
|
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
|
||||||
@@ -210,7 +172,7 @@ export function AdminAIAssistant() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = getAdminContext(pathname);
|
const starters = ['查看仪表盘数据', '列出最近用户', '查看待审核评论', '查看企业版组织'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||||
@@ -229,7 +191,7 @@ export function AdminAIAssistant() {
|
|||||||
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
|
<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" />
|
<Minus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
|
<button onClick={() => { setOpen(false); setMinimized(false); setMessages([]); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,13 +200,13 @@ export function AdminAIAssistant() {
|
|||||||
{!minimized && (
|
{!minimized && (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '320px' }}>
|
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '320px' }}>
|
||||||
{messages.length === 1 && messages[0].role === 'assistant' && (
|
{messages.length === 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
你好!我是运营助手,可以帮你完成后台管理工作。试试这些:
|
你好!我是运营助手,可以帮你查询数据、管理用户、审核内容等。试试这些:
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{ctx.starters.map((q, i) => (
|
{starters.map((q, i) => (
|
||||||
<button key={i} onClick={() => handleStarter(q)}
|
<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">
|
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}
|
{q}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
|
||||||
|
|
||||||
interface ImageUploadProps {
|
interface ImageUploadProps {
|
||||||
onUploaded: (url: string) => void;
|
onUploaded: (url: string) => void;
|
||||||
@@ -33,7 +32,7 @@ export function ImageUpload({ onUploaded, defaultImage, accept = 'image/*' }: Im
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/v1/upload`, {
|
const res = await fetch(`${API_BASE}/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { apiFetch } from '@/lib/auth';
|
||||||
|
import { isWeChatBrowser } from '@/lib/wechat';
|
||||||
|
|
||||||
|
interface PayResult {
|
||||||
|
prepay_id?: string;
|
||||||
|
nonceStr?: string;
|
||||||
|
timeStamp?: string;
|
||||||
|
package?: string;
|
||||||
|
paySign?: string;
|
||||||
|
signType?: string;
|
||||||
|
codeUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
orderNo: string;
|
||||||
|
payResult: PayResult;
|
||||||
|
tradeType: 'JSAPI' | 'NATIVE';
|
||||||
|
onPaid: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentModal({ open, orderNo, payResult, tradeType, onPaid, onClose }: Props) {
|
||||||
|
const [status, setStatus] = useState<'pending' | 'paid' | 'failed'>('pending');
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const wechatBridgeCalled = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setStatus('pending');
|
||||||
|
setMessage('');
|
||||||
|
wechatBridgeCalled.current = false;
|
||||||
|
if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATIVE: render QR code
|
||||||
|
if (tradeType === 'NATIVE' && payResult?.codeUrl) {
|
||||||
|
QRCode.toDataURL(payResult.codeUrl, { margin: 1, width: 280 }, (err, url) => {
|
||||||
|
if (!err) setQrDataUrl(url);
|
||||||
|
});
|
||||||
|
setMessage('请使用微信扫描二维码完成支付');
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSAPI in WeChat: call WeixinJSBridge
|
||||||
|
if (tradeType === 'JSAPI' && isWeChatBrowser() && !wechatBridgeCalled.current) {
|
||||||
|
wechatBridgeCalled.current = true;
|
||||||
|
setMessage('正在调起微信支付...');
|
||||||
|
callWechatJsapi(payResult);
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
}, [open, tradeType, payResult?.codeUrl]);
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/payment/wxpay/query?outTradeNo=${orderNo}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.trade_state === 'SUCCESS' || data.localStatus === 'PAID') {
|
||||||
|
setStatus('paid');
|
||||||
|
setMessage('支付成功!');
|
||||||
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||||
|
setTimeout(onPaid, 1500);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function callWechatJsapi(params: PayResult) {
|
||||||
|
if (typeof WeixinJSBridge === 'undefined') {
|
||||||
|
document.addEventListener('WeixinJSBridgeReady', () => doInvoke(params), false);
|
||||||
|
} else {
|
||||||
|
doInvoke(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doInvoke(params: PayResult) {
|
||||||
|
WeixinJSBridge.invoke(
|
||||||
|
'getBrandWCPayRequest',
|
||||||
|
{
|
||||||
|
appId: '', // filled by WeChat
|
||||||
|
timeStamp: params.timeStamp || '',
|
||||||
|
nonceStr: params.nonceStr || '',
|
||||||
|
package: params.package || '',
|
||||||
|
signType: params.signType || 'RSA',
|
||||||
|
paySign: params.paySign || '',
|
||||||
|
},
|
||||||
|
(res: any) => {
|
||||||
|
if (res.err_msg === 'get_brand_wcpay_request:ok') {
|
||||||
|
setStatus('paid');
|
||||||
|
setMessage('支付成功!');
|
||||||
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||||
|
setTimeout(onPaid, 1500);
|
||||||
|
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
|
||||||
|
setMessage('已取消支付');
|
||||||
|
setStatus('pending');
|
||||||
|
} else {
|
||||||
|
setMessage('支付失败,请重试');
|
||||||
|
setStatus('failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="bg-card rounded-2xl p-8 w-full max-w-sm mx-4 shadow-xl border border-border" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground text-center mb-4">支付</h3>
|
||||||
|
|
||||||
|
{tradeType === 'NATIVE' && (
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
{qrDataUrl ? (
|
||||||
|
<img src={qrDataUrl} alt="支付二维码" className="w-56 h-56 rounded-xl border border-border" />
|
||||||
|
) : (
|
||||||
|
<div className="w-56 h-56 bg-muted rounded-xl animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'paid' ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl mb-3">✅</div>
|
||||||
|
<p className="text-green-600 font-medium">{message}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground text-center mb-4">{message}</p>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="w-2 h-2 bg-brand-600 rounded-full animate-pulse" />
|
||||||
|
等待支付中...
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="mt-4 w-full py-2 text-sm text-muted-foreground border border-border rounded-xl hover:bg-accent">
|
||||||
|
取消支付
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
||||||
|
|
||||||
|
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://yuzhiran.com';
|
||||||
|
|
||||||
|
export const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME || '宇之然 AI';
|
||||||
|
|
||||||
|
export const WX_APPID = process.env.NEXT_PUBLIC_WX_APPID || '';
|
||||||
@@ -7,7 +7,9 @@ export interface ModelOption {
|
|||||||
|
|
||||||
export const AVAILABLE_MODELS: ModelOption[] = [
|
export const AVAILABLE_MODELS: ModelOption[] = [
|
||||||
{ id: 'general', label: '通用模式', provider: 'OpenAI 兼容', desc: '日常问答,综合能力均衡' },
|
{ id: 'general', label: '通用模式', provider: 'OpenAI 兼容', desc: '日常问答,综合能力均衡' },
|
||||||
{ id: 'opencode-go', label: 'DeepSeek V4 Flash', provider: 'OpenCode Go', desc: '高速推理,代码生成强' },
|
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', provider: '商汤科技', desc: '高速推理,代码生成强' },
|
||||||
|
{ id: 'sensenova-6.7-flash-lite', label: 'SenseNova 6.7 Flash Lite', provider: '商汤科技', desc: '轻量快速,日常使用' },
|
||||||
|
{ id: 'sensenova-u1-fast', label: 'SenseNova U1 Fast', provider: '商汤科技', desc: '高性能推理,复杂任务' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_MODEL = 'general';
|
export const DEFAULT_MODEL = 'general';
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function isWeChatBrowser(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenidFromUrl(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get('openid');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMiniProgram(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return /miniProgram/i.test(navigator.userAgent) || !!getOpenidFromUrl();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user