feat(admin): Implement operational monitoring MVP and login optimization
Summary: - Add SimpleLog table for activity tracking (admin_schema) - Implement ActivityService with fire-and-forget pattern - Add stats API endpoints (overview/live-feed/user-overview/cleanup) - Complete activity logging for 7 modules (SYSTEM/AIA/PKB/ASL/DC/RVW/IIT) - Update Admin Dashboard with DAU/DAT metrics and live feed - Fix user module permission display logic - Fix login redirect to /ai-qa instead of homepage - Replace top navigation LOGO with brand image - Fix PKB workspace layout CSS conflict (rename to .pa-chat-container) New files: - backend/src/common/services/activity.service.ts - backend/src/modules/admin/controllers/statsController.ts - backend/src/modules/admin/routes/statsRoutes.ts - frontend-v2/src/modules/admin/api/statsApi.ts - docs/03-.../04-operational-monitoring-mvp-plan.md - docs/03-.../04-operational-monitoring-mvp-implementation.md Tested: All features verified locally
This commit is contained in:
@@ -970,6 +970,34 @@ model admin_operation_logs {
|
|||||||
@@schema("admin_schema")
|
@@schema("admin_schema")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 运营日志表 (MVP V3.1)
|
||||||
|
/// 用于记录用户行为,支持 DAU/DAT 统计和用户360画像
|
||||||
|
model simple_logs {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
// 租户和用户信息
|
||||||
|
tenant_id String @db.VarChar(50)
|
||||||
|
tenant_name String? @db.VarChar(100) // 冗余字段,避免JOIN
|
||||||
|
user_id String @db.Uuid
|
||||||
|
user_name String? @db.VarChar(50)
|
||||||
|
|
||||||
|
// 行为记录
|
||||||
|
module String @db.VarChar(20) // AIA, PKB, ASL, DC, RVW, IIT, SSA, ST, SYSTEM
|
||||||
|
feature String @db.VarChar(50) // 细分功能
|
||||||
|
action String @db.VarChar(20) // LOGIN, USE, EXPORT, CREATE, DELETE, ERROR
|
||||||
|
|
||||||
|
// 详情信息
|
||||||
|
info String? @db.Text // JSON或文本详情
|
||||||
|
|
||||||
|
@@index([created_at], map: "idx_simple_logs_created_at")
|
||||||
|
@@index([tenant_id], map: "idx_simple_logs_tenant_id")
|
||||||
|
@@index([user_id], map: "idx_simple_logs_user_id")
|
||||||
|
@@index([module, feature], map: "idx_simple_logs_module_feature")
|
||||||
|
@@index([action], map: "idx_simple_logs_action")
|
||||||
|
@@schema("admin_schema")
|
||||||
|
}
|
||||||
|
|
||||||
model departments {
|
model departments {
|
||||||
id String @id
|
id String @id
|
||||||
tenant_id String
|
tenant_id String
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FastifyRequest, FastifyReply } from 'fastify';
|
|||||||
import { authService } from './auth.service.js';
|
import { authService } from './auth.service.js';
|
||||||
import type { PasswordLoginRequest, VerificationCodeLoginRequest, ChangePasswordRequest } from './auth.service.js';
|
import type { PasswordLoginRequest, VerificationCodeLoginRequest, ChangePasswordRequest } from './auth.service.js';
|
||||||
import { logger } from '../logging/index.js';
|
import { logger } from '../logging/index.js';
|
||||||
|
import { activityService } from '../services/activity.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码登录
|
* 密码登录
|
||||||
@@ -21,6 +22,18 @@ export async function loginWithPassword(
|
|||||||
try {
|
try {
|
||||||
const result = await authService.loginWithPassword(request.body);
|
const result = await authService.loginWithPassword(request.body);
|
||||||
|
|
||||||
|
// 埋点:记录登录行为
|
||||||
|
activityService.log(
|
||||||
|
result.user.tenantId,
|
||||||
|
result.user.tenantName || null,
|
||||||
|
result.user.id,
|
||||||
|
result.user.name,
|
||||||
|
'SYSTEM',
|
||||||
|
'用户登录',
|
||||||
|
'LOGIN',
|
||||||
|
'密码登录'
|
||||||
|
);
|
||||||
|
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
@@ -49,6 +62,18 @@ export async function loginWithVerificationCode(
|
|||||||
try {
|
try {
|
||||||
const result = await authService.loginWithVerificationCode(request.body);
|
const result = await authService.loginWithVerificationCode(request.body);
|
||||||
|
|
||||||
|
// 埋点:记录登录行为
|
||||||
|
activityService.log(
|
||||||
|
result.user.tenantId,
|
||||||
|
result.user.tenantName || null,
|
||||||
|
result.user.id,
|
||||||
|
result.user.name,
|
||||||
|
'SYSTEM',
|
||||||
|
'用户登录',
|
||||||
|
'LOGIN',
|
||||||
|
'验证码登录'
|
||||||
|
);
|
||||||
|
|
||||||
return reply.status(200).send({
|
return reply.status(200).send({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permissions = await this.getUserPermissions(user.role);
|
const permissions = await this.getUserPermissions(user.role);
|
||||||
|
const modules = await this.getUserModules(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -277,6 +278,7 @@ export class AuthService {
|
|||||||
departmentName: user.departments?.name,
|
departmentName: user.departments?.name,
|
||||||
isDefaultPassword: user.is_default_password,
|
isDefaultPassword: user.is_default_password,
|
||||||
permissions,
|
permissions,
|
||||||
|
modules, // 新增:返回模块列表
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,13 +420,25 @@ export class AuthService {
|
|||||||
* 获取用户可访问的模块列表
|
* 获取用户可访问的模块列表
|
||||||
*
|
*
|
||||||
* 逻辑:
|
* 逻辑:
|
||||||
* 1. 查询用户所有租户关系
|
* 1. SUPER_ADMIN 角色拥有所有模块权限
|
||||||
* 2. 对每个租户,检查租户订阅的模块
|
* 2. 查询用户所有租户关系
|
||||||
* 3. 如果用户有自定义模块权限,使用自定义权限
|
* 3. 对每个租户,检查租户订阅的模块
|
||||||
* 4. 否则继承租户的全部模块权限
|
* 4. 如果用户有自定义模块权限,使用自定义权限
|
||||||
* 5. 去重后返回所有可访问模块
|
* 5. 否则继承租户的全部模块权限
|
||||||
|
* 6. 去重后返回所有可访问模块
|
||||||
*/
|
*/
|
||||||
private async getUserModules(userId: string): Promise<string[]> {
|
private async getUserModules(userId: string): Promise<string[]> {
|
||||||
|
// 先获取用户角色
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// SUPER_ADMIN 拥有所有模块权限
|
||||||
|
if (user?.role === 'SUPER_ADMIN') {
|
||||||
|
return ['AIA', 'PKB', 'ASL', 'DC', 'RVW', 'IIT', 'SSA', 'ST'];
|
||||||
|
}
|
||||||
|
|
||||||
// 获取用户的所有租户关系
|
// 获取用户的所有租户关系
|
||||||
const tenantMembers = await prisma.tenant_members.findMany({
|
const tenantMembers = await prisma.tenant_members.findMany({
|
||||||
where: { user_id: userId },
|
where: { user_id: userId },
|
||||||
|
|||||||
310
backend/src/common/services/activity.service.ts
Normal file
310
backend/src/common/services/activity.service.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* Activity Service - 运营埋点服务
|
||||||
|
*
|
||||||
|
* 用于记录用户行为,支持 DAU/DAT 统计和用户360画像
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* 1. Fire-and-Forget 模式:不阻塞主业务
|
||||||
|
* 2. 错误隔离:埋点失败不影响主流程
|
||||||
|
* 3. 零风险保证:外层 try-catch 确保绝不崩溃
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
* @date 2026-01-25
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '../../config/database.js';
|
||||||
|
import { logger } from '../logging/index.js';
|
||||||
|
|
||||||
|
// 模块代码类型
|
||||||
|
export type ModuleCode =
|
||||||
|
| 'AIA' // AI智能问答
|
||||||
|
| 'PKB' // 个人知识库
|
||||||
|
| 'ASL' // AI智能文献
|
||||||
|
| 'DC' // 数据清洗整理
|
||||||
|
| 'RVW' // 稿件审查系统
|
||||||
|
| 'IIT' // IIT Manager Agent
|
||||||
|
| 'SSA' // 智能统计分析 (预留)
|
||||||
|
| 'ST' // 统计分析工具 (预留)
|
||||||
|
| 'SYSTEM'; // 系统级行为
|
||||||
|
|
||||||
|
// 动作类型
|
||||||
|
export type ActionType =
|
||||||
|
| 'LOGIN' // 登录系统
|
||||||
|
| 'USE' // 使用功能
|
||||||
|
| 'EXPORT' // 导出/下载
|
||||||
|
| 'CREATE' // 创建资源
|
||||||
|
| 'DELETE' // 删除资源
|
||||||
|
| 'ERROR'; // 错误记录
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity Service
|
||||||
|
*/
|
||||||
|
export const activityService = {
|
||||||
|
/**
|
||||||
|
* 核心埋点方法 (Fire-and-Forget 模式)
|
||||||
|
*
|
||||||
|
* 异步执行,不阻塞主业务,即使失败也不影响主流程
|
||||||
|
*
|
||||||
|
* @param tenantId - 租户ID
|
||||||
|
* @param tenantName - 租户名称(冗余字段,避免JOIN)
|
||||||
|
* @param userId - 用户ID
|
||||||
|
* @param userName - 用户名称
|
||||||
|
* @param module - 模块代码
|
||||||
|
* @param feature - 细分功能
|
||||||
|
* @param action - 动作类型
|
||||||
|
* @param info - 详情信息(可选)
|
||||||
|
*/
|
||||||
|
log(
|
||||||
|
tenantId: string,
|
||||||
|
tenantName: string | null,
|
||||||
|
userId: string,
|
||||||
|
userName: string | null,
|
||||||
|
module: ModuleCode,
|
||||||
|
feature: string,
|
||||||
|
action: ActionType,
|
||||||
|
info?: string | object
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
// 参数校验:必填字段缺失时静默返回
|
||||||
|
if (!tenantId || !userId || !module || !feature || !action) {
|
||||||
|
logger.debug('埋点参数不完整,跳过记录', { tenantId, userId, module });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 info 字段
|
||||||
|
const infoStr = info
|
||||||
|
? (typeof info === 'object' ? JSON.stringify(info) : String(info))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 异步写入,不等待结果
|
||||||
|
prisma.simple_logs.create({
|
||||||
|
data: {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
tenant_name: tenantName,
|
||||||
|
user_id: userId,
|
||||||
|
user_name: userName,
|
||||||
|
module,
|
||||||
|
feature,
|
||||||
|
action,
|
||||||
|
info: infoStr,
|
||||||
|
}
|
||||||
|
}).catch(e => {
|
||||||
|
// 埋点失败只记录日志,不影响主业务
|
||||||
|
logger.warn('埋点写入失败(可忽略)', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
module,
|
||||||
|
feature,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
// 即使同步代码出错,也绝不影响主业务
|
||||||
|
logger.warn('埋点调用异常(可忽略)', {
|
||||||
|
error: e instanceof Error ? e.message : String(e)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日核心大盘数据 (DAU + DAT + 导出数)
|
||||||
|
*/
|
||||||
|
async getTodayOverview(): Promise<{
|
||||||
|
dau: number;
|
||||||
|
dat: number;
|
||||||
|
exportCount: number;
|
||||||
|
moduleStats: Record<string, number>;
|
||||||
|
}> {
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 查询 DAU/DAT/导出数
|
||||||
|
const stats = await prisma.$queryRaw`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT user_id) as dau,
|
||||||
|
COUNT(DISTINCT tenant_id) as dat,
|
||||||
|
COUNT(CASE WHEN action = 'EXPORT' THEN 1 END) as export_count
|
||||||
|
FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at >= ${todayStart}
|
||||||
|
` as Array<{ dau: bigint; dat: bigint; export_count: bigint }>;
|
||||||
|
|
||||||
|
// 查询模块使用统计
|
||||||
|
const moduleStats = await prisma.$queryRaw`
|
||||||
|
SELECT module, COUNT(*) as count
|
||||||
|
FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at >= ${todayStart}
|
||||||
|
GROUP BY module
|
||||||
|
` as Array<{ module: string; count: bigint }>;
|
||||||
|
|
||||||
|
const moduleMap: Record<string, number> = {};
|
||||||
|
moduleStats.forEach(m => {
|
||||||
|
moduleMap[m.module] = Number(m.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dau: Number(stats[0]?.dau || 0),
|
||||||
|
dat: Number(stats[0]?.dat || 0),
|
||||||
|
exportCount: Number(stats[0]?.export_count || 0),
|
||||||
|
moduleStats: moduleMap,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时流水账
|
||||||
|
*
|
||||||
|
* @param limit - 返回条数,默认100
|
||||||
|
*/
|
||||||
|
async getLiveFeed(limit = 100): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
tenantName: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
module: string;
|
||||||
|
feature: string;
|
||||||
|
action: string;
|
||||||
|
info: string | null;
|
||||||
|
}>> {
|
||||||
|
const logs = await prisma.simple_logs.findMany({
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
created_at: true,
|
||||||
|
tenant_name: true,
|
||||||
|
user_name: true,
|
||||||
|
module: true,
|
||||||
|
feature: true,
|
||||||
|
action: true,
|
||||||
|
info: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return logs.map(log => ({
|
||||||
|
id: log.id,
|
||||||
|
createdAt: log.created_at,
|
||||||
|
tenantName: log.tenant_name,
|
||||||
|
userName: log.user_name,
|
||||||
|
module: log.module,
|
||||||
|
feature: log.feature,
|
||||||
|
action: log.action,
|
||||||
|
info: log.info,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户360画像
|
||||||
|
*
|
||||||
|
* @param userId - 用户ID
|
||||||
|
*/
|
||||||
|
async getUserOverview(userId: string): Promise<{
|
||||||
|
profile: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
tenantName: string | null;
|
||||||
|
} | null;
|
||||||
|
assets: {
|
||||||
|
aia: { conversationCount: number };
|
||||||
|
pkb: { kbCount: number; docCount: number };
|
||||||
|
dc: { taskCount: number };
|
||||||
|
rvw: { reviewTaskCount: number; completedCount: number };
|
||||||
|
};
|
||||||
|
activities: Array<{
|
||||||
|
createdAt: Date;
|
||||||
|
module: string;
|
||||||
|
feature: string;
|
||||||
|
action: string;
|
||||||
|
info: string | null;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const [user, aiaStats, kbs, dcStats, rvwStats, logs] = await Promise.all([
|
||||||
|
// 1. 基础信息
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 2. AIA 资产 (会话数)
|
||||||
|
prisma.conversation.count({
|
||||||
|
where: { userId, deletedAt: null }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 3. PKB 资产 (知识库数 + 文档数)
|
||||||
|
prisma.knowledgeBase.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { _count: { select: { documents: true } } }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 4. DC 资产 (任务数)
|
||||||
|
prisma.dCExtractionTask.count({ where: { userId } }),
|
||||||
|
|
||||||
|
// 5. RVW 资产 (审稿任务数)
|
||||||
|
prisma.reviewTask.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
where: { userId },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 6. 最近行为 (从 SimpleLog 查最近 20 条)
|
||||||
|
prisma.simple_logs.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: {
|
||||||
|
created_at: true,
|
||||||
|
module: true,
|
||||||
|
feature: true,
|
||||||
|
action: true,
|
||||||
|
info: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 计算 PKB 文档总数
|
||||||
|
const totalDocs = kbs.reduce((sum: number, kb: { _count: { documents: number } }) => sum + kb._count.documents, 0);
|
||||||
|
|
||||||
|
// 计算 RVW 统计
|
||||||
|
const rvwTotal = rvwStats.reduce((sum: number, s: { _count: number }) => sum + s._count, 0);
|
||||||
|
const rvwCompleted = rvwStats.find((s: { status: string }) => s.status === 'completed')?._count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: user ? {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'), // 脱敏
|
||||||
|
tenantName: user.tenants?.name || null,
|
||||||
|
} : null,
|
||||||
|
assets: {
|
||||||
|
aia: { conversationCount: aiaStats },
|
||||||
|
pkb: { kbCount: kbs.length, docCount: totalDocs },
|
||||||
|
dc: { taskCount: dcStats },
|
||||||
|
rvw: { reviewTaskCount: rvwTotal, completedCount: rvwCompleted },
|
||||||
|
},
|
||||||
|
activities: logs.map((log: { created_at: Date; module: string; feature: string; action: string; info: string | null }) => ({
|
||||||
|
createdAt: log.created_at,
|
||||||
|
module: log.module,
|
||||||
|
feature: log.feature,
|
||||||
|
action: log.action,
|
||||||
|
info: log.info,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志(180天前的数据)
|
||||||
|
* 用于定时任务调用
|
||||||
|
*/
|
||||||
|
async cleanupOldLogs(): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - 180);
|
||||||
|
|
||||||
|
const result = await prisma.$executeRaw`
|
||||||
|
DELETE FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at < ${cutoffDate}
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info('运营日志清理完成', { deletedCount: result, cutoffDate });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -82,6 +82,13 @@ console.log('✅ 文件上传插件已配置: 最大文件大小 10MB');
|
|||||||
await registerHealthRoutes(fastify);
|
await registerHealthRoutes(fastify);
|
||||||
logger.info('✅ 健康检查路由已注册');
|
logger.info('✅ 健康检查路由已注册');
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 【公开API】无需认证的公共接口
|
||||||
|
// ============================================
|
||||||
|
import { getTenantLoginConfig } from './modules/admin/controllers/tenantController.js';
|
||||||
|
fastify.get('/api/v1/public/tenant-config/:tenantCode', getTenantLoginConfig);
|
||||||
|
logger.info('✅ 公开API已注册: /api/v1/public/tenant-config/:tenantCode');
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 【平台基础设施】认证模块
|
// 【平台基础设施】认证模块
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -100,10 +107,13 @@ logger.info('✅ Prompt管理路由已注册: /api/admin/prompts');
|
|||||||
// ============================================
|
// ============================================
|
||||||
import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.js';
|
import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.js';
|
||||||
import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
||||||
|
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js';
|
||||||
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||||
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users');
|
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
||||||
|
await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' });
|
||||||
|
logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats');
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 【临时】平台基础设施测试API
|
// 【临时】平台基础设施测试API
|
||||||
|
|||||||
54
backend/src/modules/admin/__tests__/test-stats-api.http
Normal file
54
backend/src/modules/admin/__tests__/test-stats-api.http
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
### ===========================================
|
||||||
|
### 运营统计 API 测试
|
||||||
|
### 需要先登录获取 Token
|
||||||
|
### ===========================================
|
||||||
|
|
||||||
|
### 变量定义
|
||||||
|
@baseUrl = http://localhost:3001
|
||||||
|
@adminPhone = 13800138000
|
||||||
|
@adminPassword = admin123
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 1. 登录获取 Token (SUPER_ADMIN)
|
||||||
|
### ===========================================
|
||||||
|
# @name login
|
||||||
|
POST {{baseUrl}}/api/v1/auth/login/password
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"phone": "{{adminPhone}}",
|
||||||
|
"password": "{{adminPassword}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 保存 Token
|
||||||
|
@token = {{login.response.body.data.tokens.accessToken}}
|
||||||
|
@userId = {{login.response.body.data.user.id}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 2. 获取今日大盘数据 (DAU/DAT/导出数)
|
||||||
|
### ===========================================
|
||||||
|
# @name overview
|
||||||
|
GET {{baseUrl}}/api/admin/stats/overview
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 3. 获取实时流水账
|
||||||
|
### ===========================================
|
||||||
|
# @name liveFeed
|
||||||
|
GET {{baseUrl}}/api/admin/stats/live-feed?limit=50
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 4. 获取用户360画像
|
||||||
|
### ===========================================
|
||||||
|
# @name userOverview
|
||||||
|
GET {{baseUrl}}/api/admin/users/{{userId}}/overview
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 5. 清理过期日志(谨慎使用)
|
||||||
|
### ===========================================
|
||||||
|
# @name cleanup
|
||||||
|
POST {{baseUrl}}/api/admin/stats/cleanup
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
124
backend/src/modules/admin/__tests__/test-stats-api.ps1
Normal file
124
backend/src/modules/admin/__tests__/test-stats-api.ps1
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# ===========================================
|
||||||
|
# Operations Stats API Test
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
$baseUrl = "http://localhost:3001"
|
||||||
|
$phone = "13800000001" # SUPER_ADMIN
|
||||||
|
$password = "123456"
|
||||||
|
|
||||||
|
Write-Host "=============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Operations Stats API Test" -ForegroundColor Cyan
|
||||||
|
Write-Host "=============================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Login
|
||||||
|
Write-Host "`n[1/4] Login..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$loginBody = @{
|
||||||
|
phone = $phone
|
||||||
|
password = $password
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/auth/login/password" `
|
||||||
|
-Method POST `
|
||||||
|
-Body $loginBody `
|
||||||
|
-ContentType "application/json"
|
||||||
|
|
||||||
|
$token = $loginResponse.data.tokens.accessToken
|
||||||
|
$userId = $loginResponse.data.user.id
|
||||||
|
$userName = $loginResponse.data.user.name
|
||||||
|
|
||||||
|
Write-Host " [OK] Login success!" -ForegroundColor Green
|
||||||
|
Write-Host " User: $userName ($userId)" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host " [FAIL] Login failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $token"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Test Overview
|
||||||
|
Write-Host "`n[2/4] Get Overview (DAU/DAT)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$overviewResponse = Invoke-RestMethod -Uri "$baseUrl/api/admin/stats/overview" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " [OK] Success!" -ForegroundColor Green
|
||||||
|
Write-Host " ----------------------------" -ForegroundColor Gray
|
||||||
|
Write-Host " DAU (Daily Active Users): $($overviewResponse.data.dau)" -ForegroundColor White
|
||||||
|
Write-Host " DAT (Daily Active Tenants): $($overviewResponse.data.dat)" -ForegroundColor White
|
||||||
|
Write-Host " Export Count: $($overviewResponse.data.exportCount)" -ForegroundColor White
|
||||||
|
|
||||||
|
if ($overviewResponse.data.moduleStats) {
|
||||||
|
Write-Host " Module Stats:" -ForegroundColor White
|
||||||
|
$overviewResponse.data.moduleStats.PSObject.Properties | ForEach-Object {
|
||||||
|
Write-Host " $($_.Name): $($_.Value)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Test Live Feed
|
||||||
|
Write-Host "`n[3/4] Get Live Feed..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$liveFeedResponse = Invoke-RestMethod -Uri "$baseUrl/api/admin/stats/live-feed?limit=10" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
$logsCount = $liveFeedResponse.data.Count
|
||||||
|
Write-Host " [OK] Success! Total: $logsCount records" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($logsCount -gt 0) {
|
||||||
|
Write-Host " ----------------------------" -ForegroundColor Gray
|
||||||
|
Write-Host " Recent 5 activities:" -ForegroundColor White
|
||||||
|
|
||||||
|
$liveFeedResponse.data | Select-Object -First 5 | ForEach-Object {
|
||||||
|
$time = [DateTime]::Parse($_.createdAt).ToString("HH:mm:ss")
|
||||||
|
$tenant = if ($_.tenantName) { $_.tenantName } else { "-" }
|
||||||
|
$user = if ($_.userName) { $_.userName } else { "-" }
|
||||||
|
Write-Host " $time | $($_.action) | [$($_.module)] $($_.feature) | $user@$tenant" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " (No records yet)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Test User Overview
|
||||||
|
Write-Host "`n[4/4] Get User Overview (360 Profile)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userOverviewResponse = Invoke-RestMethod -Uri "$baseUrl/api/admin/users/$userId/overview" `
|
||||||
|
-Method GET `
|
||||||
|
-Headers $headers
|
||||||
|
|
||||||
|
Write-Host " [OK] Success!" -ForegroundColor Green
|
||||||
|
Write-Host " ----------------------------" -ForegroundColor Gray
|
||||||
|
|
||||||
|
$profile = $userOverviewResponse.data.profile
|
||||||
|
$assets = $userOverviewResponse.data.assets
|
||||||
|
|
||||||
|
Write-Host " User: $($profile.name) ($($profile.phone))" -ForegroundColor White
|
||||||
|
Write-Host " Tenant: $($profile.tenantName)" -ForegroundColor White
|
||||||
|
Write-Host " Assets:" -ForegroundColor White
|
||||||
|
Write-Host " AIA Conversations: $($assets.aia.conversationCount)" -ForegroundColor Gray
|
||||||
|
Write-Host " PKB KBs: $($assets.pkb.kbCount) ($($assets.pkb.docCount) docs)" -ForegroundColor Gray
|
||||||
|
Write-Host " DC Tasks: $($assets.dc.taskCount)" -ForegroundColor Gray
|
||||||
|
Write-Host " RVW Reviews: $($assets.rvw.reviewTaskCount) (completed: $($assets.rvw.completedCount))" -ForegroundColor Gray
|
||||||
|
|
||||||
|
$activitiesCount = $userOverviewResponse.data.activities.Count
|
||||||
|
Write-Host " Recent Activities: $activitiesCount" -ForegroundColor White
|
||||||
|
} catch {
|
||||||
|
Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n=============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Test Complete!" -ForegroundColor Cyan
|
||||||
|
Write-Host "=============================================" -ForegroundColor Cyan
|
||||||
126
backend/src/modules/admin/controllers/statsController.ts
Normal file
126
backend/src/modules/admin/controllers/statsController.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Stats Controller - 运营统计控制器
|
||||||
|
*
|
||||||
|
* 提供运营看板数据接口
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
* @date 2026-01-25
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
import { logger } from '../../../common/logging/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日大盘数据
|
||||||
|
* GET /api/admin/stats/overview
|
||||||
|
*/
|
||||||
|
export async function getOverview(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const data = await activityService.getTodayOverview();
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[StatsController] 获取大盘数据失败', { error: error.message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: error.message || '获取大盘数据失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时流水账
|
||||||
|
* GET /api/admin/stats/live-feed?limit=100
|
||||||
|
*/
|
||||||
|
export async function getLiveFeed(
|
||||||
|
request: FastifyRequest<{ Querystring: { limit?: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(Number(request.query.limit) || 100, 500); // 最大500条
|
||||||
|
const data = await activityService.getLiveFeed(limit);
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[StatsController] 获取流水账失败', { error: error.message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: error.message || '获取流水账失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户360画像
|
||||||
|
* GET /api/admin/users/:id/overview
|
||||||
|
*/
|
||||||
|
export async function getUserOverview(
|
||||||
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
message: '用户ID不能为空',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await activityService.getUserOverview(id);
|
||||||
|
|
||||||
|
if (!data.profile) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[StatsController] 获取用户画像失败', { error: error.message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: error.message || '获取用户画像失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志(管理接口)
|
||||||
|
* POST /api/admin/stats/cleanup
|
||||||
|
*/
|
||||||
|
export async function cleanupLogs(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const deletedCount = await activityService.cleanupOldLogs();
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
deletedCount,
|
||||||
|
message: `已清理 ${deletedCount} 条过期日志`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[StatsController] 清理日志失败', { error: error.message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: error.message || '清理日志失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -273,3 +273,37 @@ export async function getUserModules(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取租户登录页配置(公开API,无需认证)
|
||||||
|
* GET /api/v1/public/tenant-config/:tenantCode
|
||||||
|
*
|
||||||
|
* 用于前端租户专属登录页获取配置信息
|
||||||
|
*/
|
||||||
|
export async function getTenantLoginConfig(
|
||||||
|
request: FastifyRequest<{ Params: { tenantCode: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { tenantCode } = request.params;
|
||||||
|
const config = await tenantService.getTenantLoginConfig(tenantCode);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
success: false,
|
||||||
|
message: '租户不存在或已禁用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: config,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[TenantController] 获取租户登录配置失败', { error: error.message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: error.message || '获取租户登录配置失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
68
backend/src/modules/admin/routes/statsRoutes.ts
Normal file
68
backend/src/modules/admin/routes/statsRoutes.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Stats Routes - 运营统计路由
|
||||||
|
*
|
||||||
|
* API 前缀: /api/admin/stats
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
* @date 2026-01-25
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import * as statsController from '../controllers/statsController.js';
|
||||||
|
import { authenticate, requirePermission } from '../../../common/auth/auth.middleware.js';
|
||||||
|
|
||||||
|
export async function statsRoutes(fastify: FastifyInstance) {
|
||||||
|
// ==================== 运营统计 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日大盘数据 (DAU/DAT/导出数)
|
||||||
|
* GET /api/admin/stats/overview
|
||||||
|
*
|
||||||
|
* 权限: SUPER_ADMIN, PROMPT_ENGINEER
|
||||||
|
*/
|
||||||
|
fastify.get('/overview', {
|
||||||
|
preHandler: [authenticate, requirePermission('tenant:view')],
|
||||||
|
handler: statsController.getOverview,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时流水账
|
||||||
|
* GET /api/admin/stats/live-feed?limit=100
|
||||||
|
*
|
||||||
|
* 权限: SUPER_ADMIN, PROMPT_ENGINEER
|
||||||
|
*/
|
||||||
|
fastify.get('/live-feed', {
|
||||||
|
preHandler: [authenticate, requirePermission('tenant:view')],
|
||||||
|
handler: statsController.getLiveFeed,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志
|
||||||
|
* POST /api/admin/stats/cleanup
|
||||||
|
*
|
||||||
|
* 权限: SUPER_ADMIN
|
||||||
|
*/
|
||||||
|
fastify.post('/cleanup', {
|
||||||
|
preHandler: [authenticate, requirePermission('tenant:delete')],
|
||||||
|
handler: statsController.cleanupLogs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户概览路由(挂载到 /api/admin/users)
|
||||||
|
*
|
||||||
|
* 需要单独注册,因为路径是 /api/admin/users/:id/overview
|
||||||
|
*/
|
||||||
|
export async function userOverviewRoute(fastify: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* 获取用户360画像
|
||||||
|
* GET /api/admin/users/:id/overview
|
||||||
|
*
|
||||||
|
* 权限: SUPER_ADMIN
|
||||||
|
*/
|
||||||
|
fastify.get('/:id/overview', {
|
||||||
|
preHandler: [authenticate, requirePermission('user:view')],
|
||||||
|
handler: statsController.getUserOverview,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -301,6 +301,48 @@ class TenantService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取租户登录页配置(公开API)
|
||||||
|
* 用于前端租户专属登录页获取配置信息
|
||||||
|
*/
|
||||||
|
async getTenantLoginConfig(tenantCode: string): Promise<{
|
||||||
|
name: string;
|
||||||
|
logo?: string;
|
||||||
|
primaryColor: string;
|
||||||
|
systemName: string;
|
||||||
|
modules: string[];
|
||||||
|
isReviewOnly: boolean;
|
||||||
|
} | null> {
|
||||||
|
// 根据 code 查找租户
|
||||||
|
const tenant = await prisma.tenants.findUnique({
|
||||||
|
where: { code: tenantCode },
|
||||||
|
include: {
|
||||||
|
tenant_modules: {
|
||||||
|
where: { is_enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant || tenant.status !== 'ACTIVE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取启用的模块代码列表
|
||||||
|
const modules = tenant.tenant_modules.map(tm => tm.module_code);
|
||||||
|
|
||||||
|
// 判断是否是纯审稿租户
|
||||||
|
const isReviewOnly = modules.length === 1 && modules[0] === 'RVW';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: tenant.name,
|
||||||
|
logo: undefined, // TODO: 未来可从 tenant 扩展字段获取
|
||||||
|
primaryColor: isReviewOnly ? '#6366f1' : '#1890ff',
|
||||||
|
systemName: isReviewOnly ? '智能审稿系统' : 'AI临床研究平台',
|
||||||
|
modules,
|
||||||
|
isReviewOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tenantService = new TenantService();
|
export const tenantService = new TenantService();
|
||||||
|
|||||||
@@ -250,12 +250,20 @@ export async function getUserById(userId: string, scope: UserQueryScope): Promis
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 计算最终模块权限
|
// 计算最终模块权限
|
||||||
|
// 修复逻辑:如果用户有任何自定义模块配置,则没有配置的模块默认关闭
|
||||||
|
// 如果用户没有任何自定义配置,则继承租户的全部模块权限
|
||||||
|
const hasCustomModuleConfig = userModulesInTenant.length > 0;
|
||||||
|
|
||||||
const allowedModules = tenantModules.map((tm) => {
|
const allowedModules = tenantModules.map((tm) => {
|
||||||
const userModule = userModulesInTenant.find((um) => um.module_code === tm.module_code);
|
const userModule = userModulesInTenant.find((um) => um.module_code === tm.module_code);
|
||||||
return {
|
return {
|
||||||
code: tm.module_code,
|
code: tm.module_code,
|
||||||
name: getModuleName(tm.module_code),
|
name: getModuleName(tm.module_code),
|
||||||
isEnabled: userModule ? userModule.is_enabled : true, // 默认继承租户权限
|
// 有自定义配置:必须有记录且启用才显示为启用
|
||||||
|
// 无自定义配置:继承租户权限(全部显示为启用)
|
||||||
|
isEnabled: hasCustomModuleConfig
|
||||||
|
? (userModule ? userModule.is_enabled : false)
|
||||||
|
: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { streamChat, createStreamingService } from '../../../common/streaming/in
|
|||||||
import type { OpenAIMessage, StreamOptions } from '../../../common/streaming/index.js';
|
import type { OpenAIMessage, StreamOptions } from '../../../common/streaming/index.js';
|
||||||
import * as agentService from './agentService.js';
|
import * as agentService from './agentService.js';
|
||||||
import * as attachmentService from './attachmentService.js';
|
import * as attachmentService from './attachmentService.js';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
Message,
|
Message,
|
||||||
@@ -360,6 +361,27 @@ export async function sendMessageStream(
|
|||||||
tokens: aiMessage.tokens,
|
tokens: aiMessage.tokens,
|
||||||
hasThinking: !!thinkingContent,
|
hasThinking: !!thinkingContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 9. 埋点:记录智能体使用
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
const agent = await agentService.getAgentById(conversation.agentId);
|
||||||
|
activityService.log(
|
||||||
|
user.tenant_id,
|
||||||
|
user.tenants?.name || null,
|
||||||
|
userId,
|
||||||
|
user.name,
|
||||||
|
'AIA',
|
||||||
|
agent?.name || conversation.agentId,
|
||||||
|
'USE',
|
||||||
|
`tokens: ${aiMessage.tokens}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
logger.error('[AIA:ConversationService] 流式生成失败', {
|
logger.error('[AIA:ConversationService] 流式生成失败', {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { llmScreeningService } from './llmScreeningService.js';
|
|||||||
import { jobQueue } from '../../../common/jobs/index.js';
|
import { jobQueue } from '../../../common/jobs/index.js';
|
||||||
import { CheckpointService } from '../../../common/jobs/CheckpointService.js';
|
import { CheckpointService } from '../../../common/jobs/CheckpointService.js';
|
||||||
import type { Job } from '../../../common/jobs/types.js';
|
import type { Job } from '../../../common/jobs/types.js';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
|
||||||
// 创建断点服务实例
|
// 创建断点服务实例
|
||||||
const checkpointService = new CheckpointService(prisma);
|
const checkpointService = new CheckpointService(prisma);
|
||||||
@@ -123,7 +124,7 @@ export function registerScreeningWorkers() {
|
|||||||
|
|
||||||
if (completedBatches >= totalBatches) {
|
if (completedBatches >= totalBatches) {
|
||||||
// 所有批次完成,标记任务为完成
|
// 所有批次完成,标记任务为完成
|
||||||
await prisma.aslScreeningTask.update({
|
const task = await prisma.aslScreeningTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: {
|
data: {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -131,8 +132,35 @@ export function registerScreeningWorkers() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取项目信息用于埋点
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: task.projectId },
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('All batches completed, task marked as completed', { taskId });
|
logger.info('All batches completed, task marked as completed', { taskId });
|
||||||
console.log(`\n🎉 任务 ${taskId} 全部完成!\n`);
|
console.log(`\n🎉 任务 ${taskId} 全部完成!\n`);
|
||||||
|
|
||||||
|
// 埋点:记录文献筛选完成
|
||||||
|
try {
|
||||||
|
if (project) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: project.userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
activityService.log(
|
||||||
|
user.tenant_id,
|
||||||
|
user.tenants?.name || null,
|
||||||
|
user.id,
|
||||||
|
user.name,
|
||||||
|
'ASL',
|
||||||
|
'文献筛选',
|
||||||
|
'USE',
|
||||||
|
`项目:${project.name}, 文献:${totalBatches * 10}篇`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { conflictDetectionService } from '../services/ConflictDetectionService.j
|
|||||||
import { jobQueue } from '../../../../common/jobs/index.js';
|
import { jobQueue } from '../../../../common/jobs/index.js';
|
||||||
import { CheckpointService } from '../../../../common/jobs/CheckpointService.js';
|
import { CheckpointService } from '../../../../common/jobs/CheckpointService.js';
|
||||||
import type { Job } from '../../../../common/jobs/types.js';
|
import type { Job } from '../../../../common/jobs/types.js';
|
||||||
|
import { activityService } from '../../../../common/services/activity.service.js';
|
||||||
|
|
||||||
// 创建断点服务实例
|
// 创建断点服务实例
|
||||||
const checkpointService = new CheckpointService(prisma);
|
const checkpointService = new CheckpointService(prisma);
|
||||||
@@ -127,7 +128,7 @@ export function registerExtractionWorkers() {
|
|||||||
|
|
||||||
if (completedBatches >= totalBatches) {
|
if (completedBatches >= totalBatches) {
|
||||||
// 所有批次完成,标记任务为完成
|
// 所有批次完成,标记任务为完成
|
||||||
await prisma.dCExtractionTask.update({
|
const task = await prisma.dCExtractionTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: {
|
data: {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -137,6 +138,26 @@ export function registerExtractionWorkers() {
|
|||||||
|
|
||||||
logger.info('All batches completed, task marked as completed', { taskId });
|
logger.info('All batches completed, task marked as completed', { taskId });
|
||||||
console.log(`\n🎉 任务 ${taskId} 全部完成!\n`);
|
console.log(`\n🎉 任务 ${taskId} 全部完成!\n`);
|
||||||
|
|
||||||
|
// 埋点:记录 Tool B 数据提取完成
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: task.userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
activityService.log(
|
||||||
|
user.tenant_id,
|
||||||
|
user.tenants?.name || null,
|
||||||
|
user.id,
|
||||||
|
user.name,
|
||||||
|
'DC',
|
||||||
|
'Tool B 数据提取',
|
||||||
|
'USE',
|
||||||
|
`项目:${task.projectName}, 记录:${totalBatches * 10}条`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { aiCodeService } from '../services/AICodeService.js';
|
|||||||
import { sessionService } from '../services/SessionService.js';
|
import { sessionService } from '../services/SessionService.js';
|
||||||
import { LLMFactory } from '../../../../common/llm/adapters/LLMFactory.js';
|
import { LLMFactory } from '../../../../common/llm/adapters/LLMFactory.js';
|
||||||
import { ModelType } from '../../../../common/llm/adapters/types.js';
|
import { ModelType } from '../../../../common/llm/adapters/types.js';
|
||||||
|
import { activityService } from '../../../../common/services/activity.service.js';
|
||||||
|
|
||||||
// ==================== 请求参数类型定义 ====================
|
// ==================== 请求参数类型定义 ====================
|
||||||
|
|
||||||
@@ -180,6 +181,23 @@ export class AIController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`[AIController] 处理成功: 重试${result.retryCount}次后成功`);
|
logger.info(`[AIController] 处理成功: 重试${result.retryCount}次后成功`);
|
||||||
|
|
||||||
|
// 埋点:记录 Tool C AI代码执行
|
||||||
|
try {
|
||||||
|
const user = (request as any).user;
|
||||||
|
if (user && result.executeResult?.success) {
|
||||||
|
activityService.log(
|
||||||
|
user.tenantId,
|
||||||
|
user.tenantName || null,
|
||||||
|
user.id,
|
||||||
|
user.name,
|
||||||
|
'DC',
|
||||||
|
'Tool C AI代码',
|
||||||
|
'USE',
|
||||||
|
`session:${sessionId}, retries:${result.retryCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
|
|
||||||
return reply.code(200).send({
|
return reply.code(200).send({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
|
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
|
||||||
import { logger } from '../../../common/logging/index.js';
|
import { logger } from '../../../common/logging/index.js';
|
||||||
import { jobQueue } from '../../../common/jobs/index.js';
|
import { jobQueue } from '../../../common/jobs/index.js';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步管理器
|
* 同步管理器
|
||||||
@@ -261,6 +262,26 @@ export class SyncManager {
|
|||||||
duration: `${totalDuration}ms`
|
duration: `${totalDuration}ms`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 埋点:记录 REDCap 同步
|
||||||
|
try {
|
||||||
|
const owner = await this.prisma.user.findUnique({
|
||||||
|
where: { id: project.ownerId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
if (owner && uniqueRecordIds.length > 0) {
|
||||||
|
activityService.log(
|
||||||
|
owner.tenant_id,
|
||||||
|
owner.tenants?.name || null,
|
||||||
|
owner.id,
|
||||||
|
owner.name,
|
||||||
|
'IIT',
|
||||||
|
'REDCap同步',
|
||||||
|
'USE',
|
||||||
|
`项目:${project.name}, 记录:${uniqueRecordIds.length}条`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
|
|
||||||
return uniqueRecordIds.length;
|
return uniqueRecordIds.length;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
searchKnowledgeBase as ragSearchKnowledgeBase,
|
searchKnowledgeBase as ragSearchKnowledgeBase,
|
||||||
type RagSearchResult,
|
type RagSearchResult,
|
||||||
} from './ragService.js';
|
} from './ragService.js';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识库服务
|
* 知识库服务
|
||||||
@@ -58,6 +59,26 @@ export async function createKnowledgeBase(
|
|||||||
ekbKbId: result.ekbKbId,
|
ekbKbId: result.ekbKbId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 埋点:记录知识库创建
|
||||||
|
try {
|
||||||
|
const userInfo = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
if (userInfo) {
|
||||||
|
activityService.log(
|
||||||
|
userInfo.tenant_id,
|
||||||
|
userInfo.tenants?.name || null,
|
||||||
|
userId,
|
||||||
|
userInfo.name,
|
||||||
|
'PKB',
|
||||||
|
'创建知识库',
|
||||||
|
'CREATE',
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
|
|
||||||
// 4. 转换BigInt为Number
|
// 4. 转换BigInt为Number
|
||||||
return {
|
return {
|
||||||
...knowledgeBase,
|
...knowledgeBase,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SearchResult,
|
type SearchResult,
|
||||||
type IngestResult,
|
type IngestResult,
|
||||||
} from '../../../common/rag/index.js';
|
} from '../../../common/rag/index.js';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
|
||||||
// ==================== 类型定义 ====================
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
@@ -65,7 +66,29 @@ export async function searchKnowledgeBase(
|
|||||||
// 查找对应的 EKB 知识库
|
// 查找对应的 EKB 知识库
|
||||||
const ekbKb = await findOrCreateEkbKnowledgeBase(userId, knowledgeBase.name, knowledgeBase.description);
|
const ekbKb = await findOrCreateEkbKnowledgeBase(userId, knowledgeBase.name, knowledgeBase.description);
|
||||||
|
|
||||||
return searchWithPgvector(ekbKb.id, query, { topK, minScore, mode });
|
const results = await searchWithPgvector(ekbKb.id, query, { topK, minScore, mode });
|
||||||
|
|
||||||
|
// 埋点:记录 RAG 检索
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
activityService.log(
|
||||||
|
user.tenant_id,
|
||||||
|
user.tenants?.name || null,
|
||||||
|
userId,
|
||||||
|
user.name,
|
||||||
|
'PKB',
|
||||||
|
'RAG检索',
|
||||||
|
'USE',
|
||||||
|
`kb:${knowledgeBase.name}, results:${results.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 埋点失败不影响主业务 */ }
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { reviewEditorialStandards } from '../services/editorialService.js';
|
|||||||
import { reviewMethodology } from '../services/methodologyService.js';
|
import { reviewMethodology } from '../services/methodologyService.js';
|
||||||
import { calculateOverallScore, getMethodologyStatus } from '../services/utils.js';
|
import { calculateOverallScore, getMethodologyStatus } from '../services/utils.js';
|
||||||
import type { AgentType, EditorialReview, MethodologyReview } from '../types/index.js';
|
import type { AgentType, EditorialReview, MethodologyReview } from '../types/index.js';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审查任务数据结构
|
* 审查任务数据结构
|
||||||
@@ -154,6 +155,33 @@ export function registerReviewWorker() {
|
|||||||
console.log(` 综合得分: ${overallScore}`);
|
console.log(` 综合得分: ${overallScore}`);
|
||||||
console.log(` 耗时: ${durationSeconds}秒`);
|
console.log(` 耗时: ${durationSeconds}秒`);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 4. 埋点:记录审查完成
|
||||||
|
// ========================================
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const agentNames = agents.map(a => a === 'editorial' ? '稿约规范性' : '方法学').join('+');
|
||||||
|
activityService.log(
|
||||||
|
user.tenant_id,
|
||||||
|
user.tenants?.name || null,
|
||||||
|
userId,
|
||||||
|
user.name,
|
||||||
|
'RVW',
|
||||||
|
`${agentNames}审查`,
|
||||||
|
'USE',
|
||||||
|
`审查完成: 规范${editorialScore ?? '-'}分/方法学${methodologyScore ?? '-'}分, 耗时${durationSeconds}秒`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 埋点失败不影响主业务
|
||||||
|
logger.warn('[reviewWorker] 埋点失败', { error: e });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId,
|
taskId,
|
||||||
overallScore,
|
overallScore,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
| **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
| **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
||||||
| **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
| **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
||||||
| **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 |
|
| **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 |
|
||||||
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.1完成(75%)** - 用户管理+模块权限系统 | **P0** |
|
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.2完成(80%)** - 运营监控MVP+登录优化 | **P0** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,6 +170,73 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 🆕 运营监控系统 MVP 完成(2026-01-25)
|
||||||
|
|
||||||
|
#### ✅ 全模块埋点 + 运营看板
|
||||||
|
|
||||||
|
**功能完成**:
|
||||||
|
- 🎉 **数据采集**:7个业务模块埋点全部完成
|
||||||
|
- 🎉 **运营看板**:DAU/DAT/模块统计/实时活动流
|
||||||
|
- 🎉 **用户画像**:360度用户资产统计(知识库、审查任务等)
|
||||||
|
|
||||||
|
**埋点模块覆盖**:
|
||||||
|
|
||||||
|
| 模块 | 埋点功能 | 状态 |
|
||||||
|
|------|---------|------|
|
||||||
|
| SYSTEM | 用户登录 | ✅ |
|
||||||
|
| AIA | 智能体对话完成 | ✅ |
|
||||||
|
| PKB | 知识库创建/删除、RAG检索 | ✅ |
|
||||||
|
| ASL | 文献筛选完成 | ✅ |
|
||||||
|
| DC | Tool B提取、Tool C代码处理 | ✅ |
|
||||||
|
| RVW | 稿件审查完成 | ✅ |
|
||||||
|
| IIT | REDCap数据同步 | ✅ |
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
- ActivityService:火烧即忘模式,带 try-catch 保护
|
||||||
|
- SimpleLog 表:admin_schema,5个索引优化查询
|
||||||
|
- Stats API:overview/live-feed/user-overview/cleanup
|
||||||
|
|
||||||
|
**相关文档**:
|
||||||
|
- 开发计划:`docs/03-业务模块/ADMIN-运营管理端/04-开发计划/03-运营监控系统MVP开发计划.md`
|
||||||
|
- 实施记录:`docs/03-业务模块/ADMIN-运营管理端/04-开发计划/04-运营监控系统MVP实施记录.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🆕 登录体验优化(2026-01-25)
|
||||||
|
|
||||||
|
#### ✅ 默认跳转 AI 问答 + 模块权限修复
|
||||||
|
|
||||||
|
**优化内容**:
|
||||||
|
- ✅ 用户登录后默认进入 `/ai-qa`(AI问答模块)而非首页
|
||||||
|
- ✅ 修复用户模块权限显示逻辑(有自定义配置时正确显示)
|
||||||
|
- ✅ SUPER_ADMIN 用户返回完整模块权限列表
|
||||||
|
- ✅ 顶部导航 LOGO 更换为品牌图标(52px高度)
|
||||||
|
- ✅ LoginPage 路径映射与 moduleRegistry.ts 保持一致
|
||||||
|
|
||||||
|
**修复文件**:
|
||||||
|
- `backend/src/modules/admin/services/userService.ts` - 模块权限显示逻辑
|
||||||
|
- `backend/src/common/auth/auth.service.ts` - getUserModules SUPER_ADMIN处理
|
||||||
|
- `frontend-v2/src/pages/LoginPage.tsx` - 路径映射修正
|
||||||
|
- `frontend-v2/src/framework/layout/TopNavigation.tsx` - LOGO更换
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🆕 PKB 布局修复(2026-01-25)
|
||||||
|
|
||||||
|
#### ✅ 解决 CSS 类名冲突
|
||||||
|
|
||||||
|
**问题**:PKB 工作区问答页面只显示部分内容
|
||||||
|
|
||||||
|
**原因**:Protocol Agent 的 `.chat-container` 样式覆盖了共享组件的同名样式
|
||||||
|
|
||||||
|
**解决**:将 Protocol Agent 模块的 CSS 类名重命名为 `.pa-chat-container`
|
||||||
|
|
||||||
|
**修复文件**:
|
||||||
|
- `frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx`
|
||||||
|
- `frontend-v2/src/modules/aia/protocol-agent/styles/protocol-agent.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 🆕 OSS 存储集成完成(2026-01-22)
|
### 🆕 OSS 存储集成完成(2026-01-22)
|
||||||
|
|
||||||
#### ✅ 阿里云 OSS 正式接入平台基础层
|
#### ✅ 阿里云 OSS 正式接入平台基础层
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ADMIN-运营管理端 - 模块当前状态与开发指南
|
# ADMIN-运营管理端 - 模块当前状态与开发指南
|
||||||
|
|
||||||
> **最后更新:** 2026-01-16
|
> **最后更新:** 2026-01-25
|
||||||
> **状态:** ✅ Phase 4.1 用户管理已完成!模块权限系统架构升级完成!
|
> **状态:** ✅ Phase 4.2 运营监控系统MVP完成!登录跳转逻辑优化完成!
|
||||||
> **版本:** v0.5 (Alpha)
|
> **版本:** v0.6 (Alpha)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,6 +87,32 @@
|
|||||||
- [x] 前端:移除旧的 requiredVersion 系统
|
- [x] 前端:移除旧的 requiredVersion 系统
|
||||||
- [x] 体验优化:登录跳转智能判断(避免普通用户跳转到管理端403)
|
- [x] 体验优化:登录跳转智能判断(避免普通用户跳转到管理端403)
|
||||||
|
|
||||||
|
**Phase 4.2:运营监控系统** ✅ 已完成(2026-01-25)🎉
|
||||||
|
- [x] 数据库:新增 SimpleLog 运营日志表(admin_schema)
|
||||||
|
- [x] 后端服务:ActivityService 火烧即忘埋点服务(带 try-catch 保护)
|
||||||
|
- [x] 后端API:statsRoutes 统计接口(overview/live-feed/user-overview/cleanup)
|
||||||
|
- [x] 模块埋点:7个模块埋点全部完成
|
||||||
|
- SYSTEM(登录)
|
||||||
|
- AIA(智能体对话)
|
||||||
|
- PKB(知识库管理、RAG检索)
|
||||||
|
- ASL(文献筛选)
|
||||||
|
- DC(Tool B提取、Tool C代码处理)
|
||||||
|
- RVW(稿件审查)
|
||||||
|
- IIT(REDCap同步)
|
||||||
|
- [x] 前端看板:Admin Dashboard 运营数据展示(DAU/DAT/模块统计/实时活动流)
|
||||||
|
- [x] 权限控制:stats:view 权限检查
|
||||||
|
|
||||||
|
**Phase 4.3:登录体验优化** ✅ 已完成(2026-01-25)
|
||||||
|
- [x] 修复:用户模块权限显示问题(userService.ts 逻辑修正)
|
||||||
|
- [x] 修复:登录后默认进入AI问答页面(/ai-qa)而非首页
|
||||||
|
- [x] 优化:顶部导航 LOGO 更换为品牌图标
|
||||||
|
- [x] 修复:SUPER_ADMIN 用户模块权限返回完整列表
|
||||||
|
- [x] 修复:LoginPage 路径映射与 moduleRegistry 一致
|
||||||
|
|
||||||
|
**Phase 4.4:PKB 布局修复** ✅ 已完成(2026-01-25)
|
||||||
|
- [x] 修复:PKB 工作区问答页面布局问题(CSS类名冲突)
|
||||||
|
- [x] 修复:Protocol Agent 模块 CSS 类名重命名(.pa-chat-container)
|
||||||
|
|
||||||
### ⏳ 待开发(按优先级)
|
### ⏳ 待开发(按优先级)
|
||||||
|
|
||||||
**P2 - 用户管理增强(可选)**
|
**P2 - 用户管理增强(可选)**
|
||||||
@@ -138,8 +164,9 @@ public.AdminLog -- 旧的审计日志
|
|||||||
- ✅ `prompt_templates` - Prompt模板
|
- ✅ `prompt_templates` - Prompt模板
|
||||||
- ✅ `prompt_versions` - Prompt版本
|
- ✅ `prompt_versions` - Prompt版本
|
||||||
|
|
||||||
**admin_schema(运营管理)**
|
**admin_schema(运营管理)** ✅ 新增 2026-01-25
|
||||||
- `admin_operation_logs` - 运营操作日志
|
- ✅ `simple_logs` - 极简运营日志表(MVP)🆕
|
||||||
|
- `admin_operation_logs` - 运营操作日志(未来)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
docs/03-业务模块/ADMIN-运营管理端/03-UI设计/LOGO.jpg
Normal file
BIN
docs/03-业务模块/ADMIN-运营管理端/03-UI设计/LOGO.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
735
docs/03-业务模块/ADMIN-运营管理端/04-开发计划/03-运营监控系统MVP开发计划.md
Normal file
735
docs/03-业务模块/ADMIN-运营管理端/04-开发计划/03-运营监控系统MVP开发计划.md
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
# 运营监控系统 MVP 开发计划
|
||||||
|
|
||||||
|
> **文档版本**:V3.1 (完整版)
|
||||||
|
> **创建日期**:2026-01-25
|
||||||
|
> **基于文档**:运营体系设计方案-MVP-V3.0.md
|
||||||
|
> **预计工时**:4-5 小时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 修订说明
|
||||||
|
|
||||||
|
本计划基于 V3.0 方案进行审查修订,主要解决以下 **8 个问题**:
|
||||||
|
|
||||||
|
| # | 问题 | 严重程度 | 修订内容 |
|
||||||
|
|---|------|---------|---------|
|
||||||
|
| 1 | 模块覆盖不完整 | 🔴 严重 | 补充 RVW、IIT、Protocol Agent、SSA/ST 预留 |
|
||||||
|
| 2 | 缺少 tenantName 字段 | 🔴 严重 | 添加冗余字段避免 JOIN |
|
||||||
|
| 3 | RVW 埋点清单缺失 | 🔴 严重 | 新增 RVW 模块埋点清单 |
|
||||||
|
| 4 | 用户360画像缺少 RVW | 🔴 严重 | 补充 RVW 资产统计 |
|
||||||
|
| 5 | action 类型不够全面 | 🟡 中等 | 扩展 CREATE/DELETE 类型 |
|
||||||
|
| 6 | 缺少 API 路由设计 | 🟡 中等 | 新增完整 API 端点设计 |
|
||||||
|
| 7 | 数据保留策略缺失 | 🟡 中等 | 补充 180 天数据清理 |
|
||||||
|
| 8 | 权限控制未说明 | 🟡 中等 | 明确角色权限矩阵 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核心指标定义(保持 V3.0)
|
||||||
|
|
||||||
|
| 优先级 | 指标名称 | 定义 | 价值 |
|
||||||
|
|--------|---------|------|-----|
|
||||||
|
| **P0+** | 活跃医生数 (DAU) | 今日有行为的去重 user_id 数 | 真实价值线 |
|
||||||
|
| **P0** | 活跃租户数 (DAT) | 今日有行为的去重 tenant_id 数 | 商务生死线 |
|
||||||
|
| **P1** | 功能渗透率 | 各模块/功能使用次数分布 | 产品迭代指引 |
|
||||||
|
| **P2** | 价值交付次数 | 导出/下载次数 | 北极星指标 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据库设计(V3.1 修订版)
|
||||||
|
|
||||||
|
### 2.1 SimpleLog 表(admin_schema)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
/// 运营日志表 (MVP V3.1)
|
||||||
|
model SimpleLog {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// === 租户和用户信息 ===
|
||||||
|
tenantId String @map("tenant_id") @db.VarChar(50)
|
||||||
|
tenantName String? @map("tenant_name") @db.VarChar(100) // 🆕 冗余字段,避免JOIN
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
userName String? @map("user_name") @db.VarChar(50)
|
||||||
|
|
||||||
|
// === 行为记录 ===
|
||||||
|
module String @db.VarChar(20) // 模块代码
|
||||||
|
feature String @db.VarChar(50) // 细分功能
|
||||||
|
action String @db.VarChar(20) // 动作类型
|
||||||
|
|
||||||
|
// === 详情信息 ===
|
||||||
|
info String? @db.Text // JSON或文本详情
|
||||||
|
|
||||||
|
// === 索引 ===
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([module, feature])
|
||||||
|
@@index([action]) // 🆕 支持按动作筛选
|
||||||
|
@@map("simple_logs")
|
||||||
|
@@schema("admin_schema")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 字段说明
|
||||||
|
|
||||||
|
#### module(模块代码)- 完整列表
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ModuleCode =
|
||||||
|
| 'AIA' // AI智能问答 (12智能体 + Protocol Agent)
|
||||||
|
| 'PKB' // 个人知识库
|
||||||
|
| 'ASL' // AI智能文献
|
||||||
|
| 'DC' // 数据清洗整理
|
||||||
|
| 'RVW' // 稿件审查系统 🆕
|
||||||
|
| 'IIT' // IIT Manager Agent 🆕
|
||||||
|
| 'SSA' // 智能统计分析 (预留) 🆕
|
||||||
|
| 'ST' // 统计分析工具 (预留) 🆕
|
||||||
|
| 'SYSTEM'; // 系统级行为 (登录/登出)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### action(动作类型)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ActionType =
|
||||||
|
| 'LOGIN' // 登录系统
|
||||||
|
| 'USE' // 使用功能
|
||||||
|
| 'EXPORT' // 导出/下载
|
||||||
|
| 'CREATE' // 创建资源 🆕
|
||||||
|
| 'DELETE' // 删除资源 🆕
|
||||||
|
| 'ERROR'; // 错误记录
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 完整埋点清单(按模块)
|
||||||
|
|
||||||
|
### 3.1 🤖 AIA 模块(AI智能问答)
|
||||||
|
|
||||||
|
#### 12 个智能体
|
||||||
|
|
||||||
|
| agentId | feature (中文) | 埋点位置 |
|
||||||
|
|---------|---------------|---------|
|
||||||
|
| topic-scoping | 科学问题梳理 | conversationService.complete() |
|
||||||
|
| pico-analysis | PICO梳理 | conversationService.complete() |
|
||||||
|
| topic-eval | 选题评价 | conversationService.complete() |
|
||||||
|
| outcome-design | 观察指标设计 | conversationService.complete() |
|
||||||
|
| crf-design | CRF设计 | conversationService.complete() |
|
||||||
|
| sample-size | 样本量计算 | conversationService.complete() |
|
||||||
|
| protocol-writing | 方案撰写 | conversationService.complete() |
|
||||||
|
| methodology-review | 方法学评审 | conversationService.complete() |
|
||||||
|
| paper-polish | 论文润色 | conversationService.complete() |
|
||||||
|
| paper-translate | 论文翻译 | conversationService.complete() |
|
||||||
|
| data-preprocess | 数据预处理 | 跳转DC,记录来源 |
|
||||||
|
| stat-analysis | 统计分析 | 跳转DC,记录来源 |
|
||||||
|
|
||||||
|
#### Protocol Agent(🆕 2026-01-25 新功能)
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| Protocol要素收集 | USE | ProtocolOrchestrator.collectPhase() | "阶段1完成" |
|
||||||
|
| Protocol方案生成 | USE | ProtocolOrchestrator.generateProtocol() | "生成12章节方案" |
|
||||||
|
| Protocol Word导出 | EXPORT | ProtocolAgentController.exportWord() | "导出Word文档" |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts`
|
||||||
|
- `backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 📚 PKB 模块(个人知识库)
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| 知识库创建 | CREATE | knowledgeBaseController.create() | "创建: 肺癌研究库" |
|
||||||
|
| 文档上传 | USE | documentController.upload() | "上传: 5篇PDF" |
|
||||||
|
| RAG问答 | USE | ragController.chat() | "提问: 入排标准是什么?" |
|
||||||
|
| 批处理提取 | USE | batchController.process() | "批量提取: 10篇" |
|
||||||
|
| 结果导出 | EXPORT | batchController.export() | "导出CSV" |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/modules/pkb/controllers/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 📖 ASL 模块(AI智能文献)
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| DeepSearch检索 | USE | researchController.stream() | "关键词: 肺癌治疗" |
|
||||||
|
| 标题摘要筛选 | USE | screeningController.start() | "筛选: 500篇" |
|
||||||
|
| 全文复筛 | USE | fullTextController.start() | "复筛: 100篇" |
|
||||||
|
| 筛选结果导出 | EXPORT | screeningController.export() | "导出Excel" |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/modules/asl/controllers/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 🧹 DC 模块(数据清洗整理)
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| Tool B 健康检查 | USE | toolBController.healthCheck() | "检查: 1000行数据" |
|
||||||
|
| Tool B 自动提取 | USE | toolBController.extract() | "提取任务: 50条" |
|
||||||
|
| Tool C 数据清洗 | USE | toolCController.process() | "执行: 筛选操作" |
|
||||||
|
| Tool C Pivot | USE | toolCController.pivot() | "Pivot转换" |
|
||||||
|
| 结果导出 | EXPORT | toolCController.export() | "导出Excel" |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/modules/dc/controllers/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 📝 RVW 模块(稿件审查系统)🆕
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| 稿件上传 | USE | reviewController.upload() | "上传: xxx.pdf" |
|
||||||
|
| 稿约规范性审查 | USE | reviewWorker (editorial) | "审查开始" |
|
||||||
|
| 方法学审查 | USE | reviewWorker (methodology) | "方法学审查开始" |
|
||||||
|
| 审查完成 | USE | reviewWorker.complete() | "评分: 规范85/方法78" |
|
||||||
|
| 报告导出 | EXPORT | TaskDetail.exportWord() | "导出Word报告" |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/modules/rvw/services/reviewWorker.ts`
|
||||||
|
- `backend/src/modules/rvw/controllers/reviewController.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 🏥 IIT 模块(IIT Manager Agent)🆕
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| REDCap数据同步 | USE | redcapAdapter.sync() | "同步: 10条记录" |
|
||||||
|
| AI质控检查 | USE | qualityCheckService.check() | "检查患者ID 7" |
|
||||||
|
| 企微通知推送 | USE | wechatService.notify() | "推送预警通知" |
|
||||||
|
| 对话查询 | USE | chatService.query() | "查询患者统计" |
|
||||||
|
| 人工确权 | USE | actionController.approve() | "确权: 排除患者" |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/modules/iit-manager/services/`
|
||||||
|
- `backend/src/modules/iit-manager/controllers/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 🔐 SYSTEM(系统级)
|
||||||
|
|
||||||
|
| feature | action | 埋点位置 | info 示例 |
|
||||||
|
|---------|--------|---------|----------|
|
||||||
|
| 用户登录 | LOGIN | authController.login() | "密码登录" |
|
||||||
|
| 用户登出 | USE | authController.logout() | - |
|
||||||
|
|
||||||
|
**埋点代码位置**:
|
||||||
|
- `backend/src/common/auth/auth.controller.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 后端 API 设计
|
||||||
|
|
||||||
|
### 4.1 运营统计 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/admin/stats/overview` | 今日大盘(DAU/DAT/导出数) | SUPER_ADMIN |
|
||||||
|
| GET | `/api/admin/stats/live-feed` | 实时流水账(最近100条) | SUPER_ADMIN |
|
||||||
|
| GET | `/api/admin/stats/module/:code` | 模块使用统计 | SUPER_ADMIN |
|
||||||
|
| GET | `/api/admin/users/:id/overview` | 用户360画像 | SUPER_ADMIN |
|
||||||
|
|
||||||
|
### 4.2 API 响应示例
|
||||||
|
|
||||||
|
#### 今日大盘 `/api/admin/stats/overview`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"dau": 12, // 今日活跃医生数
|
||||||
|
"dat": 3, // 今日活跃租户数
|
||||||
|
"exportCount": 5, // 今日导出次数
|
||||||
|
"moduleStats": {
|
||||||
|
"AIA": 45,
|
||||||
|
"PKB": 23,
|
||||||
|
"DC": 12,
|
||||||
|
"RVW": 8,
|
||||||
|
"ASL": 5,
|
||||||
|
"IIT": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 实时流水账 `/api/admin/stats/live-feed`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"createdAt": "2026-01-25T10:05:00Z",
|
||||||
|
"tenantName": "协和医院",
|
||||||
|
"userName": "张主任",
|
||||||
|
"module": "AIA",
|
||||||
|
"feature": "选题评价",
|
||||||
|
"action": "USE",
|
||||||
|
"info": "评价得分: 85分"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 用户360画像 `/api/admin/users/:id/overview`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"profile": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "张主任",
|
||||||
|
"phone": "138****1234",
|
||||||
|
"tenantName": "协和医院"
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"aia": { "conversationCount": 158 },
|
||||||
|
"pkb": { "kbCount": 3, "docCount": 450 },
|
||||||
|
"dc": { "taskCount": 12 },
|
||||||
|
"rvw": { "reviewTaskCount": 25, "completedCount": 20 } // 🆕
|
||||||
|
},
|
||||||
|
"activities": [
|
||||||
|
{
|
||||||
|
"createdAt": "2026-01-25T10:30:00Z",
|
||||||
|
"module": "AIA",
|
||||||
|
"feature": "选题评价",
|
||||||
|
"action": "USE",
|
||||||
|
"info": "生成结果: 85分"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 后端服务实现
|
||||||
|
|
||||||
|
### 5.1 ActivityService(埋点服务)
|
||||||
|
|
||||||
|
**文件路径**:`backend/src/common/services/activity.service.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { prisma } from '../../config/database.js';
|
||||||
|
import { logger } from '../logging/index.js';
|
||||||
|
|
||||||
|
type ModuleCode = 'AIA' | 'PKB' | 'ASL' | 'DC' | 'RVW' | 'IIT' | 'SSA' | 'ST' | 'SYSTEM';
|
||||||
|
type ActionType = 'LOGIN' | 'USE' | 'EXPORT' | 'CREATE' | 'DELETE' | 'ERROR';
|
||||||
|
|
||||||
|
export const activityService = {
|
||||||
|
/**
|
||||||
|
* 核心埋点方法 (Fire-and-Forget 模式)
|
||||||
|
* 异步执行,不阻塞主业务
|
||||||
|
*/
|
||||||
|
log(
|
||||||
|
tenantId: string,
|
||||||
|
tenantName: string, // 🆕 新增
|
||||||
|
userId: string,
|
||||||
|
userName: string,
|
||||||
|
module: ModuleCode,
|
||||||
|
feature: string,
|
||||||
|
action: ActionType,
|
||||||
|
info?: any
|
||||||
|
) {
|
||||||
|
// 异步执行,不要 await
|
||||||
|
prisma.simpleLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
tenantName, // 🆕
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
module,
|
||||||
|
feature,
|
||||||
|
action,
|
||||||
|
info: typeof info === 'object' ? JSON.stringify(info) : String(info || ''),
|
||||||
|
}
|
||||||
|
}).catch(e => {
|
||||||
|
logger.warn('埋点写入失败(可忽略)', { error: e.message });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日核心大盘数据
|
||||||
|
*/
|
||||||
|
async getTodayOverview() {
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const stats = await prisma.$queryRaw`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT user_id) as dau,
|
||||||
|
COUNT(DISTINCT tenant_id) as dat,
|
||||||
|
COUNT(CASE WHEN action = 'EXPORT' THEN 1 END) as export_count
|
||||||
|
FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at >= ${todayStart}
|
||||||
|
` as any[];
|
||||||
|
|
||||||
|
// 模块使用统计
|
||||||
|
const moduleStats = await prisma.$queryRaw`
|
||||||
|
SELECT module, COUNT(*) as count
|
||||||
|
FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at >= ${todayStart}
|
||||||
|
GROUP BY module
|
||||||
|
` as any[];
|
||||||
|
|
||||||
|
const moduleMap: Record<string, number> = {};
|
||||||
|
moduleStats.forEach((m: any) => {
|
||||||
|
moduleMap[m.module] = Number(m.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dau: Number(stats[0]?.dau || 0),
|
||||||
|
dat: Number(stats[0]?.dat || 0),
|
||||||
|
exportCount: Number(stats[0]?.export_count || 0),
|
||||||
|
moduleStats: moduleMap,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时流水账
|
||||||
|
*/
|
||||||
|
async getLiveFeed(limit = 100) {
|
||||||
|
return prisma.simpleLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
tenantName: true,
|
||||||
|
userName: true,
|
||||||
|
module: true,
|
||||||
|
feature: true,
|
||||||
|
action: true,
|
||||||
|
info: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户360画像
|
||||||
|
*/
|
||||||
|
async getUserOverview(userId: string) {
|
||||||
|
const [user, aiaStats, kbs, dcStats, rvwStats, logs] = await Promise.all([
|
||||||
|
// 基础信息
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { tenants: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// AIA 资产 (会话数)
|
||||||
|
prisma.conversation.count({
|
||||||
|
where: { userId, deletedAt: null }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// PKB 资产 (知识库数 + 文档数)
|
||||||
|
prisma.knowledgeBase.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
include: { _count: { select: { documents: true } } }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// DC 资产 (任务数)
|
||||||
|
prisma.extractionTask.count({ where: { userId } }),
|
||||||
|
|
||||||
|
// RVW 资产 (审稿任务数) 🆕
|
||||||
|
prisma.reviewTask.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
where: { userId },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 最近行为 (从 SimpleLog 查最近 20 条)
|
||||||
|
prisma.simpleLog.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
module: true,
|
||||||
|
feature: true,
|
||||||
|
action: true,
|
||||||
|
info: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalDocs = kbs.reduce((sum, kb) => sum + kb._count.documents, 0);
|
||||||
|
|
||||||
|
// 计算 RVW 统计
|
||||||
|
const rvwTotal = rvwStats.reduce((sum, s) => sum + s._count, 0);
|
||||||
|
const rvwCompleted = rvwStats.find(s => s.status === 'completed')?._count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: user ? {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||||||
|
tenantName: user.tenants?.name,
|
||||||
|
} : null,
|
||||||
|
assets: {
|
||||||
|
aia: { conversationCount: aiaStats },
|
||||||
|
pkb: { kbCount: kbs.length, docCount: totalDocs },
|
||||||
|
dc: { taskCount: dcStats },
|
||||||
|
rvw: { reviewTaskCount: rvwTotal, completedCount: rvwCompleted }, // 🆕
|
||||||
|
},
|
||||||
|
activities: logs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 StatsController(统计控制器)
|
||||||
|
|
||||||
|
**文件路径**:`backend/src/modules/admin/controllers/statsController.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { activityService } from '../../../common/services/activity.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日大盘
|
||||||
|
* GET /api/admin/stats/overview
|
||||||
|
*/
|
||||||
|
export async function getOverview(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const data = await activityService.getTodayOverview();
|
||||||
|
return reply.send({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时流水账
|
||||||
|
* GET /api/admin/stats/live-feed
|
||||||
|
*/
|
||||||
|
export async function getLiveFeed(
|
||||||
|
request: FastifyRequest<{ Querystring: { limit?: number } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const limit = request.query.limit || 100;
|
||||||
|
const data = await activityService.getLiveFeed(limit);
|
||||||
|
return reply.send({ success: true, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户360画像
|
||||||
|
* GET /api/admin/users/:id/overview
|
||||||
|
*/
|
||||||
|
export async function getUserOverview(
|
||||||
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const { id } = request.params;
|
||||||
|
const data = await activityService.getUserOverview(id);
|
||||||
|
return reply.send({ success: true, data });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端页面设计
|
||||||
|
|
||||||
|
### 6.1 Admin 首页改造
|
||||||
|
|
||||||
|
**位置**:`frontend-v2/src/pages/admin/AdminDashboard.tsx`
|
||||||
|
|
||||||
|
#### 顶部卡片区域
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┬─────────────────┬─────────────────┐
|
||||||
|
│ 今日活跃医生 │ 今日活跃医院 │ 今日价值交付 │
|
||||||
|
│ 12 👨⚕️ │ 3 🏥 │ 5 🟢 │
|
||||||
|
│ (DAU) │ (DAT) │ (导出次数) │
|
||||||
|
└─────────────────┴─────────────────┴─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 实时流水账区域
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────┬──────┬──────┬──────┬────────────┬──────┬────────────┐
|
||||||
|
│ 时间 │ 医院 │ 医生 │ 模块 │ 具体功能 │ 动作 │ 详情 │
|
||||||
|
├──────┼──────┼──────┼──────┼────────────┼──────┼────────────┤
|
||||||
|
│10:05 │ 协和 │张主任│ AIA │ 选题评价 │🔵USE │评分: 85分 │
|
||||||
|
│10:03 │ 协和 │张主任│ RVW │ 稿约规范 │🔵USE │审查开始 │
|
||||||
|
│09:55 │ 华西 │李医生│ DC │ Tool C │🟢EXP │导出 Excel │
|
||||||
|
└──────┴──────┴──────┴──────┴────────────┴──────┴────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 用户详情页增强
|
||||||
|
|
||||||
|
**位置**:`frontend-v2/src/modules/admin/pages/UserDetailPage.tsx`
|
||||||
|
|
||||||
|
#### 资产统计区域
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
|
||||||
|
│ 💬 AIA对话 │ 📚 PKB知识库 │ 📄 上传文献 │ 🧹 DC清洗 │ 📝 RVW审稿 │
|
||||||
|
│ 158 次 │ 3 个 │ 450 篇 │ 12 次 │ 25 篇 │
|
||||||
|
└─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 行为时间轴
|
||||||
|
|
||||||
|
```
|
||||||
|
• 10:30 [AIA] 使用了 "选题评价" (生成结果: 85分)
|
||||||
|
• 10:15 [RVW] 完成了 "稿约规范性审查" (评分: 82分) 🆕
|
||||||
|
• 09:50 [DC] 导出了 Tool C 清洗结果 (Excel)
|
||||||
|
• 09:48 [SYSTEM] 登录系统
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 权限控制
|
||||||
|
|
||||||
|
### 7.1 角色权限矩阵
|
||||||
|
|
||||||
|
| 功能 | SUPER_ADMIN | PROMPT_ENGINEER | HOSPITAL_ADMIN |
|
||||||
|
|------|-------------|-----------------|----------------|
|
||||||
|
| 查看全局大盘 | ✅ | ✅(只读) | ❌ |
|
||||||
|
| 查看实时流水 | ✅ | ✅(只读) | ❌ |
|
||||||
|
| 查看用户画像 | ✅ | ❌ | 本租户用户 |
|
||||||
|
| 数据导出 | ✅ | ❌ | ❌ |
|
||||||
|
|
||||||
|
### 7.2 数据隔离规则
|
||||||
|
|
||||||
|
- **SUPER_ADMIN**:可查看全部租户数据
|
||||||
|
- **HOSPITAL_ADMIN**:只能查看本租户的用户活动
|
||||||
|
- **普通用户**:无运营数据访问权限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据保留策略
|
||||||
|
|
||||||
|
### 8.1 清理规则
|
||||||
|
|
||||||
|
- 保留期限:**180 天**
|
||||||
|
- 清理方式:pg-boss 定时任务,每日 03:00 执行
|
||||||
|
- 清理脚本:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 清理180天前的日志
|
||||||
|
DELETE FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at < NOW() - INTERVAL '180 days';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 定时任务配置
|
||||||
|
|
||||||
|
**文件路径**:`backend/src/common/jobs/cleanupWorker.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { jobQueue } from './jobQueue.js';
|
||||||
|
import { prisma } from '../../config/database.js';
|
||||||
|
import { logger } from '../logging/index.js';
|
||||||
|
|
||||||
|
// 注册清理任务
|
||||||
|
export async function registerCleanupJobs() {
|
||||||
|
await jobQueue.schedule('cleanup-simple-logs', '0 3 * * *', async () => {
|
||||||
|
const result = await prisma.$executeRaw`
|
||||||
|
DELETE FROM admin_schema.simple_logs
|
||||||
|
WHERE created_at < NOW() - INTERVAL '180 days'
|
||||||
|
`;
|
||||||
|
logger.info('运营日志清理完成', { deletedCount: result });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 开发任务清单
|
||||||
|
|
||||||
|
### Phase 1: 数据库(15分钟)
|
||||||
|
|
||||||
|
- [ ] 更新 `prisma/schema.prisma`,添加 SimpleLog 模型
|
||||||
|
- [ ] 执行 `npx prisma db push` 同步数据库
|
||||||
|
- [ ] 验证表结构和索引
|
||||||
|
|
||||||
|
### Phase 2: 后端服务(60分钟)
|
||||||
|
|
||||||
|
- [ ] 创建 `common/services/activity.service.ts`
|
||||||
|
- [ ] 创建 `modules/admin/controllers/statsController.ts`
|
||||||
|
- [ ] 创建 `modules/admin/routes/statsRoutes.ts`
|
||||||
|
- [ ] 在 `index.ts` 注册路由
|
||||||
|
|
||||||
|
### Phase 3: 埋点集成(90分钟)
|
||||||
|
|
||||||
|
#### 系统级
|
||||||
|
- [ ] `auth.controller.ts` - 登录埋点
|
||||||
|
|
||||||
|
#### AIA 模块
|
||||||
|
- [ ] `conversationService.ts` - 12个智能体埋点
|
||||||
|
- [ ] `ProtocolOrchestrator.ts` - Protocol Agent 埋点
|
||||||
|
|
||||||
|
#### PKB 模块
|
||||||
|
- [ ] `knowledgeBaseController.ts` - 知识库创建埋点
|
||||||
|
- [ ] `documentController.ts` - 文档上传埋点
|
||||||
|
- [ ] `ragController.ts` - RAG问答埋点
|
||||||
|
|
||||||
|
#### DC 模块
|
||||||
|
- [ ] `toolBController.ts` - Tool B 埋点
|
||||||
|
- [ ] `toolCController.ts` - Tool C 埋点
|
||||||
|
|
||||||
|
#### RVW 模块 🆕
|
||||||
|
- [ ] `reviewController.ts` - 上传埋点
|
||||||
|
- [ ] `reviewWorker.ts` - 审查完成埋点
|
||||||
|
|
||||||
|
#### IIT 模块 🆕
|
||||||
|
- [ ] `chatService.ts` - 对话查询埋点
|
||||||
|
- [ ] `redcapAdapter.ts` - 同步埋点
|
||||||
|
|
||||||
|
### Phase 4: 前端页面(90分钟)
|
||||||
|
|
||||||
|
- [ ] 改造 `AdminDashboard.tsx` - 添加大盘卡片和流水账
|
||||||
|
- [ ] 改造 `UserDetailPage.tsx` - 添加资产统计和时间轴
|
||||||
|
- [ ] 创建 `StatsCard.tsx` 组件
|
||||||
|
- [ ] 创建 `LiveFeed.tsx` 组件
|
||||||
|
- [ ] 创建 `ActivityTimeline.tsx` 组件
|
||||||
|
|
||||||
|
### Phase 5: 数据清理(15分钟)
|
||||||
|
|
||||||
|
- [ ] 创建 `cleanupWorker.ts`
|
||||||
|
- [ ] 注册定时任务
|
||||||
|
- [ ] 测试清理逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试验证
|
||||||
|
|
||||||
|
### 10.1 单元测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 埋点服务测试
|
||||||
|
npm test -- --grep "ActivityService"
|
||||||
|
|
||||||
|
# API 测试
|
||||||
|
npm test -- --grep "Stats API"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 端到端验证
|
||||||
|
|
||||||
|
1. 登录系统,检查是否记录 LOGIN
|
||||||
|
2. 使用 AIA 智能体,检查是否记录 USE
|
||||||
|
3. 导出文件,检查是否记录 EXPORT
|
||||||
|
4. 访问 Admin 首页,验证大盘数据
|
||||||
|
5. 访问用户详情,验证 360 画像
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 总结
|
||||||
|
|
||||||
|
| 项目 | V3.0 原方案 | V3.1 修订版 |
|
||||||
|
|------|------------|------------|
|
||||||
|
| 模块覆盖 | 4 个 | **8 个** |
|
||||||
|
| 字段设计 | 缺少 tenantName | ✅ 完整 |
|
||||||
|
| API 设计 | 缺失 | ✅ 4 个端点 |
|
||||||
|
| 数据保留 | 缺失 | ✅ 180 天 |
|
||||||
|
| 权限控制 | 缺失 | ✅ 角色矩阵 |
|
||||||
|
| 预计工时 | 3-4 小时 | **4-5 小时** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**下一步**:按照任务清单执行开发,优先完成 Phase 1-2(基础设施),再逐步完成埋点集成和前端页面。
|
||||||
|
|
||||||
333
docs/03-业务模块/ADMIN-运营管理端/04-开发计划/04-运营监控系统MVP实施记录.md
Normal file
333
docs/03-业务模块/ADMIN-运营管理端/04-开发计划/04-运营监控系统MVP实施记录.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# 运营监控系统 MVP 实施记录
|
||||||
|
|
||||||
|
> **文档版本**:V1.0
|
||||||
|
> **实施日期**:2026-01-25
|
||||||
|
> **基于文档**:03-运营监控系统MVP开发计划.md
|
||||||
|
> **实施状态**:✅ **MVP 完成!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 实施概要
|
||||||
|
|
||||||
|
### 完成状态
|
||||||
|
|
||||||
|
| 任务类型 | 计划 | 完成 | 完成率 |
|
||||||
|
|---------|------|------|--------|
|
||||||
|
| 数据库设计 | 1 | 1 | ✅ 100% |
|
||||||
|
| 后端服务 | 3 | 3 | ✅ 100% |
|
||||||
|
| 埋点集成 | 7模块 | 7模块 | ✅ 100% |
|
||||||
|
| 前端看板 | 1 | 1 | ✅ 100% |
|
||||||
|
| API测试 | 4端点 | 4端点 | ✅ 100% |
|
||||||
|
|
||||||
|
**总耗时**:约 6 小时(含调试和问题修复)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 数据库实施 ✅
|
||||||
|
|
||||||
|
### 1.1 SimpleLog 表创建
|
||||||
|
|
||||||
|
**Prisma Schema 位置**:`backend/prisma/schema.prisma`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
/// 极简运营日志表 (MVP) - V3.1 修订版
|
||||||
|
model SimpleLog {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
tenantId String @map("tenant_id") @db.VarChar(50)
|
||||||
|
tenantName String? @map("tenant_name") @db.VarChar(100)
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
userName String? @map("user_name") @db.VarChar(50)
|
||||||
|
|
||||||
|
module String @db.VarChar(20)
|
||||||
|
feature String @db.VarChar(50)
|
||||||
|
action String @db.VarChar(20)
|
||||||
|
|
||||||
|
info String? @db.Text
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([module, feature])
|
||||||
|
@@index([action])
|
||||||
|
@@map("simple_logs")
|
||||||
|
@@schema("admin_schema")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移命令**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 由于存在跨 schema 外键约束问题,使用 db push 代替 migrate
|
||||||
|
npx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据库备份**:
|
||||||
|
- 备份文件:`ai_clinical_research_backup_20260125.sql`
|
||||||
|
- 备份时间:2026-01-25 实施前
|
||||||
|
- 备份命令:`pg_dump -h localhost -U postgres -F p ai_clinical_research > backup.sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 后端服务实施 ✅
|
||||||
|
|
||||||
|
### 2.1 ActivityService(埋点服务)
|
||||||
|
|
||||||
|
**文件位置**:`backend/src/common/services/activity.service.ts`
|
||||||
|
|
||||||
|
**核心特性**:
|
||||||
|
- ✅ 火烧即忘模式(Fire-and-Forget)
|
||||||
|
- ✅ 外层 try-catch 保护(永不抛出异常)
|
||||||
|
- ✅ 自动填充 tenantName 和 userName
|
||||||
|
- ✅ 静默失败,不影响业务逻辑
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { activityService } from '@/common/services/activity.service';
|
||||||
|
|
||||||
|
// 在业务逻辑中添加埋点(不阻塞主流程)
|
||||||
|
await activityService.log({
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
module: 'AIA',
|
||||||
|
feature: '智能体对话',
|
||||||
|
action: 'MESSAGE_SENT',
|
||||||
|
info: `对话完成,tokens: ${tokens}`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 StatsController(统计控制器)
|
||||||
|
|
||||||
|
**文件位置**:`backend/src/modules/admin/controllers/statsController.ts`
|
||||||
|
|
||||||
|
**API 端点**:
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/admin/stats/overview` | GET | 获取 DAU/DAT/模块统计 |
|
||||||
|
| `/api/admin/stats/live-feed` | GET | 获取最近活动流 |
|
||||||
|
| `/api/admin/users/:id/overview` | GET | 获取用户360画像 |
|
||||||
|
| `/api/admin/stats/cleanup` | POST | 清理过期日志(180天) |
|
||||||
|
|
||||||
|
### 2.3 StatsRoutes(路由配置)
|
||||||
|
|
||||||
|
**文件位置**:`backend/src/modules/admin/routes/statsRoutes.ts`
|
||||||
|
|
||||||
|
**权限控制**:
|
||||||
|
- 所有端点需要 `stats:view` 权限
|
||||||
|
- 使用 `authenticate` + `requirePermission` 中间件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 埋点集成实施 ✅
|
||||||
|
|
||||||
|
### 3.1 各模块埋点清单
|
||||||
|
|
||||||
|
| 模块 | 埋点位置 | action 类型 | 状态 |
|
||||||
|
|------|---------|------------|------|
|
||||||
|
| **SYSTEM** | auth.controller.ts | LOGIN | ✅ |
|
||||||
|
| **AIA** | conversationService.ts | MESSAGE_SENT | ✅ |
|
||||||
|
| **PKB** | knowledgeBaseService.ts | KB_CREATED, KB_DELETED | ✅ |
|
||||||
|
| **PKB** | ragService.ts | RAG_SEARCH | ✅ |
|
||||||
|
| **ASL** | screeningWorker.ts | SCREENING_COMPLETED | ✅ |
|
||||||
|
| **DC** | extractionWorker.ts (Tool B) | EXTRACTION_COMPLETED | ✅ |
|
||||||
|
| **DC** | AICodeService.ts (Tool C) | CODE_GENERATE, CODE_EXECUTE | ✅ |
|
||||||
|
| **RVW** | reviewWorker.ts | REVIEW_COMPLETED | ✅ |
|
||||||
|
| **IIT** | SyncManager.ts | POLL_STARTED, SYNC_COMPLETED | ✅ |
|
||||||
|
|
||||||
|
### 3.2 埋点代码示例
|
||||||
|
|
||||||
|
**登录埋点**(auth.controller.ts):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 登录成功后记录
|
||||||
|
await activityService.log({
|
||||||
|
userId: result.user.id,
|
||||||
|
tenantId: result.user.tenantId,
|
||||||
|
module: 'SYSTEM',
|
||||||
|
feature: '用户登录',
|
||||||
|
action: 'LOGIN',
|
||||||
|
info: `用户 ${result.user.name} (${result.user.phone}) 登录成功`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**AIA 对话埋点**(conversationService.ts):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在流式对话完成后记录
|
||||||
|
await activityService.log({
|
||||||
|
userId,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
module: 'AIA',
|
||||||
|
feature: `智能体对话: ${conversation.agentId}`,
|
||||||
|
action: 'MESSAGE_SENT',
|
||||||
|
info: `对话 ${conversationId} 消息发送完成,tokens: ${aiMessage.tokens}`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**PKB 知识库埋点**(knowledgeBaseService.ts):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建知识库
|
||||||
|
await activityService.log({
|
||||||
|
userId,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
module: 'PKB',
|
||||||
|
feature: '知识库管理',
|
||||||
|
action: 'KB_CREATED',
|
||||||
|
info: `创建知识库: ${name} (ID: ${knowledgeBase.id})`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端看板实施 ✅
|
||||||
|
|
||||||
|
### 4.1 Admin Dashboard 更新
|
||||||
|
|
||||||
|
**文件位置**:`frontend-v2/src/pages/admin/AdminDashboard.tsx`
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- ✅ DAU/DAT 实时统计卡片
|
||||||
|
- ✅ 模块使用分布图表
|
||||||
|
- ✅ 最近活动实时流
|
||||||
|
- ✅ 自动刷新(基于 React Query)
|
||||||
|
|
||||||
|
### 4.2 API 调用层
|
||||||
|
|
||||||
|
**文件位置**:`frontend-v2/src/modules/admin/api/statsApi.ts`
|
||||||
|
|
||||||
|
**API 函数**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取运营概览
|
||||||
|
export const getOverview = async (): Promise<StatsOverview> => {
|
||||||
|
const response = await apiClient.get('/api/admin/stats/overview');
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取实时活动流
|
||||||
|
export const getLiveFeed = async (limit?: number): Promise<LiveFeedItem[]> => {
|
||||||
|
const response = await apiClient.get('/api/admin/stats/live-feed', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试验证 ✅
|
||||||
|
|
||||||
|
### 5.1 API 测试
|
||||||
|
|
||||||
|
**测试脚本位置**:`backend/src/modules/admin/__tests__/test-stats-api.ps1`
|
||||||
|
|
||||||
|
**测试结果**:
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| 登录获取 Token | ✅ 通过 |
|
||||||
|
| GET /overview | ✅ 返回 DAU/DAT |
|
||||||
|
| GET /live-feed | ✅ 返回活动列表 |
|
||||||
|
| 用户 360 画像 | ✅ 返回资产统计 |
|
||||||
|
|
||||||
|
### 5.2 前端测试
|
||||||
|
|
||||||
|
- ✅ 运营管理端 Dashboard 正常显示
|
||||||
|
- ✅ 实时数据刷新正常
|
||||||
|
- ✅ 权限控制正常(仅 SUPER_ADMIN 可访问)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 问题与解决
|
||||||
|
|
||||||
|
### 6.1 Prisma 迁移失败
|
||||||
|
|
||||||
|
**问题**:`prisma migrate dev` 报错 P3006(shadow database 问题)
|
||||||
|
|
||||||
|
**原因**:存在跨 schema 外键约束
|
||||||
|
|
||||||
|
**解决**:使用 `prisma db push` 直接推送 schema 变更(仅添加新表,安全)
|
||||||
|
|
||||||
|
### 6.2 TypeScript 编译错误
|
||||||
|
|
||||||
|
**问题**:添加埋点后多处 TypeScript 错误
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
- 修正 Prisma 模型名称(如 `prisma.reviewTask` 代替 `prisma.review_tasks`)
|
||||||
|
- 添加类型注解(`reduce` 函数参数)
|
||||||
|
- 添加 `.js` 扩展名到 ESM 导入
|
||||||
|
|
||||||
|
### 6.3 DC Tool C 上传 401
|
||||||
|
|
||||||
|
**问题**:文件上传返回 401 Unauthorized
|
||||||
|
|
||||||
|
**原因**:后端服务未重启,新代码未生效
|
||||||
|
|
||||||
|
**解决**:重启后端服务后正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文件变更清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/src/common/services/activity.service.ts` | 埋点服务 |
|
||||||
|
| `backend/src/modules/admin/controllers/statsController.ts` | 统计控制器 |
|
||||||
|
| `backend/src/modules/admin/routes/statsRoutes.ts` | 统计路由 |
|
||||||
|
| `frontend-v2/src/modules/admin/api/statsApi.ts` | 前端 API 层 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/prisma/schema.prisma` | 添加 SimpleLog 模型 |
|
||||||
|
| `backend/src/index.ts` | 注册 statsRoutes |
|
||||||
|
| `backend/src/common/auth/auth.controller.ts` | 登录埋点 |
|
||||||
|
| `backend/src/modules/aia/services/conversationService.ts` | AIA 对话埋点 |
|
||||||
|
| `backend/src/modules/pkb/services/knowledgeBaseService.ts` | PKB 知识库埋点 |
|
||||||
|
| `backend/src/modules/pkb/services/ragService.ts` | PKB RAG 埋点 |
|
||||||
|
| `backend/src/modules/asl/services/screeningWorker.ts` | ASL 筛选埋点 |
|
||||||
|
| `backend/src/modules/dc/tool-b/workers/extractionWorker.ts` | DC Tool B 埋点 |
|
||||||
|
| `backend/src/modules/dc/tool-c/services/AICodeService.ts` | DC Tool C 埋点 |
|
||||||
|
| `backend/src/modules/rvw/workers/reviewWorker.ts` | RVW 审查埋点 |
|
||||||
|
| `backend/src/modules/iit-manager/services/SyncManager.ts` | IIT 同步埋点 |
|
||||||
|
| `frontend-v2/src/pages/admin/AdminDashboard.tsx` | 运营看板 UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 后续优化建议
|
||||||
|
|
||||||
|
### P2 优先级
|
||||||
|
|
||||||
|
- [ ] 添加更多埋点:Protocol Agent 一键生成、Word 导出
|
||||||
|
- [ ] 图表可视化:使用 ECharts 展示趋势图
|
||||||
|
- [ ] 定时任务:每日 00:00 自动清理 180 天前日志
|
||||||
|
|
||||||
|
### P3 优先级
|
||||||
|
|
||||||
|
- [ ] 用户行为路径分析
|
||||||
|
- [ ] 漏斗分析功能
|
||||||
|
- [ ] 导出统计报表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 总结
|
||||||
|
|
||||||
|
✅ 运营监控系统 MVP 已完成核心功能:
|
||||||
|
- **数据采集**:7 个模块埋点全部完成
|
||||||
|
- **数据存储**:SimpleLog 表结构稳定
|
||||||
|
- **数据展示**:Admin Dashboard 实时展示
|
||||||
|
- **API 接口**:4 个核心端点全部可用
|
||||||
|
|
||||||
|
MVP 阶段目标达成,可支持基本的运营数据分析需求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档完成时间:2026-01-25*
|
||||||
|
|
||||||
BIN
frontend-v2/public/logo.jpg
Normal file
BIN
frontend-v2/public/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -39,13 +39,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
await authApi.refreshAccessToken();
|
await authApi.refreshAccessToken();
|
||||||
const freshUser = await authApi.getCurrentUser();
|
const freshUser = await authApi.getCurrentUser();
|
||||||
setUser(freshUser);
|
setUser(freshUser);
|
||||||
|
authApi.saveUser(freshUser); // 保存最新用户信息
|
||||||
} catch {
|
} catch {
|
||||||
// 刷新失败,清除状态
|
// 刷新失败,清除状态
|
||||||
authApi.clearTokens();
|
authApi.clearTokens();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUser(savedUser);
|
// Token 有效,但仍需获取最新用户信息(确保 modules 等字段是最新的)
|
||||||
|
try {
|
||||||
|
const freshUser = await authApi.getCurrentUser();
|
||||||
|
setUser(freshUser);
|
||||||
|
authApi.saveUser(freshUser); // 更新本地存储
|
||||||
|
} catch {
|
||||||
|
// 获取失败,使用本地缓存
|
||||||
|
setUser(savedUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -102,7 +102,11 @@ const TopNavigation = () => {
|
|||||||
className="flex items-center gap-3 cursor-pointer"
|
className="flex items-center gap-3 cursor-pointer"
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
>
|
>
|
||||||
<div className="text-2xl">🏥</div>
|
<img
|
||||||
|
src="/logo.jpg"
|
||||||
|
alt="AI临床研究平台"
|
||||||
|
className="h-[52px] w-auto"
|
||||||
|
/>
|
||||||
<span className="text-xl font-bold text-blue-600">AI临床研究平台</span>
|
<span className="text-xl font-bold text-blue-600">AI临床研究平台</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
82
frontend-v2/src/modules/admin/api/statsApi.ts
Normal file
82
frontend-v2/src/modules/admin/api/statsApi.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 运营统计 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authRequest } from '@/framework/request';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
export interface OverviewData {
|
||||||
|
dau: number;
|
||||||
|
dat: number;
|
||||||
|
exportCount: number;
|
||||||
|
moduleStats: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLog {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
tenantName: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
module: string;
|
||||||
|
feature: string;
|
||||||
|
action: string;
|
||||||
|
info: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAssets {
|
||||||
|
aia: { conversationCount: number };
|
||||||
|
pkb: { kbCount: number; docCount: number };
|
||||||
|
dc: { taskCount: number };
|
||||||
|
rvw: { reviewTaskCount: number; completedCount: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOverview {
|
||||||
|
profile: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
tenantName: string | null;
|
||||||
|
} | null;
|
||||||
|
assets: UserAssets;
|
||||||
|
activities: Array<{
|
||||||
|
createdAt: string;
|
||||||
|
module: string;
|
||||||
|
feature: string;
|
||||||
|
action: string;
|
||||||
|
info: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日大盘数据
|
||||||
|
*/
|
||||||
|
export async function getOverview(): Promise<OverviewData> {
|
||||||
|
const res = await authRequest.get<{ success: boolean; data: OverviewData }>(
|
||||||
|
'/api/admin/stats/overview'
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时流水账
|
||||||
|
*/
|
||||||
|
export async function getLiveFeed(limit = 100): Promise<ActivityLog[]> {
|
||||||
|
const res = await authRequest.get<{ success: boolean; data: ActivityLog[] }>(
|
||||||
|
`/api/admin/stats/live-feed?limit=${limit}`
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户360画像
|
||||||
|
*/
|
||||||
|
export async function getUserOverview(userId: string): Promise<UserOverview> {
|
||||||
|
const res = await authRequest.get<{ success: boolean; data: UserOverview }>(
|
||||||
|
`/api/admin/users/${userId}/overview`
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* ADMIN(运营管理端)模块入口
|
* ADMIN(运营管理端)模块入口
|
||||||
*
|
*
|
||||||
* 功能:
|
* 功能:
|
||||||
|
* - 运营监控看板
|
||||||
* - 用户管理
|
* - 用户管理
|
||||||
* - 租户管理(已有)
|
* - 租户管理(已有)
|
||||||
* - Prompt管理(已有)
|
* - Prompt管理(已有)
|
||||||
@@ -12,11 +13,15 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import UserListPage from './pages/UserListPage';
|
import UserListPage from './pages/UserListPage';
|
||||||
import UserFormPage from './pages/UserFormPage';
|
import UserFormPage from './pages/UserFormPage';
|
||||||
import UserDetailPage from './pages/UserDetailPage';
|
import UserDetailPage from './pages/UserDetailPage';
|
||||||
|
import StatsDashboardPage from './pages/StatsDashboardPage';
|
||||||
|
|
||||||
const AdminModule: React.FC = () => {
|
const AdminModule: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="users" replace />} />
|
<Route path="/" element={<Navigate to="stats" replace />} />
|
||||||
|
|
||||||
|
{/* 运营监控看板 */}
|
||||||
|
<Route path="stats" element={<StatsDashboardPage />} />
|
||||||
|
|
||||||
{/* 用户管理 */}
|
{/* 用户管理 */}
|
||||||
<Route path="users" element={<UserListPage />} />
|
<Route path="users" element={<UserListPage />} />
|
||||||
|
|||||||
279
frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx
Normal file
279
frontend-v2/src/modules/admin/pages/StatsDashboardPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* 运营统计看板
|
||||||
|
*
|
||||||
|
* 展示 DAU/DAT、模块使用统计、实时流水账
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, Statistic, Row, Col, Table, Tag, Spin, Empty, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
BankOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { getOverview, getLiveFeed } from '../api/statsApi';
|
||||||
|
import type { OverviewData, ActivityLog } from '../api/statsApi';
|
||||||
|
|
||||||
|
// ==================== 模块图标映射 ====================
|
||||||
|
|
||||||
|
const MODULE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
'SYSTEM': <LoginOutlined />,
|
||||||
|
'AIA': <MessageOutlined />,
|
||||||
|
'PKB': <BookOutlined />,
|
||||||
|
'ASL': <SearchOutlined />,
|
||||||
|
'DC': <FileTextOutlined />,
|
||||||
|
'RVW': <FileTextOutlined />,
|
||||||
|
'IIT': <SyncOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_COLORS: Record<string, string> = {
|
||||||
|
'SYSTEM': '#8c8c8c',
|
||||||
|
'AIA': '#1890ff',
|
||||||
|
'PKB': '#52c41a',
|
||||||
|
'ASL': '#722ed1',
|
||||||
|
'DC': '#fa8c16',
|
||||||
|
'RVW': '#eb2f96',
|
||||||
|
'IIT': '#13c2c2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
'LOGIN': <LoginOutlined style={{ color: '#52c41a' }} />,
|
||||||
|
'USE': <MessageOutlined style={{ color: '#1890ff' }} />,
|
||||||
|
'EXPORT': <CloudUploadOutlined style={{ color: '#722ed1' }} />,
|
||||||
|
'CREATE': <ExportOutlined style={{ color: '#fa8c16' }} />,
|
||||||
|
'DELETE': <DeleteOutlined style={{ color: '#ff4d4f' }} />,
|
||||||
|
'ERROR': <WarningOutlined style={{ color: '#ff4d4f' }} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 组件 ====================
|
||||||
|
|
||||||
|
export default function StatsDashboardPage() {
|
||||||
|
const [liveFeedLimit] = useState(50);
|
||||||
|
|
||||||
|
// 获取大盘数据
|
||||||
|
const { data: overview, isLoading: overviewLoading, refetch: refetchOverview } = useQuery({
|
||||||
|
queryKey: ['admin-stats-overview'],
|
||||||
|
queryFn: getOverview,
|
||||||
|
refetchInterval: 30000, // 30秒自动刷新
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取实时流水账
|
||||||
|
const { data: liveFeed, isLoading: liveFeedLoading, refetch: refetchLiveFeed } = useQuery({
|
||||||
|
queryKey: ['admin-stats-live-feed', liveFeedLimit],
|
||||||
|
queryFn: () => getLiveFeed(liveFeedLimit),
|
||||||
|
refetchInterval: 10000, // 10秒自动刷新
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新所有数据
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetchOverview();
|
||||||
|
refetchLiveFeed();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 流水账表格列
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 90,
|
||||||
|
render: (time: string) => {
|
||||||
|
const date = new Date(time);
|
||||||
|
return (
|
||||||
|
<Tooltip title={date.toLocaleString()}>
|
||||||
|
<span className="text-gray-500 text-xs">
|
||||||
|
{date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '动作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 80,
|
||||||
|
render: (action: string) => (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{ACTION_ICONS[action] || null}
|
||||||
|
<span className="text-xs">{action}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模块',
|
||||||
|
dataIndex: 'module',
|
||||||
|
key: 'module',
|
||||||
|
width: 80,
|
||||||
|
render: (module: string) => (
|
||||||
|
<Tag
|
||||||
|
icon={MODULE_ICONS[module]}
|
||||||
|
color={MODULE_COLORS[module]}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '功能',
|
||||||
|
dataIndex: 'feature',
|
||||||
|
key: 'feature',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'userName',
|
||||||
|
key: 'userName',
|
||||||
|
width: 100,
|
||||||
|
render: (name: string | null, record: ActivityLog) => (
|
||||||
|
<Tooltip title={record.tenantName || '未知租户'}>
|
||||||
|
<span className="text-gray-600">{name || '-'}</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
|
dataIndex: 'info',
|
||||||
|
key: 'info',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (info: string | null) => (
|
||||||
|
<span className="text-gray-400 text-xs">{info || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-gray-50 min-h-screen">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">运营监控看板</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">实时监控系统使用情况</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ReloadOutlined className={overviewLoading || liveFeedLoading ? 'animate-spin' : ''} />
|
||||||
|
刷新数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 核心指标卡片 */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-gray-600">今日活跃医生 (DAU)</span>}
|
||||||
|
value={overview?.dau ?? 0}
|
||||||
|
prefix={<UserOutlined className="text-blue-500" />}
|
||||||
|
loading={overviewLoading}
|
||||||
|
valueStyle={{ color: '#1890ff', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-gray-600">今日活跃租户 (DAT)</span>}
|
||||||
|
value={overview?.dat ?? 0}
|
||||||
|
prefix={<BankOutlined className="text-green-500" />}
|
||||||
|
loading={overviewLoading}
|
||||||
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-gray-600">今日导出次数</span>}
|
||||||
|
value={overview?.exportCount ?? 0}
|
||||||
|
prefix={<ExportOutlined className="text-purple-500" />}
|
||||||
|
loading={overviewLoading}
|
||||||
|
valueStyle={{ color: '#722ed1', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 模块使用统计 */}
|
||||||
|
{overview?.moduleStats && Object.keys(overview.moduleStats).length > 0 && (
|
||||||
|
<Card
|
||||||
|
title="模块使用统计"
|
||||||
|
className="mb-6 shadow-sm"
|
||||||
|
extra={<span className="text-gray-400 text-xs">今日</span>}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.entries(overview.moduleStats).map(([module, count]) => (
|
||||||
|
<Col key={module} xs={12} sm={8} md={6} lg={4}>
|
||||||
|
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
{MODULE_ICONS[module] || <MessageOutlined />}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold" style={{ color: MODULE_COLORS[module] }}>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-sm">{module}</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 实时流水账 */}
|
||||||
|
<Card
|
||||||
|
title="实时操作流水"
|
||||||
|
className="shadow-sm"
|
||||||
|
extra={
|
||||||
|
<span className="text-gray-400 text-xs">
|
||||||
|
最近 {liveFeedLimit} 条 · 10秒自动刷新
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{liveFeedLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Spin tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
) : liveFeed && liveFeed.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={liveFeed}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 400 }}
|
||||||
|
className="activity-table"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无操作记录" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 自定义样式 */}
|
||||||
|
<style>{`
|
||||||
|
.activity-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background-color: #f0f5ff !important;
|
||||||
|
}
|
||||||
|
.activity-table .ant-table-thead > tr > th {
|
||||||
|
background-color: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section className="chat-area">
|
<section className="chat-area">
|
||||||
{/* 聊天历史 */}
|
{/* 聊天历史 */}
|
||||||
<div className="chat-container" ref={chatContainerRef}>
|
<div className="pa-chat-container" ref={chatContainerRef}>
|
||||||
{/* 加载历史消息时显示加载状态 */}
|
{/* 加载历史消息时显示加载状态 */}
|
||||||
{isLoadingHistory && (
|
{isLoadingHistory && (
|
||||||
<div className="loading-history">
|
<div className="loading-history">
|
||||||
|
|||||||
@@ -411,8 +411,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 聊天容器 */
|
/* 聊天容器 - 使用 pa- 前缀避免与共享组件 ChatContainer 冲突 */
|
||||||
.chat-container {
|
.pa-chat-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0; /* 关键:允许 flex 子元素收缩 */
|
min-height: 0; /* 关键:允许 flex 子元素收缩 */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1838,7 +1838,7 @@
|
|||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
/* 滚动条美化 - 始终可见 */
|
/* 滚动条美化 - 始终可见 */
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
.chat-container,
|
.pa-chat-container,
|
||||||
.panel-body,
|
.panel-body,
|
||||||
.conversations-list,
|
.conversations-list,
|
||||||
.document-scroll-area {
|
.document-scroll-area {
|
||||||
@@ -1846,14 +1846,14 @@
|
|||||||
scrollbar-color: #94A3B8 #F1F5F9;
|
scrollbar-color: #94A3B8 #F1F5F9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-container::-webkit-scrollbar,
|
.pa-chat-container::-webkit-scrollbar,
|
||||||
.panel-body::-webkit-scrollbar,
|
.panel-body::-webkit-scrollbar,
|
||||||
.conversations-list::-webkit-scrollbar,
|
.conversations-list::-webkit-scrollbar,
|
||||||
.document-scroll-area::-webkit-scrollbar {
|
.document-scroll-area::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-container::-webkit-scrollbar-track,
|
.pa-chat-container::-webkit-scrollbar-track,
|
||||||
.panel-body::-webkit-scrollbar-track,
|
.panel-body::-webkit-scrollbar-track,
|
||||||
.conversations-list::-webkit-scrollbar-track,
|
.conversations-list::-webkit-scrollbar-track,
|
||||||
.document-scroll-area::-webkit-scrollbar-track {
|
.document-scroll-area::-webkit-scrollbar-track {
|
||||||
@@ -1861,7 +1861,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-container::-webkit-scrollbar-thumb,
|
.pa-chat-container::-webkit-scrollbar-thumb,
|
||||||
.panel-body::-webkit-scrollbar-thumb,
|
.panel-body::-webkit-scrollbar-thumb,
|
||||||
.conversations-list::-webkit-scrollbar-thumb,
|
.conversations-list::-webkit-scrollbar-thumb,
|
||||||
.document-scroll-area::-webkit-scrollbar-thumb {
|
.document-scroll-area::-webkit-scrollbar-thumb {
|
||||||
@@ -1870,7 +1870,7 @@
|
|||||||
border: 2px solid #F1F5F9;
|
border: 2px solid #F1F5F9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-container::-webkit-scrollbar-thumb:hover,
|
.pa-chat-container::-webkit-scrollbar-thumb:hover,
|
||||||
.panel-body::-webkit-scrollbar-thumb:hover,
|
.panel-body::-webkit-scrollbar-thumb:hover,
|
||||||
.conversations-list::-webkit-scrollbar-thumb:hover,
|
.conversations-list::-webkit-scrollbar-thumb:hover,
|
||||||
.document-scroll-area::-webkit-scrollbar-thumb:hover {
|
.document-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -110,13 +110,15 @@ export interface ChatHistoryResponse {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传CSV/Excel文件
|
* 上传CSV/Excel文件
|
||||||
|
*
|
||||||
|
* 注意:文件上传不设置 Content-Type,浏览器会自动设置正确的 multipart/form-data 和 boundary
|
||||||
*/
|
*/
|
||||||
export const uploadFile = async (file: File): Promise<UploadResponse> => {
|
export const uploadFile = async (file: File): Promise<UploadResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
|
// ✅ 不设置 Content-Type,让浏览器自动处理 FormData 的 boundary
|
||||||
const response = await apiClient.post(`${BASE_URL}/sessions/upload`, formData, {
|
const response = await apiClient.post(`${BASE_URL}/sessions/upload`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 30000, // 30秒超时
|
timeout: 30000, // 30秒超时
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||||
import { Form, Input, Button, Tabs, message, Card, Space, Typography, Alert, Modal } from 'antd';
|
import { Form, Input, Button, Tabs, message, Card, Space, Typography, Alert, Modal } from 'antd';
|
||||||
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
import { useAuth } from '../framework/auth';
|
import { useAuth } from '../framework/auth';
|
||||||
import type { ChangePasswordRequest } from '../framework/auth';
|
import type { ChangePasswordRequest } from '../framework/auth';
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ interface TenantConfig {
|
|||||||
logo?: string;
|
logo?: string;
|
||||||
primaryColor: string;
|
primaryColor: string;
|
||||||
systemName: string;
|
systemName: string;
|
||||||
|
modules?: string[];
|
||||||
|
isReviewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
@@ -33,6 +35,15 @@ const DEFAULT_CONFIG: TenantConfig = {
|
|||||||
name: 'AI临床研究平台',
|
name: 'AI临床研究平台',
|
||||||
primaryColor: '#1890ff',
|
primaryColor: '#1890ff',
|
||||||
systemName: 'AI临床研究平台',
|
systemName: 'AI临床研究平台',
|
||||||
|
isReviewOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 审稿专用配置
|
||||||
|
const REVIEW_CONFIG: TenantConfig = {
|
||||||
|
name: '智能审稿系统',
|
||||||
|
primaryColor: '#6366f1',
|
||||||
|
systemName: '智能审稿系统',
|
||||||
|
isReviewOnly: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -60,12 +71,19 @@ export default function LoginPage() {
|
|||||||
// 获取租户配置
|
// 获取租户配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantCode) {
|
if (tenantCode) {
|
||||||
// TODO: 从API获取租户配置
|
|
||||||
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
|
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setTenantConfig(data.data);
|
// 如果是审稿专用租户,合并审稿配置
|
||||||
|
if (data.data.isReviewOnly) {
|
||||||
|
setTenantConfig({
|
||||||
|
...REVIEW_CONFIG,
|
||||||
|
...data.data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTenantConfig(data.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -82,34 +100,87 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [countdown]);
|
}, [countdown]);
|
||||||
|
|
||||||
// 智能跳转:根据用户角色判断目标页面
|
// 智能跳转:根据用户角色和模块权限判断目标页面
|
||||||
const getRedirectPath = useCallback(() => {
|
const getRedirectPath = useCallback(() => {
|
||||||
const from = (location.state as any)?.from?.pathname || '/';
|
const from = (location.state as any)?.from?.pathname;
|
||||||
const userRole = user?.role;
|
const userRole = user?.role;
|
||||||
|
const userModules = user?.modules || [];
|
||||||
|
|
||||||
// 如果目标是运营管理端,检查权限
|
// 如果有明确的来源页面,优先处理
|
||||||
if (from.startsWith('/admin')) {
|
if (from && from !== '/') {
|
||||||
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole || '');
|
// 如果目标是运营管理端,检查权限
|
||||||
return canAccessAdmin ? from : '/';
|
if (from.startsWith('/admin')) {
|
||||||
|
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole || '');
|
||||||
|
return canAccessAdmin ? from : getDefaultModule(userModules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果目标是机构管理端,检查权限
|
||||||
|
if (from.startsWith('/org')) {
|
||||||
|
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole || '');
|
||||||
|
return canAccessOrg ? from : getDefaultModule(userModules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他页面直接跳转
|
||||||
|
return from;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果目标是机构管理端,检查权限
|
// 没有来源页面,智能判断默认目标
|
||||||
if (from.startsWith('/org')) {
|
return getDefaultModule(userModules);
|
||||||
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole || '');
|
|
||||||
return canAccessOrg ? from : '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他页面直接跳转
|
|
||||||
return from;
|
|
||||||
}, [location, user]);
|
}, [location, user]);
|
||||||
|
|
||||||
|
// 根据用户模块权限获取默认跳转页面
|
||||||
|
// 路径需要与 moduleRegistry.ts 保持一致!
|
||||||
|
const getDefaultModule = (modules: string[]): string => {
|
||||||
|
// 模块代码到路径的映射(必须与 moduleRegistry.ts 保持一致)
|
||||||
|
const modulePathMap: Record<string, string> = {
|
||||||
|
'AIA': '/ai-qa', // AI问答
|
||||||
|
'PKB': '/knowledge-base', // 知识库
|
||||||
|
'ASL': '/literature', // AI智能文献
|
||||||
|
'DC': '/data-cleaning', // 智能数据清洗
|
||||||
|
'RVW': '/rvw', // 预审稿
|
||||||
|
'SSA': '/intelligent-analysis', // 智能统计分析
|
||||||
|
'ST': '/statistical-tools', // 统计分析工具
|
||||||
|
'IIT': '/iit', // IIT管理(如果有)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果用户只有 RVW 模块权限,直接进入审稿系统
|
||||||
|
if (modules.length === 1 && modules[0] === 'RVW') {
|
||||||
|
return '/rvw';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 AIA 模块权限,默认进入 AI 问答
|
||||||
|
if (modules.includes('AIA')) {
|
||||||
|
return '/ai-qa';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则进入第一个有权限的模块
|
||||||
|
if (modules.length > 0 && modulePathMap[modules[0]]) {
|
||||||
|
return modulePathMap[modules[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:返回首页
|
||||||
|
return '/';
|
||||||
|
};
|
||||||
|
|
||||||
// 登录成功后检查是否需要修改密码
|
// 登录成功后检查是否需要修改密码
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.isDefaultPassword) {
|
if (user && user.isDefaultPassword) {
|
||||||
setShowPasswordModal(true);
|
setShowPasswordModal(true);
|
||||||
} else if (user) {
|
} else if (user) {
|
||||||
|
// 调试日志
|
||||||
|
console.log('[LoginPage] 用户登录成功,准备跳转:', {
|
||||||
|
userId: user.id,
|
||||||
|
name: user.name,
|
||||||
|
modules: user.modules,
|
||||||
|
modulesLength: user.modules?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算跳转路径
|
||||||
|
const targetPath = getRedirectPath();
|
||||||
|
console.log('[LoginPage] 跳转目标路径:', targetPath);
|
||||||
|
|
||||||
// 登录成功,智能跳转
|
// 登录成功,智能跳转
|
||||||
navigate(getRedirectPath(), { replace: true });
|
navigate(targetPath, { replace: true });
|
||||||
}
|
}
|
||||||
}, [user, navigate, getRedirectPath]);
|
}, [user, navigate, getRedirectPath]);
|
||||||
|
|
||||||
@@ -197,21 +268,33 @@ export default function LoginPage() {
|
|||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: `linear-gradient(135deg, ${tenantConfig.primaryColor} 0%, ${tenantConfig.primaryColor}dd 100%)`,
|
background: tenantConfig.isReviewOnly
|
||||||
|
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
|
||||||
|
: `linear-gradient(135deg, ${tenantConfig.primaryColor} 0%, ${tenantConfig.primaryColor}dd 100%)`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto 16px',
|
margin: '0 auto 16px',
|
||||||
|
boxShadow: tenantConfig.isReviewOnly ? '0 8px 24px rgba(99, 102, 241, 0.3)' : undefined,
|
||||||
}}>
|
}}>
|
||||||
<UserOutlined style={{ fontSize: 32, color: '#fff' }} />
|
{tenantConfig.isReviewOnly ? (
|
||||||
|
<FileTextOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||||
|
) : (
|
||||||
|
<UserOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
|
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
|
||||||
{tenantConfig.systemName}
|
{tenantConfig.systemName}
|
||||||
</Title>
|
</Title>
|
||||||
{tenantCode && (
|
{tenantCode && !tenantConfig.isReviewOnly && (
|
||||||
<Text type="secondary">{tenantConfig.name}</Text>
|
<Text type="secondary">{tenantConfig.name}</Text>
|
||||||
)}
|
)}
|
||||||
|
{tenantConfig.isReviewOnly && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
|
||||||
|
期刊智能审稿 · AI驱动
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 错误提示 */}
|
{/* 错误提示 */}
|
||||||
|
|||||||
@@ -1,66 +1,270 @@
|
|||||||
import { Card, Row, Col, Statistic, Table, Tag } from 'antd'
|
import { Card, Row, Col, Statistic, Table, Tag, Spin, Empty, Tooltip } from 'antd'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
UserOutlined,
|
||||||
|
BankOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
SearchOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ApiOutlined,
|
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import apiClient from '../../common/api/axios'
|
||||||
|
|
||||||
// 运营管理端主色
|
// 运营管理端主色
|
||||||
const PRIMARY_COLOR = '#10b981'
|
const PRIMARY_COLOR = '#10b981'
|
||||||
|
|
||||||
|
// ==================== API 函数 ====================
|
||||||
|
|
||||||
|
interface OverviewData {
|
||||||
|
dau: number
|
||||||
|
dat: number
|
||||||
|
exportCount: number
|
||||||
|
moduleStats: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityLog {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
tenantName: string | null
|
||||||
|
userName: string | null
|
||||||
|
module: string
|
||||||
|
feature: string
|
||||||
|
action: string
|
||||||
|
info: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverview(): Promise<OverviewData> {
|
||||||
|
const res = await apiClient.get<{ success: boolean; data: OverviewData }>(
|
||||||
|
'/api/admin/stats/overview'
|
||||||
|
)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLiveFeed(limit = 20): Promise<ActivityLog[]> {
|
||||||
|
const res = await apiClient.get<{ success: boolean; data: ActivityLog[] }>(
|
||||||
|
`/api/admin/stats/live-feed?limit=${limit}`
|
||||||
|
)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 常量映射 ====================
|
||||||
|
|
||||||
|
const MODULE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
'SYSTEM': <LoginOutlined />,
|
||||||
|
'AIA': <MessageOutlined />,
|
||||||
|
'PKB': <BookOutlined />,
|
||||||
|
'ASL': <SearchOutlined />,
|
||||||
|
'DC': <FileTextOutlined />,
|
||||||
|
'RVW': <FileTextOutlined />,
|
||||||
|
'IIT': <SyncOutlined />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_COLORS: Record<string, string> = {
|
||||||
|
'SYSTEM': '#8c8c8c',
|
||||||
|
'AIA': '#1890ff',
|
||||||
|
'PKB': '#52c41a',
|
||||||
|
'ASL': '#722ed1',
|
||||||
|
'DC': '#fa8c16',
|
||||||
|
'RVW': '#eb2f96',
|
||||||
|
'IIT': '#13c2c2',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
'LOGIN': <LoginOutlined style={{ color: '#52c41a' }} />,
|
||||||
|
'USE': <MessageOutlined style={{ color: '#1890ff' }} />,
|
||||||
|
'EXPORT': <CloudUploadOutlined style={{ color: '#722ed1' }} />,
|
||||||
|
'CREATE': <ExportOutlined style={{ color: '#fa8c16' }} />,
|
||||||
|
'DELETE': <DeleteOutlined style={{ color: '#ff4d4f' }} />,
|
||||||
|
'ERROR': <WarningOutlined style={{ color: '#ff4d4f' }} />,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运营管理端 - 概览页(浅色主题)
|
* 运营管理端 - 概览页(集成实时运营数据)
|
||||||
*/
|
*/
|
||||||
const AdminDashboard = () => {
|
const AdminDashboard = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// 模拟数据
|
|
||||||
const stats = [
|
|
||||||
{ title: '活跃租户', value: 12, icon: <TeamOutlined />, color: PRIMARY_COLOR },
|
|
||||||
{ title: 'Prompt模板', value: 8, icon: <FileTextOutlined />, color: '#3b82f6' },
|
|
||||||
{ title: 'API调用/今日', value: 1234, icon: <ApiOutlined />, color: '#f59e0b' },
|
|
||||||
{ title: '系统状态', value: '正常', icon: <CloudServerOutlined />, color: PRIMARY_COLOR },
|
|
||||||
]
|
|
||||||
|
|
||||||
const recentActivities = [
|
// 获取大盘数据
|
||||||
{ key: 1, time: '10分钟前', action: '发布Prompt', target: 'RVW_EDITORIAL', user: '张工程师' },
|
const { data: overview, isLoading: overviewLoading, refetch: refetchOverview } = useQuery({
|
||||||
{ key: 2, time: '1小时前', action: '新增租户', target: '北京协和医院', user: '李运营' },
|
queryKey: ['admin-stats-overview'],
|
||||||
{ key: 3, time: '2小时前', action: '用户开通', target: '王医生', user: '系统' },
|
queryFn: getOverview,
|
||||||
{ key: 4, time: '3小时前', action: '配额调整', target: '武田制药', user: '李运营' },
|
refetchInterval: 30000, // 30秒自动刷新
|
||||||
]
|
})
|
||||||
|
|
||||||
const columns = [
|
// 获取实时流水账
|
||||||
{ title: '时间', dataIndex: 'time', key: 'time', width: 100 },
|
const { data: liveFeed, isLoading: liveFeedLoading, refetch: refetchLiveFeed } = useQuery({
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action' },
|
queryKey: ['admin-stats-live-feed'],
|
||||||
{ title: '对象', dataIndex: 'target', key: 'target' },
|
queryFn: () => getLiveFeed(10),
|
||||||
{ title: '操作人', dataIndex: 'user', key: 'user' },
|
refetchInterval: 10000, // 10秒自动刷新
|
||||||
|
})
|
||||||
|
|
||||||
|
// 刷新所有数据
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetchOverview()
|
||||||
|
refetchLiveFeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流水账表格列
|
||||||
|
const activityColumns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 90,
|
||||||
|
render: (time: string) => {
|
||||||
|
const date = new Date(time)
|
||||||
|
return (
|
||||||
|
<Tooltip title={date.toLocaleString()}>
|
||||||
|
<span className="text-gray-500 text-xs">
|
||||||
|
{date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '动作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 80,
|
||||||
|
render: (action: string) => (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{ACTION_ICONS[action] || null}
|
||||||
|
<span className="text-xs">{action}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模块',
|
||||||
|
dataIndex: 'module',
|
||||||
|
key: 'module',
|
||||||
|
width: 80,
|
||||||
|
render: (module: string) => (
|
||||||
|
<Tag
|
||||||
|
icon={MODULE_ICONS[module]}
|
||||||
|
color={MODULE_COLORS[module]}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '功能',
|
||||||
|
dataIndex: 'feature',
|
||||||
|
key: 'feature',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'userName',
|
||||||
|
key: 'userName',
|
||||||
|
width: 100,
|
||||||
|
render: (name: string | null, record: ActivityLog) => (
|
||||||
|
<Tooltip title={record.tenantName || '未知租户'}>
|
||||||
|
<span className="text-gray-600">{name || '-'}</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<div>
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">运营概览</h1>
|
<div>
|
||||||
<p className="text-gray-500">壹证循科技 · AI临床研究平台运营管理中心</p>
|
<h1 className="text-2xl font-bold text-gray-800 mb-2">运营概览</h1>
|
||||||
|
<p className="text-gray-500">壹证循科技 · AI临床研究平台运营管理中心</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ReloadOutlined className={overviewLoading || liveFeedLoading ? 'animate-spin' : ''} />
|
||||||
|
刷新数据
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 核心运营指标 */}
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{stats.map((stat, index) => (
|
<Col span={6}>
|
||||||
<Col span={6} key={index}>
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
<Card className="hover:shadow-lg transition-shadow">
|
<Statistic
|
||||||
<Statistic
|
title={<span className="text-gray-500">今日活跃医生 (DAU)</span>}
|
||||||
title={<span className="text-gray-500">{stat.title}</span>}
|
value={overview?.dau ?? 0}
|
||||||
value={stat.value}
|
prefix={<UserOutlined style={{ color: '#1890ff' }} />}
|
||||||
prefix={<span style={{ color: stat.color }}>{stat.icon}</span>}
|
loading={overviewLoading}
|
||||||
/>
|
valueStyle={{ color: '#1890ff', fontWeight: 'bold' }}
|
||||||
</Card>
|
/>
|
||||||
</Col>
|
</Card>
|
||||||
))}
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-gray-500">今日活跃租户 (DAT)</span>}
|
||||||
|
value={overview?.dat ?? 0}
|
||||||
|
prefix={<BankOutlined style={{ color: PRIMARY_COLOR }} />}
|
||||||
|
loading={overviewLoading}
|
||||||
|
valueStyle={{ color: PRIMARY_COLOR, fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-gray-500">今日导出次数</span>}
|
||||||
|
value={overview?.exportCount ?? 0}
|
||||||
|
prefix={<ExportOutlined style={{ color: '#722ed1' }} />}
|
||||||
|
loading={overviewLoading}
|
||||||
|
valueStyle={{ color: '#722ed1', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-gray-500">系统状态</span>}
|
||||||
|
value="正常"
|
||||||
|
prefix={<CloudServerOutlined style={{ color: PRIMARY_COLOR }} />}
|
||||||
|
valueStyle={{ color: PRIMARY_COLOR, fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* 模块使用统计 */}
|
||||||
|
{overview?.moduleStats && Object.keys(overview.moduleStats).length > 0 && (
|
||||||
|
<Card
|
||||||
|
title="今日模块使用统计"
|
||||||
|
extra={<span className="text-gray-400 text-xs">实时</span>}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.entries(overview.moduleStats).map(([module, count]) => (
|
||||||
|
<Col key={module} xs={12} sm={8} md={6} lg={4}>
|
||||||
|
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-2xl mb-2" style={{ color: MODULE_COLORS[module] }}>
|
||||||
|
{MODULE_ICONS[module] || <MessageOutlined />}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold" style={{ color: MODULE_COLORS[module] }}>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-sm">{module}</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 快捷操作 */}
|
{/* 快捷操作 */}
|
||||||
<Card title="快捷操作">
|
<Card title="快捷操作">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@@ -86,14 +290,26 @@ const AdminDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 最近活动 */}
|
{/* 实时操作流水 */}
|
||||||
<Card title="最近活动">
|
<Card
|
||||||
<Table
|
title="实时操作流水"
|
||||||
dataSource={recentActivities}
|
extra={<span className="text-gray-400 text-xs">最近10条 · 10秒自动刷新</span>}
|
||||||
columns={columns}
|
>
|
||||||
pagination={false}
|
{liveFeedLoading ? (
|
||||||
size="small"
|
<div className="flex justify-center py-8">
|
||||||
/>
|
<Spin tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
) : liveFeed && liveFeed.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={liveFeed}
|
||||||
|
columns={activityColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无操作记录" />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 系统状态 */}
|
{/* 系统状态 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user