Compare commits

...

76 Commits

Author SHA1 Message Date
yuliang_guo
2f38a0b77a refactor: 改造 CI/CD 使用阿里云 ACR 镜像仓库
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-03 17:38:01 +08:00
yuliang_guo
ed47286955 style: 优化陪练记录页面UI设计
All checks were successful
continuous-integration/drone/push Build is passing
- 统计卡片改用 el-statistic 组件,与错题分析保持一致
- 搜索框改为圆角胶囊形状,添加 hover 聚焦效果
- 下拉选择框使用统一的圆角灰底设计,添加 emoji 图标前缀
- 筛选标签改为胶囊形状
- 重置按钮仅在有筛选条件时显示
- 添加表格行悬浮效果和操作按钮悬浮样式
- 优化响应式布局

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:38:45 +08:00
yuliang_guo
4c1b70e9d6 fix: 修复陪练记录回放对话功能
All checks were successful
continuous-integration/drone/push Build is passing
- 回放对话时调用 API 获取对话详情
- 添加加载状态显示
- 添加空数据提示

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:27:02 +08:00
yuliang_guo
149cc5f6b0 fix: 权限和显示优化
All checks were successful
continuous-integration/drone/push Build is passing
1. 侧边栏:根据角色过滤菜单,无可访问子菜单时隐藏父菜单
2. Dashboard:智能工牌分析、统计卡片、最近考试仅对学员显示
3. 快捷操作:根据角色显示不同的操作入口
4. 欢迎语:根据角色显示不同的欢迎信息
5. 学习天数:改为基于注册日期计算(至少为1天)
6. 成长路径:AI分析按钮仅对学员显示

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 15:41:56 +08:00
yuliang_guo
7555de2275 fix: 修复课程库加载 - 后端限制每页最多100条
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 15:12:34 +08:00
yuliang_guo
344d8c1770 feat: 完善成长路径管理功能
All checks were successful
continuous-integration/drone/push Build is passing
新增功能:
1. 阶段自定义管理 - 添加/删除/编辑阶段名称
2. 列表分页功能
3. 状态筛选(启用/禁用)
4. 课程分类筛选
5. 岗位全选按钮
6. 创建时间列显示
7. 点击必修/选修标签直接切换

画布高度根据阶段数量动态调整

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 15:05:57 +08:00
yuliang_guo
8892511f10 fix: 后端保存和返回节点位置坐标(position_x, position_y)
Some checks failed
continuous-integration/drone/push Build is failing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 15:00:54 +08:00
yuliang_guo
973ce53bf3 feat: 完善成长路径画布设计器
All checks were successful
continuous-integration/drone/push Build is passing
后端:
- 添加 position_x, position_y 字段保存节点位置

前端:
- 支持从节点右侧圆点拖拽出箭头连接到其他课程
- 自动根据节点Y坐标识别所属阶段
- 保存并恢复节点位置,不再重置
- 阶段区域高亮显示
- 循环依赖检测

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:55:01 +08:00
yuliang_guo
9c916195c6 feat: 恢复成长路径画布式设计器
All checks were successful
continuous-integration/drone/push Build is passing
- 右侧改为画布式设计器,节点可自由拖拽定位
- 支持箭头连接线显示前置课程依赖关系
- 阶段分隔线可视化显示
- 设置前置课程弹窗,用箭头连接
- 自动布局和清空画布功能
- 保留列表管理、多岗位关联等功能

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:46:42 +08:00
yuliang_guo
20905b72cc feat: 成长路径管理添加拖拽排序功能
All checks were successful
continuous-integration/drone/push Build is passing
- 已选课程支持拖拽调整顺序
- 支持跨阶段拖拽移动课程
- 拖拽时显示视觉反馈(高亮线条)
- 拖拽到空阶段时显示占位提示
- 自动更新课程排序编号

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:34:37 +08:00
yuliang_guo
e110067840 feat: 添加统一启动脚本,支持通过环境变量配置workers数量
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 start.sh 启动脚本,根据 WORKERS/RELOAD 环境变量自动配置
- 修改 Dockerfile 使用启动脚本,默认 WORKERS=4
- 更新 docker-compose.prod-multi.yml,所有租户使用环境变量配置
- 生产环境默认4个workers,提升并发处理能力

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:25:34 +08:00
yuliang_guo
879247c8e9 docs: 添加MinIO对象存储配置文档
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:11:09 +08:00
yuliang_guo
2f47193059 feat: 集成MinIO对象存储服务
All checks were successful
continuous-integration/drone/push Build is passing
- 新增storage_service.py封装MinIO操作
- 修改upload.py使用storage_service上传文件
- 修改course_service.py使用storage_service删除文件
- 适配preview.py支持从MinIO获取文件
- 适配knowledge_analysis_v2.py支持MinIO存储
- 在config.py添加MinIO配置项
- 添加minio依赖到requirements.txt

支持特性:
- 自动降级到本地存储(MinIO不可用时)
- 保持URL格式兼容(/static/uploads/)
- 文件自动缓存到本地(用于预览和分析)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:06:22 +08:00
yuliang_guo
fca82e2d44 fix: 优化路由加载失败的错误处理
All checks were successful
continuous-integration/drone/push Build is passing
- 检测chunk加载失败(部署后旧文件被清理)
- 自动刷新页面加载最新资源
- 改进错误提示,告知用户正在刷新

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 13:42:42 +08:00
yuliang_guo
99ded54616 style: 优化错题卡片操作按钮UI
All checks were successful
continuous-integration/drone/push Build is passing
- 操作按钮改为实心渐变样式,更加醒目
- 添加View和Check图标增强辨识度
- 按钮hover时上浮+阴影效果
- 查看解析:蓝色渐变,hover变深蓝
- 标记已掌握:绿色渐变,hover变深绿
- 表格视图按钮同步优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 13:19:43 +08:00
yuliang_guo
c66b355a5a style: 优化错题分析筛选框UI设计
All checks were successful
continuous-integration/drone/push Build is passing
- 重新设计筛选工具栏布局,采用flex横向排列
- 搜索框改为圆角胶囊形状,添加hover聚焦效果
- 下拉选择框使用统一的圆角灰底设计
- 添加emoji图标前缀增加辨识度
- 筛选标签改为胶囊形状
- 重置按钮仅在有筛选条件时显示
- 整体视觉更加现代简洁

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 11:16:46 +08:00
yuliang_guo
078117807d style: 优化错题分析页面的答案选项显示格式
All checks were successful
continuous-integration/drone/push Build is passing
1. 重新设计答案对比区域UI:
   - 添加渐变色背景和圆角边框
   - 添加图标徽章(✓/✗)区分正确和错误答案
   - 添加hover悬浮效果增强交互感
   - 响应式布局适配移动端

2. 优化错题详情弹窗:
   - 重新设计弹窗头部为渐变色
   - 答案对比采用卡片式布局
   - 知识点使用标签样式展示
   - 整体视觉更加专业美观

3. 新增formatAnswer函数:
   - 支持JSON数组格式解析
   - 多选答案用顿号分隔显示
   - 空值友好显示"未作答"

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:49:32 +08:00
yuliang_guo
ac686c27e7 feat: 优化登录策略 - 延长token有效期并支持自动刷新
Some checks failed
continuous-integration/drone/push Build is failing
1. 后端配置 (.env.ex):
   - ACCESS_TOKEN_EXPIRE_MINUTES: 30 -> 480 (8小时)

2. 前端 (request.ts):
   - 401错误时先尝试使用refresh_token刷新
   - 刷新成功后自动重试原请求
   - 支持并发请求时的token刷新队列
   - 刷新失败才清除状态并跳转登录页
2026-02-02 17:35:29 +08:00
yuliang_guo
58f746cf46 fix: 完整开放manager课程管理权限
All checks were successful
continuous-integration/drone/push Build is passing
将以下API权限从 require_admin 改为 require_admin_or_manager:
- add_course_material: 添加课程资料
- delete_course_material: 删除课程资料
- create_knowledge_point: 创建知识点
- update_knowledge_point: 更新知识点
- delete_knowledge_point: 删除知识点
- create_growth_path: 创建成长路径

Manager现在拥有完整的课程管理权限,包括:
- 课程CRUD
- 课程资料管理
- 知识点管理
- 岗位分配
- 成长路径创建
2026-02-02 16:54:27 +08:00
yuliang_guo
7c9f235a0e fix: 允许manager分配课程到岗位
All checks were successful
continuous-integration/drone/push Build is passing
- assign_course_positions: require_admin -> require_admin_or_manager
- remove_course_position: require_admin -> require_admin_or_manager
2026-02-02 16:39:04 +08:00
yuliang_guo
4b03e56e89 deploy: 部署仪表盘和岗位筛选修复 [ex]
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 16:27:42 +08:00
yuliang_guo
7857b4fb22 fix: 修复仪表盘用户数统计和岗位筛选问题
All checks were successful
continuous-integration/drone/push Build is passing
1. 后端 admin.py:
   - 用户总数统计添加 is_deleted=False, is_active=True 过滤
   - 现在只统计有效的活跃用户数

2. 前端 user-management.vue:
   - 岗位筛选从硬编码改为动态加载 positionOptions
   - 岗位列表从API获取,而不是写死的4个选项
2026-02-02 16:27:35 +08:00
yuliang_guo
e357f44e37 deploy: 部署课程权限和409处理修复 [ex]
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 16:21:10 +08:00
yuliang_guo
659f60e765 fix: 修复课程权限和添加409冲突统一处理
Some checks failed
continuous-integration/drone/push Build is failing
1. 课程权限修复:
   - 创建课程: require_admin -> require_admin_or_manager
   - 更新课程: require_admin -> require_admin_or_manager
   - 现在manager角色也可以创建和编辑课程

2. 全局409冲突处理:
   - 添加IntegrityError异常处理器
   - 自动识别常见冲突类型(用户名/邮箱/手机号/名称/编码)
   - 返回友好的中文错误提示
2026-02-02 16:21:02 +08:00
yuliang_guo
6317eb2479 deploy: 部署Coze陪练修复到ex环境 [ex]
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 13:21:08 +08:00
yuliang_guo
82f8e6596c fix: 修复CozeService初始化时get_bot_config缺少参数的问题
Some checks failed
continuous-integration/drone/push Build is failing
- 移除 __init__ 中对 get_bot_config() 的无参数调用
- 改为在需要时根据 session_type 动态获取 bot_config
- 修复 _get_bot_id_by_type 方法使用正确的配置获取方式
2026-02-02 13:21:00 +08:00
yuliang_guo
5156cbf4d2 deploy: 部署钉钉同步修复到ex环境 [ex]
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 13:09:14 +08:00
yuliang_guo
8024c38c32 fix: 修复钉钉同步误删用户的问题
All checks were successful
continuous-integration/drone/push Build is passing
问题原因:钉钉应用缺少手机号读取权限,导致返回的员工手机号全为空,
同步逻辑认为"钉钉没有这些员工"而错误删除了系统中的用户。

修复方案:
1. 增加安全检查:如果钉钉返回员工但手机号全为空,跳过删除操作
2. 使用双重匹配:同时考虑手机号和钉钉ID进行员工匹配
3. 增强日志:记录有手机号和钉钉ID的员工数量
4. 增加保护:只有手机号和钉钉ID都不匹配时才删除
2026-02-02 13:09:03 +08:00
yuliang_guo
8bfd5aa3de fix: 修复TrainingSession状态比较大小写问题
All checks were successful
continuous-integration/drone/push Build is passing
- COMPLETED -> completed (枚举值是小写)
2026-02-02 13:02:19 +08:00
yuliang_guo
cf71fabef0 fix: 修复企业看板API 500错误
All checks were successful
continuous-integration/drone/push Build is passing
- 修复 get_realtime_activities() 中字段名错误 (exp_amount -> exp_change)
- 添加 get_enterprise_overview() 的异常处理,防止单个查询失败导致整体失败
- 满分人数查询添加 NULL 值检查
2026-02-02 12:57:31 +08:00
yuliang_guo
99c4ac5473 fix: 团队统计只计算未删除的活跃用户
All checks were successful
continuous-integration/drone/push Build is passing
- get_accessible_team_member_ids 增加 is_deleted=False 和 is_active=True 过滤
- 避免统计已离职/删除的用户导致数量不准
2026-01-31 19:01:01 +08:00
yuliang_guo
616bb7185e fix: 任务中心标签页显示真实任务数量
Some checks failed
continuous-integration/drone/push Build is failing
- 移除硬编码的任务数量(12/5/28/3)
- 加载所有任务后统计各状态数量
- 后端任务API page_size限制调整为500
2026-01-31 18:48:26 +08:00
yuliang_guo
e942a9de2c fix: 任务列表优先级显示英文转中文
Some checks failed
continuous-integration/drone/push Build is failing
API返回英文优先级(high/medium/low),前端显示转换为中文(高/中/低)
2026-01-31 18:42:14 +08:00
yuliang_guo
586c51955e fix: 增加分页参数page_size上限到500
All checks were successful
continuous-integration/drone/push Build is passing
任务中心需要加载全部用户列表,将限制从100提升到500
2026-01-31 18:38:25 +08:00
yuliang_guo
ebf196686f fix: 修复任务API枚举值访问错误
All checks were successful
continuous-integration/drone/push Build is passing
- 使用 hasattr 检查是否为枚举类型,兼容字符串和枚举
- 移除 get_tasks 中多余的 get_task_detail 调用,使用已加载的关联数据
2026-01-31 18:35:17 +08:00
yuliang_guo
fc9775e61f fix: 修复任务服务SQLAlchemy异步加载错误
All checks were successful
continuous-integration/drone/push Build is passing
- create_task 和 get_tasks 现在使用 selectinload 预加载关联关系
- 避免懒加载导致的 MissingGreenlet 错误
2026-01-31 18:31:07 +08:00
yuliang_guo
eca0ed8c9d fix: 修复创建任务时优先级转换错误
Some checks failed
continuous-integration/drone/push Build is failing
前端优先级使用中文(高/中/低),需要转换为英文(high/medium/low)
.toLowerCase() 无法转换中文字符
2026-01-31 18:19:19 +08:00
yuliang_guo
506e9ea2e2 feat: 员工同步增加离职处理功能
Some checks failed
continuous-integration/drone/push Build is failing
- 全量同步 (sync_employees) 现在会检测并软删除离职员工
- 增量同步改为软删除而非物理删除,更安全
- 离职处理:设置 is_active=False, is_deleted=True
- 前端显示离职处理数量统计
2026-01-31 18:10:45 +08:00
yuliang_guo
e5dd6f3acb fix: 修复员工同步统计错误
Some checks failed
continuous-integration/drone/push Build is failing
- create_user 现在返回 (user, status) 元组,区分 created/existing/restored/skipped
- sync_employees 正确统计新增、已存在、恢复、跳过的用户数
- 前端显示更准确的同步结果信息
2026-01-31 18:04:27 +08:00
yuliang_guo
6b7b828854 feat: 添加员工同步立即执行按钮
All checks were successful
continuous-integration/drone/push Build is passing
在系统设置页面的员工同步配置中增加"立即同步"按钮,
允许管理员手动触发钉钉员工数据同步
2026-01-31 17:51:41 +08:00
yuliang_guo
940777a86e fix: 修复员工同步功能开关保存失败的问题
Some checks failed
continuous-integration/drone/push Build is failing
当 feature_switches 表中没有默认记录时,set_feature_switch 函数
现在会使用预定义的默认值创建记录,而不是静默失败
2026-01-31 17:46:35 +08:00
yuliang_guo
41a2f7944a fix: 修复flake8 lint检查错误
All checks were successful
continuous-integration/drone/push Build is passing
- 删除废弃的 admin_positions_backup.py 备份文件
- 修复 courses.py 缺失的 select 导入
- 修复 coze_gateway.py 异常变量作用域问题
- 修复 scheduler_service.py 无用的 global 声明
- 添加 TYPE_CHECKING 导入解决模型前向引用警告
2026-01-31 17:43:39 +08:00
yuliang_guo
18d6d5aff3 refactor: 员工同步复用钉钉免密登录配置
Some checks failed
continuous-integration/drone/push Build is failing
- 移除员工同步独立的 API 凭证配置
- 复用 dingtalk 配置组的 CorpId、AppKey、AppSecret
- 简化前端界面,只保留开关和测试连接
2026-01-31 17:29:10 +08:00
yuliang_guo
7be1ac1787 feat: 员工同步改为钉钉开放API方式
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 dingtalk_service.py 调用钉钉开放API
- 支持获取 Access Token、部门列表、员工列表
- employee_sync_service 改为从钉钉API获取员工
- 前端配置界面支持配置 CorpId、ClientId、ClientSecret
- 移除外部数据库表依赖
2026-01-31 17:25:44 +08:00
yuliang_guo
cabc3c3442 fix: 修复练习结束时的DetachedInstanceError
All checks were successful
continuous-integration/drone/push Build is passing
- 在第二次commit后refresh session对象
- 避免异步session管理导致的对象脱离错误
2026-01-31 17:13:00 +08:00
yuliang_guo
07638152fc refactor: 员工同步数据库配置改为环境变量
All checks were successful
continuous-integration/drone/push Build is passing
- 前端隐藏数据库连接配置输入
- 只保留"启用开关"和"表名"配置
- 数据库连接从 EMPLOYEE_SYNC_DB_URL 环境变量读取
- 显示数据源配置状态
- 保留默认值用于向后兼容
2026-01-31 17:07:55 +08:00
yuliang_guo
78e1bb3dc3 feat: 员工同步配置支持多租户
All checks were successful
continuous-integration/drone/push Build is passing
- 后端新增员工同步配置API(获取/保存/测试连接)
- employee_sync_service 从数据库读取配置
- 前端系统设置页面添加"员工同步"Tab
- 支持配置:数据库主机、端口、库名、用户名、密码、表名
- 保留默认配置用于向后兼容
2026-01-31 17:01:30 +08:00
yuliang_guo
8500308919 feat: 添加功能开关机制
All checks were successful
continuous-integration/drone/push Build is passing
- 添加环境变量配置 VITE_FEATURE_DUO_PRACTICE 等
- env.ts 新增 isFeatureEnabled 方法
- 菜单根据功能开关动态显示/隐藏
- 路由守卫拦截未启用功能的直接访问
- 开发环境默认开启双人对练,生产环境默认关闭
2026-01-31 14:26:52 +08:00
yuliang_guo
d2e6abfc80 feat: 完善任务中心全部功能
All checks were successful
continuous-integration/drone/push Build is passing
1. 动态加载选项数据
   - 从API获取团队、成员、课程列表
   - 替换硬编码选项为动态渲染

2. 编辑任务功能
   - 复用创建对话框,添加编辑模式
   - 填充表单数据并调用updateTask API

3. 查看详情弹窗
   - 展示任务基本信息、进度、课程、要求
   - 调用getTaskDetail API获取详情

4. 结束任务功能
   - 确认后调用updateTask API更新状态为completed
   - 刷新列表和统计数据

5. 复制任务功能
   - 复制任务内容到表单(标题添加"副本"后缀)
   - 打开创建对话框

6. 发送提醒功能
   - 后端新增 /tasks/{id}/remind API
   - 前端调用API并显示结果
2026-01-31 14:05:55 +08:00
yuliang_guo
9bd9e58439 fix: 课程资料schema支持PPT/PPTX文件类型
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-31 12:02:11 +08:00
yuliang_guo
0b8f8aa6ca fix: 前端上传组件支持PPT/PPTX文件
All checks were successful
continuous-integration/drone/push Build is passing
- edit-course.vue: 更新accept属性添加.ppt和.pptx
- 更新提示文字显示支持PPT格式
2026-01-31 11:54:16 +08:00
yuliang_guo
c3aa4e85e7 feat: 添加PPT/PPTX文件类型支持
All checks were successful
continuous-integration/drone/push Build is passing
1. upload.py: 添加ppt/pptx到允许上传的文件类型
2. knowledge_analysis_v2.py: 添加PPT内容提取方法_extract_ppt_content
3. requirements.txt: 添加python-pptx依赖
2026-01-31 11:49:10 +08:00
yuliang_guo
4e817f6eef fix: 修复exam_service解析questions JSON格式
All checks were successful
continuous-integration/drone/push Build is passing
questions可能是{"questions":[...]}或直接是列表,需要兼容处理
2026-01-31 11:28:00 +08:00
yuliang_guo
64a70d5c2c fix: 修复考试API路由冲突和响应验证问题
All checks were successful
continuous-integration/drone/push Build is passing
1. 调整路由顺序:将/records和/statistics放在/{exam_id}之前
2. 修复RecentExamItem.start_time允许None值
2026-01-31 11:26:54 +08:00
yuliang_guo
e1d10605c9 fix: ExamService.start_exam返回ID避免懒加载
All checks were successful
continuous-integration/drone/push Build is passing
修改start_exam返回exam.id而不是整个Exam对象,
彻底避免SQLAlchemy异步会话的懒加载问题
2026-01-31 11:21:39 +08:00
yuliang_guo
50c511d825 fix: 修复考试API的SQLAlchemy懒加载问题
All checks were successful
continuous-integration/drone/push Build is passing
在访问current_user属性前先提取到局部变量,避免MissingGreenlet错误
2026-01-31 11:20:09 +08:00
yuliang_guo
2334a2544c fix: 修复exam_service异常类导入错误
All checks were successful
continuous-integration/drone/push Build is passing
将不存在的BusinessException/ErrorCode替换为现有的NotFoundError/ValidationError
2026-01-31 11:15:52 +08:00
yuliang_guo
ae4ba8afd3 fix: 修复考试API的ExamService导入缺失
All checks were successful
continuous-integration/drone/push Build is passing
考试开始/提交等API因缺少ExamService导入返回500错误
2026-01-31 11:14:17 +08:00
yuliang_guo
4a273e627a fix: 成长路径管理API添加权限控制
All checks were successful
continuous-integration/drone/push Build is passing
管理端所有成长路径API现在需要管理员或经理权限才能访问:
- GET/POST /manager/growth-paths
- GET/PUT/DELETE /manager/growth-paths/{path_id}
2026-01-31 11:06:02 +08:00
yuliang_guo
bdb91aabea fix: SQL执行器仅允许管理员访问
All checks were successful
continuous-integration/drone/push Build is passing
- 所有SQL执行器端点改用 require_admin 权限校验
- /sql/execute - 执行SQL
- /sql/validate - 验证SQL
- /sql/tables - 获取表列表
- /sql/table/{name}/schema - 获取表结构
2026-01-31 11:01:35 +08:00
yuliang_guo
79b55cfd12 fix: 修复权限提升漏洞和添加安全头
All checks were successful
continuous-integration/drone/push Build is passing
安全修复:
- 创建 UserSelfUpdate schema,禁止用户修改自己的 role 和 is_active
- /users/me 端点现在使用 UserSelfUpdate 而非 UserUpdate

安全增强:
- 添加 SecurityHeadersMiddleware 中间件
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- X-XSS-Protection: 1; mode=block
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: 禁用敏感功能
- Cache-Control: API响应不缓存
2026-01-31 10:57:41 +08:00
yuliang_guo
52dccaab79 feat: 添加API限流和优化错误处理
Some checks failed
continuous-integration/drone/push Build is failing
- 添加 RateLimitMiddleware 限流中间件 (200请求/分钟)
- 优化 Content-Type 错误返回 400 而非 500
- 添加 JSON 解析错误处理
- 统一 HTTP 异常处理格式
2026-01-31 10:50:27 +08:00
yuliang_guo
d59a4355a5 fix: 修复安全问题 - 登录失败返回401 + XSS过滤
All checks were successful
continuous-integration/drone/push Build is passing
- 登录失败返回 HTTP 401 而非 200
- 添加 XSS 输入过滤工具函数
- 课程名称和描述字段添加 XSS 过滤验证器
2026-01-31 10:39:07 +08:00
yuliang_guo
0b7c07eb7f feat: 添加请求验证错误详细日志
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-01-31 10:03:54 +08:00
yuliang_guo
fadeaadd65 fix: 修复学员端成长路径数据解析,兼容直接返回数据格式
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 18:32:51 +08:00
yuliang_guo
67b3c28d33 debug: 添加成长路径返回数据日志
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 18:28:58 +08:00
yuliang_guo
1f60012a97 fix: 修复学员端成长路径按用户岗位匹配的逻辑,支持多岗位
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 18:20:18 +08:00
yuliang_guo
3dcaaaaa35 feat: 成长路径页面增加岗位全选按钮,调整下方区域高度
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 18:12:08 +08:00
yuliang_guo
ceb0ae966b fix: 修复模板结构错误
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 17:56:12 +08:00
yuliang_guo
52236ff2a8 feat: 重构成长路径编辑页面布局 - 上方基本信息/阶段/统计,下方左右分栏课程配置
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-30 17:55:06 +08:00
yuliang_guo
ebcc0da4c7 fix: 修复课程加载 size 参数超过后端限制的问题
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 17:47:55 +08:00
yuliang_guo
a6a87e2d41 fix: 修复课程列表API路径和数据结构
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 17:39:40 +08:00
yuliang_guo
14e2e948f5 feat: 优化课程列表显示,双列布局展示更多课程
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 16:34:40 +08:00
yuliang_guo
b2e1ed02d1 feat: 增强课程搜索功能,添加分类筛选
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-30 16:27:34 +08:00
yuliang_guo
920c6a64c8 feat: 成长路径支持多岗位关联 + 增强拖拽功能
All checks were successful
continuous-integration/drone/push Build is passing
前端:
- 岗位选择改为多选模式
- 增强拖拽视觉反馈(高亮、动画提示)
- 列表显示多个岗位标签

后端:
- 添加 position_ids 字段支持多岗位
- 兼容旧版 position_id 单选数据
- 返回 position_names 数组
2026-01-30 16:19:40 +08:00
yuliang_guo
a92bfa2b0f fix: 修复考试题目分数显示为小数的问题
All checks were successful
continuous-integration/drone/push Build is passing
- 使用 distributeScores 智能整数分配题目分数
- 格式化分数显示,避免显示长小数
- 总分100分,根据题目数量智能分配整数分数

例如:11道题 = [10,10,10,10,10,10,10,10,10,5,5] 而不是 [9.09...,9.09...]
2026-01-30 16:09:14 +08:00
70 changed files with 7633 additions and 3906 deletions

View File

@@ -1,8 +1,8 @@
kind: pipeline kind: pipeline
type: docker type: docker
name: deploy-test name: build-and-push-test
# 测试环境test 分支触发,部署到 kpl # 测试环境test 分支触发,构建并推送到 ACR
trigger: trigger:
branch: branch:
- test - test
@@ -10,64 +10,61 @@ trigger:
- push - push
steps: steps:
- name: sync-code # 构建并推送后端镜像
image: appleboy/drone-ssh - name: build-push-backend
settings: image: docker:dind
host: 120.79.247.16 volumes:
username: root - name: docker-sock
password: path: /var/run/docker.sock
from_secret: prod_ssh_password environment:
port: 22 DOCKER_REGISTRY:
command_timeout: 5m from_secret: docker_registry
script: DOCKER_USERNAME:
- echo "=== 测试环境 同步代码 ===" from_secret: docker_username
- cd /root/aiedu DOCKER_PASSWORD:
- git fetch cicd from_secret: docker_password
- git checkout test 2>/dev/null || git checkout -b test cicd/test commands:
- git reset --hard cicd/test - echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- echo "代码同步完成" - cd backend
- docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-backend:test -f Dockerfile .
- docker tag $DOCKER_REGISTRY/ireborn/kaopeilian-backend:test $DOCKER_REGISTRY/ireborn/kaopeilian-backend:${DRONE_COMMIT_SHA:0:8}
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:test
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:${DRONE_COMMIT_SHA:0:8}
- echo "后端镜像推送完成"
- name: build-frontend-test # 构建并推送前端镜像
image: appleboy/drone-ssh - name: build-push-frontend
settings: image: docker:dind
host: 120.79.247.16 volumes:
username: root - name: docker-sock
password: path: /var/run/docker.sock
from_secret: prod_ssh_password environment:
port: 22 DOCKER_REGISTRY:
command_timeout: 10m from_secret: docker_registry
script: DOCKER_USERNAME:
- echo "=== 测试环境 编译前端到 dist-test ===" from_secret: docker_username
- cd /root/aiedu/frontend DOCKER_PASSWORD:
- npm install --silent from_secret: docker_password
- npm run build commands:
- rm -rf /root/aiedu/dist-test/* - echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cp -r dist/* /root/aiedu/dist-test/ - cd frontend
- echo "前端编译完成 dist-test" - docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:test -f Dockerfile --build-arg VITE_API_BASE_URL=https://kpl.ireborn.com.cn .
- docker tag $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:test $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:${DRONE_COMMIT_SHA:0:8}
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:test
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:${DRONE_COMMIT_SHA:0:8}
- echo "前端镜像推送完成"
- name: deploy-backend volumes:
image: appleboy/drone-ssh - name: docker-sock
settings: host:
host: 120.79.247.16 path: /var/run/docker.sock
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 5m
script:
- echo "=== 测试环境 部署后端 ==="
- cp -r /root/aiedu/backend/app/* /root/aiedu/backend-test/app/
- docker restart kpl-backend-dev
- sleep 5
- docker ps | grep kpl-
- echo "=== 测试环境部署完成 ==="
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
name: deploy-staging name: build-and-push-staging
# 预生产环境staging 分支触发,部署到 aiedu # 预生产环境staging 分支触发
trigger: trigger:
branch: branch:
- staging - staging
@@ -75,71 +72,53 @@ trigger:
- push - push
steps: steps:
- name: sync-code - name: build-push-backend
image: appleboy/drone-ssh image: docker:dind
settings: volumes:
host: 120.79.247.16 - name: docker-sock
username: root path: /var/run/docker.sock
password: environment:
from_secret: prod_ssh_password DOCKER_REGISTRY:
port: 22 from_secret: docker_registry
command_timeout: 5m DOCKER_USERNAME:
script: from_secret: docker_username
- echo "=== 预生产 同步代码 ===" DOCKER_PASSWORD:
- cd /root/aiedu from_secret: docker_password
- git fetch cicd commands:
- git checkout staging 2>/dev/null || git checkout -b staging cicd/staging - echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- git reset --hard cicd/staging - cd backend
- echo "代码同步完成" - docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-backend:staging -f Dockerfile .
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:staging
- name: build-frontend-staging - name: build-push-frontend
image: appleboy/drone-ssh image: docker:dind
settings: volumes:
host: 120.79.247.16 - name: docker-sock
username: root path: /var/run/docker.sock
password: environment:
from_secret: prod_ssh_password DOCKER_REGISTRY:
port: 22 from_secret: docker_registry
command_timeout: 10m DOCKER_USERNAME:
script: from_secret: docker_username
- echo "=== 预生产 编译前端到 dist-staging ===" DOCKER_PASSWORD:
- cd /root/aiedu/frontend from_secret: docker_password
- npm install --silent commands:
- npm run build - echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- rm -rf /root/aiedu/dist-staging/* - cd frontend
- cp -r dist/* /root/aiedu/dist-staging/ - docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:staging -f Dockerfile --build-arg VITE_API_BASE_URL=https://aiedu.ireborn.com.cn .
- echo "前端编译完成 dist-staging" - docker push $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:staging
- name: deploy-backend volumes:
image: appleboy/drone-ssh - name: docker-sock
settings: host:
host: 120.79.247.16 path: /var/run/docker.sock
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 5m
script:
- echo "=== 预生产 部署后端 ==="
- cp -r /root/aiedu/backend/app/* /root/aiedu/backend-staging/app/
- docker restart kaopeilian-backend
- sleep 5
- docker ps | grep kaopeilian-
- echo "=== 预生产部署完成 ==="
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
name: deploy-prod name: build-and-push-prod
# 生产环境main 分支触发,部署到所有租户 # 生产环境main 分支触发
#
# 使用方法:
# git commit -m "feat: xxx [all]" - 部署所有租户
# git commit -m "feat: xxx [hua]" - 仅部署 hua
# git commit -m "feat: xxx [cxw,yy,hl]" - 部署指定多个租户
# git commit -m "feat: xxx" - 默认部署所有租户
#
trigger: trigger:
branch: branch:
- main - main
@@ -147,107 +126,47 @@ trigger:
- push - push
steps: steps:
- name: sync-code-to-server - name: build-push-backend
image: appleboy/drone-ssh image: docker:dind
settings: volumes:
host: 120.79.247.16 - name: docker-sock
username: root path: /var/run/docker.sock
password: environment:
from_secret: prod_ssh_password DOCKER_REGISTRY:
port: 22 from_secret: docker_registry
command_timeout: 10m DOCKER_USERNAME:
script: from_secret: docker_username
- echo "=== 生产 同步代码 ===" DOCKER_PASSWORD:
- cd /root/aiedu from_secret: docker_password
- git fetch cicd
- git reset --hard cicd/main
- echo "代码同步完成"
- name: build-frontend-prod
image: appleboy/drone-ssh
settings:
host: 120.79.247.16
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 10m
script:
- echo "=== 生产 编译前端到 dist-prod ==="
- cd /root/aiedu/frontend
- npm install --silent
- npm run build
- rm -rf /root/aiedu/dist-prod/*
- cp -r dist/* /root/aiedu/dist-prod/
- echo "前端编译完成 dist-prod 所有生产租户已更新"
- name: deploy-backend
image: appleboy/drone-ssh
settings:
host: 120.79.247.16
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 15m
script:
- echo "=== 生产 部署后端 ==="
- |
# 同步后端代码到生产环境目录
cp -r /root/aiedu/backend/app/* /root/aiedu/backend-prod/app/
echo "后端代码已同步到生产目录"
# 获取 commit message
COMMIT_MSG="${DRONE_COMMIT_MESSAGE}"
echo "Commit: $COMMIT_MSG"
# 所有可用租户
ALL_TENANTS="hua yy hl xy fw ex cxw"
# 解析要部署的租户
if echo "$COMMIT_MSG" | grep -q '\[all\]'; then
TENANTS="$ALL_TENANTS"
echo "部署所有租户: $TENANTS"
elif echo "$COMMIT_MSG" | grep -oP '\[\K[a-z,]+(?=\])' > /tmp/tenants.txt 2>/dev/null; then
TENANTS=$(cat /tmp/tenants.txt | tr ',' ' ')
echo "部署指定租户: $TENANTS"
else
TENANTS="$ALL_TENANTS"
echo "默认部署所有租户: $TENANTS"
fi
# 重启指定租户的后端容器
for t in $TENANTS; do
echo "重启 ${t}-backend ..."
docker restart ${t}-backend || echo "警告: ${t}-backend 不存在或重启失败"
done
sleep 10
docker ps | grep backend
echo "=== 生产 部署完成 ==="
echo "已更新租户: $TENANTS"
---
kind: pipeline
type: docker
name: code-check
trigger:
event:
- push
- pull_request
steps:
- name: python-lint
image: python:3.9-slim
commands: commands:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd backend - cd backend
- pip install flake8 -q - docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-backend:main -f Dockerfile .
- flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics || true - docker tag $DOCKER_REGISTRY/ireborn/kaopeilian-backend:main $DOCKER_REGISTRY/ireborn/kaopeilian-backend:latest
- echo "Python lint completed" - docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:main
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:latest
- name: frontend-check - name: build-push-frontend
image: node:18-alpine image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
environment:
DOCKER_REGISTRY:
from_secret: docker_registry
DOCKER_USERNAME:
from_secret: docker_username
DOCKER_PASSWORD:
from_secret: docker_password
commands: commands:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd frontend - cd frontend
- echo "Frontend check completed" - docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:main -f Dockerfile --build-arg VITE_API_BASE_URL=https://hua.ireborn.com.cn .
- docker tag $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:main $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:latest
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:main
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:latest
volumes:
- name: docker-sock
host:
path: /var/run/docker.sock

155
TEST_REPORT_2026-01-31.md Normal file
View File

@@ -0,0 +1,155 @@
# KPL 考培练系统测试报告
**测试环境**: dev (https://kpl.ireborn.com.cn)
**测试时间**: 2026-01-31
**测试人员**: AI 自动化测试系统
---
## 一、测试概要
| 模块 | 测试用例数 | 通过 | 失败 | 警告 |
|------|-----------|------|------|------|
| 认证模块 | 7 | 5 | 2 | 0 |
| 课程管理 | 7 | 7 | 0 | 0 |
| 成长路径 | 4 | 4 | 0 | 0 |
| 岗位管理 | 2 | 2 | 0 | 0 |
| 考试模块 | 3 | 2 | 1 | 0 |
| AI练习 | 3 | 2 | 0 | 1 |
| 通知系统 | 2 | 2 | 0 | 0 |
| 极端边界 | 8 | 7 | 0 | 1 |
| 安全测试 | 7 | 5 | 0 | 2 |
| **合计** | **43** | **36** | **3** | **4** |
**通过率**: 83.7%
---
## 二、发现的问题
### 严重 (High)
#### 1. 错误密码登录返回200
- **位置**: `POST /api/v1/auth/login`
- **描述**: 使用错误密码登录时返回 HTTP 200应返回 401
- **影响**: 可能导致暴力破解攻击难以被检测
- **建议**: 检查登录逻辑,确保密码错误时返回 401
#### 2. XSS 内容被原样存储
- **位置**: `POST /api/v1/courses` (name, description 字段)
- **描述**: `<script>alert(1)</script>` 等 XSS 代码被原样存入数据库
- **影响**: 潜在的存储型 XSS 攻击风险
- **建议**:
- 输入时转义或过滤 HTML 标签
- 输出时使用 HTML 实体编码
### 中等 (Medium)
#### 3. 不存在用户登录返回422
- **位置**: `POST /api/v1/auth/login`
- **描述**: 登录不存在的用户返回 422应返回 401
- **影响**: 用户枚举风险(可判断用户是否存在)
- **建议**: 统一返回 401 "用户名或密码错误"
#### 4. API 限流未配置
- **位置**: 全局
- **描述**: 10次快速请求未触发限流
- **影响**: 可能被恶意请求攻击
- **建议**: 配置 API 限流中间件
### 低等 (Low)
#### 5. 越权访问返回404而非403
- **位置**: `GET /api/v1/admin/users`
- **描述**: 普通用户访问管理接口返回 404 而非 403
- **影响**: 信息泄露(可探测接口是否存在)
- **建议**: 统一返回 403 Forbidden
#### 6. 部分API端点404
- **位置**:
- `GET /api/v1/exams` (考试列表)
- `GET /api/v1/practice/sessions` (练习记录)
- **描述**: 这些端点返回 404可能是路径变更或未实现
- **建议**: 确认 API 路径或补充实现
---
## 三、测试详情
### 3.1 认证模块测试
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 正常登录 | ✓ PASS | HTTP 200, Token 获取成功 |
| 错误密码登录 | ✗ FAIL | HTTP 200 (应返回401) |
| 不存在用户登录 | ✗ FAIL | HTTP 422 (应返回401) |
| Token验证 | ✓ PASS | HTTP 200 |
| 无效Token访问 | ✓ PASS | HTTP 401 |
| 无Token访问 | ✓ PASS | HTTP 403 |
| 获取用户信息 | ✓ PASS | HTTP 200 |
### 3.2 课程管理测试
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 获取课程列表 | ✓ PASS | 总课程数: 16 |
| 创建课程 | ✓ PASS | HTTP 201 |
| 获取课程详情 | ✓ PASS | HTTP 200 |
| 更新课程 | ✓ PASS | HTTP 200 |
| 获取考试设置 | ✓ PASS | HTTP 200 |
| 更新考试设置 | ✓ PASS | HTTP 200 |
| 获取不存在课程 | ✓ PASS | HTTP 404 |
### 3.3 极端边界测试
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 空名称创建课程 | ✓ PASS | 正确返回 422 |
| 超长名称(1000字符) | ✓ PASS | 正确返回 422 |
| XSS注入 | ⚠ WARN | 内容被原样存储 |
| SQL注入 | ✓ PASS | 注入被防护 |
| 负数分页参数 | ✓ PASS | 正确返回 422 |
| 超大分页(10000) | ✓ PASS | 正确返回 422 |
| Unicode/Emoji | ✓ PASS | 正确处理 |
| 特殊字符 | ✓ PASS | 正确处理 |
### 3.4 安全测试
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 越权访问 | ⚠ WARN | 返回404而非403 |
| 伪造Token | ✓ PASS | 正确拒绝 |
| 过期Token | ✓ PASS | 正确拒绝 |
| 访问他人数据 | ✓ PASS | 访问被限制 |
| 敏感信息泄露 | ✓ PASS | 未泄露密码/Token |
| API限流 | ⚠ INFO | 未触发限流 |
| 目录遍历 | ✓ PASS | 攻击被阻止 |
---
## 四、修复建议优先级
### P0 - 立即修复
1. 修复错误密码登录返回200的问题
2. 添加 XSS 输入过滤/输出编码
### P1 - 尽快修复
3. 统一登录错误响应码为401
4. 配置 API 限流保护
### P2 - 计划修复
5. 越权访问统一返回403
6. 确认并修复404的API端点
---
## 五、测试环境信息
- **后端容器**: kpl-backend-dev
- **数据库**: MySQL 8.0
- **测试账号**: admin / admin123
- **测试时间**: 2026-01-31 10:30 UTC+8
---
*本报告由自动化测试系统生成*

View File

@@ -22,7 +22,7 @@ REDIS_DB=0
# 安全配置 # 安全配置
SECRET_KEY=ex_8f7a9c3e1b4d6f2a5c8e7b9d1f3a6c4e8b2d5f7a9c1e3b6d8f2a4c7e9b1d3f5a SECRET_KEY=ex_8f7a9c3e1b4d6f2a5c8e7b9d1f3a6c4e8b2d5f7a9c1e3b6d8f2a4c7e9b1d3f5a
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30 ACCESS_TOKEN_EXPIRE_MINUTES=480
REFRESH_TOKEN_EXPIRE_DAYS=7 REFRESH_TOKEN_EXPIRE_DAYS=7
# CORS配置 # CORS配置
@@ -41,7 +41,7 @@ UPLOAD_DIR=uploads
COZE_OAUTH_CLIENT_ID=1114009328887 COZE_OAUTH_CLIENT_ID=1114009328887
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
COZE_PRACTICE_BOT_ID=7560643598174683145 COZE_PRACTICE_BOT_ID=7602204855037591602
# Dify 工作流 API Key 配置 # Dify 工作流 API Key 配置
# 01-知识点分析 # 01-知识点分析

View File

@@ -43,9 +43,18 @@ RUN pip install --upgrade pip && \
# 复制应用代码 # 复制应用代码
COPY app/ ./app/ COPY app/ ./app/
# 复制启动脚本
COPY start.sh ./start.sh
RUN chmod +x ./start.sh
# 创建上传目录和日志目录 # 创建上传目录和日志目录
RUN mkdir -p uploads logs RUN mkdir -p uploads logs
# 默认环境变量可通过docker-compose或环境变量覆盖
ENV WORKERS=4 \
RELOAD=false \
TIMEOUT_KEEP_ALIVE=600
# 暴露端口 # 暴露端口
EXPOSE 8000 EXPOSE 8000
@@ -53,5 +62,5 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1 CMD curl -f http://localhost:8000/health || exit 1
# 启动命令(生产模式,无热重载) # 使用启动脚本
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--timeout-keep-alive", "600"] CMD ["./start.sh"]

View File

@@ -34,8 +34,11 @@ async def get_dashboard_stats(
message="权限不足,需要管理员权限" message="权限不足,需要管理员权限"
) )
# 用户统计 # 用户统计 - 只统计未删除且活跃的用户
total_users = await db.scalar(select(func.count(User.id))) total_users = await db.scalar(
select(func.count(User.id))
.where(User.is_deleted == False, User.is_active == True)
)
# 计算最近30天的新增用户 # 计算最近30天的新增用户
thirty_days_ago = datetime.now() - timedelta(days=30) thirty_days_ago = datetime.now() - timedelta(days=30)

View File

@@ -1,158 +0,0 @@
# 此文件备份了admin.py中的positions相关路由代码
# 这些路由已移至positions.py为避免冲突从admin.py中移除
@router.get("/positions")
async def list_positions(
keyword: Optional[str] = Query(None, description="关键词"),
page: int = Query(1, ge=1),
pageSize: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取岗位列表stub 数据)
返回结构兼容前端data.list/total/page/pageSize
"""
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
try:
items = _sample_positions()
if keyword:
kw = keyword.lower()
items = [
p for p in items if kw in (p.get("name", "") + p.get("description", "")).lower()
]
total = len(items)
start = (page - 1) * pageSize
end = start + pageSize
page_items = items[start:end]
return ResponseModel(
code=200,
message="获取岗位列表成功",
data={
"list": page_items,
"total": total,
"page": page,
"pageSize": pageSize,
},
)
except Exception as exc:
# 记录错误堆栈由全局异常中间件处理;此处返回统一结构
return ResponseModel(code=500, message=f"服务器错误:{exc}")
@router.get("/positions/tree")
async def get_position_tree(
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取岗位树stub 数据)
"""
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
try:
items = _sample_positions()
id_to_node: Dict[int, Dict[str, Any]] = {}
for p in items:
node = {**p, "children": []}
id_to_node[p["id"]] = node
roots: List[Dict[str, Any]] = []
for p in items:
parent_id = p.get("parentId")
if parent_id and parent_id in id_to_node:
id_to_node[parent_id]["children"].append(id_to_node[p["id"]])
else:
roots.append(id_to_node[p["id"]])
return ResponseModel(code=200, message="获取岗位树成功", data=roots)
except Exception as exc:
return ResponseModel(code=500, message=f"服务器错误:{exc}")
@router.get("/positions/{position_id}")
async def get_position_detail(
position_id: int,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
items = _sample_positions()
for p in items:
if p["id"] == position_id:
return ResponseModel(code=200, message="获取岗位详情成功", data=p)
return ResponseModel(code=404, message="岗位不存在")
@router.get("/positions/{position_id}/check-delete")
async def check_position_delete(
position_id: int,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
# stub允许删除非根岗位
deletable = position_id != 1
reason = "根岗位不允许删除" if not deletable else ""
return ResponseModel(code=200, message="检查成功", data={"deletable": deletable, "reason": reason})
@router.post("/positions")
async def create_position(
payload: Dict[str, Any],
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
# stub直接回显并附带一个伪ID
payload = dict(payload)
payload.setdefault("id", 999)
payload.setdefault("createTime", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
return ResponseModel(code=200, message="创建岗位成功", data=payload)
@router.put("/positions/{position_id}")
async def update_position(
position_id: int,
payload: Dict[str, Any],
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
# stub直接回显
updated = {"id": position_id, **payload}
return ResponseModel(code=200, message="更新岗位成功", data=updated)
@router.delete("/positions/{position_id}")
async def delete_position(
position_id: int,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
) -> ResponseModel:
not_admin = _ensure_admin(current_user)
if not_admin:
return not_admin
# stub直接返回成功
return ResponseModel(code=200, message="删除岗位成功", data={"id": position_id})

View File

@@ -67,7 +67,7 @@ async def login(
SystemLogCreate( SystemLogCreate(
level="WARNING", level="WARNING",
type="security", type="security",
message=f"用户 {login_data.username} 登录失败:密码错误", message=f"用户 {login_data.username} 登录失败:用户名或密码错误",
user=login_data.username, user=login_data.username,
ip=request.client.host if request.client else None, ip=request.client.host if request.client else None,
path="/api/v1/auth/login", path="/api/v1/auth/login",
@@ -75,19 +75,27 @@ async def login(
user_agent=request.headers.get("user-agent") user_agent=request.headers.get("user-agent")
) )
) )
# 不返回 401统一返回 HTTP 200 + 业务失败码,便于前端友好提示
logger.warning("login_failed_wrong_credentials", username=login_data.username) logger.warning("login_failed_wrong_credentials", username=login_data.username)
return ResponseModel( # 返回 HTTP 401 + 统一错误消息(避免用户枚举)
code=400, from fastapi.responses import JSONResponse
message=str(e) or "用户名或密码错误", return JSONResponse(
data=None, status_code=401,
content={
"code": 401,
"message": "用户名或密码错误",
"data": None,
}
) )
except Exception as e: except Exception as e:
logger.error("login_failed_unexpected", error=str(e)) logger.error("login_failed_unexpected", error=str(e))
return ResponseModel( from fastapi.responses import JSONResponse
code=500, return JSONResponse(
message="登录失败,请稍后重试", status_code=500,
data=None, content={
"code": 500,
"message": "登录失败,请稍后重试",
"data": None,
}
) )

View File

@@ -4,6 +4,7 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User from app.core.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User
@@ -80,11 +81,11 @@ async def get_courses(
async def create_course( async def create_course(
course_in: CourseCreate, course_in: CourseCreate,
request: Request, request: Request,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
创建课程(需要管理员权限) 创建课程(需要管理员或经理权限)
- **name**: 课程名称 - **name**: 课程名称
- **description**: 课程描述 - **description**: 课程描述
@@ -143,11 +144,11 @@ async def get_course(
async def update_course( async def update_course(
course_id: int, course_id: int,
course_in: CourseUpdate, course_in: CourseUpdate,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
更新课程(需要管理员权限) 更新课程(需要管理员或经理权限)
- **course_id**: 课程ID - **course_id**: 课程ID
- **course_in**: 更新的课程数据(所有字段都是可选的) - **course_in**: 更新的课程数据(所有字段都是可选的)
@@ -211,11 +212,11 @@ async def add_course_material(
course_id: int, course_id: int,
material_in: CourseMaterialCreate, material_in: CourseMaterialCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
添加课程资料(需要管理员权限) 添加课程资料(需要管理员或经理权限)
- **course_id**: 课程ID - **course_id**: 课程ID
- **name**: 资料名称 - **name**: 资料名称
@@ -277,11 +278,11 @@ async def list_course_materials(
async def delete_course_material( async def delete_course_material(
course_id: int, course_id: int,
material_id: int, material_id: int,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
删除课程资料(需要管理员权限) 删除课程资料(需要管理员或经理权限)
- **course_id**: 课程ID - **course_id**: 课程ID
- **material_id**: 资料ID - **material_id**: 资料ID
@@ -329,11 +330,11 @@ async def get_course_knowledge_points(
async def create_knowledge_point( async def create_knowledge_point(
course_id: int, course_id: int,
point_in: KnowledgePointCreate, point_in: KnowledgePointCreate,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
创建知识点(需要管理员权限) 创建知识点(需要管理员或经理权限)
- **course_id**: 课程ID - **course_id**: 课程ID
- **name**: 知识点名称 - **name**: 知识点名称
@@ -356,11 +357,11 @@ async def create_knowledge_point(
async def update_knowledge_point( async def update_knowledge_point(
point_id: int, point_id: int,
point_in: KnowledgePointUpdate, point_in: KnowledgePointUpdate,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
更新知识点(需要管理员权限) 更新知识点(需要管理员或经理权限)
- **point_id**: 知识点ID - **point_id**: 知识点ID
- **point_in**: 更新的知识点数据(所有字段都是可选的) - **point_in**: 更新的知识点数据(所有字段都是可选的)
@@ -375,11 +376,11 @@ async def update_knowledge_point(
@router.delete("/knowledge-points/{point_id}", response_model=ResponseModel[bool]) @router.delete("/knowledge-points/{point_id}", response_model=ResponseModel[bool])
async def delete_knowledge_point( async def delete_knowledge_point(
point_id: int, point_id: int,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
删除知识点(需要管理员权限) 删除知识点(需要管理员或经理权限)
- **point_id**: 知识点ID - **point_id**: 知识点ID
""" """
@@ -459,11 +460,11 @@ async def remove_material_knowledge_point(
) )
async def create_growth_path( async def create_growth_path(
path_in: GrowthPathCreate, path_in: GrowthPathCreate,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
创建成长路径(需要管理员权限) 创建成长路径(需要管理员或经理权限)
- **name**: 路径名称 - **name**: 路径名称
- **description**: 路径描述 - **description**: 路径描述
@@ -649,11 +650,11 @@ async def get_course_positions(
async def assign_course_positions( async def assign_course_positions(
course_id: int, course_id: int,
assignments: List[CoursePositionAssignment], assignments: List[CoursePositionAssignment],
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
批量分配课程到岗位(需要管理员权限) 批量分配课程到岗位(需要管理员或经理权限)
- **course_id**: 课程ID - **course_id**: 课程ID
- **assignments**: 岗位分配列表 - **assignments**: 岗位分配列表
@@ -716,7 +717,7 @@ async def assign_course_positions(
async def remove_course_position( async def remove_course_position(
course_id: int, course_id: int,
position_id: int, position_id: int,
current_user: User = Depends(require_admin), current_user: User = Depends(require_admin_or_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """

View File

@@ -203,25 +203,29 @@ async def send_message(request: SendMessageRequest, user=Depends(get_current_use
}, },
} }
except CozeException as e: except CozeException as coze_err:
logger.error(f"发送消息失败: {e}") logger.error(f"发送消息失败: {coze_err}")
if request.stream: if request.stream:
# 流式响应的错误处理 # 流式响应的错误处理 - 捕获异常信息避免闭包问题
err_code = coze_err.code
err_message = coze_err.message
err_details = coze_err.details
async def error_generator(): async def error_generator():
yield { yield {
"event": "error", "event": "error",
"data": { "data": {
"code": e.code, "code": err_code,
"message": e.message, "message": err_message,
"details": e.details, "details": err_details,
}, },
} }
return EventSourceResponse(error_generator()) return EventSourceResponse(error_generator())
else: else:
raise HTTPException( raise HTTPException(
status_code=e.status_code or 500, status_code=coze_err.status_code or 500,
detail={"code": e.code, "message": e.message, "details": e.details}, detail={"code": err_code, "message": err_message, "details": err_details},
) )
except Exception as e: except Exception as e:
logger.error(f"未知错误: {e}", exc_info=True) logger.error(f"未知错误: {e}", exc_info=True)

View File

@@ -1,234 +1,234 @@
""" """
成长路径 API 端点 成长路径 API 端点
""" """
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user from app.core.deps import get_db, get_current_user, require_admin_or_manager
from app.models.user import User from app.models.user import User
from app.services.growth_path_service import growth_path_service from app.services.growth_path_service import growth_path_service
from app.schemas.growth_path import ( from app.schemas.growth_path import (
GrowthPathCreate, GrowthPathCreate,
GrowthPathUpdate, GrowthPathUpdate,
GrowthPathResponse, GrowthPathResponse,
GrowthPathListResponse, GrowthPathListResponse,
TraineeGrowthPathResponse, TraineeGrowthPathResponse,
UserGrowthPathProgressResponse, UserGrowthPathProgressResponse,
StartGrowthPathRequest, StartGrowthPathRequest,
CompleteNodeRequest, CompleteNodeRequest,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ===================================================== # =====================================================
# 学员端 API # 学员端 API
# ===================================================== # =====================================================
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse]) @router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
async def get_trainee_growth_path( async def get_trainee_growth_path(
position_id: Optional[int] = Query(None, description="岗位ID不传则自动匹配"), position_id: Optional[int] = Query(None, description="岗位ID不传则自动匹配"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
获取学员的成长路径(含进度) 获取学员的成长路径(含进度)
返回数据包含: 返回数据包含:
- 成长路径基本信息 - 成长路径基本信息
- 各阶段及节点信息 - 各阶段及节点信息
- 每个节点的学习状态locked/unlocked/in_progress/completed - 每个节点的学习状态locked/unlocked/in_progress/completed
- 每个节点的课程学习进度 - 每个节点的课程学习进度
""" """
try: try:
result = await growth_path_service.get_trainee_growth_path( result = await growth_path_service.get_trainee_growth_path(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
position_id=position_id position_id=position_id
) )
return result return result
except Exception as e: except Exception as e:
logger.error(f"获取成长路径失败: {e}") logger.error(f"获取成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/trainee/growth-path/start") @router.post("/trainee/growth-path/start")
async def start_growth_path( async def start_growth_path(
request: StartGrowthPathRequest, request: StartGrowthPathRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
开始学习成长路径 开始学习成长路径
""" """
try: try:
progress = await growth_path_service.start_growth_path( progress = await growth_path_service.start_growth_path(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
growth_path_id=request.growth_path_id growth_path_id=request.growth_path_id
) )
return { return {
"success": True, "success": True,
"message": "已开始学习成长路径", "message": "已开始学习成长路径",
"progress_id": progress.id, "progress_id": progress.id,
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"开始成长路径失败: {e}") logger.error(f"开始成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/trainee/growth-path/node/complete") @router.post("/trainee/growth-path/node/complete")
async def complete_growth_path_node( async def complete_growth_path_node(
request: CompleteNodeRequest, request: CompleteNodeRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
完成成长路径节点 完成成长路径节点
""" """
try: try:
result = await growth_path_service.complete_node( result = await growth_path_service.complete_node(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
node_id=request.node_id node_id=request.node_id
) )
return result return result
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"完成节点失败: {e}") logger.error(f"完成节点失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ===================================================== # =====================================================
# 管理端 API # 管理端 API
# ===================================================== # =====================================================
@router.get("/manager/growth-paths") @router.get("/manager/growth-paths")
async def list_growth_paths( async def list_growth_paths(
position_id: Optional[int] = Query(None, description="岗位ID筛选"), position_id: Optional[int] = Query(None, description="岗位ID筛选"),
is_active: Optional[bool] = Query(None, description="是否启用"), is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"), page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin_or_manager),
): ):
""" """
获取成长路径列表(管理端) 获取成长路径列表(管理端)
""" """
try: try:
result = await growth_path_service.list_growth_paths( result = await growth_path_service.list_growth_paths(
db=db, db=db,
position_id=position_id, position_id=position_id,
is_active=is_active, is_active=is_active,
page=page, page=page,
page_size=page_size page_size=page_size
) )
return result return result
except Exception as e: except Exception as e:
logger.error(f"获取成长路径列表失败: {e}") logger.error(f"获取成长路径列表失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/manager/growth-paths") @router.post("/manager/growth-paths")
async def create_growth_path( async def create_growth_path(
data: GrowthPathCreate, data: GrowthPathCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin_or_manager),
): ):
""" """
创建成长路径(管理端) 创建成长路径(管理端)
""" """
try: try:
growth_path = await growth_path_service.create_growth_path( growth_path = await growth_path_service.create_growth_path(
db=db, db=db,
data=data, data=data,
created_by=current_user.id created_by=current_user.id
) )
return { return {
"success": True, "success": True,
"message": "创建成功", "message": "创建成功",
"id": growth_path.id, "id": growth_path.id,
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"创建成长路径失败: {e}") logger.error(f"创建成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/manager/growth-paths/{path_id}") @router.get("/manager/growth-paths/{path_id}")
async def get_growth_path( async def get_growth_path(
path_id: int, path_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin_or_manager),
): ):
""" """
获取成长路径详情(管理端) 获取成长路径详情(管理端)
""" """
try: try:
result = await growth_path_service.get_growth_path(db=db, path_id=path_id) result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
if not result: if not result:
raise HTTPException(status_code=404, detail="成长路径不存在") raise HTTPException(status_code=404, detail="成长路径不存在")
return result return result
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"获取成长路径详情失败: {e}") logger.error(f"获取成长路径详情失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/manager/growth-paths/{path_id}") @router.put("/manager/growth-paths/{path_id}")
async def update_growth_path( async def update_growth_path(
path_id: int, path_id: int,
data: GrowthPathUpdate, data: GrowthPathUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin_or_manager),
): ):
""" """
更新成长路径(管理端) 更新成长路径(管理端)
""" """
try: try:
await growth_path_service.update_growth_path( await growth_path_service.update_growth_path(
db=db, db=db,
path_id=path_id, path_id=path_id,
data=data data=data
) )
return { return {
"success": True, "success": True,
"message": "更新成功", "message": "更新成功",
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"更新成长路径失败: {e}") logger.error(f"更新成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/manager/growth-paths/{path_id}") @router.delete("/manager/growth-paths/{path_id}")
async def delete_growth_path( async def delete_growth_path(
path_id: int, path_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin_or_manager),
): ):
""" """
删除成长路径(管理端) 删除成长路径(管理端)
""" """
try: try:
await growth_path_service.delete_growth_path(db=db, path_id=path_id) await growth_path_service.delete_growth_path(db=db, path_id=path_id)
return { return {
"success": True, "success": True,
"message": "删除成功", "message": "删除成功",
} }
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"删除成长路径失败: {e}") logger.error(f"删除成长路径失败: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -16,6 +16,7 @@ from app.models.exam_mistake import ExamMistake
from app.models.position_member import PositionMember from app.models.position_member import PositionMember
from app.models.position_course import PositionCourse from app.models.position_course import PositionCourse
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
from app.services.exam_service import ExamService
from app.schemas.exam import ( from app.schemas.exam import (
StartExamRequest, StartExamRequest,
StartExamResponse, StartExamResponse,
@@ -61,9 +62,13 @@ async def start_exam(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""开始考试""" """开始考试"""
exam = await ExamService.start_exam( # 先提取用户信息,避免后续懒加载问题
user_id = current_user.id
username = current_user.username
exam_id = await ExamService.start_exam(
db=db, db=db,
user_id=current_user.id, user_id=user_id,
course_id=request.course_id, course_id=request.course_id,
question_count=request.count, question_count=request.count,
) )
@@ -81,9 +86,9 @@ async def start_exam(
SystemLogCreate( SystemLogCreate(
level="INFO", level="INFO",
type="api", type="api",
message=f"用户 {current_user.username} 开始考试课程ID: {request.course_id}", message=f"用户 {username} 开始考试课程ID: {request.course_id}",
user_id=current_user.id, user_id=user_id,
user=current_user.username, user=username,
ip=http_request.client.host if http_request.client else None, ip=http_request.client.host if http_request.client else None,
path="/api/v1/exams/start", path="/api/v1/exams/start",
method="POST", method="POST",
@@ -91,7 +96,7 @@ async def start_exam(
) )
) )
return ResponseModel(code=200, data=StartExamResponse(exam_id=exam.id), message="考试开始") return ResponseModel(code=200, data=StartExamResponse(exam_id=exam_id), message="考试开始")
@router.post("/submit", response_model=ResponseModel[SubmitExamResponse]) @router.post("/submit", response_model=ResponseModel[SubmitExamResponse])
@@ -102,8 +107,12 @@ async def submit_exam(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""提交考试答案""" """提交考试答案"""
# 先提取用户信息,避免后续懒加载问题
user_id = current_user.id
username = current_user.username
result = await ExamService.submit_exam( result = await ExamService.submit_exam(
db=db, user_id=current_user.id, exam_id=request.exam_id, answers=request.answers db=db, user_id=user_id, exam_id=request.exam_id, answers=request.answers
) )
# 获取考试记录以获取course_id # 获取考试记录以获取course_id
@@ -125,9 +134,9 @@ async def submit_exam(
SystemLogCreate( SystemLogCreate(
level="INFO", level="INFO",
type="api", type="api",
message=f"用户 {current_user.username} 提交考试考试ID: {request.exam_id},得分: {result.get('score', 0)}", message=f"用户 {username} 提交考试考试ID: {request.exam_id},得分: {result.get('score', 0)}",
user_id=current_user.id, user_id=user_id,
user=current_user.username, user=username,
ip=http_request.client.host if http_request.client else None, ip=http_request.client.host if http_request.client else None,
path="/api/v1/exams/submit", path="/api/v1/exams/submit",
method="POST", method="POST",
@@ -251,20 +260,6 @@ async def get_mistakes(
) )
@router.get("/{exam_id}", response_model=ResponseModel[ExamDetailResponse])
async def get_exam_detail(
exam_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取考试详情"""
exam_data = await ExamService.get_exam_detail(
db=db, user_id=current_user.id, exam_id=exam_id
)
return ResponseModel(code=200, data=ExamDetailResponse(**exam_data), message="获取成功")
@router.get("/records", response_model=ResponseModel[dict]) @router.get("/records", response_model=ResponseModel[dict])
async def get_exam_records( async def get_exam_records(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -295,6 +290,21 @@ async def get_exam_statistics(
return ResponseModel(code=200, data=stats, message="获取成功") return ResponseModel(code=200, data=stats, message="获取成功")
# 注意:动态路由 /{exam_id} 必须放在固定路由之后
@router.get("/{exam_id}", response_model=ResponseModel[ExamDetailResponse])
async def get_exam_detail(
exam_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取考试详情"""
exam_data = await ExamService.get_exam_detail(
db=db, user_id=current_user.id, exam_id=exam_id
)
return ResponseModel(code=200, data=ExamDetailResponse(**exam_data), message="获取成功")
# ==================== 试题生成接口 ==================== # ==================== 试题生成接口 ====================
@router.post("/generate", response_model=ResponseModel[GenerateExamResponse]) @router.post("/generate", response_model=ResponseModel[GenerateExamResponse])

View File

@@ -724,8 +724,15 @@ async def end_practice_session(
new_badges = await badge_service.check_and_award_badges(current_user.id) new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit() await db.commit()
# 第二次commit后需要refresh避免DetachedInstanceError
await db.refresh(session)
except Exception as e: except Exception as e:
logger.warning(f"练习经验值/奖章处理失败: {str(e)}") logger.warning(f"练习经验值/奖章处理失败: {str(e)}")
# 确保 session 仍然可用
try:
await db.refresh(session)
except Exception:
pass
return ResponseModel( return ResponseModel(
code=200, code=200,

View File

@@ -1,6 +1,8 @@
""" """
文件预览API 文件预览API
提供课程资料的在线预览功能 提供课程资料的在线预览功能
支持MinIO和本地文件系统两种存储后端
""" """
import logging import logging
from pathlib import Path from pathlib import Path
@@ -15,6 +17,7 @@ from app.core.config import settings
from app.models.user import User from app.models.user import User
from app.models.course import CourseMaterial from app.models.course import CourseMaterial
from app.services.document_converter import document_converter from app.services.document_converter import document_converter
from app.services.storage_service import storage_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -81,10 +84,12 @@ def get_preview_type(file_ext: str) -> str:
return PreviewType.DOWNLOAD return PreviewType.DOWNLOAD
def get_file_path_from_url(file_url: str) -> Optional[Path]: async def get_file_path_from_url(file_url: str) -> Optional[Path]:
""" """
从文件URL获取本地文件路径 从文件URL获取本地文件路径
支持MinIO和本地文件系统。如果文件在MinIO中会先下载到本地缓存。
Args: Args:
file_url: 文件URL如 /static/uploads/courses/1/xxx.pdf file_url: 文件URL如 /static/uploads/courses/1/xxx.pdf
@@ -94,11 +99,12 @@ def get_file_path_from_url(file_url: str) -> Optional[Path]:
try: try:
# 移除 /static/uploads/ 前缀 # 移除 /static/uploads/ 前缀
if file_url.startswith('/static/uploads/'): if file_url.startswith('/static/uploads/'):
relative_path = file_url.replace('/static/uploads/', '') object_name = file_url.replace('/static/uploads/', '')
full_path = Path(settings.UPLOAD_PATH) / relative_path # 使用storage_service获取文件路径自动处理MinIO下载
return full_path return await storage_service.get_file_path(object_name)
return None return None
except Exception: except Exception as e:
logger.error(f"获取文件路径失败: {e}")
return None return None
@@ -158,7 +164,7 @@ async def get_material_preview(
# 根据预览类型处理 # 根据预览类型处理
if preview_type == PreviewType.TEXT: if preview_type == PreviewType.TEXT:
# 文本类型,读取文件内容 # 文本类型,读取文件内容
file_path = get_file_path_from_url(material.file_url) file_path = await get_file_path_from_url(material.file_url)
if file_path and file_path.exists(): if file_path and file_path.exists():
try: try:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
@@ -176,7 +182,7 @@ async def get_material_preview(
elif preview_type == PreviewType.EXCEL_HTML: elif preview_type == PreviewType.EXCEL_HTML:
# Excel文件转换为HTML预览 # Excel文件转换为HTML预览
file_path = get_file_path_from_url(material.file_url) file_path = await get_file_path_from_url(material.file_url)
if file_path and file_path.exists(): if file_path and file_path.exists():
converted_url = document_converter.convert_excel_to_html( converted_url = document_converter.convert_excel_to_html(
str(file_path), str(file_path),
@@ -200,7 +206,7 @@ async def get_material_preview(
elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext): elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext):
# Office文档需要转换为PDF # Office文档需要转换为PDF
file_path = get_file_path_from_url(material.file_url) file_path = await get_file_path_from_url(material.file_url)
if file_path and file_path.exists(): if file_path and file_path.exists():
# 执行转换 # 执行转换
converted_url = document_converter.convert_to_pdf( converted_url = document_converter.convert_to_pdf(

View File

@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.engine.result import Result from sqlalchemy.engine.result import Result
import structlog import structlog
from app.core.deps import get_current_user, get_db from app.core.deps import get_current_user, get_db, require_admin
try: try:
from app.core.simple_auth import get_current_user_simple from app.core.simple_auth import get_current_user_simple
except ImportError: except ImportError:
@@ -57,7 +57,7 @@ def serialize_row(row: Any) -> Union[Dict[str, Any], Any]:
@router.post("/execute", response_model=ResponseModel) @router.post("/execute", response_model=ResponseModel)
async def execute_sql( async def execute_sql(
request: Dict[str, Any], request: Dict[str, Any],
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
) -> ResponseModel: ) -> ResponseModel:
""" """
@@ -74,7 +74,7 @@ async def execute_sql(
- 写入操作:返回影响的行数 - 写入操作:返回影响的行数
安全说明: 安全说明:
- 需要用户身份验证 - 需要管理员权限
- 所有操作都会记录日志 - 所有操作都会记录日志
- 建议在生产环境中限制可执行的 SQL 类型 - 建议在生产环境中限制可执行的 SQL 类型
""" """
@@ -196,11 +196,13 @@ async def execute_sql(
@router.post("/validate", response_model=ResponseModel) @router.post("/validate", response_model=ResponseModel)
async def validate_sql( async def validate_sql(
request: Dict[str, Any], request: Dict[str, Any],
current_user: User = Depends(get_current_user) current_user: User = Depends(require_admin)
) -> ResponseModel: ) -> ResponseModel:
""" """
验证 SQL 语句的语法(不执行) 验证 SQL 语句的语法(不执行)
权限:需要管理员权限
Args: Args:
request: 包含 sql 字段的请求 request: 包含 sql 字段的请求
@@ -253,12 +255,14 @@ async def validate_sql(
@router.get("/tables", response_model=ResponseModel) @router.get("/tables", response_model=ResponseModel)
async def get_tables( async def get_tables(
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
) -> ResponseModel: ) -> ResponseModel:
""" """
获取数据库中的所有表 获取数据库中的所有表
权限:需要管理员权限
Returns: Returns:
数据库表列表 数据库表列表
""" """
@@ -290,12 +294,14 @@ async def get_tables(
@router.get("/table/{table_name}/schema", response_model=ResponseModel) @router.get("/table/{table_name}/schema", response_model=ResponseModel)
async def get_table_schema( async def get_table_schema(
table_name: str, table_name: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
) -> ResponseModel: ) -> ResponseModel:
""" """
获取指定表的结构信息 获取指定表的结构信息
权限:需要管理员权限
Args: Args:
table_name: 表名 table_name: 表名

View File

@@ -40,6 +40,11 @@ class DingtalkConfigResponse(BaseModel):
enabled: bool = False enabled: bool = False
class EmployeeSyncConfigUpdate(BaseModel):
"""员工同步配置更新请求(复用钉钉免密登录配置)"""
enabled: Optional[bool] = Field(None, description="是否启用自动同步")
# ============================================ # ============================================
# 辅助函数 # 辅助函数
# ============================================ # ============================================
@@ -163,22 +168,41 @@ async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str
) )
default_row = result.fetchone() default_row = result.fetchone()
# 定义功能开关默认值映射
FEATURE_DEFAULTS = {
'employee_sync': ('员工同步', 'system', '从钉钉同步员工信息'),
'dingtalk_login': ('钉钉免密登录', 'system', '允许通过钉钉免密登录'),
'ai_practice': ('AI 对练', 'feature', 'AI 对练功能'),
'dual_practice': ('双人对练', 'feature', '双人对练功能'),
}
if default_row: if default_row:
# 插入租户级配置 feature_name = default_row[0]
await db.execute( feature_group = default_row[1]
text(""" description = default_row[2]
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) elif feature_code in FEATURE_DEFAULTS:
VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description) feature_name, feature_group, description = FEATURE_DEFAULTS[feature_code]
"""), else:
{ # 未知功能码,使用默认值
"tenant_id": tenant_id, feature_name = feature_code
"feature_code": feature_code, feature_group = 'system'
"feature_name": default_row[0], description = f'{feature_code} 功能开关'
"feature_group": default_row[1],
"is_enabled": 1 if is_enabled else 0, # 插入租户级配置
"description": default_row[2] await db.execute(
} text("""
) INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description)
"""),
{
"tenant_id": tenant_id,
"feature_code": feature_code,
"feature_name": feature_name,
"feature_group": feature_group,
"is_enabled": 1 if is_enabled else 0,
"description": description
}
)
# ============================================ # ============================================
@@ -277,6 +301,133 @@ async def update_dingtalk_config(
) )
@router.get("/employee-sync", response_model=ResponseModel)
async def get_employee_sync_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取员工同步配置(复用钉钉免密登录配置)
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 从 dingtalk 配置组读取(与免密登录共用)
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
enabled = await get_feature_switch(db, tenant_id, 'employee_sync')
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
# 检查钉钉配置是否完整
configured = bool(corp_id and app_key and app_secret)
return ResponseModel(
message="获取成功",
data={
"enabled": enabled,
"configured": configured,
"dingtalk_enabled": dingtalk_enabled, # 免密登录是否启用
}
)
@router.put("/employee-sync", response_model=ResponseModel)
async def update_employee_sync_config(
config: EmployeeSyncConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
更新员工同步配置仅开关API 凭证复用钉钉免密登录)
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
try:
if config.enabled is not None:
await set_feature_switch(db, tenant_id, 'employee_sync', config.enabled)
await db.commit()
logger.info(
"员工同步配置已更新",
user_id=current_user.id,
username=current_user.username,
)
return ResponseModel(message="配置已保存")
except Exception as e:
await db.rollback()
logger.error(f"更新员工同步配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="保存配置失败"
)
@router.post("/employee-sync/test", response_model=ResponseModel)
async def test_employee_sync_connection(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
测试钉钉 API 连接(复用免密登录配置)
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 从 dingtalk 配置组读取(与免密登录共用)
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
client_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
client_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
if not all([corp_id, client_id, client_secret]):
return ResponseModel(
code=400,
message="请先在「钉钉免密登录」页签配置 CorpId、AppKey、AppSecret"
)
try:
from app.services.dingtalk_service import DingTalkService
dingtalk = DingTalkService(
corp_id=corp_id,
client_id=client_id,
client_secret=client_secret
)
result = await dingtalk.test_connection()
if result["success"]:
return ResponseModel(
message=f"连接成功!已获取到组织架构",
data=result
)
else:
return ResponseModel(
code=500,
message=result["message"]
)
except Exception as e:
logger.error(f"测试连接失败: {str(e)}")
return ResponseModel(
code=500,
message=f"连接失败: {str(e)}"
)
@router.get("/all", response_model=ResponseModel) @router.get("/all", response_model=ResponseModel)
async def get_all_settings( async def get_all_settings(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
@@ -295,12 +446,20 @@ async def get_all_settings(
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
# 员工同步配置状态
employee_sync_enabled = await get_feature_switch(db, tenant_id, 'employee_sync')
employee_sync_host = await get_system_config(db, tenant_id, 'employee_sync', 'DB_HOST')
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data={ data={
"dingtalk": { "dingtalk": {
"enabled": dingtalk_enabled, "enabled": dingtalk_enabled,
"configured": bool(dingtalk_corp_id), # 是否已配置 "configured": bool(dingtalk_corp_id),
},
"employee_sync": {
"enabled": employee_sync_enabled,
"configured": bool(employee_sync_host),
} }
} }
) )

View File

@@ -43,13 +43,20 @@ async def create_task(
# 构建响应 # 构建响应
courses = [link.course.name for link in task.course_links] courses = [link.course.name for link in task.course_links]
# 安全获取枚举值(兼容字符串和枚举类型)
priority_val = task.priority.value if hasattr(task.priority, 'value') else task.priority
status_val = task.status.value if hasattr(task.status, 'value') else task.status
completed_count = sum(
1 for a in task.assignments
if (a.status.value if hasattr(a.status, 'value') else a.status) == "completed"
)
return ResponseModel( return ResponseModel(
data=TaskResponse( data=TaskResponse(
id=task.id, id=task.id,
title=task.title, title=task.title,
description=task.description, description=task.description,
priority=task.priority.value, priority=priority_val,
status=task.status.value, status=status_val,
creator_id=task.creator_id, creator_id=task.creator_id,
deadline=task.deadline, deadline=task.deadline,
requirements=task.requirements, requirements=task.requirements,
@@ -58,7 +65,7 @@ async def create_task(
updated_at=task.updated_at, updated_at=task.updated_at,
courses=courses, courses=courses,
assigned_count=len(task.assignments), assigned_count=len(task.assignments),
completed_count=sum(1 for a in task.assignments if a.status.value == "completed") completed_count=completed_count
) )
) )
@@ -67,7 +74,7 @@ async def create_task(
async def get_tasks( async def get_tasks(
status: Optional[str] = Query(None, description="任务状态筛选"), status: Optional[str] = Query(None, description="任务状态筛选"),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin_or_manager) current_user: User = Depends(require_admin_or_manager)
): ):
@@ -77,26 +84,33 @@ async def get_tasks(
# 构建响应 # 构建响应
items = [] items = []
for task in tasks: for task in tasks:
# 加载关联数据 # 安全获取枚举值
task_detail = await task_service.get_task_detail(db, task.id) priority_val = task.priority.value if hasattr(task.priority, 'value') else task.priority
if task_detail: status_val = task.status.value if hasattr(task.status, 'value') else task.status
courses = [link.course.name for link in task_detail.course_links]
items.append(TaskResponse( # 使用已加载的关联数据(通过 selectinload
id=task.id, courses = [link.course.name for link in task.course_links] if task.course_links else []
title=task.title, completed_count = sum(
description=task.description, 1 for a in task.assignments
priority=task.priority.value, if (a.status.value if hasattr(a.status, 'value') else a.status) == "completed"
status=task.status.value, ) if task.assignments else 0
creator_id=task.creator_id,
deadline=task.deadline, items.append(TaskResponse(
requirements=task.requirements, id=task.id,
progress=task.progress, title=task.title,
created_at=task.created_at, description=task.description,
updated_at=task.updated_at, priority=priority_val,
courses=courses, status=status_val,
assigned_count=len(task_detail.assignments), creator_id=task.creator_id,
completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") deadline=task.deadline,
)) requirements=task.requirements,
progress=task.progress,
created_at=task.created_at,
updated_at=task.updated_at,
courses=courses,
assigned_count=len(task.assignments) if task.assignments else 0,
completed_count=completed_count
))
return ResponseModel( return ResponseModel(
data=PaginatedResponse.create( data=PaginatedResponse.create(
@@ -226,3 +240,44 @@ async def delete_task(
return ResponseModel(message="任务已删除") return ResponseModel(message="任务已删除")
@router.post("/{task_id}/remind", response_model=ResponseModel, summary="发送任务提醒")
async def send_task_reminder(
task_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin_or_manager)
):
"""向未完成任务的成员发送提醒"""
task = await task_service.get_task_detail(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 获取未完成的成员数量
incomplete_count = sum(1 for a in task.assignments if a.status.value != "completed")
if incomplete_count == 0:
return ResponseModel(message="所有成员已完成任务,无需发送提醒")
# 记录提醒日志
await system_log_service.create_log(
db,
SystemLogCreate(
level="INFO",
type="notification",
message=f"发送任务提醒: {task.title},提醒 {incomplete_count}",
user_id=current_user.id,
user=current_user.username,
ip=request.client.host if request.client else None,
path=f"/api/v1/manager/tasks/{task_id}/remind",
method="POST",
user_agent=request.headers.get("user-agent")
)
)
# TODO: 实际发送通知逻辑(通过通知服务)
# 可以调用 notification_service.send_task_reminder(task, incomplete_assignments)
return ResponseModel(message=f"已向 {incomplete_count} 位未完成成员发送提醒")

View File

@@ -28,10 +28,17 @@ async def get_accessible_team_member_ids(
current_user: User, current_user: User,
db: AsyncSession db: AsyncSession
) -> List[int]: ) -> List[int]:
"""获取用户可访问的团队成员ID列表""" """获取用户可访问的团队成员ID列表(只返回未删除的用户)"""
if current_user.role in ['admin', 'manager']: if current_user.role in ['admin', 'manager']:
# 管理员查看所有团队成员 # 管理员查看所有团队成员(过滤已删除用户)
stmt = select(UserTeam.user_id).distinct() stmt = select(UserTeam.user_id).join(
User, UserTeam.user_id == User.id
).where(
and_(
User.is_deleted == False, # noqa: E712
User.is_active == True # noqa: E712
)
).distinct()
result = await db.execute(stmt) result = await db.execute(stmt)
return [row[0] for row in result.all()] return [row[0] for row in result.all()]
else: else:
@@ -44,9 +51,15 @@ async def get_accessible_team_member_ids(
if not team_ids: if not team_ids:
return [] return []
# 2. 查询这些团队的所有成员 # 2. 查询这些团队的所有成员(过滤已删除用户)
stmt = select(UserTeam.user_id).where( stmt = select(UserTeam.user_id).join(
UserTeam.team_id.in_(team_ids) User, UserTeam.user_id == User.id
).where(
and_(
UserTeam.team_id.in_(team_ids),
User.is_deleted == False, # noqa: E712
User.is_active == True # noqa: E712
)
).distinct() ).distinct()
result = await db.execute(stmt) result = await db.execute(stmt)
return [row[0] for row in result.all()] return [row[0] for row in result.all()]

View File

@@ -1,5 +1,9 @@
""" """
文件上传API接口 文件上传API接口
支持两种存储后端:
1. MinIO对象存储生产环境推荐
2. 本地文件系统(开发环境或降级方案)
""" """
import os import os
import shutil import shutil
@@ -17,16 +21,17 @@ from app.models.user import User
from app.models.course import Course from app.models.course import Course
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
from app.core.logger import get_logger from app.core.logger import get_logger
from app.services.storage_service import storage_service
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(prefix="/upload") router = APIRouter(prefix="/upload")
# 支持的文件类型和大小限制 # 支持的文件类型和大小限制
# 支持格式TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties # 支持格式TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties
ALLOWED_EXTENSIONS = { ALLOWED_EXTENSIONS = {
'txt', 'md', 'mdx', 'pdf', 'html', 'htm', 'txt', 'md', 'mdx', 'pdf', 'html', 'htm',
'xlsx', 'xls', 'docx', 'doc', 'csv', 'vtt', 'properties' 'xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'csv', 'vtt', 'properties'
} }
MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB
@@ -93,16 +98,13 @@ async def upload_file(
# 生成唯一文件名 # 生成唯一文件名
unique_filename = generate_unique_filename(file.filename) unique_filename = generate_unique_filename(file.filename)
# 获取上传路径 # 使用storage_service上传文件
upload_path = get_upload_path(file_type) object_name = f"{file_type}/{unique_filename}"
file_path = upload_path / unique_filename file_url = await storage_service.upload(
contents,
# 保存文件 object_name,
with open(file_path, "wb") as f: content_type=file.content_type
f.write(contents) )
# 生成文件访问URL
file_url = f"/static/uploads/{file_type}/{unique_filename}"
logger.info( logger.info(
"文件上传成功", "文件上传成功",
@@ -111,6 +113,7 @@ async def upload_file(
saved_filename=unique_filename, saved_filename=unique_filename,
file_size=file_size, file_size=file_size,
file_type=file_type, file_type=file_type,
storage="minio" if storage_service.is_minio_enabled else "local",
) )
return ResponseModel( return ResponseModel(
@@ -184,17 +187,13 @@ async def upload_course_material(
# 生成唯一文件名 # 生成唯一文件名
unique_filename = generate_unique_filename(file.filename) unique_filename = generate_unique_filename(file.filename)
# 创建课程专属目录 # 使用storage_service上传文件
course_upload_path = Path(settings.UPLOAD_PATH) / "courses" / str(course_id) object_name = f"courses/{course_id}/{unique_filename}"
course_upload_path.mkdir(parents=True, exist_ok=True) file_url = await storage_service.upload(
contents,
# 保存文件 object_name,
file_path = course_upload_path / unique_filename content_type=file.content_type
with open(file_path, "wb") as f: )
f.write(contents)
# 生成文件访问URL
file_url = f"/static/uploads/courses/{course_id}/{unique_filename}"
logger.info( logger.info(
"课程资料上传成功", "课程资料上传成功",
@@ -203,6 +202,7 @@ async def upload_course_material(
original_filename=file.filename, original_filename=file.filename,
saved_filename=unique_filename, saved_filename=unique_filename,
file_size=file_size, file_size=file_size,
storage="minio" if storage_service.is_minio_enabled else "local",
) )
return ResponseModel( return ResponseModel(
@@ -243,24 +243,24 @@ async def delete_file(
detail="无效的文件URL" detail="无效的文件URL"
) )
# 转换为实际文件路径 # 从URL中提取对象名称
relative_path = file_url.replace("/static/uploads/", "") object_name = file_url.replace("/static/uploads/", "")
file_path = Path(settings.UPLOAD_PATH) / relative_path
# 检查文件是否存在 # 检查文件是否存在
if not file_path.exists(): if not await storage_service.exists(object_name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在" detail="文件不存在"
) )
# 删除文件 # 使用storage_service删除文件
os.remove(file_path) await storage_service.delete(object_name)
logger.info( logger.info(
"文件删除成功", "文件删除成功",
user_id=current_user.id, user_id=current_user.id,
file_url=file_url, file_url=file_url,
storage="minio" if storage_service.is_minio_enabled else "local",
) )
return ResponseModel(data=True, message="文件删除成功") return ResponseModel(data=True, message="文件删除成功")

View File

@@ -13,7 +13,7 @@ from app.core.logger import logger
from app.models.user import User from app.models.user import User
from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel
from app.schemas.user import User as UserSchema from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate, UserSelfUpdate
from app.services.user_service import UserService from app.services.user_service import UserService
from app.services.system_log_service import system_log_service from app.services.system_log_service import system_log_service
from app.schemas.system_log import SystemLogCreate from app.schemas.system_log import SystemLogCreate
@@ -47,20 +47,23 @@ async def get_current_user_statistics(
获取当前用户学习统计 获取当前用户学习统计
返回字段: 返回字段:
- learningDays: 学习天数(按陪练会话开始日期去重 - learningDays: 学习天数(从注册日期到今天的天数至少为1
- totalHours: 学习总时长小时取整到1位小数 - totalHours: 学习总时长小时取整到1位小数
- practiceQuestions: 练习题数(答题记录条数汇总) - practiceQuestions: 练习题数(答题记录条数汇总)
- averageScore: 平均成绩已提交考试的平均分保留1位小数 - averageScore: 平均成绩已提交考试的平均分保留1位小数
- examsCompleted: 已完成考试数量 - examsCompleted: 已完成考试数量
""" """
try: try:
from datetime import date
user_id = current_user.id user_id = current_user.id
# 学习天数:按会话开始日期去重 # 学习天数:从注册日期到今天的天数至少为1天
learning_days_stmt = select(func.count(func.distinct(func.date(TrainingSession.start_time)))).where( if current_user.created_at:
TrainingSession.user_id == user_id registration_date = current_user.created_at.date() if hasattr(current_user.created_at, 'date') else current_user.created_at
) learning_days = (date.today() - registration_date).days + 1 # +1 是因为注册当天也算第1天
learning_days = (await db.scalar(learning_days_stmt)) or 0 learning_days = max(1, learning_days) # 确保至少为1
else:
learning_days = 1
# 总时长(小时) # 总时长(小时)
total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where( total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where(
@@ -157,7 +160,7 @@ async def get_recent_exams(
@router.put("/me", response_model=ResponseModel) @router.put("/me", response_model=ResponseModel)
async def update_current_user( async def update_current_user(
user_in: UserUpdate, user_in: UserSelfUpdate,
current_user: dict = Depends(get_current_active_user), current_user: dict = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ResponseModel: ) -> ResponseModel:
@@ -165,6 +168,7 @@ async def update_current_user(
更新当前用户信息 更新当前用户信息
权限:需要登录 权限:需要登录
注意:用户只能修改自己的基本信息,不能修改角色(role)和激活状态(is_active)
""" """
user_service = UserService(db) user_service = UserService(db)
user = await user_service.update_user( user = await user_service.update_user(

View File

@@ -106,6 +106,14 @@ class Settings(BaseSettings):
"""获取上传文件的完整路径""" """获取上传文件的完整路径"""
import os import os
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), self.UPLOAD_DIR) return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), self.UPLOAD_DIR)
# MinIO对象存储配置
MINIO_ENABLED: bool = Field(default=True, description="是否启用MinIO存储")
MINIO_ENDPOINT: str = Field(default="kaopeilian-minio:9000", description="MinIO服务地址")
MINIO_ACCESS_KEY: str = Field(default="kaopeilian_admin", description="MinIO访问密钥")
MINIO_SECRET_KEY: str = Field(default="KplMinio2026!@#", description="MinIO秘密密钥")
MINIO_SECURE: bool = Field(default=False, description="是否使用HTTPS")
MINIO_PUBLIC_URL: str = Field(default="", description="MinIO公开访问URL留空则使用Nginx代理")
# Coze 平台配置(陪练对话、播课等) # Coze 平台配置(陪练对话、播课等)
COZE_API_BASE: Optional[str] = Field(default="https://api.coze.cn") COZE_API_BASE: Optional[str] = Field(default="https://api.coze.cn")

View File

@@ -3,14 +3,129 @@
""" """
import time import time
import uuid import uuid
from typing import Callable from typing import Callable, Dict
from collections import defaultdict
from datetime import datetime, timedelta
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from app.core.logger import logger from app.core.logger import logger
class RateLimitMiddleware(BaseHTTPMiddleware):
"""
API 限流中间件
基于IP地址进行限流防止恶意请求攻击
"""
def __init__(self, app, requests_per_minute: int = 60, burst_limit: int = 100):
super().__init__(app)
self.requests_per_minute = requests_per_minute
self.burst_limit = burst_limit # 突发请求限制
self.request_counts: Dict[str, list] = defaultdict(list)
def _get_client_ip(self, request: Request) -> str:
"""获取客户端真实IP"""
# 优先从代理头获取
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
return request.client.host if request.client else "unknown"
def _clean_old_requests(self, ip: str, window_start: datetime):
"""清理窗口外的请求记录"""
self.request_counts[ip] = [
t for t in self.request_counts[ip]
if t > window_start
]
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# 跳过健康检查和静态文件
if request.url.path in ["/health", "/docs", "/openapi.json", "/redoc"]:
return await call_next(request)
if request.url.path.startswith("/static/"):
return await call_next(request)
client_ip = self._get_client_ip(request)
now = datetime.now()
window_start = now - timedelta(minutes=1)
# 清理过期记录
self._clean_old_requests(client_ip, window_start)
# 检查请求数
request_count = len(self.request_counts[client_ip])
if request_count >= self.burst_limit:
logger.warning(
"请求被限流",
client_ip=client_ip,
request_count=request_count,
path=request.url.path,
)
return JSONResponse(
status_code=429,
content={
"code": 429,
"message": "请求过于频繁,请稍后再试",
"retry_after": 60,
},
headers={"Retry-After": "60"}
)
# 记录本次请求
self.request_counts[client_ip].append(now)
# 如果接近限制,添加警告头
response = await call_next(request)
remaining = self.burst_limit - len(self.request_counts[client_ip])
response.headers["X-RateLimit-Limit"] = str(self.burst_limit)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int((window_start + timedelta(minutes=1)).timestamp()))
return response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
安全响应头中间件
添加各种安全相关的 HTTP 响应头
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
# 防止 MIME 类型嗅探
response.headers["X-Content-Type-Options"] = "nosniff"
# 防止点击劫持
response.headers["X-Frame-Options"] = "DENY"
# XSS 过滤器(现代浏览器已弃用,但仍有一些旧浏览器支持)
response.headers["X-XSS-Protection"] = "1; mode=block"
# 引用策略
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# 权限策略(禁用一些敏感功能)
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
# 缓存控制API 响应不应被缓存)
if request.url.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
class RequestIDMiddleware(BaseHTTPMiddleware): class RequestIDMiddleware(BaseHTTPMiddleware):
"""请求ID中间件""" """请求ID中间件"""

View File

@@ -0,0 +1,136 @@
"""
输入清理和XSS防护工具
"""
import re
import html
from typing import Optional
# 危险的HTML标签和属性
DANGEROUS_TAGS = [
'script', 'iframe', 'object', 'embed', 'form', 'input',
'textarea', 'button', 'select', 'style', 'link', 'meta',
'base', 'applet', 'frame', 'frameset', 'layer', 'ilayer',
'bgsound', 'xml', 'blink', 'marquee'
]
DANGEROUS_ATTRS = [
'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover',
'onmousemove', 'onmouseout', 'onkeypress', 'onkeydown', 'onkeyup',
'onload', 'onerror', 'onabort', 'onblur', 'onchange', 'onfocus',
'onreset', 'onsubmit', 'onunload', 'onbeforeunload', 'onresize',
'onscroll', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
'ondragover', 'ondragstart', 'ondrop', 'onmousewheel', 'onwheel',
'oncopy', 'oncut', 'onpaste', 'oncontextmenu', 'oninput', 'oninvalid',
'onsearch', 'onselect', 'ontoggle', 'formaction', 'xlink:href'
]
def sanitize_html(text: Optional[str]) -> Optional[str]:
"""
清理HTML内容移除危险标签和属性
Args:
text: 输入文本
Returns:
清理后的文本
"""
if text is None:
return None
if not isinstance(text, str):
return text
result = text
# 移除危险标签
for tag in DANGEROUS_TAGS:
# 移除开标签
pattern = re.compile(rf'<{tag}[^>]*>', re.IGNORECASE)
result = pattern.sub('', result)
# 移除闭标签
pattern = re.compile(rf'</{tag}>', re.IGNORECASE)
result = pattern.sub('', result)
# 移除危险属性
for attr in DANGEROUS_ATTRS:
pattern = re.compile(rf'\s*{attr}\s*=\s*["\'][^"\']*["\']', re.IGNORECASE)
result = pattern.sub('', result)
# 也处理没有引号的情况
pattern = re.compile(rf'\s*{attr}\s*=\s*\S+', re.IGNORECASE)
result = pattern.sub('', result)
# 移除 javascript: 协议
pattern = re.compile(r'javascript\s*:', re.IGNORECASE)
result = pattern.sub('', result)
# 移除 data: 协议(可能包含恶意代码)
pattern = re.compile(r'data\s*:\s*text/html', re.IGNORECASE)
result = pattern.sub('', result)
# 移除 vbscript: 协议
pattern = re.compile(r'vbscript\s*:', re.IGNORECASE)
result = pattern.sub('', result)
return result
def escape_html(text: Optional[str]) -> Optional[str]:
"""
转义HTML特殊字符
Args:
text: 输入文本
Returns:
转义后的文本
"""
if text is None:
return None
if not isinstance(text, str):
return text
return html.escape(text, quote=True)
def strip_tags(text: Optional[str]) -> Optional[str]:
"""
完全移除所有HTML标签
Args:
text: 输入文本
Returns:
移除标签后的纯文本
"""
if text is None:
return None
if not isinstance(text, str):
return text
# 移除所有HTML标签
clean = re.compile('<[^>]*>')
return clean.sub('', text)
def sanitize_input(text: Optional[str], strict: bool = False) -> Optional[str]:
"""
清理用户输入
Args:
text: 输入文本
strict: 是否使用严格模式完全移除所有HTML标签
Returns:
清理后的文本
"""
if text is None:
return None
if strict:
return strip_tags(text)
else:
return sanitize_html(text)

View File

@@ -2,10 +2,11 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
import json import json
import os import os
@@ -96,6 +97,17 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# 添加限流中间件
from app.core.middleware import RateLimitMiddleware, SecurityHeadersMiddleware
app.add_middleware(
RateLimitMiddleware,
requests_per_minute=120, # 每分钟最大请求数
burst_limit=200, # 突发请求限制
)
# 添加安全响应头中间件
app.add_middleware(SecurityHeadersMiddleware)
# 健康检查端点 # 健康检查端点
@app.get("/health") @app.get("/health")
@@ -131,10 +143,108 @@ os.makedirs(upload_path, exist_ok=True)
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads") app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
# 请求验证错误处理 (422)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""处理请求验证错误,记录详细日志"""
logger.error(f"请求验证错误 [{request.method} {request.url.path}]: {exc.errors()}")
return JSONResponse(
status_code=422,
content={
"code": 422,
"message": "请求参数验证失败",
"detail": exc.errors(),
},
)
# JSON 解析错误处理
from json import JSONDecodeError
@app.exception_handler(JSONDecodeError)
async def json_decode_exception_handler(request: Request, exc: JSONDecodeError):
"""处理 JSON 解析错误"""
logger.warning(f"JSON解析错误 [{request.method} {request.url.path}]: {exc}")
return JSONResponse(
status_code=400,
content={
"code": 400,
"message": "请求体格式错误,需要有效的 JSON",
"detail": str(exc),
},
)
# HTTP 异常处理
from fastapi import HTTPException
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""处理 HTTP 异常"""
return JSONResponse(
status_code=exc.status_code,
content={
"code": exc.status_code,
"message": exc.detail,
},
)
# 数据库唯一约束冲突处理 (409)
from sqlalchemy.exc import IntegrityError
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError):
"""处理数据库唯一约束冲突错误"""
error_msg = str(exc.orig) if exc.orig else str(exc)
logger.warning(f"数据库冲突 [{request.method} {request.url.path}]: {error_msg}")
# 解析常见的冲突类型,提供友好的错误信息
friendly_message = "数据冲突,该记录可能已存在"
if "Duplicate entry" in error_msg:
# MySQL 唯一约束冲突
if "username" in error_msg.lower():
friendly_message = "用户名已存在,请使用其他用户名"
elif "email" in error_msg.lower():
friendly_message = "邮箱已被注册,请使用其他邮箱"
elif "phone" in error_msg.lower():
friendly_message = "手机号已被注册,请使用其他手机号"
elif "name" in error_msg.lower():
friendly_message = "名称已存在,请使用其他名称"
elif "code" in error_msg.lower():
friendly_message = "编码已存在,请使用其他编码"
else:
friendly_message = "该记录已存在,不能重复创建"
elif "FOREIGN KEY" in error_msg.upper():
friendly_message = "关联的数据不存在或已被删除"
elif "cannot be null" in error_msg.lower():
friendly_message = "必填字段不能为空"
return JSONResponse(
status_code=409,
content={
"code": 409,
"message": friendly_message,
"detail": error_msg if settings.DEBUG else None,
},
)
# 全局异常处理 # 全局异常处理
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def global_exception_handler(request, exc): async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理""" """全局异常处理"""
error_msg = str(exc)
# 检查是否是 Content-Type 相关错误
if "Expecting value" in error_msg or "JSON" in error_msg.upper():
logger.warning(f"请求体解析错误 [{request.method} {request.url.path}]: {error_msg}")
return JSONResponse(
status_code=400,
content={
"code": 400,
"message": "请求体格式错误,请使用 application/json",
},
)
logger.error(f"未处理的异常: {exc}", exc_info=True) logger.error(f"未处理的异常: {exc}", exc_info=True)
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,

View File

@@ -1,118 +1,118 @@
-- 成长路径功能数据库迁移脚本 -- 成长路径功能数据库迁移脚本
-- 创建时间: 2026-01-30 -- 创建时间: 2026-01-30
-- ===================================================== -- =====================================================
-- 1. 修改 growth_paths 表,添加岗位关联 -- 1. 修改 growth_paths 表,添加岗位关联
-- ===================================================== -- =====================================================
ALTER TABLE growth_paths ALTER TABLE growth_paths
ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role, ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses, ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
ADD INDEX idx_position_id (position_id); ADD INDEX idx_position_id (position_id);
-- ===================================================== -- =====================================================
-- 2. 创建成长路径节点表 -- 2. 创建成长路径节点表
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS growth_path_nodes ( CREATE TABLE IF NOT EXISTS growth_path_nodes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
growth_path_id INT NOT NULL COMMENT '成长路径ID', growth_path_id INT NOT NULL COMMENT '成长路径ID',
course_id INT NOT NULL COMMENT '课程ID', course_id INT NOT NULL COMMENT '课程ID',
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称', stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
title VARCHAR(200) NOT NULL COMMENT '节点标题', title VARCHAR(200) NOT NULL COMMENT '节点标题',
description TEXT NULL COMMENT '节点描述', description TEXT NULL COMMENT '节点描述',
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序', order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修', is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]', prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
estimated_days INT DEFAULT 7 COMMENT '预计学习天数', estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
-- 软删除 -- 软删除
is_deleted BOOLEAN DEFAULT FALSE NOT NULL, is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
deleted_at DATETIME NULL, deleted_at DATETIME NULL,
-- 时间戳 -- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
-- 索引 -- 索引
INDEX idx_growth_path_id (growth_path_id), INDEX idx_growth_path_id (growth_path_id),
INDEX idx_course_id (course_id), INDEX idx_course_id (course_id),
INDEX idx_stage_name (stage_name), INDEX idx_stage_name (stage_name),
INDEX idx_order_num (order_num), INDEX idx_order_num (order_num),
INDEX idx_is_deleted (is_deleted), INDEX idx_is_deleted (is_deleted),
-- 外键 -- 外键
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id) CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
REFERENCES growth_paths(id) ON DELETE CASCADE, REFERENCES growth_paths(id) ON DELETE CASCADE,
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id) CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
REFERENCES courses(id) ON DELETE CASCADE REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='成长路径节点表'; COMMENT='成长路径节点表';
-- ===================================================== -- =====================================================
-- 3. 创建用户成长路径进度表 -- 3. 创建用户成长路径进度表
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS user_growth_path_progress ( CREATE TABLE IF NOT EXISTS user_growth_path_progress (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
growth_path_id INT NOT NULL COMMENT '成长路径ID', growth_path_id INT NOT NULL COMMENT '成长路径ID',
current_node_id INT NULL COMMENT '当前学习节点ID', current_node_id INT NULL COMMENT '当前学习节点ID',
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]', completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比', total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed', status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
-- 时间记录 -- 时间记录
started_at DATETIME NULL COMMENT '开始时间', started_at DATETIME NULL COMMENT '开始时间',
completed_at DATETIME NULL COMMENT '完成时间', completed_at DATETIME NULL COMMENT '完成时间',
last_activity_at DATETIME NULL COMMENT '最后活动时间', last_activity_at DATETIME NULL COMMENT '最后活动时间',
-- 时间戳 -- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
-- 索引 -- 索引
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_growth_path_id (growth_path_id), INDEX idx_growth_path_id (growth_path_id),
INDEX idx_status (status), INDEX idx_status (status),
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id), UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
-- 外键 -- 外键
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id) CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE, REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id) CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
REFERENCES growth_paths(id) ON DELETE CASCADE REFERENCES growth_paths(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='用户成长路径进度表'; COMMENT='用户成长路径进度表';
-- ===================================================== -- =====================================================
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况) -- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS user_node_completions ( CREATE TABLE IF NOT EXISTS user_node_completions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
growth_path_id INT NOT NULL COMMENT '成长路径ID', growth_path_id INT NOT NULL COMMENT '成长路径ID',
node_id INT NOT NULL COMMENT '节点ID', node_id INT NOT NULL COMMENT '节点ID',
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度', course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed', status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
-- 时间记录 -- 时间记录
unlocked_at DATETIME NULL COMMENT '解锁时间', unlocked_at DATETIME NULL COMMENT '解锁时间',
started_at DATETIME NULL COMMENT '开始学习时间', started_at DATETIME NULL COMMENT '开始学习时间',
completed_at DATETIME NULL COMMENT '完成时间', completed_at DATETIME NULL COMMENT '完成时间',
-- 时间戳 -- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
-- 索引 -- 索引
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_growth_path_id (growth_path_id), INDEX idx_growth_path_id (growth_path_id),
INDEX idx_node_id (node_id), INDEX idx_node_id (node_id),
INDEX idx_status (status), INDEX idx_status (status),
UNIQUE KEY uk_user_node (user_id, node_id), UNIQUE KEY uk_user_node (user_id, node_id),
-- 外键 -- 外键
CONSTRAINT fk_unc_user FOREIGN KEY (user_id) CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE, REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_unc_node FOREIGN KEY (node_id) CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
REFERENCES growth_path_nodes(id) ON DELETE CASCADE REFERENCES growth_path_nodes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='用户节点完成记录表'; COMMENT='用户节点完成记录表';

View File

@@ -2,9 +2,12 @@
课程相关数据库模型 课程相关数据库模型
""" """
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional, TYPE_CHECKING
from datetime import datetime from datetime import datetime
if TYPE_CHECKING:
from app.models.growth_path import GrowthPathNode
from sqlalchemy import ( from sqlalchemy import (
String, String,
Text, Text,
@@ -226,7 +229,10 @@ class GrowthPath(BaseModel, SoftDeleteMixin):
# 岗位关联 # 岗位关联
position_id: Mapped[Optional[int]] = mapped_column( position_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, comment="关联岗位ID" Integer, nullable=True, comment="关联岗位ID兼容旧版优先使用position_ids"
)
position_ids: Mapped[Optional[List[int]]] = mapped_column(
JSON, nullable=True, comment="关联岗位ID列表支持多选"
) )
# 路径配置(保留用于兼容,新版使用 nodes 关联表) # 路径配置(保留用于兼容,新版使用 nodes 关联表)

View File

@@ -1,206 +1,218 @@
""" """
成长路径相关数据库模型 成长路径相关数据库模型
""" """
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional, TYPE_CHECKING
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import ( if TYPE_CHECKING:
String, from app.models.course import Course
Text, from app.models.user import User
Integer,
Boolean, from sqlalchemy import (
ForeignKey, String,
Enum as SQLEnum, Text,
JSON, Integer,
DateTime, Boolean,
DECIMAL, ForeignKey,
) Enum as SQLEnum,
from sqlalchemy.orm import Mapped, mapped_column, relationship JSON,
DateTime,
from app.models.base import BaseModel, SoftDeleteMixin DECIMAL,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
class GrowthPathStatus(str, Enum):
"""成长路径学习状态""" from app.models.base import BaseModel, SoftDeleteMixin
NOT_STARTED = "not_started" # 未开始
IN_PROGRESS = "in_progress" # 进行中
COMPLETED = "completed" # 已完成 class GrowthPathStatus(str, Enum):
"""成长路径学习状态"""
NOT_STARTED = "not_started" # 未开始
class NodeStatus(str, Enum): IN_PROGRESS = "in_progress" # 进行中
"""节点状态""" COMPLETED = "completed" # 已完成
LOCKED = "locked" # 锁定(前置未完成)
UNLOCKED = "unlocked" # 已解锁(可以开始)
IN_PROGRESS = "in_progress" # 学习中 class NodeStatus(str, Enum):
COMPLETED = "completed" # 已完成 """节点状态"""
LOCKED = "locked" # 锁定(前置未完成)
UNLOCKED = "unlocked" # 已解锁(可以开始)
class GrowthPathNode(BaseModel, SoftDeleteMixin): IN_PROGRESS = "in_progress" # 学习中
""" COMPLETED = "completed" # 已完成
成长路径节点表
每个节点对应一门课程
""" class GrowthPathNode(BaseModel, SoftDeleteMixin):
__tablename__ = "growth_path_nodes" """
成长路径节点表
# 关联 每个节点对应一门课程
growth_path_id: Mapped[int] = mapped_column( """
Integer, __tablename__ = "growth_path_nodes"
ForeignKey("growth_paths.id", ondelete="CASCADE"),
nullable=False, # 关联
comment="成长路径ID" growth_path_id: Mapped[int] = mapped_column(
) Integer,
course_id: Mapped[int] = mapped_column( ForeignKey("growth_paths.id", ondelete="CASCADE"),
Integer, nullable=False,
ForeignKey("courses.id", ondelete="CASCADE"), comment="成长路径ID"
nullable=False, )
comment="课程ID" course_id: Mapped[int] = mapped_column(
) Integer,
ForeignKey("courses.id", ondelete="CASCADE"),
# 节点信息 nullable=False,
stage_name: Mapped[Optional[str]] = mapped_column( comment="课程ID"
String(100), nullable=True, comment="所属阶段名称" )
)
title: Mapped[str] = mapped_column( # 节点信息
String(200), nullable=False, comment="节点标题" stage_name: Mapped[Optional[str]] = mapped_column(
) String(100), nullable=True, comment="所属阶段名称"
description: Mapped[Optional[str]] = mapped_column( )
Text, nullable=True, comment="节点描述" title: Mapped[str] = mapped_column(
) String(200), nullable=False, comment="节点标题"
)
# 配置 description: Mapped[Optional[str]] = mapped_column(
order_num: Mapped[int] = mapped_column( Text, nullable=True, comment="节点描述"
Integer, default=0, nullable=False, comment="排序顺序" )
)
is_required: Mapped[bool] = mapped_column( # 配置
Boolean, default=True, nullable=False, comment="是否必修" order_num: Mapped[int] = mapped_column(
) Integer, default=0, nullable=False, comment="排序顺序"
prerequisites: Mapped[Optional[List[int]]] = mapped_column( )
JSON, nullable=True, comment="前置节点IDs" is_required: Mapped[bool] = mapped_column(
) Boolean, default=True, nullable=False, comment="是否必修"
estimated_days: Mapped[int] = mapped_column( )
Integer, default=7, nullable=False, comment="预计学习天数" prerequisites: Mapped[Optional[List[int]]] = mapped_column(
) JSON, nullable=True, comment="前置节点IDs"
)
# 关联关系 estimated_days: Mapped[int] = mapped_column(
growth_path: Mapped["GrowthPath"] = relationship( Integer, default=7, nullable=False, comment="预计学习天数"
"GrowthPath", back_populates="nodes" )
)
course: Mapped["Course"] = relationship("Course") # 画布位置(用于可视化编辑器)
user_completions: Mapped[List["UserNodeCompletion"]] = relationship( position_x: Mapped[Optional[int]] = mapped_column(
"UserNodeCompletion", back_populates="node" Integer, nullable=True, default=0, comment="画布X坐标"
) )
position_y: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0, comment="画布Y坐标"
class UserGrowthPathProgress(BaseModel): )
"""
用户成长路径进度表 # 关联关系
记录用户在某条成长路径上的整体进度 growth_path: Mapped["GrowthPath"] = relationship( # noqa: F821
""" "GrowthPath", back_populates="nodes"
__tablename__ = "user_growth_path_progress" )
course: Mapped["Course"] = relationship("Course")
# 关联 user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
user_id: Mapped[int] = mapped_column( "UserNodeCompletion", back_populates="node"
Integer, )
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID" class UserGrowthPathProgress(BaseModel):
) """
growth_path_id: Mapped[int] = mapped_column( 用户成长路径进度表
Integer, 记录用户在某条成长路径上的整体进度
ForeignKey("growth_paths.id", ondelete="CASCADE"), """
nullable=False, __tablename__ = "user_growth_path_progress"
comment="成长路径ID"
) # 关联
user_id: Mapped[int] = mapped_column(
# 进度信息 Integer,
current_node_id: Mapped[Optional[int]] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"),
Integer, nullable=True, comment="当前学习节点ID" nullable=False,
) comment="用户ID"
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column( )
JSON, nullable=True, comment="已完成节点IDs" growth_path_id: Mapped[int] = mapped_column(
) Integer,
total_progress: Mapped[Decimal] = mapped_column( ForeignKey("growth_paths.id", ondelete="CASCADE"),
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比" nullable=False,
) comment="成长路径ID"
)
# 状态
status: Mapped[str] = mapped_column( # 进度信息
String(20), current_node_id: Mapped[Optional[int]] = mapped_column(
default=GrowthPathStatus.NOT_STARTED.value, Integer, nullable=True, comment="当前学习节点ID"
nullable=False, )
comment="状态" completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
) JSON, nullable=True, comment="已完成节点IDs"
)
# 时间记录 total_progress: Mapped[Decimal] = mapped_column(
started_at: Mapped[Optional[datetime]] = mapped_column( DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
DateTime(timezone=True), nullable=True, comment="开始时间" )
)
completed_at: Mapped[Optional[datetime]] = mapped_column( # 状态
DateTime(timezone=True), nullable=True, comment="完成时间" status: Mapped[str] = mapped_column(
) String(20),
last_activity_at: Mapped[Optional[datetime]] = mapped_column( default=GrowthPathStatus.NOT_STARTED.value,
DateTime(timezone=True), nullable=True, comment="最后活动时间" nullable=False,
) comment="状态"
)
# 关联关系
user: Mapped["User"] = relationship("User") # 时间记录
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") started_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="开始时间"
)
class UserNodeCompletion(BaseModel): completed_at: Mapped[Optional[datetime]] = mapped_column(
""" DateTime(timezone=True), nullable=True, comment="完成时间"
用户节点完成记录表 )
详细记录用户在每个节点上的学习状态 last_activity_at: Mapped[Optional[datetime]] = mapped_column(
""" DateTime(timezone=True), nullable=True, comment="最后活动时间"
__tablename__ = "user_node_completions" )
# 关联 # 关联关系
user_id: Mapped[int] = mapped_column( user: Mapped["User"] = relationship("User")
Integer, growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") # noqa: F821
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID" class UserNodeCompletion(BaseModel):
) """
growth_path_id: Mapped[int] = mapped_column( 用户节点完成记录表
Integer, 详细记录用户在每个节点上的学习状态
ForeignKey("growth_paths.id", ondelete="CASCADE"), """
nullable=False, __tablename__ = "user_node_completions"
comment="成长路径ID"
) # 关联
node_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, Integer,
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
comment="节点ID" comment="用户ID"
) )
growth_path_id: Mapped[int] = mapped_column(
# 进度信息 Integer,
course_progress: Mapped[Decimal] = mapped_column( ForeignKey("growth_paths.id", ondelete="CASCADE"),
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度" nullable=False,
) comment="成长路径ID"
status: Mapped[str] = mapped_column( )
String(20), node_id: Mapped[int] = mapped_column(
default=NodeStatus.LOCKED.value, Integer,
nullable=False, ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
comment="状态" nullable=False,
) comment="节点ID"
)
# 时间记录
unlocked_at: Mapped[Optional[datetime]] = mapped_column( # 进度信息
DateTime(timezone=True), nullable=True, comment="解锁时间" course_progress: Mapped[Decimal] = mapped_column(
) DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
started_at: Mapped[Optional[datetime]] = mapped_column( )
DateTime(timezone=True), nullable=True, comment="开始学习时间" status: Mapped[str] = mapped_column(
) String(20),
completed_at: Mapped[Optional[datetime]] = mapped_column( default=NodeStatus.LOCKED.value,
DateTime(timezone=True), nullable=True, comment="完成时间" nullable=False,
) comment="状态"
)
# 关联关系
user: Mapped["User"] = relationship("User") # 时间记录
node: Mapped["GrowthPathNode"] = relationship( unlocked_at: Mapped[Optional[datetime]] = mapped_column(
"GrowthPathNode", back_populates="user_completions" DateTime(timezone=True), nullable=True, comment="解锁时间"
) )
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") started_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="开始学习时间"
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, comment="完成时间"
)
# 关联关系
user: Mapped["User"] = relationship("User")
node: Mapped["GrowthPathNode"] = relationship(
"GrowthPathNode", back_populates="user_completions"
)
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath") # noqa: F821

View File

@@ -42,7 +42,7 @@ class PaginationParams(BaseModel):
"""分页参数""" """分页参数"""
page: int = Field(default=1, ge=1, description="页码") page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量") page_size: int = Field(default=20, ge=1, le=500, description="每页数量")
@property @property
def offset(self) -> int: def offset(self) -> int:

View File

@@ -8,6 +8,7 @@ from enum import Enum
from pydantic import BaseModel, Field, ConfigDict, field_validator from pydantic import BaseModel, Field, ConfigDict, field_validator
from app.models.course import CourseStatus, CourseCategory from app.models.course import CourseStatus, CourseCategory
from app.core.sanitize import sanitize_input
class CourseBase(BaseModel): class CourseBase(BaseModel):
@@ -26,6 +27,18 @@ class CourseBase(BaseModel):
is_featured: bool = Field(default=False, description="是否推荐") is_featured: bool = Field(default=False, description="是否推荐")
allow_download: bool = Field(default=False, description="是否允许下载资料") allow_download: bool = Field(default=False, description="是否允许下载资料")
@field_validator("name", mode="before")
@classmethod
def sanitize_name(cls, v):
"""清理课程名称中的XSS内容"""
return sanitize_input(v, strict=True) if v else v
@field_validator("description", mode="before")
@classmethod
def sanitize_description(cls, v):
"""清理课程描述中的XSS内容"""
return sanitize_input(v, strict=False) if v else v
@field_validator("category", mode="before") @field_validator("category", mode="before")
@classmethod @classmethod
def normalize_category(cls, v): def normalize_category(cls, v):
@@ -75,6 +88,18 @@ class CourseUpdate(BaseModel):
is_featured: Optional[bool] = Field(None, description="是否推荐") is_featured: Optional[bool] = Field(None, description="是否推荐")
allow_download: Optional[bool] = Field(None, description="是否允许下载资料") allow_download: Optional[bool] = Field(None, description="是否允许下载资料")
@field_validator("name", mode="before")
@classmethod
def sanitize_name_update(cls, v):
"""清理课程名称中的XSS内容"""
return sanitize_input(v, strict=True) if v else v
@field_validator("description", mode="before")
@classmethod
def sanitize_description_update(cls, v):
"""清理课程描述中的XSS内容"""
return sanitize_input(v, strict=False) if v else v
@field_validator("category", mode="before") @field_validator("category", mode="before")
@classmethod @classmethod
def normalize_category_update(cls, v): def normalize_category_update(cls, v):
@@ -150,15 +175,15 @@ class CourseMaterialCreate(CourseMaterialBase):
@field_validator("file_type") @field_validator("file_type")
def validate_file_type(cls, v): def validate_file_type(cls, v):
"""验证文件类型 """验证文件类型
支持格式TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties 支持格式TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties
""" """
allowed_types = [ allowed_types = [
"txt", "md", "mdx", "pdf", "html", "htm", "txt", "md", "mdx", "pdf", "html", "htm",
"xlsx", "xls", "docx", "doc", "csv", "vtt", "properties" "xlsx", "xls", "docx", "doc", "pptx", "ppt", "csv", "vtt", "properties"
] ]
file_ext = v.lower() file_ext = v.lower()
if file_ext not in allowed_types: if file_ext not in allowed_types:
raise ValueError(f"不支持的文件类型: {v}。允许的类型: TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties") raise ValueError(f"不支持的文件类型: {v}。允许的类型: TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties")
return file_ext return file_ext

View File

@@ -244,7 +244,7 @@ class RecentExamItem(BaseModel):
total_score: float = Field(..., description="总分") total_score: float = Field(..., description="总分")
is_passed: Optional[bool] = Field(None, description="是否通过") is_passed: Optional[bool] = Field(None, description="是否通过")
duration_seconds: Optional[int] = Field(None, description="考试用时(秒)") duration_seconds: Optional[int] = Field(None, description="考试用时(秒)")
start_time: str = Field(..., description="开始时间") start_time: Optional[str] = Field(None, description="开始时间")
end_time: Optional[str] = Field(None, description="结束时间") end_time: Optional[str] = Field(None, description="结束时间")
round_scores: RoundScores = Field(..., description="三轮得分") round_scores: RoundScores = Field(..., description="三轮得分")

View File

@@ -1,224 +1,228 @@
""" """
成长路径相关 Schema 成长路径相关 Schema
""" """
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ===================================================== # =====================================================
# 基础数据结构 # 基础数据结构
# ===================================================== # =====================================================
class StageConfig(BaseModel): class StageConfig(BaseModel):
"""阶段配置""" """阶段配置"""
name: str = Field(..., description="阶段名称") name: str = Field(..., description="阶段名称")
description: Optional[str] = Field(None, description="阶段描述") description: Optional[str] = Field(None, description="阶段描述")
order: int = Field(0, description="排序") order: int = Field(0, description="排序")
class NodeBase(BaseModel): class NodeBase(BaseModel):
"""节点基础信息""" """节点基础信息"""
course_id: int = Field(..., description="课程ID") course_id: int = Field(..., description="课程ID")
stage_name: Optional[str] = Field(None, description="所属阶段名称") stage_name: Optional[str] = Field(None, description="所属阶段名称")
title: str = Field(..., description="节点标题") title: str = Field(..., description="节点标题")
description: Optional[str] = Field(None, description="节点描述") description: Optional[str] = Field(None, description="节点描述")
order_num: int = Field(0, description="排序顺序") order_num: int = Field(0, description="排序顺序")
is_required: bool = Field(True, description="是否必修") is_required: bool = Field(True, description="是否必修")
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs") prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
estimated_days: int = Field(7, description="预计学习天数") estimated_days: int = Field(7, description="预计学习天数")
position_x: Optional[int] = Field(0, description="画布X坐标")
position_y: Optional[int] = Field(0, description="画布Y坐标")
# =====================================================
# 管理端 - 创建/更新
# ===================================================== # =====================================================
# 管理端 - 创建/更新
class GrowthPathNodeCreate(NodeBase): # =====================================================
"""创建节点"""
pass class GrowthPathNodeCreate(NodeBase):
"""创建节点"""
pass
class GrowthPathNodeUpdate(BaseModel):
"""更新节点"""
course_id: Optional[int] = None class GrowthPathNodeUpdate(BaseModel):
stage_name: Optional[str] = None """更新节点"""
title: Optional[str] = None course_id: Optional[int] = None
description: Optional[str] = None stage_name: Optional[str] = None
order_num: Optional[int] = None title: Optional[str] = None
is_required: Optional[bool] = None description: Optional[str] = None
prerequisites: Optional[List[int]] = None order_num: Optional[int] = None
estimated_days: Optional[int] = None is_required: Optional[bool] = None
prerequisites: Optional[List[int]] = None
estimated_days: Optional[int] = None
class GrowthPathCreate(BaseModel):
"""创建成长路径"""
name: str = Field(..., description="路径名称") class GrowthPathCreate(BaseModel):
description: Optional[str] = Field(None, description="路径描述") """创建成长路径"""
target_role: Optional[str] = Field(None, description="目标角色") name: str = Field(..., description="路径名称")
position_id: Optional[int] = Field(None, description="关联岗位ID") description: Optional[str] = Field(None, description="路径描述")
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置") target_role: Optional[str] = Field(None, description="目标角色")
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数") position_id: Optional[int] = Field(None, description="关联岗位ID兼容旧版")
is_active: bool = Field(True, description="是否启用") position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表支持多选")
sort_order: int = Field(0, description="排序") stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表") estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
is_active: bool = Field(True, description="是否启用")
sort_order: int = Field(0, description="排序")
class GrowthPathUpdate(BaseModel): nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
"""更新成长路径"""
name: Optional[str] = None
description: Optional[str] = None class GrowthPathUpdate(BaseModel):
target_role: Optional[str] = None """更新成长路径"""
position_id: Optional[int] = None name: Optional[str] = None
stages: Optional[List[StageConfig]] = None description: Optional[str] = None
estimated_duration_days: Optional[int] = None target_role: Optional[str] = None
is_active: Optional[bool] = None position_id: Optional[int] = None
sort_order: Optional[int] = None position_ids: Optional[List[int]] = None
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点 stages: Optional[List[StageConfig]] = None
estimated_duration_days: Optional[int] = None
is_active: Optional[bool] = None
# ===================================================== sort_order: Optional[int] = None
# 管理端 - 响应 nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
# =====================================================
class GrowthPathNodeResponse(NodeBase): # =====================================================
"""节点响应""" # 管理端 - 响应
id: int # =====================================================
growth_path_id: int
course_name: Optional[str] = None # 课程名称(关联查询) class GrowthPathNodeResponse(NodeBase):
created_at: datetime """节点响应"""
updated_at: datetime id: int
growth_path_id: int
class Config: course_name: Optional[str] = None # 课程名称(关联查询)
from_attributes = True created_at: datetime
updated_at: datetime
class GrowthPathResponse(BaseModel): class Config:
"""成长路径响应(管理端)""" from_attributes = True
id: int
name: str
description: Optional[str] = None class GrowthPathResponse(BaseModel):
target_role: Optional[str] = None """成长路径响应(管理端)"""
position_id: Optional[int] = None id: int
position_name: Optional[str] = None # 岗位名称(关联查询) name: str
stages: Optional[List[StageConfig]] = None description: Optional[str] = None
estimated_duration_days: Optional[int] = None target_role: Optional[str] = None
is_active: bool position_id: Optional[int] = None
sort_order: int position_name: Optional[str] = None # 岗位名称(关联查询)
nodes: List[GrowthPathNodeResponse] = [] stages: Optional[List[StageConfig]] = None
node_count: int = 0 # 节点数量 estimated_duration_days: Optional[int] = None
created_at: datetime is_active: bool
updated_at: datetime sort_order: int
nodes: List[GrowthPathNodeResponse] = []
class Config: node_count: int = 0 # 节点数量
from_attributes = True created_at: datetime
updated_at: datetime
class GrowthPathListResponse(BaseModel): class Config:
"""成长路径列表响应""" from_attributes = True
id: int
name: str
description: Optional[str] = None class GrowthPathListResponse(BaseModel):
position_id: Optional[int] = None """成长路径列表响应"""
position_name: Optional[str] = None id: int
is_active: bool name: str
node_count: int = 0 description: Optional[str] = None
estimated_duration_days: Optional[int] = None position_id: Optional[int] = None
created_at: datetime position_name: Optional[str] = None
is_active: bool
class Config: node_count: int = 0
from_attributes = True estimated_duration_days: Optional[int] = None
created_at: datetime
# ===================================================== class Config:
# 学员端 - 响应 from_attributes = True
# =====================================================
class TraineeNodeResponse(BaseModel): # =====================================================
"""学员端节点响应(含进度状态)""" # 学员端 - 响应
id: int # =====================================================
course_id: int
title: str class TraineeNodeResponse(BaseModel):
description: Optional[str] = None """学员端节点响应(含进度状态)"""
stage_name: Optional[str] = None id: int
is_required: bool course_id: int
estimated_days: int title: str
order_num: int description: Optional[str] = None
stage_name: Optional[str] = None
# 学员特有 is_required: bool
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed") estimated_days: int
progress: float = Field(0, description="课程学习进度 0-100") order_num: int
# 课程信息 # 学员特有
course_name: Optional[str] = None status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
course_cover: Optional[str] = None progress: float = Field(0, description="课程学习进度 0-100")
class Config: # 课程信息
from_attributes = True course_name: Optional[str] = None
course_cover: Optional[str] = None
class TraineeStageResponse(BaseModel): class Config:
"""学员端阶段响应""" from_attributes = True
name: str
description: Optional[str] = None
completed: int = Field(0, description="已完成节点数") class TraineeStageResponse(BaseModel):
total: int = Field(0, description="总节点数") """学员端阶段响应"""
nodes: List[TraineeNodeResponse] = [] name: str
description: Optional[str] = None
completed: int = Field(0, description="已完成节点数")
class TraineeGrowthPathResponse(BaseModel): total: int = Field(0, description="总节点数")
"""学员端成长路径响应""" nodes: List[TraineeNodeResponse] = []
id: int
name: str
description: Optional[str] = None class TraineeGrowthPathResponse(BaseModel):
position_id: Optional[int] = None """学员端成长路径响应"""
position_name: Optional[str] = None id: int
name: str
# 进度信息 description: Optional[str] = None
total_progress: float = Field(0, description="总进度百分比") position_id: Optional[int] = None
completed_nodes: int = Field(0, description="已完成节点数") position_name: Optional[str] = None
total_nodes: int = Field(0, description="总节点数")
status: str = Field("not_started", description="状态: not_started/in_progress/completed") # 进度信息
total_progress: float = Field(0, description="总进度百分比")
# 时间信息 completed_nodes: int = Field(0, description="已完成节点数")
started_at: Optional[datetime] = None total_nodes: int = Field(0, description="总节点数")
estimated_completion_days: Optional[int] = None status: str = Field("not_started", description="状态: not_started/in_progress/completed")
# 阶段和节点 # 时间信息
stages: List[TraineeStageResponse] = [] started_at: Optional[datetime] = None
estimated_completion_days: Optional[int] = None
class Config:
from_attributes = True # 阶段和节点
stages: List[TraineeStageResponse] = []
# ===================================================== class Config:
# 用户进度 from_attributes = True
# =====================================================
class UserGrowthPathProgressResponse(BaseModel): # =====================================================
"""用户成长路径进度响应""" # 用户进度
id: int # =====================================================
user_id: int
growth_path_id: int class UserGrowthPathProgressResponse(BaseModel):
growth_path_name: str """用户成长路径进度响应"""
current_node_id: Optional[int] = None id: int
current_node_title: Optional[str] = None user_id: int
completed_node_ids: List[int] = [] growth_path_id: int
total_progress: float growth_path_name: str
status: str current_node_id: Optional[int] = None
started_at: Optional[datetime] = None current_node_title: Optional[str] = None
completed_at: Optional[datetime] = None completed_node_ids: List[int] = []
last_activity_at: Optional[datetime] = None total_progress: float
status: str
class Config: started_at: Optional[datetime] = None
from_attributes = True completed_at: Optional[datetime] = None
last_activity_at: Optional[datetime] = None
class StartGrowthPathRequest(BaseModel): class Config:
"""开始学习成长路径请求""" from_attributes = True
growth_path_id: int = Field(..., description="成长路径ID")
class StartGrowthPathRequest(BaseModel):
class CompleteNodeRequest(BaseModel): """开始学习成长路径请求"""
"""完成节点请求""" growth_path_id: int = Field(..., description="成长路径ID")
node_id: int = Field(..., description="节点ID")
class CompleteNodeRequest(BaseModel):
"""完成节点请求"""
node_id: int = Field(..., description="节点ID")

View File

@@ -38,7 +38,7 @@ class UserCreate(UserBase):
class UserUpdate(BaseSchema): class UserUpdate(BaseSchema):
"""更新用户""" """更新用户(管理员使用)"""
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
@@ -52,6 +52,19 @@ class UserUpdate(BaseSchema):
major: Optional[str] = Field(None, max_length=100) major: Optional[str] = Field(None, max_length=100)
class UserSelfUpdate(BaseSchema):
"""用户自己更新个人信息不允许修改role和is_active"""
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
full_name: Optional[str] = Field(None, max_length=100)
avatar_url: Optional[str] = None
bio: Optional[str] = None
gender: Optional[str] = Field(None, pattern="^(male|female)$")
school: Optional[str] = Field(None, max_length=100)
major: Optional[str] = Field(None, max_length=100)
class UserPasswordUpdate(BaseSchema): class UserPasswordUpdate(BaseSchema):
"""更新密码""" """更新密码"""

View File

@@ -43,12 +43,15 @@ class CozeService:
def __init__(self): def __init__(self):
self.client = get_coze_client() self.client = get_coze_client()
self.bot_config = get_bot_config()
self.workspace_id = get_workspace_id() self.workspace_id = get_workspace_id()
# 内存中的会话存储(生产环境应使用 Redis # 内存中的会话存储(生产环境应使用 Redis
self._sessions: Dict[str, CozeSession] = {} self._sessions: Dict[str, CozeSession] = {}
self._messages: Dict[str, List[CozeMessage]] = {} self._messages: Dict[str, List[CozeMessage]] = {}
def _get_bot_config(self, session_type: str) -> Dict[str, Any]:
"""根据会话类型获取 Bot 配置"""
return get_bot_config(session_type)
async def create_session( async def create_session(
self, request: CreateSessionRequest self, request: CreateSessionRequest
@@ -211,12 +214,15 @@ class CozeService:
def _get_bot_id_by_type(self, session_type: SessionType) -> str: def _get_bot_id_by_type(self, session_type: SessionType) -> str:
"""根据会话类型获取 Bot ID""" """根据会话类型获取 Bot ID"""
mapping = { # 将 SessionType 枚举映射到配置字符串
SessionType.COURSE_CHAT: self.bot_config["course_chat"], type_mapping = {
SessionType.TRAINING: self.bot_config["training"], SessionType.COURSE_CHAT: "course_chat",
SessionType.EXAM: self.bot_config["exam"], SessionType.TRAINING: "training",
SessionType.EXAM: "training", # 考试类型使用训练 bot
} }
return mapping.get(session_type, self.bot_config["training"]) config_type = type_mapping.get(session_type, "training")
bot_config = get_bot_config(config_type)
return bot_config["bot_id"]
def _get_session(self, session_id: str) -> Optional[CozeSession]: def _get_session(self, session_id: str) -> Optional[CozeSession]:
"""获取会话""" """获取会话"""

View File

@@ -8,6 +8,7 @@
- 写入数据库 - 写入数据库
提供稳定可靠的知识点分析能力。 提供稳定可靠的知识点分析能力。
支持MinIO和本地文件系统两种存储后端。
""" """
import logging import logging
@@ -20,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.exceptions import ExternalServiceError from app.core.exceptions import ExternalServiceError
from app.schemas.course import KnowledgePointCreate from app.schemas.course import KnowledgePointCreate
from app.services.storage_service import storage_service
from .ai_service import AIService, AIResponse from .ai_service import AIService, AIResponse
from .llm_json_parser import parse_with_fallback, clean_llm_output from .llm_json_parser import parse_with_fallback, clean_llm_output
@@ -92,8 +94,8 @@ class KnowledgeAnalysisServiceV2:
f"file_url: {file_url}" f"file_url: {file_url}"
) )
# 1. 解析文件路径 # 1. 解析文件路径支持MinIO和本地文件系统
file_path = self._resolve_file_path(file_url) file_path = await self._resolve_file_path(file_url)
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
@@ -160,11 +162,20 @@ class KnowledgeAnalysisServiceV2:
) )
raise ExternalServiceError(f"知识点分析失败: {e}") raise ExternalServiceError(f"知识点分析失败: {e}")
def _resolve_file_path(self, file_url: str) -> Path: async def _resolve_file_path(self, file_url: str) -> Path:
"""解析文件 URL 为本地路径""" """
解析文件 URL 为本地路径
支持MinIO和本地文件系统。如果文件在MinIO中会先下载到本地缓存。
"""
if file_url.startswith(STATIC_UPLOADS_PREFIX): if file_url.startswith(STATIC_UPLOADS_PREFIX):
relative_path = file_url.replace(STATIC_UPLOADS_PREFIX, '') object_name = file_url.replace(STATIC_UPLOADS_PREFIX, '')
return Path(self.upload_path) / relative_path # 使用storage_service获取文件路径自动处理MinIO下载
file_path = await storage_service.get_file_path(object_name)
if file_path:
return file_path
# 如果storage_service返回None尝试本地路径兼容旧数据
return Path(self.upload_path) / object_name
elif file_url.startswith('/'): elif file_url.startswith('/'):
# 绝对路径 # 绝对路径
return Path(file_url) return Path(file_url)
@@ -176,7 +187,7 @@ class KnowledgeAnalysisServiceV2:
""" """
提取文档内容 提取文档内容
支持PDF、Worddocx、Excelxlsx/xls、文本文件 支持PDF、Worddocx、Excelxlsx/xlsPPTpptx/ppt文本文件
""" """
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
@@ -187,6 +198,8 @@ class KnowledgeAnalysisServiceV2:
return await self._extract_docx_content(file_path) return await self._extract_docx_content(file_path)
elif suffix in ['.xlsx', '.xls']: elif suffix in ['.xlsx', '.xls']:
return await self._extract_excel_content(file_path) return await self._extract_excel_content(file_path)
elif suffix in ['.pptx', '.ppt']:
return await self._extract_ppt_content(file_path)
elif suffix in ['.txt', '.md', '.text']: elif suffix in ['.txt', '.md', '.text']:
return await self._extract_text_content(file_path) return await self._extract_text_content(file_path)
else: else:
@@ -303,6 +316,49 @@ class KnowledgeAnalysisServiceV2:
logger.error(f"Excel 文件读取失败: {e}") logger.error(f"Excel 文件读取失败: {e}")
raise ValueError(f"Excel 文件读取失败: {e}") raise ValueError(f"Excel 文件读取失败: {e}")
async def _extract_ppt_content(self, file_path: Path) -> str:
"""提取 PowerPoint 文件内容"""
try:
from pptx import Presentation
from pptx.util import Inches
prs = Presentation(str(file_path))
text_parts = []
for slide_num, slide in enumerate(prs.slides, 1):
slide_texts = []
text_parts.append(f"【幻灯片 {slide_num}")
for shape in slide.shapes:
# 提取文本框内容
if hasattr(shape, "text") and shape.text.strip():
slide_texts.append(shape.text.strip())
# 提取表格内容
if shape.has_table:
table = shape.table
for row in table.rows:
row_text = ' | '.join(
cell.text.strip() for cell in row.cells if cell.text.strip()
)
if row_text:
slide_texts.append(row_text)
if slide_texts:
text_parts.append('\n'.join(slide_texts))
else:
text_parts.append("(无文本内容)")
content = '\n\n'.join(text_parts)
return self._clean_content(content)
except ImportError:
logger.error("python-pptx 未安装,无法读取 PPT 文件")
raise ValueError("服务器未安装 PPT 读取组件(python-pptx)")
except Exception as e:
logger.error(f"PPT 文件读取失败: {e}")
raise ValueError(f"PPT 文件读取失败: {e}")
def _clean_content(self, content: str) -> str: def _clean_content(self, content: str) -> str:
"""清理和截断内容""" """清理和截断内容"""
# 移除多余空白 # 移除多余空白

View File

@@ -465,9 +465,7 @@ class CourseService(BaseService[Course]):
Returns: Returns:
是否删除成功 是否删除成功
""" """
import os from app.services.storage_service import storage_service
from pathlib import Path
from app.core.config import settings
# 先确认课程存在 # 先确认课程存在
course = await self.get_by_id(db, course_id) course = await self.get_by_id(db, course_id)
@@ -498,21 +496,18 @@ class CourseService(BaseService[Course]):
db.add(material) db.add(material)
await db.commit() await db.commit()
# 删除物理文件 # 删除物理文件使用storage_service
if file_url and file_url.startswith("/static/uploads/"): if file_url and file_url.startswith("/static/uploads/"):
try: try:
# 从URL中提取相对路径 # 从URL中提取相对路径
relative_path = file_url.replace("/static/uploads/", "") object_name = file_url.replace("/static/uploads/", "")
file_path = Path(settings.UPLOAD_PATH) / relative_path await storage_service.delete(object_name)
logger.info(
# 检查文件是否存在并删除 "删除物理文件成功",
if file_path.exists() and file_path.is_file(): object_name=object_name,
os.remove(file_path) material_id=material_id,
logger.info( storage="minio" if storage_service.is_minio_enabled else "local",
"删除物理文件成功", )
file_path=str(file_path),
material_id=material_id,
)
except Exception as e: except Exception as e:
# 物理文件删除失败不影响业务流程,仅记录日志 # 物理文件删除失败不影响业务流程,仅记录日志
logger.error( logger.error(

View File

@@ -42,101 +42,164 @@ class DashboardService:
Returns: Returns:
企业级数据概览 企业级数据概览
""" """
today = date.today() try:
week_ago = today - timedelta(days=7) today = date.today()
month_ago = today - timedelta(days=30) week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数 # 基础统计
result = await self.db.execute( # 1. 总学员数
select(func.count(User.id)) result = await self.db.execute(
.where(User.is_deleted == False, User.role == 'trainee') select(func.count(User.id))
) .where(User.is_deleted == False, User.role == 'trainee')
total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today)
)
today_active = result.scalar() or 0
# 3. 本周活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
)
week_active = result.scalar() or 0
# 4. 本月活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
)
month_active = result.scalar() or 0
# 5. 总学习时长(小时)
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED')
)
training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
) )
.where(Exam.status == 'submitted') total_users = result.scalar() or 0
)
exam_row = result.first() # 2. 今日活跃用户(有经验值记录)
exam_count = exam_row[0] or 0 try:
exam_passed = exam_row[1] or 0 result = await self.db.execute(
exam_avg_score = round(exam_row[2] or 0, 1) select(func.count(func.distinct(ExpHistory.user_id)))
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0 .where(func.date(ExpHistory.created_at) == today)
)
# 7. 满分人数 today_active = result.scalar() or 0
result = await self.db.execute( except Exception as e:
select(func.count(func.distinct(Exam.user_id))) logger.warning(f"获取今日活跃用户失败: {e}")
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score) today_active = 0
)
perfect_users = result.scalar() or 0 # 3. 本周活跃用户
try:
# 8. 签到率(今日签到人数/总用户数) result = await self.db.execute(
result = await self.db.execute( select(func.count(func.distinct(ExpHistory.user_id)))
select(func.count(UserLevel.id)) .where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
.where(func.date(UserLevel.last_login_date) == today) )
) week_active = result.scalar() or 0
today_checkin = result.scalar() or 0 except Exception as e:
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0 logger.warning(f"获取本周活跃用户失败: {e}")
week_active = 0
return {
"overview": { # 4. 本月活跃用户
"total_users": total_users, try:
"today_active": today_active, result = await self.db.execute(
"week_active": week_active, select(func.count(func.distinct(ExpHistory.user_id)))
"month_active": month_active, .where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
"total_hours": total_hours, )
"checkin_rate": checkin_rate, month_active = result.scalar() or 0
}, except Exception as e:
"exam": { logger.warning(f"获取本月活跃用户失败: {e}")
"total_count": exam_count, month_active = 0
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score, # 5. 总学习时长(小时)
"perfect_users": perfect_users, practice_hours = 0
}, training_hours = 0
"updated_at": datetime.now().isoformat() try:
} result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
except Exception as e:
logger.warning(f"获取陪练时长失败: {e}")
try:
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'completed') # 修复: 使用小写
)
training_hours = (result.scalar() or 0) / 3600
except Exception as e:
logger.warning(f"获取培训时长失败: {e}")
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
exam_count = 0
exam_passed = 0
exam_avg_score = 0
try:
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
if exam_row:
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
exam_avg_score = round(exam_row[2] or 0, 1)
except Exception as e:
logger.warning(f"获取考试统计失败: {e}")
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
# 7. 满分人数
perfect_users = 0
try:
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(
Exam.status == 'submitted',
Exam.score.isnot(None),
Exam.total_score.isnot(None),
Exam.score >= Exam.total_score
)
)
perfect_users = result.scalar() or 0
except Exception as e:
logger.warning(f"获取满分人数失败: {e}")
# 8. 签到率(今日签到人数/总用户数)
today_checkin = 0
try:
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(UserLevel.last_login_date == today)
)
today_checkin = result.scalar() or 0
except Exception as e:
logger.warning(f"获取签到率失败: {e}")
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return {
"overview": {
"total_users": total_users,
"today_active": today_active,
"week_active": week_active,
"month_active": month_active,
"total_hours": total_hours,
"checkin_rate": checkin_rate,
},
"exam": {
"total_count": exam_count,
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score,
"perfect_users": perfect_users,
},
"updated_at": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"获取企业概览失败: {e}")
# 返回默认数据而不是抛出异常
return {
"overview": {
"total_users": 0,
"today_active": 0,
"week_active": 0,
"month_active": 0,
"total_hours": 0,
"checkin_rate": 0,
},
"exam": {
"total_count": 0,
"pass_rate": 0,
"avg_score": 0,
"perfect_users": 0,
},
"updated_at": datetime.now().isoformat()
}
async def get_department_comparison(self) -> List[Dict[str, Any]]: async def get_department_comparison(self) -> List[Dict[str, Any]]:
""" """
@@ -313,35 +376,40 @@ class DashboardService:
""" """
activities = [] activities = []
# 获取最近的经验值记录 try:
result = await self.db.execute( # 获取最近的经验值记录
select(ExpHistory, User) result = await self.db.execute(
.join(User, ExpHistory.user_id == User.id) select(ExpHistory, User)
.order_by(ExpHistory.created_at.desc()) .join(User, ExpHistory.user_id == User.id)
.limit(limit) .order_by(ExpHistory.created_at.desc())
) .limit(limit)
rows = result.all() )
rows = result.all()
for exp, user in rows:
activity_type = "学习"
if "考试" in (exp.description or ""):
activity_type = "考试"
elif "签到" in (exp.description or ""):
activity_type = "签到"
elif "陪练" in (exp.description or ""):
activity_type = "陪练"
elif "奖章" in (exp.description or ""):
activity_type = "奖章"
activities.append({ for exp, user in rows:
"id": exp.id, activity_type = "学习"
"user_id": user.id, description = exp.description or ""
"user_name": user.full_name or user.username, if "考试" in description:
"type": activity_type, activity_type = "考试"
"description": exp.description, elif "签到" in description:
"exp_amount": exp.exp_amount, activity_type = "签到"
"created_at": exp.created_at.isoformat() if exp.created_at else None, elif "陪练" in description:
}) activity_type = "陪练"
elif "奖章" in description:
activity_type = "奖章"
activities.append({
"id": exp.id,
"user_id": user.id,
"user_name": user.full_name or user.username,
"type": activity_type,
"description": description,
"exp_amount": exp.exp_change, # 修复: exp_change 而非 exp_amount
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
except Exception as e:
logger.error(f"获取实时动态失败: {e}")
# 返回空列表而不是抛出异常
return activities return activities

View File

@@ -0,0 +1,276 @@
"""
钉钉开放平台 API 服务
用于通过钉钉 API 获取组织架构和员工信息
"""
import httpx
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
from app.core.logger import get_logger
logger = get_logger(__name__)
class DingTalkService:
"""钉钉 API 服务"""
BASE_URL = "https://api.dingtalk.com"
OAPI_URL = "https://oapi.dingtalk.com"
def __init__(
self,
corp_id: str,
client_id: str,
client_secret: str
):
"""
初始化钉钉服务
Args:
corp_id: 企业 CorpId
client_id: 应用 ClientId (AppKey)
client_secret: 应用 ClientSecret (AppSecret)
"""
self.corp_id = corp_id
self.client_id = client_id
self.client_secret = client_secret
self._access_token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None
async def get_access_token(self) -> str:
"""
获取钉钉 Access Token
使用新版 OAuth2 接口获取
Returns:
access_token
"""
# 检查缓存的 token 是否有效
if self._access_token and self._token_expires_at:
if datetime.now() < self._token_expires_at - timedelta(minutes=5):
return self._access_token
url = f"{self.BASE_URL}/v1.0/oauth2/{self.corp_id}/token"
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials"
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
self._access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
self._token_expires_at = datetime.now() + timedelta(seconds=expires_in)
logger.info(f"获取钉钉 Access Token 成功,有效期 {expires_in}")
return self._access_token
async def get_department_list(self, dept_id: int = 1) -> List[Dict[str, Any]]:
"""
获取部门列表
Args:
dept_id: 父部门ID根部门为1
Returns:
部门列表
"""
access_token = await self.get_access_token()
url = f"{self.OAPI_URL}/topapi/v2/department/listsub"
params = {"access_token": access_token}
payload = {"dept_id": dept_id}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, params=params, json=payload)
response.raise_for_status()
data = response.json()
if data.get("errcode") != 0:
raise Exception(f"获取部门列表失败: {data.get('errmsg')}")
return data.get("result", [])
async def get_all_departments(self) -> List[Dict[str, Any]]:
"""
递归获取所有部门
Returns:
所有部门列表(扁平化)
"""
all_departments = []
async def fetch_recursive(parent_id: int):
departments = await self.get_department_list(parent_id)
for dept in departments:
all_departments.append(dept)
# 递归获取子部门
await fetch_recursive(dept["dept_id"])
await fetch_recursive(1) # 从根部门开始
logger.info(f"获取到 {len(all_departments)} 个部门")
return all_departments
async def get_department_users(
self,
dept_id: int,
cursor: int = 0,
size: int = 100
) -> Dict[str, Any]:
"""
获取部门用户列表
Args:
dept_id: 部门ID
cursor: 分页游标
size: 每页大小最大100
Returns:
用户列表和分页信息
"""
access_token = await self.get_access_token()
url = f"{self.OAPI_URL}/topapi/v2/user/list"
params = {"access_token": access_token}
payload = {
"dept_id": dept_id,
"cursor": cursor,
"size": size
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, params=params, json=payload)
response.raise_for_status()
data = response.json()
if data.get("errcode") != 0:
raise Exception(f"获取部门用户失败: {data.get('errmsg')}")
return data.get("result", {})
async def get_all_employees(self) -> List[Dict[str, Any]]:
"""
获取所有在职员工
遍历所有部门获取员工列表
Returns:
员工列表
"""
logger.info("开始从钉钉 API 获取所有员工...")
# 1. 获取所有部门
departments = await self.get_all_departments()
# 创建部门ID到名称的映射
dept_map = {dept["dept_id"]: dept["name"] for dept in departments}
dept_map[1] = "根部门" # 添加根部门
# 2. 遍历所有部门获取员工
all_employees = {} # 使用字典去重(按 userid
for dept in [{"dept_id": 1, "name": "根部门"}] + departments:
dept_id = dept["dept_id"]
dept_name = dept["name"]
cursor = 0
while True:
result = await self.get_department_users(dept_id, cursor)
users = result.get("list", [])
for user in users:
userid = user.get("userid")
if userid and userid not in all_employees:
# 转换为统一格式
employee = self._convert_user_to_employee(user, dept_name)
all_employees[userid] = employee
# 检查是否还有更多数据
if not result.get("has_more", False):
break
cursor = result.get("next_cursor", 0)
employees = list(all_employees.values())
logger.info(f"获取到 {len(employees)} 位在职员工")
return employees
def _convert_user_to_employee(
self,
user: Dict[str, Any],
dept_name: str
) -> Dict[str, Any]:
"""
将钉钉用户数据转换为员工数据格式
Args:
user: 钉钉用户数据
dept_name: 部门名称
Returns:
标准员工数据格式
"""
return {
'full_name': user.get('name', ''),
'phone': user.get('mobile', ''),
'email': user.get('email', ''),
'department': dept_name,
'position': user.get('title', ''),
'employee_no': user.get('job_number', ''),
'is_leader': user.get('leader', False),
'is_active': user.get('active', True),
'dingtalk_id': user.get('userid', ''),
'join_date': user.get('hired_date'),
'work_location': user.get('work_place', ''),
'avatar': user.get('avatar', ''),
}
async def test_connection(self) -> Dict[str, Any]:
"""
测试钉钉 API 连接
Returns:
测试结果
"""
try:
# 1. 测试获取 token
token = await self.get_access_token()
# 2. 测试获取根部门信息
departments = await self.get_department_list(1)
# 3. 获取根部门员工数量
result = await self.get_department_users(1, size=1)
return {
"success": True,
"message": "连接成功",
"corp_id": self.corp_id,
"department_count": len(departments) + 1, # +1 是根部门
"has_employees": result.get("has_more", False) or len(result.get("list", [])) > 0
}
except httpx.HTTPStatusError as e:
error_detail = "HTTP错误"
if e.response.status_code == 400:
try:
error_data = e.response.json()
error_detail = error_data.get("message", str(e))
except:
pass
return {
"success": False,
"message": f"连接失败: {error_detail}",
"error": str(e)
}
except Exception as e:
return {
"success": False,
"message": f"连接失败: {str(e)}",
"error": str(e)
}

View File

@@ -1,6 +1,6 @@
""" """
员工同步服务 员工同步服务
外部钉钉员工表同步员工数据到考培练系统 钉钉开放 API 同步员工数据到考培练系统
""" """
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
@@ -23,77 +23,86 @@ logger = get_logger(__name__)
class EmployeeSyncService: class EmployeeSyncService:
"""员工同步服务""" """员工同步服务"""
# 外部数据库连接配置 def __init__(self, db: AsyncSession, tenant_id: int = 1):
EXTERNAL_DB_URL = "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4"
def __init__(self, db: AsyncSession):
self.db = db self.db = db
self.external_engine = None self.tenant_id = tenant_id
self._dingtalk_config = None
async def _get_dingtalk_config(self) -> Dict[str, str]:
"""从数据库获取钉钉 API 配置(复用免密登录配置)"""
if self._dingtalk_config:
return self._dingtalk_config
try:
# 从 dingtalk 配置组读取(与免密登录共用)
result = await self.db.execute(
text("""
SELECT config_key, config_value
FROM tenant_configs
WHERE tenant_id = :tenant_id
AND config_group = 'dingtalk'
"""),
{"tenant_id": self.tenant_id}
)
rows = result.fetchall()
config = {}
for key, value in rows:
# 转换 key 名称以匹配 DingTalkService 需要的格式
if key == 'DINGTALK_CORP_ID':
config['CORP_ID'] = value
elif key == 'DINGTALK_APP_KEY':
config['CLIENT_ID'] = value
elif key == 'DINGTALK_APP_SECRET':
config['CLIENT_SECRET'] = value
self._dingtalk_config = config
return config
except Exception as e:
logger.error(f"获取钉钉配置失败: {e}")
return {}
async def __aenter__(self): async def __aenter__(self):
"""异步上下文管理器入口""" """异步上下文管理器入口"""
self.external_engine = create_async_engine( # 预加载钉钉配置
self.EXTERNAL_DB_URL, await self._get_dingtalk_config()
echo=False,
pool_pre_ping=True,
pool_recycle=3600
)
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器出口""" """异步上下文管理器出口"""
if self.external_engine: pass
await self.external_engine.dispose()
async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]: async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]:
""" """
从钉钉员工表获取在职员工数据 从钉钉 API 获取在职员工数据
Returns: Returns:
员工数据列表 员工数据列表
""" """
logger.info("开始从钉钉员工表获取数据...") config = await self._get_dingtalk_config()
query = """ corp_id = config.get('CORP_ID')
SELECT client_id = config.get('CLIENT_ID')
员工姓名, client_secret = config.get('CLIENT_SECRET')
手机号,
邮箱,
所属部门,
职位,
工号,
是否领导,
是否在职,
钉钉用户ID,
入职日期,
工作地点
FROM v_钉钉员工表
WHERE 是否在职 = 1
ORDER BY 员工姓名
"""
async with self.external_engine.connect() as conn: if not all([corp_id, client_id, client_secret]):
result = await conn.execute(text(query)) raise Exception("钉钉 API 配置不完整,请先配置 CorpId、ClientId、ClientSecret")
rows = result.fetchall()
from app.services.dingtalk_service import DingTalkService
employees = []
for row in rows: dingtalk = DingTalkService(
employees.append({ corp_id=corp_id,
'full_name': row[0], client_id=client_id,
'phone': row[1], client_secret=client_secret
'email': row[2], )
'department': row[3],
'position': row[4], employees = await dingtalk.get_all_employees()
'employee_no': row[5],
'is_leader': bool(row[6]), # 过滤在职员工
'is_active': bool(row[7]), active_employees = [emp for emp in employees if emp.get('is_active', True)]
'dingtalk_id': row[8], logger.info(f"获取到 {len(active_employees)} 条在职员工数据")
'join_date': row[9],
'work_location': row[10] return active_employees
})
logger.info(f"获取到 {len(employees)} 条在职员工数据")
return employees
def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]: def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]:
""" """
@@ -229,7 +238,7 @@ class EmployeeSyncService:
logger.info(f"创建岗位: {position_name} (ID: {position.id})") logger.info(f"创建岗位: {position_name} (ID: {position.id})")
return position return position
async def create_user(self, employee_data: Dict[str, Any]) -> Optional[User]: async def create_user(self, employee_data: Dict[str, Any]) -> Tuple[Optional[User], str]:
""" """
创建用户 创建用户
@@ -237,14 +246,14 @@ class EmployeeSyncService:
employee_data: 员工数据 employee_data: 员工数据
Returns: Returns:
用户对象或None如果创建失败 (用户对象, 状态): 状态为 'created'/'existing'/'restored'/'skipped'
""" """
phone = employee_data.get('phone') phone = employee_data.get('phone')
full_name = employee_data.get('full_name') full_name = employee_data.get('full_name')
if not phone: if not phone:
logger.warning(f"员工 {full_name} 没有手机号,跳过") logger.warning(f"员工 {full_name} 没有手机号,跳过")
return None return None, 'skipped'
# 检查用户是否已存在(通过手机号,包括已软删除的) # 检查用户是否已存在(通过手机号,包括已软删除的)
stmt = select(User).where(User.phone == phone) stmt = select(User).where(User.phone == phone)
@@ -261,15 +270,15 @@ class EmployeeSyncService:
if dingtalk_id: if dingtalk_id:
existing_user.dingtalk_id = dingtalk_id existing_user.dingtalk_id = dingtalk_id
logger.info(f"恢复软删除用户: {phone} ({full_name})") logger.info(f"恢复软删除用户: {phone} ({full_name})")
return existing_user return existing_user, 'restored'
# 如果用户已存在但没有dingtalk_id则更新 # 如果用户已存在但没有dingtalk_id则更新
dingtalk_id = employee_data.get('dingtalk_id') dingtalk_id = employee_data.get('dingtalk_id')
if dingtalk_id and not existing_user.dingtalk_id: if dingtalk_id and not existing_user.dingtalk_id:
existing_user.dingtalk_id = dingtalk_id existing_user.dingtalk_id = dingtalk_id
logger.info(f"更新用户 {phone} 的钉钉ID: {dingtalk_id}") logger.info(f"更新用户 {phone} 的钉钉ID: {dingtalk_id}")
logger.info(f"用户已存在: {phone} ({full_name})") logger.debug(f"用户已存在: {phone} ({full_name})")
return existing_user return existing_user, 'existing'
# 生成邮箱 # 生成邮箱
email = self.generate_email(phone, employee_data.get('email')) email = self.generate_email(phone, employee_data.get('email'))
@@ -306,7 +315,7 @@ class EmployeeSyncService:
await self.db.flush() await self.db.flush()
logger.info(f"创建用户: {phone} ({full_name}) - 角色: {role}") logger.info(f"创建用户: {phone} ({full_name}) - 角色: {role}")
return user return user, 'created'
async def sync_employees(self) -> Dict[str, Any]: async def sync_employees(self) -> Dict[str, Any]:
""" """
@@ -322,6 +331,9 @@ class EmployeeSyncService:
stats = { stats = {
'total_employees': 0, 'total_employees': 0,
'users_created': 0, 'users_created': 0,
'users_existing': 0,
'users_restored': 0,
'users_departed': 0,
'users_skipped': 0, 'users_skipped': 0,
'teams_created': 0, 'teams_created': 0,
'positions_created': 0, 'positions_created': 0,
@@ -342,12 +354,18 @@ class EmployeeSyncService:
for employee in employees: for employee in employees:
try: try:
# 创建用户 # 创建用户
user = await self.create_user(employee) user, status = await self.create_user(employee)
if not user: if not user:
stats['users_skipped'] += 1 stats['users_skipped'] += 1
continue continue
stats['users_created'] += 1 # 根据状态统计
if status == 'created':
stats['users_created'] += 1
elif status == 'existing':
stats['users_existing'] += 1
elif status == 'restored':
stats['users_restored'] += 1
# 创建部门团队 # 创建部门团队
department = employee.get('department') department = employee.get('department')
@@ -391,7 +409,29 @@ class EmployeeSyncService:
stats['errors'].append(error_msg) stats['errors'].append(error_msg)
continue continue
# 3. 提交所有更改 # 3. 处理离职员工(软删除)
dingtalk_phones = {emp.get('phone') for emp in employees if emp.get('phone')}
# 获取系统中所有活跃用户(排除 admin
stmt = select(User).where(
User.is_deleted == False,
User.is_active == True,
User.username != 'admin',
User.role != 'admin'
)
result = await self.db.execute(stmt)
system_users = result.scalars().all()
# 找出离职员工(系统有但钉钉没有)
for user in system_users:
if user.phone and user.phone not in dingtalk_phones:
# 软删除:标记为离职
user.is_active = False
user.is_deleted = True
stats['users_departed'] += 1
logger.info(f"🚪 标记离职员工: {user.full_name} ({user.phone})")
# 4. 提交所有更改
await self.db.commit() await self.db.commit()
logger.info("✅ 数据库事务已提交") logger.info("✅ 数据库事务已提交")
@@ -405,12 +445,15 @@ class EmployeeSyncService:
stats['end_time'] = datetime.now() stats['end_time'] = datetime.now()
stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds() stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds()
# 4. 输出统计信息 # 5. 输出统计信息
logger.info("=" * 60) logger.info("=" * 60)
logger.info("同步完成统计") logger.info("同步完成统计")
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"员工: {stats['total_employees']}") logger.info(f"钉钉在职员工: {stats['total_employees']}")
logger.info(f"创建用户: {stats['users_created']}") logger.info(f"新增用户: {stats['users_created']}")
logger.info(f"已存在用户: {stats['users_existing']}")
logger.info(f"恢复用户: {stats['users_restored']}")
logger.info(f"离职处理: {stats['users_departed']}")
logger.info(f"跳过用户: {stats['users_skipped']}") logger.info(f"跳过用户: {stats['users_skipped']}")
logger.info(f"耗时: {stats['duration']:.2f}") logger.info(f"耗时: {stats['duration']:.2f}")
@@ -568,7 +611,7 @@ class EmployeeSyncService:
""" """
增量同步员工数据 增量同步员工数据
- 新增钉钉有但系统没有的员工 - 新增钉钉有但系统没有的员工
- 删除系统有但钉钉没有的员工(物理删除) - 删除系统有但钉钉没有的员工(删除)
- 跳过两边都存在的员工(不做任何修改) - 跳过两边都存在的员工(不做任何修改)
Returns: Returns:
@@ -591,8 +634,22 @@ class EmployeeSyncService:
try: try:
# 1. 获取钉钉在职员工数据 # 1. 获取钉钉在职员工数据
dingtalk_employees = await self.fetch_employees_from_dingtalk() dingtalk_employees = await self.fetch_employees_from_dingtalk()
# 使用手机号和钉钉ID双重匹配
dingtalk_phones = {emp.get('phone') for emp in dingtalk_employees if emp.get('phone')} dingtalk_phones = {emp.get('phone') for emp in dingtalk_employees if emp.get('phone')}
logger.info(f"钉钉在职员工数量: {len(dingtalk_phones)}") dingtalk_ids = {emp.get('dingtalk_id') for emp in dingtalk_employees if emp.get('dingtalk_id')}
logger.info(f"钉钉在职员工数量: {len(dingtalk_employees)}")
logger.info(f"有手机号的员工: {len(dingtalk_phones)}")
logger.info(f"有钉钉ID的员工: {len(dingtalk_ids)}")
# 安全检查:如果钉钉返回了员工但手机号全为空,可能是权限问题,跳过删除操作
skip_delete = False
if len(dingtalk_employees) > 0 and len(dingtalk_phones) == 0:
logger.warning("⚠️ 钉钉返回员工数据但手机号全为空,可能是钉钉应用缺少手机号读取权限!")
logger.warning("⚠️ 跳过离职员工处理,避免误删")
skip_delete = True
stats['errors'].append("钉钉应用可能缺少手机号读取权限,跳过删除操作")
# 2. 获取系统现有用户排除admin和已软删除的 # 2. 获取系统现有用户排除admin和已软删除的
stmt = select(User).where( stmt = select(User).where(
@@ -602,16 +659,26 @@ class EmployeeSyncService:
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
system_users = result.scalars().all() system_users = result.scalars().all()
system_phones = {user.phone for user in system_users if user.phone} system_phones = {user.phone for user in system_users if user.phone}
logger.info(f"系统现有员工数量排除admin: {len(system_phones)}") system_dingtalk_ids = {user.dingtalk_id for user in system_users if user.dingtalk_id}
logger.info(f"系统现有员工数量排除admin: {len(system_users)}")
logger.info(f"系统有手机号的员工: {len(system_phones)}")
logger.info(f"系统有钉钉ID的员工: {len(system_dingtalk_ids)}")
# 3. 计算需要新增、删除、跳过的员工 # 3. 计算需要新增、删除、跳过的员工同时考虑手机号和钉钉ID
# 新增: 钉钉有但系统没有手机号或钉钉ID都不存在
phones_to_add = dingtalk_phones - system_phones phones_to_add = dingtalk_phones - system_phones
phones_to_delete = system_phones - dingtalk_phones ids_to_add = dingtalk_ids - system_dingtalk_ids
# 删除: 系统有但钉钉没有手机号和钉钉ID都不在钉钉列表中
phones_to_delete = system_phones - dingtalk_phones if not skip_delete else set()
# 跳过: 两边都存在
phones_to_skip = dingtalk_phones & system_phones phones_to_skip = dingtalk_phones & system_phones
ids_to_skip = dingtalk_ids & system_dingtalk_ids
logger.info(f"待新增: {len(phones_to_add)}, 待删除: {len(phones_to_delete)}, 跳过: {len(phones_to_skip)}") logger.info(f"待新增(手机号): {len(phones_to_add)}, 待删除(手机号): {len(phones_to_delete)}, 跳过(手机号): {len(phones_to_skip)}")
stats['skipped_count'] = len(phones_to_skip) stats['skipped_count'] = len(phones_to_skip) + len(ids_to_skip)
# 4. 新增员工 # 4. 新增员工
for employee in dingtalk_employees: for employee in dingtalk_employees:
@@ -621,16 +688,18 @@ class EmployeeSyncService:
try: try:
# 创建用户 # 创建用户
user = await self.create_user(employee) user, status = await self.create_user(employee)
if not user: if not user:
continue continue
stats['added_count'] += 1 # 只有真正创建的才计入新增
stats['added_users'].append({ if status == 'created':
'full_name': user.full_name, stats['added_count'] += 1
'phone': user.phone, stats['added_users'].append({
'role': user.role 'full_name': user.full_name,
}) 'phone': user.phone,
'role': user.role
})
# 创建部门团队 # 创建部门团队
department = employee.get('department') department = employee.get('department')
@@ -675,53 +744,53 @@ class EmployeeSyncService:
stats['errors'].append(error_msg) stats['errors'].append(error_msg)
continue continue
# 5. 删除离职员工(物理删除) # 5. 处理离职员工(删除)
# 先flush之前的新增操作,避免与删除操作冲突 # 先flush之前的新增操作
await self.db.flush() await self.db.flush()
# 收集需要删除的用户ID # 如果跳过删除,则不处理
users_to_delete = [] if skip_delete:
for user in system_users: logger.info("⚠️ 由于安全检查未通过,跳过离职员工处理")
if user.phone and user.phone in phones_to_delete: else:
# 标记离职员工需要手机号和钉钉ID都不在钉钉列表中才删除
for user in system_users:
# 双重保护确保不删除admin # 双重保护确保不删除admin
if user.username == 'admin' or user.role == 'admin': if user.username == 'admin' or user.role == 'admin':
logger.warning(f"⚠️ 跳过删除管理员账户: {user.username}")
continue continue
users_to_delete.append({ # 检查用户是否在钉钉列表中手机号或钉钉ID匹配任一即视为在职
'id': user.id, in_dingtalk = False
'full_name': user.full_name, if user.phone and user.phone in dingtalk_phones:
'phone': user.phone, in_dingtalk = True
'username': user.username if user.dingtalk_id and user.dingtalk_id in dingtalk_ids:
}) in_dingtalk = True
# 批量删除用户及其关联数据
for user_info in users_to_delete:
try:
user_id = user_info['id']
# 先清理关联数据(外键约束) if in_dingtalk:
await self._cleanup_user_related_data(user_id) continue # 在钉钉中,跳过
# 用SQL直接删除用户避免ORM的级联操作冲突 # 额外安全检查:如果钉钉没有返回有效数据,不删除
await self.db.execute( if len(dingtalk_phones) == 0 and len(dingtalk_ids) == 0:
text("DELETE FROM users WHERE id = :user_id"), logger.warning(f"⚠️ 钉钉数据为空,跳过删除用户: {user.full_name}")
{"user_id": user_id} continue
)
stats['deleted_users'].append({ try:
'full_name': user_info['full_name'], # 软删除:标记为离职
'phone': user_info['phone'], user.is_active = False
'username': user_info['username'] user.is_deleted = True
})
stats['deleted_count'] += 1 stats['deleted_users'].append({
logger.info(f"🗑️ 删除离职员工: {user_info['full_name']} ({user_info['phone']})") 'full_name': user.full_name,
'phone': user.phone,
except Exception as e: 'username': user.username
error_msg = f"删除员工 {user_info['full_name']} 失败: {str(e)}" })
logger.error(error_msg) stats['deleted_count'] += 1
stats['errors'].append(error_msg) logger.info(f"🚪 标记离职员工: {user.full_name} ({user.phone})")
continue
except Exception as e:
error_msg = f"处理离职员工 {user.full_name} 失败: {str(e)}"
logger.error(error_msg)
stats['errors'].append(error_msg)
continue
# 6. 提交所有更改 # 6. 提交所有更改
await self.db.commit() await self.db.commit()
@@ -742,7 +811,7 @@ class EmployeeSyncService:
logger.info("增量同步完成统计") logger.info("增量同步完成统计")
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"新增员工: {stats['added_count']}") logger.info(f"新增员工: {stats['added_count']}")
logger.info(f"删除员工: {stats['deleted_count']}") logger.info(f"离职处理: {stats['deleted_count']}")
logger.info(f"跳过员工: {stats['skipped_count']}") logger.info(f"跳过员工: {stats['skipped_count']}")
logger.info(f"耗时: {stats['duration']:.2f}") logger.info(f"耗时: {stats['duration']:.2f}")

View File

@@ -10,7 +10,7 @@ from sqlalchemy import select, func, and_, or_, desc
from app.models.exam import Exam, Question, ExamResult from app.models.exam import Exam, Question, ExamResult
from app.models.exam_mistake import ExamMistake from app.models.exam_mistake import ExamMistake
from app.models.course import Course, KnowledgePoint from app.models.course import Course, KnowledgePoint
from app.core.exceptions import BusinessException, ErrorCode from app.core.exceptions import NotFoundError, ValidationError
from app.utils.score_distributor import ScoreDistributor from app.utils.score_distributor import ScoreDistributor
@@ -20,7 +20,7 @@ class ExamService:
@staticmethod @staticmethod
async def start_exam( async def start_exam(
db: AsyncSession, user_id: int, course_id: int, question_count: int = 10 db: AsyncSession, user_id: int, course_id: int, question_count: int = 10
) -> Exam: ) -> int:
""" """
开始考试 开始考试
@@ -31,12 +31,12 @@ class ExamService:
question_count: 题目数量 question_count: 题目数量
Returns: Returns:
Exam: 考试实例 int: 考试ID
""" """
# 检查课程是否存在 # 检查课程是否存在
course = await db.get(Course, course_id) course = await db.get(Course, course_id)
if not course: if not course:
raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="课程不存在") raise NotFoundError("课程不存在")
# 获取该课程的所有可用题目 # 获取该课程的所有可用题目
stmt = select(Question).where( stmt = select(Question).where(
@@ -46,9 +46,7 @@ class ExamService:
all_questions = result.scalars().all() all_questions = result.scalars().all()
if not all_questions: if not all_questions:
raise BusinessException( raise ValidationError("该课程暂无题目")
error_code=ErrorCode.VALIDATION_ERROR, message="该课程暂无题目"
)
# 随机选择题目 # 随机选择题目
selected_questions = random.sample( selected_questions = random.sample(
@@ -95,8 +93,10 @@ class ExamService:
db.add(exam) db.add(exam)
await db.commit() await db.commit()
await db.refresh(exam) await db.refresh(exam)
return exam # 返回exam.id而不是整个对象避免懒加载问题
exam_id = exam.id
return exam_id
@staticmethod @staticmethod
async def submit_exam( async def submit_exam(
@@ -120,12 +120,10 @@ class ExamService:
exam = result.scalar_one_or_none() exam = result.scalar_one_or_none()
if not exam: if not exam:
raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在") raise NotFoundError("考试记录不存在")
if exam.status != "started": if exam.status != "started":
raise BusinessException( raise ValidationError("考试已结束或已提交")
error_code=ErrorCode.VALIDATION_ERROR, message="考试已结束或已提交"
)
# 检查考试是否超时 # 检查考试是否超时
if datetime.now() > exam.start_time + timedelta( if datetime.now() > exam.start_time + timedelta(
@@ -133,9 +131,7 @@ class ExamService:
): ):
exam.status = "timeout" exam.status = "timeout"
await db.commit() await db.commit()
raise BusinessException( raise ValidationError("考试已超时")
error_code=ErrorCode.VALIDATION_ERROR, message="考试已超时"
)
# 处理答案 # 处理答案
answers_dict = {ans["question_id"]: ans["answer"] for ans in answers} answers_dict = {ans["question_id"]: ans["answer"] for ans in answers}
@@ -223,7 +219,7 @@ class ExamService:
exam = result.scalar_one_or_none() exam = result.scalar_one_or_none()
if not exam: if not exam:
raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在") raise NotFoundError("考试记录不存在")
# 构建返回数据 # 构建返回数据
exam_data = { exam_data = {
@@ -332,7 +328,9 @@ class ExamService:
if exam.questions: if exam.questions:
try: try:
# 解析questions JSON统计每种题型的总数 # 解析questions JSON统计每种题型的总数
questions_data = json.loads(exam.questions) if isinstance(exam.questions, str) else exam.questions questions_raw = json.loads(exam.questions) if isinstance(exam.questions, str) else exam.questions
# questions可能是 {"questions": [...]} 或直接是列表
questions_data = questions_raw.get("questions", questions_raw) if isinstance(questions_raw, dict) else questions_raw
type_totals = {} type_totals = {}
type_scores = {} # 存储每种题型的总分 type_scores = {} # 存储每种题型的总分

File diff suppressed because it is too large Load Diff

View File

@@ -261,8 +261,6 @@ def start_scheduler():
def stop_scheduler(): def stop_scheduler():
"""停止调度器""" """停止调度器"""
global scheduler
if scheduler and scheduler.running: if scheduler and scheduler.running:
scheduler.shutdown() scheduler.shutdown()
logger.info("定时任务调度器已停止") logger.info("定时任务调度器已停止")

View File

@@ -0,0 +1,422 @@
"""
统一文件存储服务
支持MinIO对象存储兼容本地文件系统
使用方式:
from app.services.storage_service import storage_service
# 上传文件
file_url = await storage_service.upload(file_data, "courses/1/doc.pdf")
# 下载文件
file_data = await storage_service.download("courses/1/doc.pdf")
# 删除文件
await storage_service.delete("courses/1/doc.pdf")
"""
import os
import io
import logging
from pathlib import Path
from typing import Optional, Union, BinaryIO
from datetime import timedelta
from minio import Minio
from minio.error import S3Error
from app.core.config import settings
logger = logging.getLogger(__name__)
class StorageService:
"""
统一文件存储服务
支持两种存储后端:
1. MinIO对象存储推荐生产环境
2. 本地文件系统开发环境或MinIO不可用时的降级方案
"""
def __init__(self):
self._client: Optional[Minio] = None
self._initialized = False
self._use_minio = False
def _ensure_initialized(self):
"""确保服务已初始化"""
if self._initialized:
return
self._initialized = True
# 检查是否启用MinIO
if not settings.MINIO_ENABLED:
logger.info("MinIO未启用使用本地文件存储")
self._use_minio = False
return
try:
self._client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
# 验证连接并确保bucket存在
bucket_name = self._get_bucket_name()
if not self._client.bucket_exists(bucket_name):
self._client.make_bucket(bucket_name)
logger.info(f"创建MinIO bucket: {bucket_name}")
# 设置bucket策略为公开读取
self._set_bucket_public_read(bucket_name)
self._use_minio = True
logger.info(f"MinIO存储服务初始化成功 - endpoint: {settings.MINIO_ENDPOINT}, bucket: {bucket_name}")
except Exception as e:
logger.warning(f"MinIO初始化失败降级为本地存储: {e}")
self._use_minio = False
def _get_bucket_name(self) -> str:
"""获取当前租户的bucket名称"""
return f"kpl-{settings.TENANT_CODE}"
def _set_bucket_public_read(self, bucket_name: str):
"""设置bucket为公开读取"""
try:
# 设置匿名读取策略
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{bucket_name}/*"]
}
]
}
import json
self._client.set_bucket_policy(bucket_name, json.dumps(policy))
except Exception as e:
logger.warning(f"设置bucket公开读取策略失败: {e}")
def _normalize_object_name(self, object_name: str) -> str:
"""标准化对象名称,移除前缀斜杠"""
if object_name.startswith('/'):
object_name = object_name[1:]
if object_name.startswith('static/uploads/'):
object_name = object_name.replace('static/uploads/', '')
return object_name
def _get_file_url(self, object_name: str) -> str:
"""获取文件访问URL"""
object_name = self._normalize_object_name(object_name)
# 统一返回 /static/uploads/ 格式的URL由Nginx代理到MinIO
return f"/static/uploads/{object_name}"
def _get_local_path(self, object_name: str) -> Path:
"""获取本地文件路径"""
object_name = self._normalize_object_name(object_name)
return Path(settings.UPLOAD_PATH) / object_name
async def upload(
self,
file_data: Union[bytes, BinaryIO],
object_name: str,
content_type: Optional[str] = None,
) -> str:
"""
上传文件
Args:
file_data: 文件数据bytes或文件对象
object_name: 对象名称(如 courses/1/doc.pdf
content_type: 文件MIME类型
Returns:
文件访问URL
"""
self._ensure_initialized()
object_name = self._normalize_object_name(object_name)
# 转换为bytes
if isinstance(file_data, bytes):
data = file_data
else:
data = file_data.read()
if self._use_minio:
return await self._upload_to_minio(data, object_name, content_type)
else:
return await self._upload_to_local(data, object_name)
async def _upload_to_minio(
self,
data: bytes,
object_name: str,
content_type: Optional[str] = None,
) -> str:
"""上传到MinIO"""
try:
bucket_name = self._get_bucket_name()
# 自动检测content_type
if not content_type:
content_type = self._guess_content_type(object_name)
self._client.put_object(
bucket_name,
object_name,
io.BytesIO(data),
length=len(data),
content_type=content_type,
)
file_url = self._get_file_url(object_name)
logger.info(f"文件上传到MinIO成功: {object_name} -> {file_url}")
return file_url
except S3Error as e:
logger.error(f"MinIO上传失败: {e}")
# 降级到本地存储
return await self._upload_to_local(data, object_name)
async def _upload_to_local(self, data: bytes, object_name: str) -> str:
"""上传到本地文件系统"""
try:
file_path = self._get_local_path(object_name)
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'wb') as f:
f.write(data)
file_url = self._get_file_url(object_name)
logger.info(f"文件上传到本地成功: {object_name} -> {file_url}")
return file_url
except Exception as e:
logger.error(f"本地文件上传失败: {e}")
raise
async def download(self, object_name: str) -> Optional[bytes]:
"""
下载文件
Args:
object_name: 对象名称
Returns:
文件数据如果文件不存在返回None
"""
self._ensure_initialized()
object_name = self._normalize_object_name(object_name)
if self._use_minio:
return await self._download_from_minio(object_name)
else:
return await self._download_from_local(object_name)
async def _download_from_minio(self, object_name: str) -> Optional[bytes]:
"""从MinIO下载"""
try:
bucket_name = self._get_bucket_name()
response = self._client.get_object(bucket_name, object_name)
data = response.read()
response.close()
response.release_conn()
return data
except S3Error as e:
if e.code == 'NoSuchKey':
logger.warning(f"MinIO文件不存在: {object_name}")
# 尝试从本地读取(兼容迁移过渡期)
return await self._download_from_local(object_name)
logger.error(f"MinIO下载失败: {e}")
return None
async def _download_from_local(self, object_name: str) -> Optional[bytes]:
"""从本地文件系统下载"""
try:
file_path = self._get_local_path(object_name)
if not file_path.exists():
logger.warning(f"本地文件不存在: {file_path}")
return None
with open(file_path, 'rb') as f:
return f.read()
except Exception as e:
logger.error(f"本地文件下载失败: {e}")
return None
async def delete(self, object_name: str) -> bool:
"""
删除文件
Args:
object_name: 对象名称
Returns:
是否删除成功
"""
self._ensure_initialized()
object_name = self._normalize_object_name(object_name)
success = True
# MinIO删除
if self._use_minio:
try:
bucket_name = self._get_bucket_name()
self._client.remove_object(bucket_name, object_name)
logger.info(f"MinIO文件删除成功: {object_name}")
except S3Error as e:
if e.code != 'NoSuchKey':
logger.error(f"MinIO文件删除失败: {e}")
success = False
# 同时删除本地文件(确保彻底清理)
try:
file_path = self._get_local_path(object_name)
if file_path.exists():
os.remove(file_path)
logger.info(f"本地文件删除成功: {file_path}")
except Exception as e:
logger.warning(f"本地文件删除失败: {e}")
return success
async def exists(self, object_name: str) -> bool:
"""
检查文件是否存在
Args:
object_name: 对象名称
Returns:
文件是否存在
"""
self._ensure_initialized()
object_name = self._normalize_object_name(object_name)
if self._use_minio:
try:
bucket_name = self._get_bucket_name()
self._client.stat_object(bucket_name, object_name)
return True
except S3Error:
pass
# 检查本地文件
file_path = self._get_local_path(object_name)
return file_path.exists()
async def get_file_path(self, object_name: str) -> Optional[Path]:
"""
获取文件的本地路径(用于需要本地文件操作的场景)
如果文件在MinIO中会先下载到临时目录
Args:
object_name: 对象名称
Returns:
本地文件路径如果文件不存在返回None
"""
self._ensure_initialized()
object_name = self._normalize_object_name(object_name)
# 先检查本地是否存在
local_path = self._get_local_path(object_name)
if local_path.exists():
return local_path
# 如果MinIO启用尝试下载到本地缓存
if self._use_minio:
try:
data = await self._download_from_minio(object_name)
if data:
# 保存到本地缓存
local_path.parent.mkdir(parents=True, exist_ok=True)
with open(local_path, 'wb') as f:
f.write(data)
logger.info(f"从MinIO下载文件到本地缓存: {object_name}")
return local_path
except Exception as e:
logger.error(f"下载MinIO文件到本地失败: {e}")
return None
def get_presigned_url(self, object_name: str, expires: int = 3600) -> Optional[str]:
"""
获取预签名URL用于直接访问MinIO
Args:
object_name: 对象名称
expires: 过期时间(秒)
Returns:
预签名URL如果MinIO未启用返回None
"""
self._ensure_initialized()
if not self._use_minio:
return None
object_name = self._normalize_object_name(object_name)
try:
bucket_name = self._get_bucket_name()
url = self._client.presigned_get_object(
bucket_name,
object_name,
expires=timedelta(seconds=expires)
)
return url
except S3Error as e:
logger.error(f"获取预签名URL失败: {e}")
return None
def _guess_content_type(self, filename: str) -> str:
"""根据文件名猜测MIME类型"""
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
content_types = {
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt': 'text/plain',
'md': 'text/markdown',
'html': 'text/html',
'htm': 'text/html',
'csv': 'text/csv',
'json': 'application/json',
'xml': 'application/xml',
'zip': 'application/zip',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'mp3': 'audio/mpeg',
'wav': 'audio/wav',
'mp4': 'video/mp4',
'webm': 'video/webm',
}
return content_types.get(ext, 'application/octet-stream')
@property
def is_minio_enabled(self) -> bool:
"""检查MinIO是否启用"""
self._ensure_initialized()
return self._use_minio
# 全局单例
storage_service = StorageService()

View File

@@ -5,7 +5,7 @@ from typing import List, Optional
from datetime import datetime from datetime import datetime
from sqlalchemy import select, func, and_, case from sqlalchemy import select, func, and_, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from app.models.task import Task, TaskCourse, TaskAssignment, TaskStatus, AssignmentStatus from app.models.task import Task, TaskCourse, TaskAssignment, TaskStatus, AssignmentStatus
from app.models.course import Course from app.models.course import Course
from app.schemas.task import TaskCreate, TaskUpdate, TaskStatsResponse from app.schemas.task import TaskCreate, TaskUpdate, TaskStatsResponse
@@ -44,7 +44,14 @@ class TaskService(BaseService[Task]):
db.add(assignment) db.add(assignment)
await db.commit() await db.commit()
await db.refresh(task)
# 重新查询并加载关联关系(避免懒加载问题)
stmt = select(Task).where(Task.id == task.id).options(
selectinload(Task.course_links).selectinload(TaskCourse.course),
selectinload(Task.assignments)
)
result = await db.execute(stmt)
task = result.scalar_one()
return task return task
async def get_tasks( async def get_tasks(
@@ -61,6 +68,12 @@ class TaskService(BaseService[Task]):
stmt = stmt.where(Task.status == status) stmt = stmt.where(Task.status == status)
stmt = stmt.order_by(Task.created_at.desc()) stmt = stmt.order_by(Task.created_at.desc())
# 加载关联关系
stmt = stmt.options(
selectinload(Task.course_links).selectinload(TaskCourse.course),
selectinload(Task.assignments)
)
# 获取总数 # 获取总数
count_stmt = select(func.count()).select_from(Task).where(Task.is_deleted == False) count_stmt = select(func.count()).select_from(Task).where(Task.is_deleted == False)
@@ -71,7 +84,7 @@ class TaskService(BaseService[Task]):
# 分页 # 分页
stmt = stmt.offset((page - 1) * page_size).limit(page_size) stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt) result = await db.execute(stmt)
tasks = result.scalars().all() tasks = result.unique().scalars().all()
return tasks, total return tasks, total

View File

@@ -1,14 +1,14 @@
""" """
工具模块 工具模块
""" """
from app.utils.score_distributor import ( from app.utils.score_distributor import (
ScoreDistributor, ScoreDistributor,
distribute_scores, distribute_scores,
get_question_score, get_question_score,
) )
__all__ = [ __all__ = [
"ScoreDistributor", "ScoreDistributor",
"distribute_scores", "distribute_scores",
"get_question_score", "get_question_score",
] ]

View File

@@ -1,218 +1,218 @@
""" """
分数分配工具 分数分配工具
解决题目分数无法整除的问题,确保: 解决题目分数无法整除的问题,确保:
1. 所有题目分数之和精确等于总分 1. 所有题目分数之和精确等于总分
2. 题目分数差异最小化最多相差1分 2. 题目分数差异最小化最多相差1分
3. 支持整数分配和小数分配两种模式 3. 支持整数分配和小数分配两种模式
""" """
from typing import List, Tuple from typing import List, Tuple
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
import math import math
class ScoreDistributor: class ScoreDistributor:
""" """
智能分数分配器 智能分数分配器
使用示例: 使用示例:
distributor = ScoreDistributor(total_score=100, question_count=6) distributor = ScoreDistributor(total_score=100, question_count=6)
scores = distributor.distribute() scores = distributor.distribute()
# 结果: [17, 17, 17, 17, 16, 16] 总和=100 # 结果: [17, 17, 17, 17, 16, 16] 总和=100
""" """
def __init__(self, total_score: float, question_count: int): def __init__(self, total_score: float, question_count: int):
""" """
初始化分配器 初始化分配器
Args: Args:
total_score: 总分(如 100 total_score: 总分(如 100
question_count: 题目数量(如 6 question_count: 题目数量(如 6
""" """
if question_count <= 0: if question_count <= 0:
raise ValueError("题目数量必须大于0") raise ValueError("题目数量必须大于0")
if total_score <= 0: if total_score <= 0:
raise ValueError("总分必须大于0") raise ValueError("总分必须大于0")
self.total_score = total_score self.total_score = total_score
self.question_count = question_count self.question_count = question_count
def distribute_integer(self) -> List[int]: def distribute_integer(self) -> List[int]:
""" """
整数分配模式 整数分配模式
将总分分配为整数前面的题目分数可能比后面的多1分 将总分分配为整数前面的题目分数可能比后面的多1分
Returns: Returns:
分数列表,如 [17, 17, 17, 17, 16, 16] 分数列表,如 [17, 17, 17, 17, 16, 16]
示例: 示例:
100分 / 6题 = [17, 17, 17, 17, 16, 16] 100分 / 6题 = [17, 17, 17, 17, 16, 16]
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14] 100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
""" """
total = int(self.total_score) total = int(self.total_score)
count = self.question_count count = self.question_count
# 基础分数(向下取整) # 基础分数(向下取整)
base_score = total // count base_score = total // count
# 需要额外加1分的题目数量 # 需要额外加1分的题目数量
extra_count = total % count extra_count = total % count
# 生成分数列表 # 生成分数列表
scores = [] scores = []
for i in range(count): for i in range(count):
if i < extra_count: if i < extra_count:
scores.append(base_score + 1) scores.append(base_score + 1)
else: else:
scores.append(base_score) scores.append(base_score)
return scores return scores
def distribute_decimal(self, decimal_places: int = 1) -> List[float]: def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
""" """
小数分配模式 小数分配模式
将总分分配为小数,最后一题用于补齐差额 将总分分配为小数,最后一题用于补齐差额
Args: Args:
decimal_places: 小数位数默认1位 decimal_places: 小数位数默认1位
Returns: Returns:
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5] 分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
""" """
count = self.question_count count = self.question_count
# 计算每题分数并四舍五入 # 计算每题分数并四舍五入
per_score = self.total_score / count per_score = self.total_score / count
rounded_score = round(per_score, decimal_places) rounded_score = round(per_score, decimal_places)
# 前 n-1 题使用四舍五入的分数 # 前 n-1 题使用四舍五入的分数
scores = [rounded_score] * (count - 1) scores = [rounded_score] * (count - 1)
# 最后一题用总分减去前面的和,确保总分精确 # 最后一题用总分减去前面的和,确保总分精确
last_score = round(self.total_score - sum(scores), decimal_places) last_score = round(self.total_score - sum(scores), decimal_places)
scores.append(last_score) scores.append(last_score)
return scores return scores
def distribute(self, mode: str = "integer") -> List[float]: def distribute(self, mode: str = "integer") -> List[float]:
""" """
分配分数 分配分数
Args: Args:
mode: 分配模式 mode: 分配模式
- "integer": 整数分配(推荐) - "integer": 整数分配(推荐)
- "decimal": 小数分配 - "decimal": 小数分配
- "decimal_1": 保留1位小数 - "decimal_1": 保留1位小数
- "decimal_2": 保留2位小数 - "decimal_2": 保留2位小数
Returns: Returns:
分数列表 分数列表
""" """
if mode == "integer": if mode == "integer":
return [float(s) for s in self.distribute_integer()] return [float(s) for s in self.distribute_integer()]
elif mode == "decimal" or mode == "decimal_1": elif mode == "decimal" or mode == "decimal_1":
return self.distribute_decimal(1) return self.distribute_decimal(1)
elif mode == "decimal_2": elif mode == "decimal_2":
return self.distribute_decimal(2) return self.distribute_decimal(2)
else: else:
return [float(s) for s in self.distribute_integer()] return [float(s) for s in self.distribute_integer()]
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float: def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
""" """
获取指定题目的分数 获取指定题目的分数
Args: Args:
question_index: 题目索引从0开始 question_index: 题目索引从0开始
mode: 分配模式 mode: 分配模式
Returns: Returns:
该题目的分数 该题目的分数
""" """
scores = self.distribute(mode) scores = self.distribute(mode)
if 0 <= question_index < len(scores): if 0 <= question_index < len(scores):
return scores[question_index] return scores[question_index]
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})") raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
def validate(self) -> Tuple[bool, str]: def validate(self) -> Tuple[bool, str]:
""" """
验证分配结果 验证分配结果
Returns: Returns:
(是否有效, 信息) (是否有效, 信息)
""" """
scores = self.distribute() scores = self.distribute()
total = sum(scores) total = sum(scores)
if abs(total - self.total_score) < 0.01: if abs(total - self.total_score) < 0.01:
return True, f"分配有效:{scores},总分={total}" return True, f"分配有效:{scores},总分={total}"
else: else:
return False, f"分配无效:{scores},总分={total},期望={self.total_score}" return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
@staticmethod @staticmethod
def format_score(score: float, decimal_places: int = 1) -> str: def format_score(score: float, decimal_places: int = 1) -> str:
""" """
格式化分数显示 格式化分数显示
Args: Args:
score: 分数 score: 分数
decimal_places: 小数位数 decimal_places: 小数位数
Returns: Returns:
格式化的分数字符串 格式化的分数字符串
""" """
if score == int(score): if score == int(score):
return str(int(score)) return str(int(score))
return f"{score:.{decimal_places}f}" return f"{score:.{decimal_places}f}"
@staticmethod @staticmethod
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float: def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
""" """
计算及格分数 计算及格分数
Args: Args:
total_score: 总分 total_score: 总分
pass_rate: 及格率默认60% pass_rate: 及格率默认60%
Returns: Returns:
及格分数(整数) 及格分数(整数)
""" """
return math.ceil(total_score * pass_rate) return math.ceil(total_score * pass_rate)
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]: def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
""" """
便捷函数:分配分数 便捷函数:分配分数
Args: Args:
total_score: 总分 total_score: 总分
question_count: 题目数量 question_count: 题目数量
mode: 分配模式integer/decimal mode: 分配模式integer/decimal
Returns: Returns:
分数列表 分数列表
""" """
distributor = ScoreDistributor(total_score, question_count) distributor = ScoreDistributor(total_score, question_count)
return distributor.distribute(mode) return distributor.distribute(mode)
def get_question_score( def get_question_score(
total_score: float, total_score: float,
question_count: int, question_count: int,
question_index: int, question_index: int,
mode: str = "integer" mode: str = "integer"
) -> float: ) -> float:
""" """
便捷函数:获取指定题目的分数 便捷函数:获取指定题目的分数
Args: Args:
total_score: 总分 total_score: 总分
question_count: 题目数量 question_count: 题目数量
question_index: 题目索引从0开始 question_index: 题目索引从0开始
mode: 分配模式 mode: 分配模式
Returns: Returns:
该题目的分数 该题目的分数
""" """
distributor = ScoreDistributor(total_score, question_count) distributor = ScoreDistributor(total_score, question_count)
return distributor.get_score_for_question(question_index, mode) return distributor.get_score_for_question(question_index, mode)

View File

@@ -31,6 +31,9 @@ PyMySQL==1.1.0
httpx==0.27.2 httpx==0.27.2
aiofiles==23.2.1 aiofiles==23.2.1
# 对象存储MinIO
minio>=7.2.0
# 日志 # 日志
structlog==23.2.0 structlog==23.2.0
@@ -51,9 +54,10 @@ openpyxl==3.1.2
json-repair>=0.25.0 json-repair>=0.25.0
jsonschema>=4.0.0 jsonschema>=4.0.0
# PDF 文档提取 # 文档提取
PyPDF2>=3.0.0 PyPDF2>=3.0.0
python-docx>=1.0.0 python-docx>=1.0.0
python-pptx>=0.6.21
# 证书生成 # 证书生成
Pillow>=10.0.0 Pillow>=10.0.0

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
修复历史考试的小数分数问题
将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数
使用智能整数分配算法,确保所有题目分数之和等于总分
使用方法:
# 在后端容器中执行
cd /app
python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改
python scripts/fix_exam_scores.py # 实际执行修复
"""
import sys
import json
import argparse
from decimal import Decimal
# 添加项目路径
sys.path.insert(0, '/app')
def distribute_integer_scores(total_score: float, question_count: int) -> list:
"""
整数分配分数
将总分分配为整数前面的题目分数可能比后面的多1分
示例:
100分 / 6题 = [17, 17, 17, 17, 16, 16]
100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5]
"""
total = int(total_score)
count = question_count
# 基础分数(向下取整)
base_score = total // count
# 需要额外加1分的题目数量
extra_count = total % count
# 生成分数列表
scores = []
for i in range(count):
if i < extra_count:
scores.append(base_score + 1)
else:
scores.append(base_score)
return scores
def is_decimal_score(score) -> bool:
"""检查分数是否是小数(非整数)"""
if score is None:
return False
try:
score_float = float(score)
return score_float != int(score_float)
except (ValueError, TypeError):
return False
def fix_exam_scores(dry_run: bool = True, db_url: str = None):
"""
修复考试分数
Args:
dry_run: 如果为 True只预览不实际修改
db_url: 数据库连接字符串,如果为 None 则从环境变量读取
"""
import os
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# 获取数据库连接
if db_url is None:
db_url = os.environ.get('DATABASE_URL')
if not db_url:
# 尝试从配置文件读取
try:
from app.core.config import settings
db_url = settings.DATABASE_URL
except:
print("错误:无法获取数据库连接字符串")
print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行")
sys.exit(1)
# 将 mysql+aiomysql:// 转换为 mysql+pymysql://
if 'aiomysql' in db_url:
db_url = db_url.replace('aiomysql', 'pymysql')
print(f"连接数据库...")
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
# 查询所有考试记录
result = session.execute(text("""
SELECT id, user_id, course_id, exam_name, question_count, total_score, questions
FROM exams
WHERE questions IS NOT NULL
ORDER BY id DESC
"""))
exams = result.fetchall()
print(f"找到 {len(exams)} 条考试记录")
fixed_count = 0
skipped_count = 0
error_count = 0
for exam in exams:
exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam
try:
# 解析 questions JSON
if isinstance(questions_json, str):
questions = json.loads(questions_json)
else:
questions = questions_json
if not questions or not isinstance(questions, list):
skipped_count += 1
continue
# 检查是否有小数分数
has_decimal = False
for q in questions:
if 'score' in q and is_decimal_score(q['score']):
has_decimal = True
break
if not has_decimal:
skipped_count += 1
continue
# 计算新的整数分数
actual_count = len(questions)
actual_total = total_score or 100
new_scores = distribute_integer_scores(actual_total, actual_count)
# 更新每道题的分数
old_scores = [q.get('score', 0) for q in questions]
for i, q in enumerate(questions):
q['score'] = new_scores[i]
# 验证总分
new_total = sum(new_scores)
if abs(new_total - actual_total) > 0.01:
print(f" 警告exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}")
error_count += 1
continue
if dry_run:
print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}")
print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}")
print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}")
else:
# 实际更新数据库
new_json = json.dumps(questions, ensure_ascii=False)
session.execute(text("""
UPDATE exams SET questions = :questions WHERE id = :exam_id
"""), {"questions": new_json, "exam_id": exam_id})
print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}")
fixed_count += 1
except Exception as e:
print(f" 错误:处理 exam_id={exam_id} 时出错: {e}")
error_count += 1
continue
if not dry_run:
session.commit()
print("\n已提交数据库更改")
print(f"\n=== 统计 ===")
print(f"需要修复: {fixed_count}")
print(f"已跳过(无小数): {skipped_count}")
print(f"错误: {error_count}")
if dry_run:
print("\n这是预览模式,未实际修改数据库。")
print("如需实际执行,请去掉 --dry-run 参数重新运行。")
except Exception as e:
print(f"执行失败: {e}")
session.rollback()
raise
finally:
session.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题")
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库")
parser.add_argument("--db-url", type=str, help="数据库连接字符串")
args = parser.parse_args()
fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url)

View File

@@ -1,64 +1,38 @@
#!/bin/bash #!/bin/bash
# 统一启动脚本 - 根据环境变量自动配置
# 颜色定义 # 默认配置
RED='\033[0;31m' HOST=${HOST:-0.0.0.0}
GREEN='\033[0;32m' PORT=${PORT:-8000}
YELLOW='\033[1;33m' WORKERS=${WORKERS:-1}
NC='\033[0m' # No Color RELOAD=${RELOAD:-false}
TIMEOUT_KEEP_ALIVE=${TIMEOUT_KEEP_ALIVE:-600}
echo -e "${GREEN}考培练系统后端启动脚本${NC}" echo "=============================================="
echo "================================" echo " KaoPeiLian Backend Starting..."
echo "=============================================="
echo " HOST: $HOST"
echo " PORT: $PORT"
echo " WORKERS: $WORKERS"
echo " RELOAD: $RELOAD"
echo " TIMEOUT_KEEP_ALIVE: $TIMEOUT_KEEP_ALIVE"
echo "=============================================="
# 检查Python版本 # 构建启动命令
echo -e "${YELLOW}检查Python版本...${NC}" CMD="uvicorn app.main:app --host $HOST --port $PORT --timeout-keep-alive $TIMEOUT_KEEP_ALIVE"
python_version=$(python3 --version 2>&1)
if [[ $? -eq 0 ]]; then if [ "$RELOAD" = "true" ]; then
echo -e "${GREEN}$python_version${NC}" # 开发模式启用热重载不支持多workers
CMD="$CMD --reload --reload-dir /app/app"
echo "Mode: Development (hot reload enabled)"
else else
echo -e "${RED}✗ Python3未安装${NC}" # 生产模式多workers
exit 1 CMD="$CMD --workers $WORKERS"
echo "Mode: Production ($WORKERS workers)"
fi fi
# 检查虚拟环境 echo ""
if [ ! -d "venv" ]; then echo "Executing: $CMD"
echo -e "${YELLOW}创建虚拟环境...${NC}" echo ""
python3 -m venv venv
fi
# 激活虚拟环境 exec $CMD
echo -e "${YELLOW}激活虚拟环境...${NC}"
source venv/bin/activate
# 安装依赖
echo -e "${YELLOW}安装依赖...${NC}"
pip install -q -r requirements/base.txt
# 检查.env文件
if [ ! -f ".env" ]; then
echo -e "${YELLOW}创建.env文件...${NC}"
cp .env.example .env
echo -e "${GREEN}✓ 已创建.env文件请根据需要修改配置${NC}"
fi
# 检查数据库连接
echo -e "${YELLOW}检查数据库连接...${NC}"
python -c "
import os
from dotenv import load_dotenv
load_dotenv()
db_url = os.getenv('DATABASE_URL', '')
if 'mysql' in db_url:
print('✓ 数据库配置已设置')
else:
print('⚠ 请检查数据库配置')
" 2>/dev/null
# 启动服务
echo -e "${GREEN}启动开发服务器...${NC}"
echo "================================"
echo -e "API文档: ${GREEN}http://localhost:8000/api/docs${NC}"
echo -e "健康检查: ${GREEN}http://localhost:8000/health${NC}"
echo "================================"
# 启动uvicorn
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

View File

@@ -78,16 +78,17 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- PYTHONPATH=/app - PYTHONPATH=/app
- WORKERS=4 # 生产环境4个workers
- RELOAD=false # 生产环境关闭热重载
ports: ports:
- "8010:8000" - "8010:8000"
volumes: volumes:
- ./kaopeilian-backend/app:/app/app # 代码热重载 - ./kaopeilian-backend/app:/app/app
- /data/prod-envs/uploads-hua:/app/uploads - /data/prod-envs/uploads-hua:/app/uploads
- /data/prod-envs/logs-hua:/app/logs - /data/prod-envs/logs-hua:/app/logs
- /data/prod-envs/secrets:/app/secrets:ro - /data/prod-envs/secrets:/app/secrets:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks: networks:
- prod-network - prod-network
- kaopeilian-network - kaopeilian-network
@@ -164,16 +165,17 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- PYTHONPATH=/app - PYTHONPATH=/app
- WORKERS=4
- RELOAD=false
ports: ports:
- "8011:8000" - "8011:8000"
volumes: volumes:
- ./kaopeilian-backend/app:/app/app # 代码热重载 - ./kaopeilian-backend/app:/app/app
- /data/prod-envs/uploads-yy:/app/uploads - /data/prod-envs/uploads-yy:/app/uploads
- /data/prod-envs/logs-yy:/app/logs - /data/prod-envs/logs-yy:/app/logs
- /data/prod-envs/secrets:/app/secrets:ro - /data/prod-envs/secrets:/app/secrets:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks: networks:
- prod-network - prod-network
- kaopeilian-network - kaopeilian-network
@@ -250,16 +252,17 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- PYTHONPATH=/app - PYTHONPATH=/app
- WORKERS=4
- RELOAD=false
ports: ports:
- "8012:8000" - "8012:8000"
volumes: volumes:
- ./kaopeilian-backend/app:/app/app # 代码热重载 - ./kaopeilian-backend/app:/app/app
- /data/prod-envs/uploads-hl:/app/uploads - /data/prod-envs/uploads-hl:/app/uploads
- /data/prod-envs/logs-hl:/app/logs - /data/prod-envs/logs-hl:/app/logs
- /data/prod-envs/secrets:/app/secrets:ro - /data/prod-envs/secrets:/app/secrets:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks: networks:
- prod-network - prod-network
- kaopeilian-network - kaopeilian-network
@@ -336,16 +339,17 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- PYTHONPATH=/app - PYTHONPATH=/app
- WORKERS=4
- RELOAD=false
ports: ports:
- "8013:8000" - "8013:8000"
volumes: volumes:
- ./kaopeilian-backend/app:/app/app # 代码热重载 - ./kaopeilian-backend/app:/app/app
- /data/prod-envs/uploads-xy:/app/uploads - /data/prod-envs/uploads-xy:/app/uploads
- /data/prod-envs/logs-xy:/app/logs - /data/prod-envs/logs-xy:/app/logs
- /data/prod-envs/secrets:/app/secrets:ro - /data/prod-envs/secrets:/app/secrets:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks: networks:
- prod-network - prod-network
- kaopeilian-network - kaopeilian-network
@@ -423,16 +427,17 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- PYTHONPATH=/app - PYTHONPATH=/app
- WORKERS=4
- RELOAD=false
ports: ports:
- "8014:8000" - "8014:8000"
volumes: volumes:
- ./kaopeilian-backend/app:/app/app # 代码热重载 - ./kaopeilian-backend/app:/app/app
- /data/prod-envs/uploads-fw:/app/uploads - /data/prod-envs/uploads-fw:/app/uploads
- /data/prod-envs/logs-fw:/app/logs - /data/prod-envs/logs-fw:/app/logs
- /data/prod-envs/secrets:/app/secrets:ro - /data/prod-envs/secrets:/app/secrets:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks: networks:
- prod-network - prod-network
- kaopeilian-network - kaopeilian-network
@@ -508,6 +513,8 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- PYTHONPATH=/app - PYTHONPATH=/app
- WORKERS=4
- RELOAD=false
ports: ports:
- "8016:8000" - "8016:8000"
volumes: volumes:
@@ -517,7 +524,6 @@ services:
- /data/prod-envs/secrets:/app/secrets:ro - /data/prod-envs/secrets:/app/secrets:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks: networks:
- prod-network - prod-network
- kaopeilian-network - kaopeilian-network

View File

@@ -1,6 +1,6 @@
# 考培练系统 - 环境配置与部署指南 # 考培练系统 - 环境配置与部署指南
> 最后更新2026-01-28 > 最后更新2026-02-03
## 一、环境总览 ## 一、环境总览
@@ -156,7 +156,94 @@ npm run build
--- ---
## 六、容器管理 ## 六、对象存储MinIO
> 2026-02-03 新增,用于统一管理所有租户的文件存储
### 6.1 服务信息
| 项目 | 值 |
|------|-----|
| **容器名** | kaopeilian-minio |
| **API端口** | 9000 |
| **管理界面端口** | 9001 |
| **数据目录** | /data/minio/data |
| **网络** | prod-network |
### 6.2 访问方式
- **管理界面**: http://120.79.247.16:9001
- **API端点**: http://kaopeilian-minio:9000容器内
- **用户名**: `kaopeilian_admin`
- **密码**: `KplMinio2026!@#`
### 6.3 Bucket列表
| Bucket名称 | 租户 | 说明 |
|-----------|------|------|
| kpl-ex | 恩喜成都总院 | 生产环境 |
| kpl-hua | 华尔倍丽 | 生产环境 |
| kpl-yy | 杨扬宠物 | 生产环境 |
| kpl-hl | 武汉禾丽 | 生产环境 |
| kpl-xy | 芯颜定制 | 生产环境 |
| kpl-fw | 飞沃 | 生产环境 |
| kpl-cxw | 崔曦文 | 生产环境 |
| kpl-demo | 演示环境 | 预生产 |
| kpl-kpl | KPL测试 | 测试环境 |
| kpl-peilian | 陪练项目 | 其他项目 |
### 6.4 后端配置
`.env` 或环境变量中设置:
```bash
# MinIO配置
MINIO_ENABLED=true
MINIO_ENDPOINT=kaopeilian-minio:9000
MINIO_ACCESS_KEY=kaopeilian_admin
MINIO_SECRET_KEY=KplMinio2026!@#
MINIO_SECURE=false
```
### 6.5 常用命令
```bash
# SSH登录服务器后
# 查看MinIO状态
docker ps | grep minio
# 查看MinIO日志
docker logs kaopeilian-minio --tail 50
# 使用mc客户端操作
mc ls kpl/ # 列出所有bucket
mc ls kpl/kpl-ex/ # 列出ex租户文件
mc cp file.pdf kpl/kpl-ex/ # 上传文件
mc rm kpl/kpl-ex/file.pdf # 删除文件
```
### 6.6 架构说明
```
用户上传文件
后端 storage_service
MinIO对象存储持久化+ 本地缓存(加速预览)
Nginx代理 → 用户下载
```
**特性**
- 自动降级MinIO不可用时自动使用本地存储
- URL兼容保持 `/static/uploads/` 格式,前端无需改动
- 智能缓存:文件自动下载到本地缓存用于预览/分析
- 多租户隔离每个租户独立Bucket
---
## 七、容器管理
### 当前运行容器统计 ### 当前运行容器统计
@@ -166,8 +253,9 @@ npm run build
| 后端容器 | 11 | | 后端容器 | 11 |
| Redis | 10 | | Redis | 10 |
| MySQL | 4 | | MySQL | 4 |
| MinIO | 1 |
| Nginx | 1 | | Nginx | 1 |
| **总计** | **37** | | **总计** | **38** |
### 查看所有容器 ### 查看所有容器
@@ -177,7 +265,7 @@ docker ps --format 'table {{.Names}}\t{{.Status}}'
--- ---
## 、测试账户 ## 、测试账户
| 角色 | 用户名 | 密码 | | 角色 | 用户名 | 密码 |
|------|--------|------| |------|--------|------|
@@ -187,7 +275,7 @@ docker ps --format 'table {{.Names}}\t{{.Status}}'
--- ---
## 、注意事项 ## 、注意事项
1. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新 1. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新
2. **后端独立**:每个租户有独立的后端容器和数据库 2. **后端独立**:每个租户有独立的后端容器和数据库
@@ -198,7 +286,7 @@ docker ps --format 'table {{.Names}}\t{{.Status}}'
--- ---
## 、Git 仓库配置 ## 、Git 仓库配置
```bash ```bash
# 查看远程仓库 # 查看远程仓库

View File

@@ -25,6 +25,11 @@ VITE_ENABLE_DEVTOOLS=true
VITE_ENABLE_ERROR_REPORTING=true VITE_ENABLE_ERROR_REPORTING=true
VITE_ENABLE_ANALYTICS=false VITE_ENABLE_ANALYTICS=false
# 实验性功能开关(开发环境默认开启)
VITE_FEATURE_DUO_PRACTICE=true
VITE_FEATURE_AI_PRACTICE=true
VITE_FEATURE_GROWTH_PATH=true
# 安全配置 # 安全配置
VITE_JWT_EXPIRE_TIME=86400 VITE_JWT_EXPIRE_TIME=86400
VITE_REFRESH_TOKEN_EXPIRE_TIME=604800 VITE_REFRESH_TOKEN_EXPIRE_TIME=604800

View File

@@ -5,3 +5,8 @@ VITE_WS_BASE_URL=wss://aiedu.ireborn.com.cn
VITE_USE_MOCK_DATA=false VITE_USE_MOCK_DATA=false
VITE_ENABLE_REQUEST_LOG=false VITE_ENABLE_REQUEST_LOG=false
NODE_ENV=production NODE_ENV=production
# 实验性功能开关(生产环境默认关闭未上线功能)
VITE_FEATURE_DUO_PRACTICE=false
VITE_FEATURE_AI_PRACTICE=true
VITE_FEATURE_GROWTH_PATH=true

View File

@@ -64,9 +64,7 @@ export interface TrendData {
export interface LevelDistribution { export interface LevelDistribution {
levels: number[] levels: number[]
counts: number[] counts: number[]
} }// 实时动态
// 实时动态
export interface ActivityItem { export interface ActivityItem {
id: number id: number
user_id: number user_id: number

View File

@@ -167,6 +167,8 @@ export interface CreateGrowthPathNode {
is_required: boolean is_required: boolean
prerequisites?: number[] prerequisites?: number[]
estimated_days: number estimated_days: number
position_x?: number // 画布X坐标
position_y?: number // 画布Y坐标
} }
// 创建成长路径请求 // 创建成长路径请求
@@ -360,7 +362,7 @@ export const getManagerCourses = (params: {
total: number total: number
page: number page: number
size: number size: number
}>('/api/v1/manager/courses', { params }) }>('/api/v1/courses', { params })
} }
/** /**

View File

@@ -10,6 +10,21 @@ import { loadingManager } from '@/utils/loadingManager'
// 模拟延迟,使体验更真实 // 模拟延迟,使体验更真实
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// Token刷新状态管理
let isRefreshing = false
let refreshSubscribers: Array<(token: string) => void> = []
// 订阅token刷新
const subscribeTokenRefresh = (callback: (token: string) => void) => {
refreshSubscribers.push(callback)
}
// 通知所有订阅者
const onTokenRefreshed = (token: string) => {
refreshSubscribers.forEach(callback => callback(token))
refreshSubscribers = []
}
// 扩展RequestInit接口以支持transformRequest // 扩展RequestInit接口以支持transformRequest
interface ExtendedRequestInit extends RequestInit { interface ExtendedRequestInit extends RequestInit {
transformRequest?: Array<(data: any, headers?: any) => any> transformRequest?: Array<(data: any, headers?: any) => any>
@@ -108,24 +123,84 @@ class Request {
} catch (error) { } catch (error) {
// 处理HTTP错误 // 处理HTTP错误
const errorInfo = handleHttpError(error) const errorInfo = handleHttpError(error)
// 401 统一处理:清理本地状态并跳转登录 const status = (errorInfo as any)?.status || (error as any)?.status
try {
const status = (errorInfo as any)?.status || (error as any)?.status // 401 处理先尝试刷新Token失败后再跳转登录
if (status === 401) { if (status === 401 && !url.includes('/auth/refresh') && !url.includes('/auth/login')) {
console.warn('[Auth] Token过期或无效正在清理认证状态', { url, status }) const refreshToken = localStorage.getItem('refresh_token')
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token') if (refreshToken) {
localStorage.removeItem('current_user') // 如果已经在刷新中,等待刷新完成后重试
// 避免死循环,仅在非登录页执行 if (isRefreshing) {
if (!location.pathname.startsWith('/login')) { return new Promise<ApiResponse<T>>((resolve, reject) => {
console.info('[Auth] 重定向到登录页') subscribeTokenRefresh((newToken: string) => {
location.href = '/login' // 使用新token重试原请求
options.headers = {
...options.headers,
'Authorization': `Bearer ${newToken}`
}
this.request<T>(url, options, showLoading).then(resolve).catch(reject)
})
})
}
isRefreshing = true
console.info('[Auth] Token过期尝试刷新...')
try {
// 调用刷新接口
const refreshResponse = await fetch(`${API_CONFIG.baseURL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
})
if (refreshResponse.ok) {
const refreshData = await refreshResponse.json()
if (refreshData.code === 200 && refreshData.data?.token) {
const newAccessToken = refreshData.data.token.access_token
const newRefreshToken = refreshData.data.token.refresh_token
// 保存新token
localStorage.setItem('access_token', newAccessToken)
if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken)
}
console.info('[Auth] Token刷新成功')
isRefreshing = false
onTokenRefreshed(newAccessToken)
// 使用新token重试原请求
options.headers = {
...options.headers,
'Authorization': `Bearer ${newAccessToken}`
}
return this.request<T>(url, options, showLoading)
}
}
// 刷新失败,执行登出
console.warn('[Auth] Token刷新失败需要重新登录')
isRefreshing = false
} catch (refreshError) {
console.error('[Auth] Token刷新异常:', refreshError)
isRefreshing = false
} }
} }
} catch (authError) {
// 认证处理过程中的异常不应影响主流程,但需要记 // 无refresh_token或刷新失败清理状态并跳转登
console.error('[Auth] 处理401错误时发生异常:', authError) console.warn('[Auth] Token过期或无效正在清理认证状态', { url, status })
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('current_user')
if (!location.pathname.startsWith('/login')) {
console.info('[Auth] 重定向到登录页')
location.href = '/login'
}
} }
throw errorInfo throw errorInfo
} finally { } finally {
if (showLoading) { if (showLoading) {

View File

@@ -106,3 +106,9 @@ export function deleteTask(id: number): Promise<ResponseModel<void>> {
return http.delete(`/api/v1/manager/tasks/${id}`) return http.delete(`/api/v1/manager/tasks/${id}`)
} }
/**
* 发送任务提醒
*/
export function sendTaskReminder(id: number): Promise<ResponseModel<void>> {
return http.post(`/api/v1/manager/tasks/${id}/remind`)
}

View File

@@ -110,6 +110,23 @@ class EnvConfig {
public readonly ENABLE_ERROR_REPORTING = import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true' public readonly ENABLE_ERROR_REPORTING = import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true'
public readonly ENABLE_ANALYTICS = import.meta.env.VITE_ENABLE_ANALYTICS === 'true' public readonly ENABLE_ANALYTICS = import.meta.env.VITE_ENABLE_ANALYTICS === 'true'
// 实验性功能开关Feature Flags
public readonly FEATURE_DUO_PRACTICE = import.meta.env.VITE_FEATURE_DUO_PRACTICE === 'true'
public readonly FEATURE_AI_PRACTICE = import.meta.env.VITE_FEATURE_AI_PRACTICE !== 'false' // 默认开启
public readonly FEATURE_GROWTH_PATH = import.meta.env.VITE_FEATURE_GROWTH_PATH !== 'false' // 默认开启
/**
* 检查功能是否启用
*/
public isFeatureEnabled(feature: string): boolean {
const featureMap: Record<string, boolean> = {
'duo-practice': this.FEATURE_DUO_PRACTICE,
'ai-practice': this.FEATURE_AI_PRACTICE,
'growth-path': this.FEATURE_GROWTH_PATH
}
return featureMap[feature] ?? false
}
// 安全配置 // 安全配置
public readonly JWT_EXPIRE_TIME = parseInt(import.meta.env.VITE_JWT_EXPIRE_TIME || '86400') // 24小时 public readonly JWT_EXPIRE_TIME = parseInt(import.meta.env.VITE_JWT_EXPIRE_TIME || '86400') // 24小时
public readonly REFRESH_TOKEN_EXPIRE_TIME = parseInt(import.meta.env.VITE_REFRESH_TOKEN_EXPIRE_TIME || '604800') // 7天 public readonly REFRESH_TOKEN_EXPIRE_TIME = parseInt(import.meta.env.VITE_REFRESH_TOKEN_EXPIRE_TIME || '604800') // 7天

View File

@@ -190,6 +190,7 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { authManager } from '@/utils/auth' import { authManager } from '@/utils/auth'
import NotificationBell from '@/components/NotificationBell.vue' import NotificationBell from '@/components/NotificationBell.vue'
import { env } from '@/config/env'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -261,7 +262,8 @@ const menuConfig = [
{ {
path: '/trainee/duo-practice', path: '/trainee/duo-practice',
title: '双人对练', title: '双人对练',
icon: 'Connection' icon: 'Connection',
feature: 'duo-practice' // 功能开关标识
} }
] ]
}, },
@@ -377,11 +379,34 @@ const menuConfig = [
// 获取菜单路由 // 获取菜单路由
const menuRoutes = computed(() => { const menuRoutes = computed(() => {
// 仅保留当前用户可访问的菜单项 const userRole = authManager.getUserRole()
// 仅保留当前用户可访问的菜单项和启用的功能
const filterChildren = (children: any[] = []) => const filterChildren = (children: any[] = []) =>
children.filter((child: any) => authManager.canAccessRoute(child.path)) children.filter((child: any) => {
// 检查权限
if (!authManager.canAccessRoute(child.path)) return false
// 检查功能开关
if (child.feature && !env.isFeatureEnabled(child.feature)) return false
return true
})
// 根据角色预过滤顶级菜单
const roleMenuFilter = (route: any): boolean => {
// 管理者中心:仅 admin 和 manager 可见
if (route.path === '/manager') {
return userRole === 'admin' || userRole === 'manager'
}
// 系统管理:仅 admin 可见
if (route.path === '/admin') {
return userRole === 'admin'
}
// 数据分析:所有登录用户可见(但子菜单会进一步过滤)
return true
}
return menuConfig return menuConfig
.filter(roleMenuFilter) // 先按角色过滤顶级菜单
.map((route: any) => { .map((route: any) => {
const next = { ...route } const next = { ...route }
if (route.children && route.children.length > 0) { if (route.children && route.children.length > 0) {
@@ -390,8 +415,10 @@ const menuRoutes = computed(() => {
return next return next
}) })
.filter((route: any) => { .filter((route: any) => {
// 有子菜单至少一个可访问 // 有子菜单的必须至少一个可访问的子项
if (route.children && route.children.length > 0) return true if (route.children !== undefined) {
return route.children.length > 0
}
// 无子菜单时检查自身路径 // 无子菜单时检查自身路径
return authManager.canAccessRoute(route.path) return authManager.canAccessRoute(route.path)
}) })

View File

@@ -8,6 +8,7 @@ import { ElMessage } from 'element-plus'
import { authManager } from '@/utils/auth' import { authManager } from '@/utils/auth'
import { loadingManager } from '@/utils/loadingManager' import { loadingManager } from '@/utils/loadingManager'
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker' import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
import { env } from '@/config/env'
// 白名单路由(不需要登录) // 白名单路由(不需要登录)
const WHITE_LIST = ['/login', '/register', '/404'] const WHITE_LIST = ['/login', '/register', '/404']
@@ -54,11 +55,31 @@ export function setupRouterGuard(router: Router) {
} }
}) })
// 路由错误处理 // 路由错误处理 - 处理懒加载组件失败
router.onError((error) => { router.onError((error) => {
console.error('Router error:', error) console.error('Router error:', error)
ElMessage.error('路由加载失败')
loadingManager.stop('page-loading') loadingManager.stop('page-loading')
// 检测是否是chunk加载失败通常是部署后旧文件被清理
const isChunkLoadError =
error.message?.includes('Loading chunk') ||
error.message?.includes('Failed to fetch') ||
error.message?.includes('dynamically imported module') ||
error.name === 'ChunkLoadError'
if (isChunkLoadError) {
ElMessage({
type: 'warning',
message: '页面资源已更新,正在刷新...',
duration: 2000
})
// 延迟刷新页面以加载最新资源
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
ElMessage.error('页面加载失败,请刷新重试')
}
}) })
} }
@@ -102,6 +123,14 @@ async function handleRouteGuard(
} }
} }
// 检查功能开关
const feature = to.meta?.feature as string | undefined
if (feature && !env.isFeatureEnabled(feature)) {
ElMessage.warning('此功能暂未开放')
next(authManager.getDefaultRoute())
return
}
// 检查路由权限 // 检查路由权限
if (!checkRoutePermission(path)) { if (!checkRoutePermission(path)) {
ElMessage.error('您没有访问此页面的权限') ElMessage.error('您没有访问此页面的权限')
@@ -302,5 +331,6 @@ declare module 'vue-router' {
affix?: boolean affix?: boolean
breadcrumb?: boolean breadcrumb?: boolean
activeMenu?: string activeMenu?: string
feature?: string // 功能开关标识
} }
} }

View File

@@ -137,25 +137,25 @@ const routes: RouteRecordRaw[] = [
path: 'duo-practice', path: 'duo-practice',
name: 'DuoPractice', name: 'DuoPractice',
component: () => import('@/views/trainee/duo-practice.vue'), component: () => import('@/views/trainee/duo-practice.vue'),
meta: { title: '双人对练', icon: 'Connection' } meta: { title: '双人对练', icon: 'Connection', feature: 'duo-practice' }
}, },
{ {
path: 'duo-practice/room/:code', path: 'duo-practice/room/:code',
name: 'DuoPracticeRoom', name: 'DuoPracticeRoom',
component: () => import('@/views/trainee/duo-practice-room.vue'), component: () => import('@/views/trainee/duo-practice-room.vue'),
meta: { title: '对练房间', hidden: true } meta: { title: '对练房间', hidden: true, feature: 'duo-practice' }
}, },
{ {
path: 'duo-practice/join/:code', path: 'duo-practice/join/:code',
name: 'DuoPracticeJoin', name: 'DuoPracticeJoin',
component: () => import('@/views/trainee/duo-practice-room.vue'), component: () => import('@/views/trainee/duo-practice-room.vue'),
meta: { title: '加入对练', hidden: true } meta: { title: '加入对练', hidden: true, feature: 'duo-practice' }
}, },
{ {
path: 'duo-practice/report/:id', path: 'duo-practice/report/:id',
name: 'DuoPracticeReport', name: 'DuoPracticeReport',
component: () => import('@/views/trainee/duo-practice-report.vue'), component: () => import('@/views/trainee/duo-practice-report.vue'),
meta: { title: '对练报告', hidden: true } meta: { title: '对练报告', hidden: true, feature: 'duo-practice' }
} }
] ]
}, },

View File

@@ -1,154 +1,154 @@
/** /**
* 分数格式化工具 * 分数格式化工具
* *
* 用于在前端显示分数时进行格式化,避免显示过长的小数 * 用于在前端显示分数时进行格式化,避免显示过长的小数
*/ */
/** /**
* 格式化分数显示 * 格式化分数显示
* *
* @param score 分数 * @param score 分数
* @param decimalPlaces 小数位数默认1位 * @param decimalPlaces 小数位数默认1位
* @returns 格式化后的分数字符串 * @returns 格式化后的分数字符串
* *
* @example * @example
* formatScore(16.666666) // "16.7" * formatScore(16.666666) // "16.7"
* formatScore(17) // "17" * formatScore(17) // "17"
* formatScore(16.5, 0) // "17" * formatScore(16.5, 0) // "17"
*/ */
export function formatScore(score: number, decimalPlaces: number = 1): string { export function formatScore(score: number, decimalPlaces: number = 1): string {
// 如果是整数,直接返回 // 如果是整数,直接返回
if (Number.isInteger(score)) { if (Number.isInteger(score)) {
return score.toString() return score.toString()
} }
// 四舍五入到指定小数位 // 四舍五入到指定小数位
const rounded = Number(score.toFixed(decimalPlaces)) const rounded = Number(score.toFixed(decimalPlaces))
// 如果四舍五入后是整数,去掉小数点 // 如果四舍五入后是整数,去掉小数点
if (Number.isInteger(rounded)) { if (Number.isInteger(rounded)) {
return rounded.toString() return rounded.toString()
} }
return rounded.toFixed(decimalPlaces) return rounded.toFixed(decimalPlaces)
} }
/** /**
* 格式化分数显示(带单位) * 格式化分数显示(带单位)
* *
* @param score 分数 * @param score 分数
* @param unit 单位,默认"分" * @param unit 单位,默认"分"
* @returns 格式化后的分数字符串 * @returns 格式化后的分数字符串
* *
* @example * @example
* formatScoreWithUnit(16.7) // "16.7分" * formatScoreWithUnit(16.7) // "16.7分"
* formatScoreWithUnit(100) // "100分" * formatScoreWithUnit(100) // "100分"
*/ */
export function formatScoreWithUnit(score: number, unit: string = '分'): string { export function formatScoreWithUnit(score: number, unit: string = '分'): string {
return `${formatScore(score)}${unit}` return `${formatScore(score)}${unit}`
} }
/** /**
* 格式化百分比 * 格式化百分比
* *
* @param value 值0-1 或 0-100 * @param value 值0-1 或 0-100
* @param isPercent 是否已经是百分比形式0-100默认false * @param isPercent 是否已经是百分比形式0-100默认false
* @returns 格式化后的百分比字符串 * @returns 格式化后的百分比字符串
* *
* @example * @example
* formatPercent(0.8567) // "85.7%" * formatPercent(0.8567) // "85.7%"
* formatPercent(85.67, true) // "85.7%" * formatPercent(85.67, true) // "85.7%"
*/ */
export function formatPercent(value: number, isPercent: boolean = false): string { export function formatPercent(value: number, isPercent: boolean = false): string {
const percent = isPercent ? value : value * 100 const percent = isPercent ? value : value * 100
return `${formatScore(percent)}%` return `${formatScore(percent)}%`
} }
/** /**
* 计算及格分数 * 计算及格分数
* *
* @param totalScore 总分 * @param totalScore 总分
* @param passRate 及格率默认0.6 * @param passRate 及格率默认0.6
* @returns 及格分数(向上取整) * @returns 及格分数(向上取整)
*/ */
export function calculatePassScore(totalScore: number, passRate: number = 0.6): number { export function calculatePassScore(totalScore: number, passRate: number = 0.6): number {
return Math.ceil(totalScore * passRate) return Math.ceil(totalScore * passRate)
} }
/** /**
* 判断是否及格 * 判断是否及格
* *
* @param score 得分 * @param score 得分
* @param passScore 及格分数 * @param passScore 及格分数
* @returns 是否及格 * @returns 是否及格
*/ */
export function isPassed(score: number, passScore: number): boolean { export function isPassed(score: number, passScore: number): boolean {
return score >= passScore return score >= passScore
} }
/** /**
* 获取分数等级 * 获取分数等级
* *
* @param score 得分 * @param score 得分
* @param totalScore 总分 * @param totalScore 总分
* @returns 等级: 'excellent' | 'good' | 'pass' | 'fail' * @returns 等级: 'excellent' | 'good' | 'pass' | 'fail'
*/ */
export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' { export function getScoreLevel(score: number, totalScore: number): 'excellent' | 'good' | 'pass' | 'fail' {
const ratio = score / totalScore const ratio = score / totalScore
if (ratio >= 0.9) return 'excellent' if (ratio >= 0.9) return 'excellent'
if (ratio >= 0.75) return 'good' if (ratio >= 0.75) return 'good'
if (ratio >= 0.6) return 'pass' if (ratio >= 0.6) return 'pass'
return 'fail' return 'fail'
} }
/** /**
* 获取分数等级对应的颜色 * 获取分数等级对应的颜色
* *
* @param level 等级 * @param level 等级
* @returns 颜色值 * @returns 颜色值
*/ */
export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string { export function getScoreLevelColor(level: 'excellent' | 'good' | 'pass' | 'fail'): string {
const colors = { const colors = {
excellent: '#67c23a', // 绿色 excellent: '#67c23a', // 绿色
good: '#409eff', // 蓝色 good: '#409eff', // 蓝色
pass: '#e6a23c', // 橙色 pass: '#e6a23c', // 橙色
fail: '#f56c6c', // 红色 fail: '#f56c6c', // 红色
} }
return colors[level] return colors[level]
} }
/** /**
* 智能分配分数(前端预览用) * 智能分配分数(前端预览用)
* *
* @param totalScore 总分 * @param totalScore 总分
* @param questionCount 题目数量 * @param questionCount 题目数量
* @returns 分数数组 * @returns 分数数组
* *
* @example * @example
* distributeScores(100, 6) // [17, 17, 17, 17, 16, 16] * distributeScores(100, 6) // [17, 17, 17, 17, 16, 16]
*/ */
export function distributeScores(totalScore: number, questionCount: number): number[] { export function distributeScores(totalScore: number, questionCount: number): number[] {
if (questionCount <= 0) return [] if (questionCount <= 0) return []
const baseScore = Math.floor(totalScore / questionCount) const baseScore = Math.floor(totalScore / questionCount)
const extraCount = totalScore % questionCount const extraCount = totalScore % questionCount
const scores: number[] = [] const scores: number[] = []
for (let i = 0; i < questionCount; i++) { for (let i = 0; i < questionCount; i++) {
scores.push(i < extraCount ? baseScore + 1 : baseScore) scores.push(i < extraCount ? baseScore + 1 : baseScore)
} }
return scores return scores
} }
export default { export default {
formatScore, formatScore,
formatScoreWithUnit, formatScoreWithUnit,
formatPercent, formatPercent,
calculatePassScore, calculatePassScore,
isPassed, isPassed,
getScoreLevel, getScoreLevel,
getScoreLevelColor, getScoreLevelColor,
distributeScores, distributeScores,
} }

View File

@@ -92,10 +92,64 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 其他设置预留 --> <!-- 员工同步配置 -->
<el-tab-pane label="其他设置" name="other" disabled> <el-tab-pane label="员工同步" name="employee_sync">
<div class="tab-content"> <div class="tab-content">
<el-empty description="暂无其他设置项" /> <el-alert
title="员工同步配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px;"
>
<template #default>
<p>通过钉钉开放 API 自动同步组织架构和员工信息姓名手机号部门岗位等</p>
<p style="margin-top: 8px;">同步的员工将自动创建系统账号初始密码为 123456</p>
<p style="margin-top: 8px; color: #E6A23C;">注意员工同步复用钉钉免密登录 API 凭证配置</p>
</template>
</el-alert>
<el-form
ref="syncFormRef"
:model="syncForm"
label-width="140px"
v-loading="syncLoading"
>
<el-form-item label="启用自动同步">
<el-switch
v-model="syncForm.enabled"
active-text="已启用"
inactive-text="已禁用"
/>
<span class="form-tip">启用后将每日自动从钉钉同步员工数据</span>
</el-form-item>
<el-form-item label="钉钉 API 状态">
<el-tag :type="syncForm.configured ? 'success' : 'warning'">
{{ syncForm.configured ? '已配置' : '未配置' }}
</el-tag>
<span class="form-tip" v-if="!syncForm.configured">
请先在钉钉免密登录页签配置 API 凭证
</span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSyncConfig" :loading="syncSaving">
保存配置
</el-button>
<el-button @click="testSyncConnection" :loading="syncTesting" :disabled="!syncForm.configured">
测试连接
</el-button>
<el-button
type="success"
@click="triggerSync"
:loading="syncing"
:disabled="!syncForm.configured"
>
立即同步
</el-button>
</el-form-item>
</el-form>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -114,6 +168,13 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const dingtalkFormRef = ref<FormInstance>() const dingtalkFormRef = ref<FormInstance>()
// 员工同步配置
const syncLoading = ref(false)
const syncSaving = ref(false)
const syncTesting = ref(false)
const syncing = ref(false)
const syncFormRef = ref<FormInstance>()
// 钉钉配置表单 // 钉钉配置表单
const dingtalkForm = reactive({ const dingtalkForm = reactive({
enabled: false, enabled: false,
@@ -124,6 +185,12 @@ const dingtalkForm = reactive({
corp_id: '', corp_id: '',
}) })
// 员工同步配置表单(复用钉钉免密登录配置)
const syncForm = reactive({
enabled: false,
configured: false, // 钉钉 API 是否已配置
})
// 表单验证规则 // 表单验证规则
const dingtalkRules = reactive<FormRules>({ const dingtalkRules = reactive<FormRules>({
app_key: [ app_key: [
@@ -137,6 +204,8 @@ const dingtalkRules = reactive<FormRules>({
] ]
}) })
const syncRules = reactive<FormRules>({})
/** /**
* 加载钉钉配置 * 加载钉钉配置
*/ */
@@ -206,9 +275,105 @@ const saveDingtalkConfig = async () => {
}) })
} }
/**
* 加载员工同步配置(复用钉钉免密登录配置)
*/
const loadSyncConfig = async () => {
syncLoading.value = true
try {
const response = await request.get('/api/v1/settings/employee-sync')
if (response.code === 200 && response.data) {
syncForm.enabled = response.data.enabled || false
syncForm.configured = response.data.configured || false
}
} catch (error: any) {
console.error('加载员工同步配置失败:', error)
} finally {
syncLoading.value = false
}
}
/**
* 保存员工同步配置(仅开关)
*/
const saveSyncConfig = async () => {
syncSaving.value = true
try {
const response = await request.put('/api/v1/settings/employee-sync', {
enabled: syncForm.enabled,
})
if (response.code === 200) {
ElMessage.success('配置保存成功')
await loadSyncConfig()
} else {
ElMessage.error(response.message || '保存失败')
}
} catch (error: any) {
console.error('保存员工同步配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
syncSaving.value = false
}
}
/**
* 测试员工同步数据库连接
*/
const testSyncConnection = async () => {
syncTesting.value = true
try {
const response = await request.post('/api/v1/settings/employee-sync/test')
if (response.code === 200) {
ElMessage.success(response.message || '连接成功')
} else {
ElMessage.error(response.message || '连接失败')
}
} catch (error: any) {
console.error('测试连接失败:', error)
ElMessage.error('测试连接失败')
} finally {
syncTesting.value = false
}
}
/**
* 立即执行员工同步
*/
const triggerSync = async () => {
syncing.value = true
try {
const response = await request.post('/api/v1/employee-sync/sync')
if (response.success) {
const data = response.data
const created = data.users_created || 0
const existing = data.users_existing || 0
const restored = data.users_restored || 0
const departed = data.users_departed || 0
const skipped = data.users_skipped || 0
let msg = `同步完成!钉钉在职 ${data.total_employees || 0}`
if (created > 0) msg += `,新增 ${created}`
if (existing > 0) msg += `,已存在 ${existing}`
if (restored > 0) msg += `,恢复 ${restored}`
if (departed > 0) msg += `,离职 ${departed}`
if (skipped > 0) msg += `,跳过 ${skipped}`
ElMessage.success(msg)
} else {
ElMessage.error(response.message || '同步失败')
}
} catch (error: any) {
console.error('员工同步失败:', error)
ElMessage.error(error?.response?.data?.detail || '员工同步失败')
} finally {
syncing.value = false
}
}
// 页面加载时获取配置 // 页面加载时获取配置
onMounted(() => { onMounted(() => {
loadDingtalkConfig() loadDingtalkConfig()
loadSyncConfig()
}) })
</script> </script>

View File

@@ -63,12 +63,14 @@
placeholder="全部岗位" placeholder="全部岗位"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" style="width: 150px"
> >
<el-option label="销售专员" value="sales" /> <el-option
<el-option label="销售主管" value="sales_manager" /> v-for="p in positionOptions"
<el-option label="客服专员" value="service" /> :key="p.id"
<el-option label="技术支持" value="tech" /> :label="p.name"
:value="p.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>

View File

@@ -58,93 +58,113 @@
<!-- 搜索和筛选 --> <!-- 搜索和筛选 -->
<div class="filter-section card"> <div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form"> <div class="filter-toolbar">
<el-form-item label="关键词"> <!-- 搜索框 -->
<div class="search-box">
<el-input <el-input
v-model="filterForm.keyword" v-model="filterForm.keyword"
placeholder="搜索错题内容或知识点" placeholder="搜索错题内容或知识点..."
clearable clearable
@input="handleRealTimeSearch" @input="handleRealTimeSearch"
style="width: 200px" class="search-input"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon class="search-icon"><Search /></el-icon>
</template> </template>
</el-input> </el-input>
</el-form-item> </div>
<el-form-item label="题目类型">
<!-- 筛选项 -->
<div class="filter-items">
<el-select <el-select
v-model="filterForm.type" v-model="filterForm.type"
placeholder="全部类型" placeholder="题目类型"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon">📝</span>
</template>
<el-option label="单选题" value="single" /> <el-option label="单选题" value="single" />
<el-option label="多选题" value="multiple" /> <el-option label="多选题" value="multiple" />
<el-option label="判断题" value="judge" /> <el-option label="判断题" value="judge" />
<el-option label="填空题" value="fill" /> <el-option label="填空题" value="fill" />
<el-option label="简答题" value="essay" /> <el-option label="简答题" value="essay" />
</el-select> </el-select>
</el-form-item>
<el-form-item label="难度等级">
<el-select <el-select
v-model="filterForm.difficulty" v-model="filterForm.difficulty"
placeholder="全部难度" placeholder="难度等级"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon"></span>
</template>
<el-option label="简单" value="easy" /> <el-option label="简单" value="easy" />
<el-option label="中等" value="medium" /> <el-option label="中等" value="medium" />
<el-option label="困难" value="hard" /> <el-option label="困难" value="hard" />
</el-select> </el-select>
</el-form-item>
<el-form-item label="掌握状态">
<el-select <el-select
v-model="filterForm.status" v-model="filterForm.status"
placeholder="全部状态" placeholder="掌握状态"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon">📊</span>
</template>
<el-option label="未掌握" value="unmastered" /> <el-option label="未掌握" value="unmastered" />
<el-option label="已掌握" value="mastered" /> <el-option label="已掌握" value="mastered" />
<el-option label="需巩固" value="review" /> <el-option label="需巩固" value="review" />
</el-select> </el-select>
</el-form-item>
<el-form-item label="时间周期">
<el-select <el-select
v-model="filterForm.timePeriod" v-model="filterForm.timePeriod"
placeholder="全部时间" placeholder="时间周期"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon">📅</span>
</template>
<el-option label="最近一周" value="week" /> <el-option label="最近一周" value="week" />
<el-option label="最近一月" value="month" /> <el-option label="最近一月" value="month" />
<el-option label="最近三月" value="quarter" /> <el-option label="最近三月" value="quarter" />
<el-option label="自定义" value="custom" /> <el-option label="自定义" value="custom" />
</el-select> </el-select>
</el-form-item>
<el-form-item v-if="filterForm.timePeriod === 'custom'" label="自定义时间">
<el-date-picker <el-date-picker
v-if="filterForm.timePeriod === 'custom'"
v-model="customDateRange" v-model="customDateRange"
type="daterange" type="daterange"
range-separator="" range-separator="~"
start-placeholder="开始日期" start-placeholder="开始"
end-placeholder="结束日期" end-placeholder="结束"
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 240px" class="date-picker"
format="MM/DD"
value-format="YYYY-MM-DD"
/> />
</el-form-item> </div>
<el-form-item>
<el-button @click="handleReset"> <!-- 重置按钮 -->
<el-icon class="el-icon--left"><Refresh /></el-icon> <el-button
重置 v-if="hasActiveFilters"
</el-button> @click="handleReset"
</el-form-item> class="reset-btn"
</el-form> type="info"
plain
>
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<!-- 当前筛选条件显示 --> <!-- 当前筛选条件显示 -->
<div v-if="hasActiveFilters" class="filter-tags"> <div v-if="hasActiveFilters" class="filter-tags">
@@ -259,19 +279,29 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="question-content"> <div class="question-content">
<h4 class="question-title">{{ mistake.title }}</h4> <h4 class="question-title">{{ mistake.title }}</h4>
<div class="answer-comparison"> <div class="answer-comparison">
<div class="answer-item wrong"> <div class="answer-item wrong">
<span class="answer-label">你的答案</span> <div class="answer-header">
<span class="answer-value">{{ mistake.yourAnswer }}</span> <span class="answer-icon wrong-icon"></span>
<span class="answer-label">你的答案</span>
</div> </div>
<div class="answer-item correct"> <div class="answer-body">
<span class="answer-label">正确答案</span> <span class="answer-value">{{ formatAnswer(mistake.yourAnswer) || '未作答' }}</span>
<span class="answer-value">{{ mistake.correctAnswer }}</span> </div>
</div>
<div class="answer-item correct">
<div class="answer-header">
<span class="answer-icon correct-icon"></span>
<span class="answer-label">正确答案</span>
</div>
<div class="answer-body">
<span class="answer-value">{{ formatAnswer(mistake.correctAnswer) }}</span>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="mistake-meta"> <div class="mistake-meta">
<div class="detail-item"> <div class="detail-item">
@@ -292,10 +322,22 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<el-button link type="primary" size="small" @click="viewDetail(mistake)"> <el-button
type="primary"
plain
@click="viewDetail(mistake)"
class="action-btn view-btn"
>
<el-icon class="btn-icon"><View /></el-icon>
查看解析 查看解析
</el-button> </el-button>
<el-button link type="success" size="small" @click="markMastered(mistake)"> <el-button
type="success"
plain
@click="markMastered(mistake)"
class="action-btn master-btn"
>
<el-icon class="btn-icon"><Check /></el-icon>
标记已掌握 标记已掌握
</el-button> </el-button>
</div> </div>
@@ -329,14 +371,28 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150"> <el-table-column label="操作" width="220">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="viewDetail(scope.row)"> <div class="table-actions">
查看解析 <el-button
</el-button> type="primary"
<el-button link type="success" size="small" @click="markMastered(scope.row)"> size="small"
标记已掌握 @click="viewDetail(scope.row)"
</el-button> class="table-action-btn"
>
<el-icon><View /></el-icon>
解析
</el-button>
<el-button
type="success"
size="small"
@click="markMastered(scope.row)"
class="table-action-btn"
>
<el-icon><Check /></el-icon>
已掌握
</el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -376,7 +432,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue' import { ref, reactive, computed, onMounted, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Grid, List, Search } from '@element-plus/icons-vue' import { Refresh, Grid, List, Search, View, Check } from '@element-plus/icons-vue'
import { getMistakesList, getMistakesStatistics, markMistakeMastered } from '@/api/exam' import { getMistakesList, getMistakesStatistics, markMistakeMastered } from '@/api/exam'
import type { MistakeListItem, MistakesStatisticsResponse } from '@/api/exam' import type { MistakeListItem, MistakesStatisticsResponse } from '@/api/exam'
@@ -550,19 +606,38 @@ const loadMistakes = async () => {
*/ */
const viewDetail = (mistake: any) => { const viewDetail = (mistake: any) => {
ElMessageBox.alert( ElMessageBox.alert(
h('div', { class: 'mistake-detail' }, [ h('div', { class: 'mistake-detail-content' }, [
h('h4', '题目'), // 题目区域
h('p', mistake.title), h('div', { class: 'detail-section question-section' }, [
h('h4', '你的答案'), h('div', { class: 'section-label' }, '📝 题目内容'),
h('p', { style: 'color: #f56c6c' }, mistake.yourAnswer || '未作答'), h('div', { class: 'section-content question-text' }, mistake.title)
h('h4', '正确答案'), ]),
h('p', { style: 'color: #67c23a' }, mistake.correctAnswer), // 答案对比区域
h('h4', '知识点'), h('div', { class: 'detail-section answers-section' }, [
h('p', mistake.knowledge) h('div', { class: 'answer-box wrong-answer' }, [
h('div', { class: 'answer-badge wrong' }, [
h('span', { class: 'badge-icon' }, '✗'),
h('span', { class: 'badge-text' }, '你的答案')
]),
h('div', { class: 'answer-text' }, formatAnswer(mistake.yourAnswer) || '未作答')
]),
h('div', { class: 'answer-box correct-answer' }, [
h('div', { class: 'answer-badge correct' }, [
h('span', { class: 'badge-icon' }, '✓'),
h('span', { class: 'badge-text' }, '正确答案')
]),
h('div', { class: 'answer-text' }, formatAnswer(mistake.correctAnswer))
])
]),
// 知识点区域
h('div', { class: 'detail-section knowledge-section' }, [
h('div', { class: 'section-label' }, '💡 关联知识点'),
h('div', { class: 'section-content knowledge-tag' }, mistake.knowledge || '未关联')
])
]), ]),
'错题详情', '错题详情',
{ {
confirmButtonText: '关闭', confirmButtonText: '我知道了',
customClass: 'mistake-detail-dialog' customClass: 'mistake-detail-dialog'
} }
) )
@@ -790,6 +865,33 @@ const getStatusTagType = (status: string) => {
return map[status] || 'info' return map[status] || 'info'
} }
/**
* 格式化答案显示
* 处理多选答案(逗号分隔)和选项格式
*/
const formatAnswer = (answer: string | null | undefined) => {
if (!answer) return ''
// 如果是JSON数组格式解析并格式化
if (answer.startsWith('[') && answer.endsWith(']')) {
try {
const arr = JSON.parse(answer)
if (Array.isArray(arr)) {
return arr.join('、')
}
} catch {
// 解析失败,使用原始字符串
}
}
// 如果是逗号分隔的多选答案,用顿号分隔显示
if (answer.includes(',')) {
return answer.split(',').map(s => s.trim()).join('、')
}
return answer
}
// 初始化加载数据 // 初始化加载数据
onMounted(() => { onMounted(() => {
loadStatistics() loadStatistics()
@@ -826,15 +928,105 @@ console.log('错题分析页面已加载')
} }
.filter-section { .filter-section {
.filter-form { padding: 16px 20px;
.el-form-item {
margin-bottom: 0; .filter-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.search-box {
flex: 0 0 280px;
.search-input {
:deep(.el-input__wrapper) {
border-radius: 20px;
background: #f5f7fa;
box-shadow: none;
border: 1px solid transparent;
padding: 4px 16px;
transition: all 0.3s ease;
&:hover, &:focus-within {
background: #fff;
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}
}
.search-icon {
color: #909399;
font-size: 16px;
}
}
}
.filter-items {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex: 1;
.filter-select {
width: 130px;
:deep(.el-select__wrapper) {
border-radius: 8px;
background: #f5f7fa;
box-shadow: none;
border: 1px solid transparent;
transition: all 0.2s ease;
min-height: 36px;
&:hover {
background: #fff;
border-color: #dcdfe6;
}
&.is-focused {
background: #fff;
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
}
.select-prefix-icon {
font-size: 14px;
margin-right: 4px;
}
}
.date-picker {
:deep(.el-input__wrapper) {
border-radius: 8px;
background: #f5f7fa;
box-shadow: none;
border: 1px solid transparent;
&:hover {
background: #fff;
border-color: #dcdfe6;
}
}
}
}
.reset-btn {
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
.el-icon {
margin-right: 4px;
}
} }
} }
.filter-tags { .filter-tags {
margin-top: 16px; margin-top: 14px;
padding-top: 16px; padding-top: 14px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -842,42 +1034,62 @@ console.log('错题分析页面已加载')
gap: 8px; gap: 8px;
.filter-label { .filter-label {
color: #666; color: #606266;
font-size: 14px; font-size: 13px;
margin-right: 8px; font-weight: 500;
}
.el-tag {
border-radius: 16px;
padding: 0 12px;
height: 28px;
line-height: 26px;
font-size: 12px;
} }
.clear-all-btn { .clear-all-btn {
margin-left: 8px; margin-left: auto;
font-size: 12px;
} }
} }
.search-result-info { .search-result-info {
margin-top: 12px; margin-top: 14px;
padding-top: 12px; padding-top: 14px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.result-count { .result-count {
color: #666; color: #606266;
font-size: 14px; font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
strong { strong {
color: #409eff; color: #409eff;
font-weight: 600; font-weight: 600;
font-size: 18px;
} }
} }
.filter-hint { .filter-hint {
color: #e6a23c; color: #e6a23c;
font-size: 12px; font-size: 12px;
background: #fdf6ec;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
} }
.category-info { .category-info {
color: #666; color: #606266;
font-size: 14px; font-size: 13px;
background: #f0f9eb;
padding: 4px 12px;
border-radius: 16px;
strong { strong {
color: #67c23a; color: #67c23a;
@@ -989,6 +1201,21 @@ console.log('错题分析页面已加载')
.mistake-table { .mistake-table {
margin-top: 20px; margin-top: 20px;
.table-actions {
display: flex;
gap: 8px;
.table-action-btn {
border-radius: 6px;
font-size: 12px;
padding: 6px 12px;
.el-icon {
margin-right: 4px;
}
}
}
} }
.mistake-cards { .mistake-cards {
@@ -1036,32 +1263,90 @@ console.log('错题分析页面已加载')
.answer-comparison { .answer-comparison {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 16px;
margin-bottom: 12px; margin-bottom: 16px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
.answer-item { .answer-item {
padding: 8px 12px; border-radius: 12px;
border-radius: 6px; overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.wrong { &.wrong {
background: #fef0f0; background: linear-gradient(135deg, #fff5f5 0%, #ffe8e8 100%);
border: 1px solid #fcdede; border: 1px solid #ffccc7;
.answer-header {
background: linear-gradient(90deg, #ff7875 0%, #ff9c9c 100%);
}
.answer-icon {
background: #fff;
color: #ff4d4f;
}
} }
&.correct { &.correct {
background: #f0f9ff; background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
border: 1px solid #b3d8ff; border: 1px solid #b7eb8f;
.answer-header {
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
}
.answer-icon {
background: #fff;
color: #52c41a;
}
} }
.answer-label { .answer-header {
font-size: 12px; display: flex;
color: #666; align-items: center;
margin-right: 8px; gap: 10px;
padding: 10px 14px;
.answer-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
flex-shrink: 0;
}
.answer-label {
font-size: 13px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
}
} }
.answer-value { .answer-body {
font-weight: 500; padding: 14px 16px;
color: #333; min-height: 48px;
display: flex;
align-items: center;
.answer-value {
font-size: 15px;
font-weight: 500;
color: #333;
line-height: 1.6;
word-break: break-word;
}
} }
} }
} }
@@ -1097,11 +1382,53 @@ console.log('错题分析页面已加载')
} }
.card-actions { .card-actions {
padding: 12px 16px; padding: 14px 16px;
background: #fafafa; background: linear-gradient(135deg, #f8f9fa 0%, #f0f2f5 100%);
border-top: 1px solid #e4e7ed; border-top: 1px solid #e4e7ed;
display: flex; display: flex;
gap: 12px; gap: 12px;
.action-btn {
flex: 1;
border-radius: 8px;
font-weight: 500;
padding: 10px 16px;
transition: all 0.25s ease;
.btn-icon {
margin-right: 6px;
font-size: 16px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.view-btn {
background: linear-gradient(135deg, #e6f4ff 0%, #bae0ff 100%);
border-color: #91caff;
color: #1677ff;
&:hover {
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
border-color: #1677ff;
color: #fff;
}
}
.master-btn {
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
border-color: #b7eb8f;
color: #52c41a;
&:hover {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
border-color: #52c41a;
color: #fff;
}
}
} }
} }
} }
@@ -1113,4 +1440,181 @@ console.log('错题分析页面已加载')
} }
} }
} }
</style>
<!-- 全局样式用于弹窗 -->
<style lang="scss">
.mistake-detail-dialog {
max-width: 600px !important;
border-radius: 16px !important;
overflow: hidden;
.el-message-box__header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 16px 20px;
.el-message-box__title {
color: #fff !important;
font-size: 18px;
font-weight: 600;
}
.el-message-box__headerbtn {
.el-message-box__close {
color: #fff !important;
}
}
}
.el-message-box__content {
padding: 0 !important;
}
.el-message-box__btns {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
padding: 10px 24px;
font-weight: 500;
&:hover {
opacity: 0.9;
}
}
}
}
.mistake-detail-content {
.detail-section {
padding: 16px 20px;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 10px;
}
.section-content {
font-size: 15px;
color: #333;
line-height: 1.6;
}
}
.question-section {
background: #fafafa;
.question-text {
font-size: 16px;
font-weight: 500;
color: #1a1a1a;
}
}
.answers-section {
display: flex;
gap: 16px;
padding: 20px;
background: #fff;
@media (max-width: 500px) {
flex-direction: column;
}
.answer-box {
flex: 1;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
&.wrong-answer {
background: linear-gradient(135deg, #fff5f5 0%, #ffebeb 100%);
border: 1px solid #ffccc7;
.answer-badge {
background: linear-gradient(90deg, #ff7875 0%, #ff9c9c 100%);
}
}
&.correct-answer {
background: linear-gradient(135deg, #f6ffed 0%, #e8f8e0 100%);
border: 1px solid #b7eb8f;
.answer-badge {
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
}
}
.answer-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
color: #fff;
.badge-icon {
width: 22px;
height: 22px;
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
&.wrong .badge-icon {
color: #ff4d4f;
}
&.correct .badge-icon {
color: #52c41a;
}
.badge-text {
font-size: 13px;
font-weight: 600;
}
}
.answer-text {
padding: 14px 16px;
font-size: 15px;
font-weight: 500;
color: #333;
line-height: 1.5;
min-height: 44px;
}
}
}
.knowledge-section {
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
.knowledge-tag {
display: inline-block;
background: #1890ff;
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
}
}
</style> </style>

View File

@@ -4,7 +4,15 @@
<div class="welcome-card card"> <div class="welcome-card card">
<div class="welcome-content"> <div class="welcome-content">
<h1 class="welcome-title">欢迎回来{{ userName }}</h1> <h1 class="welcome-title">欢迎回来{{ userName }}</h1>
<p class="welcome-desc">今天是您学习的第 <span class="highlight">{{ learningDays }}</span> 继续加油</p> <p class="welcome-desc" v-if="userRole === 'trainee'">
今天是您学习的第 <span class="highlight">{{ learningDays }}</span> 继续加油
</p>
<p class="welcome-desc" v-else-if="userRole === 'manager'">
管理您的团队助力成员成长
</p>
<p class="welcome-desc" v-else>
系统运行正常一切尽在掌控
</p>
</div> </div>
<div class="welcome-image"> <div class="welcome-image">
<el-icon :size="120" color="#667eea"> <el-icon :size="120" color="#667eea">
@@ -13,8 +21,8 @@
</div> </div>
</div> </div>
<!-- 统计卡片 --> <!-- 统计卡片 - 仅学员显示 -->
<div class="stats-grid"> <div class="stats-grid" v-if="userRole === 'trainee'">
<div class="stat-card card" v-for="stat in stats" :key="stat.title"> <div class="stat-card card" v-for="stat in stats" :key="stat.title">
<div class="stat-icon" :style="{ backgroundColor: stat.bgColor }"> <div class="stat-icon" :style="{ backgroundColor: stat.bgColor }">
<el-icon :size="24" :color="stat.color"> <el-icon :size="24" :color="stat.color">
@@ -24,7 +32,7 @@
<div class="stat-content"> <div class="stat-content">
<div class="stat-value">{{ stat.value }}</div> <div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div> <div class="stat-title">{{ stat.title }}</div>
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'"> <div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'" v-if="stat.trend !== 0">
<el-icon :size="12"> <el-icon :size="12">
<component :is="stat.trend > 0 ? 'Top' : 'Bottom'" /> <component :is="stat.trend > 0 ? 'Top' : 'Bottom'" />
</el-icon> </el-icon>
@@ -48,8 +56,8 @@
</div> </div>
</div> </div>
<!-- 最近考试 --> <!-- 最近考试 - 仅学员显示 -->
<div class="recent-exams"> <div class="recent-exams" v-if="userRole === 'trainee'">
<h2 class="section-title">最近考试</h2> <h2 class="section-title">最近考试</h2>
<div v-if="recentExams.length > 0" class="exam-list"> <div v-if="recentExams.length > 0" class="exam-list">
<div class="exam-item card" v-for="exam in recentExams" :key="exam.id"> <div class="exam-item card" v-for="exam in recentExams" :key="exam.id">
@@ -99,6 +107,7 @@ const router = useRouter()
// 获取当前用户信息 // 获取当前用户信息
const currentUser = computed(() => authManager.getCurrentUser()) const currentUser = computed(() => authManager.getCurrentUser())
const userName = computed(() => currentUser.value?.full_name || currentUser.value?.username || '用户') const userName = computed(() => currentUser.value?.full_name || currentUser.value?.username || '用户')
const userRole = computed(() => authManager.getUserRole())
const learningDays = ref(0) const learningDays = ref(0)
// 统计数据 // 统计数据
@@ -156,37 +165,64 @@ const loadStatistics = async () => {
} }
} }
// 快捷操作 // 快捷操作配置(包含角色限制)
const quickActions = ref([ const allQuickActions = [
{ {
title: '智能工牌分析', title: '智能工牌分析',
desc: 'AI能力评估与成长路径规划', desc: 'AI能力评估与成长路径规划',
icon: 'TrendCharts', icon: 'TrendCharts',
color: '#e6a23c', color: '#e6a23c',
path: '/trainee/growth-path' path: '/trainee/growth-path',
roles: ['trainee'] // 仅学员可见
}, },
{ {
title: '课程中心', title: '课程中心',
desc: '查看可用课程', desc: '查看可用课程',
icon: 'Collection', icon: 'Collection',
color: '#67c23a', color: '#67c23a',
path: '/trainee/course-center' path: '/trainee/course-center',
roles: ['trainee', 'manager', 'admin'] // 所有角色可见
}, },
{ {
title: '查分中心', title: '查分中心',
desc: '查看成绩和分析报告', desc: '查看成绩和分析报告',
icon: 'DataAnalysis', icon: 'DataAnalysis',
color: '#409eff', color: '#409eff',
path: '/trainee/score-report' path: '/trainee/score-report',
roles: ['trainee'] // 仅学员可见
}, },
{ {
title: 'AI陪练', title: 'AI陪练',
desc: '智能陪练系统', desc: '智能陪练系统',
icon: 'ChatLineRound', icon: 'ChatLineRound',
color: '#f56c6c', color: '#f56c6c',
path: '/trainee/ai-practice-center' path: '/trainee/ai-practice-center',
roles: ['trainee', 'manager', 'admin'] // 所有角色可见
},
{
title: '团队看板',
desc: '查看团队学习情况',
icon: 'DataBoard',
color: '#667eea',
path: '/manager/team-dashboard',
roles: ['manager', 'admin'] // 管理者和管理员可见
},
{
title: '课程管理',
desc: '管理培训课程内容',
icon: 'Notebook',
color: '#909399',
path: '/manager/course-management',
roles: ['manager', 'admin'] // 管理者和管理员可见
} }
]) ]
// 根据角色过滤快捷操作
const quickActions = computed(() => {
const role = userRole.value
if (!role) return []
return allQuickActions.filter(action => action.roles.includes(role))
})
// 最近考试 // 最近考试
const recentExams = ref<any[]>([]) const recentExams = ref<any[]>([])

View File

@@ -35,7 +35,7 @@
<el-tag :type="getQuestionTypeTag(currentQuestion.type)"> <el-tag :type="getQuestionTypeTag(currentQuestion.type)">
{{ getQuestionTypeText(currentQuestion.type) }} {{ getQuestionTypeText(currentQuestion.type) }}
</el-tag> </el-tag>
<span class="question-score">{{ currentQuestion.score }} </span> <span class="question-score">{{ formatScore(currentQuestion.score) }} </span>
</div> </div>
<div class="question-content"> <div class="question-content">
@@ -235,6 +235,7 @@ import { courseApi } from '@/api/course'
import { generateExam, judgeAnswer, recordMistake, getMistakes, updateRoundScore, type MistakeRecordItem } from '@/api/exam' import { generateExam, judgeAnswer, recordMistake, getMistakes, updateRoundScore, type MistakeRecordItem } from '@/api/exam'
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { distributeScores, formatScore } from '@/utils/scoreFormatter'
// 路由相关 // 路由相关
const route = useRoute() const route = useRoute()
@@ -358,6 +359,10 @@ const clearAnswer = () => {
* 数据格式转换Dify格式转前端格式 * 数据格式转换Dify格式转前端格式
*/ */
const transformDifyQuestions = (difyQuestions: any[]): any[] => { const transformDifyQuestions = (difyQuestions: any[]): any[] => {
// 使用智能分数分配避免小数总分100分
const totalScore = 100
const scores = distributeScores(totalScore, difyQuestions.length)
return difyQuestions.map((q, index) => { return difyQuestions.map((q, index) => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
'single_choice': 'single', 'single_choice': 'single',
@@ -371,7 +376,7 @@ const transformDifyQuestions = (difyQuestions: any[]): any[] => {
id: index + 1, id: index + 1,
type: typeMap[q.type] || q.type, type: typeMap[q.type] || q.type,
title: q.topic?.title || q.topic || '', title: q.topic?.title || q.topic || '',
score: 10 / difyQuestions.length, // 平均分配分值,总分10分 score: scores[index], // 使用智能整数分配
explanation: q.analysis || '', explanation: q.analysis || '',
knowledge_point_id: q.knowledge_point_id ? parseInt(q.knowledge_point_id) : null knowledge_point_id: q.knowledge_point_id ? parseInt(q.knowledge_point_id) : null
} }

View File

@@ -28,22 +28,22 @@
<el-tabs v-model="activeTab" @tab-click="handleTabClick"> <el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="进行中" name="ongoing"> <el-tab-pane label="进行中" name="ongoing">
<span slot="label"> <span slot="label">
进行中 <el-badge :value="12" class="tab-badge" /> 进行中 <el-badge :value="taskCounts.ongoing" class="tab-badge" v-if="taskCounts.ongoing > 0" />
</span> </span>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="待开始" name="pending"> <el-tab-pane label="待开始" name="pending">
<span slot="label"> <span slot="label">
待开始 <el-badge :value="5" class="tab-badge" /> 待开始 <el-badge :value="taskCounts.pending" class="tab-badge" v-if="taskCounts.pending > 0" />
</span> </span>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="已完成" name="completed"> <el-tab-pane label="已完成" name="completed">
<span slot="label"> <span slot="label">
已完成 <el-badge :value="28" class="tab-badge" /> 已完成 <el-badge :value="taskCounts.completed" class="tab-badge" v-if="taskCounts.completed > 0" />
</span> </span>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="已过期" name="expired"> <el-tab-pane label="已过期" name="expired">
<span slot="label"> <span slot="label">
已过期 <el-badge :value="3" class="tab-badge" /> 已过期 <el-badge :value="taskCounts.expired" class="tab-badge" v-if="taskCounts.expired > 0" />
</span> </span>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -55,7 +55,7 @@
<div class="task-title-section"> <div class="task-title-section">
<h3 class="task-title">{{ task.title }}</h3> <h3 class="task-title">{{ task.title }}</h3>
<el-tag :type="getTaskTagType(task.priority)" size="small"> <el-tag :type="getTaskTagType(task.priority)" size="small">
{{ task.priority }} {{ getPriorityLabel(task.priority) }}
</el-tag> </el-tag>
</div> </div>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
@@ -134,12 +134,13 @@
<el-empty v-if="taskList.length === 0" description="暂无任务" /> <el-empty v-if="taskList.length === 0" description="暂无任务" />
</div> </div>
<!-- 创建任务弹窗 --> <!-- 创建/编辑任务弹窗 -->
<el-dialog <el-dialog
v-model="createDialogVisible" v-model="createDialogVisible"
title="创建学习任务" :title="isEditMode ? '编辑学习任务' : '创建学习任务'"
width="680px" width="680px"
:close-on-click-modal="false" :close-on-click-modal="false"
@close="resetForm"
> >
<el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px">
<el-form-item label="任务名称" prop="title"> <el-form-item label="任务名称" prop="title">
@@ -165,7 +166,7 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="分配对象" prop="assignType"> <el-form-item v-if="!isEditMode" label="分配对象" prop="assignType">
<el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange"> <el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange">
<el-radio label="all">全体成员</el-radio> <el-radio label="all">全体成员</el-radio>
<el-radio label="team">指定团队</el-radio> <el-radio label="team">指定团队</el-radio>
@@ -173,34 +174,36 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="taskForm.assignType === 'team'" label="选择团队" prop="teams"> <el-form-item v-if="!isEditMode && taskForm.assignType === 'team'" label="选择团队" prop="teams">
<el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%"> <el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%" filterable>
<el-option label="销售一组" value="team1" /> <el-option
<el-option label="销售二组" value="team2" /> v-for="team in teamOptions"
<el-option label="销售三组" value="team3" /> :key="team.id"
:label="team.name"
:value="team.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="taskForm.assignType === 'member'" label="选择成员" prop="members"> <el-form-item v-if="!isEditMode && taskForm.assignType === 'member'" label="选择成员" prop="members">
<el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%"> <el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%" filterable>
<el-option label="张三" value="user1" /> <el-option
<el-option label="李四" value="user2" /> v-for="member in memberOptions"
<el-option label="王五" value="user3" /> :key="member.id"
<el-option label="赵六" value="user4" /> :label="member.full_name || member.username"
:value="member.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="选择课程" prop="courses"> <el-form-item v-if="!isEditMode" label="选择课程" prop="courses">
<el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%"> <el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%" filterable>
<el-option-group label="销售技巧"> <el-option
<el-option label="客户沟通技巧" value="course1" /> v-for="course in courseOptions"
<el-option label="需求挖掘方法" value="course2" /> :key="course.id"
<el-option label="异议处理技巧" value="course3" /> :label="course.name"
</el-option-group> :value="course.id"
<el-option-group label="产品知识"> />
<el-option label="产品基础知识" value="course4" />
<el-option label="竞品分析" value="course5" />
</el-option-group>
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -228,11 +231,91 @@
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button> <el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreateTask" :loading="createLoading"> <el-button type="primary" @click="handleCreateTask" :loading="createLoading">
确定 {{ isEditMode ? '保存' : '确定' }}
</el-button> </el-button>
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
<!-- 任务详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="任务详情"
width="600px"
>
<div v-loading="detailLoading" class="task-detail-content">
<template v-if="currentTaskDetail">
<div class="detail-section">
<h4>基本信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务名称" :span="2">
{{ currentTaskDetail.title }}
</el-descriptions-item>
<el-descriptions-item label="任务描述" :span="2">
{{ currentTaskDetail.description || '暂无描述' }}
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="getTaskTagType(currentTaskDetail.priority)" size="small">
{{ currentTaskDetail.priority }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentTaskDetail.status === 'completed' ? 'success' : 'warning'" size="small">
{{ currentTaskDetail.status === 'completed' ? '已完成' : currentTaskDetail.status === 'ongoing' ? '进行中' : currentTaskDetail.status }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDeadline(currentTaskDetail.deadline) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDeadline(currentTaskDetail.created_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h4>完成进度</h4>
<div class="progress-info">
<el-progress
:percentage="currentTaskDetail.progress"
:color="getProgressColor(currentTaskDetail.progress)"
:stroke-width="20"
/>
<p class="progress-text">
{{ currentTaskDetail.completed_count }}/{{ currentTaskDetail.assigned_count }} 人完成
</p>
</div>
</div>
<div class="detail-section" v-if="currentTaskDetail.courses && currentTaskDetail.courses.length > 0">
<h4>包含课程 ({{ currentTaskDetail.courses.length }})</h4>
<div class="course-tags">
<el-tag v-for="course in currentTaskDetail.courses" :key="course" class="course-tag">
{{ course }}
</el-tag>
</div>
</div>
<div class="detail-section" v-if="currentTaskDetail.requirements">
<h4>任务要求</h4>
<ul class="requirements-list">
<li v-if="currentTaskDetail.requirements.mustComplete">
<el-icon><CircleCheck /></el-icon> 必须完成所有课程
</li>
<li v-if="currentTaskDetail.requirements.mustPass">
<el-icon><CircleCheck /></el-icon> 考试必须及格
</li>
<li v-if="currentTaskDetail.requirements.mustPractice">
<el-icon><CircleCheck /></el-icon> 必须完成AI陪练
</li>
</ul>
</div>
</template>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -240,16 +323,30 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, type Task } from '@/api/task' import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, updateTask, getTaskDetail, sendTaskReminder, type Task } from '@/api/task'
import { getUserList, getTeamList } from '@/api/user/index'
import { getCourseList } from '@/api/score'
// 当前标签页 // 当前标签页
const activeTab = ref('ongoing') const activeTab = ref('ongoing')
// 创建任务弹窗 // 创建/编辑任务弹窗
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const createLoading = ref(false) const createLoading = ref(false)
const loading = ref(false) const loading = ref(false)
const isEditMode = ref(false)
const editingTaskId = ref<number | null>(null)
// 详情弹窗
const detailDialogVisible = ref(false)
const detailLoading = ref(false)
const currentTaskDetail = ref<Task | null>(null)
// 选项数据
const teamOptions = ref<Array<{ id: number; name: string }>>([])
const memberOptions = ref<Array<{ id: number; username: string; full_name?: string }>>([])
const courseOptions = ref<Array<{ id: number; name: string; category?: string }>>([])
// 任务统计数据 // 任务统计数据
const taskStats = ref([ const taskStats = ref([
@@ -286,15 +383,23 @@ const taskStats = ref([
// 任务列表数据 // 任务列表数据
const allTasks = ref<Task[]>([]) const allTasks = ref<Task[]>([])
// 各状态任务数量
const taskCounts = reactive({
ongoing: 0,
pending: 0,
completed: 0,
expired: 0
})
// 任务表单 // 任务表单
const taskForm = reactive({ const taskForm = reactive({
title: '', title: '',
description: '', description: '',
priority: '中', priority: '中',
assignType: 'all', assignType: 'all',
teams: [], teams: [] as number[],
members: [], members: [] as number[],
courses: [], courses: [] as number[],
deadline: '', deadline: '',
requirements: ['mustComplete'] requirements: ['mustComplete']
}) })
@@ -320,9 +425,6 @@ const rules = reactive<FormRules>({
// 根据当前标签页筛选的任务列表 // 根据当前标签页筛选的任务列表
const taskList = computed(() => { const taskList = computed(() => {
if (activeTab.value === 'ongoing') {
return allTasks.value
}
return allTasks.value.filter(task => task.status === activeTab.value) return allTasks.value.filter(task => task.status === activeTab.value)
}) })
@@ -345,15 +447,20 @@ const loadTaskStats = async () => {
} }
/** /**
* 加载任务列表 * 加载任务列表(加载所有任务,前端筛选)
*/ */
const loadTasks = async () => { const loadTasks = async () => {
loading.value = true loading.value = true
try { try {
const status = activeTab.value === 'ongoing' ? 'ongoing' : activeTab.value // 不传status参数获取所有任务
const res = await getTasks({ status }) const res = await getTasks({ page_size: 500 })
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
allTasks.value = res.data.items allTasks.value = res.data.items
// 统计各状态任务数量
taskCounts.ongoing = allTasks.value.filter(t => t.status === 'ongoing').length
taskCounts.pending = allTasks.value.filter(t => t.status === 'pending').length
taskCounts.completed = allTasks.value.filter(t => t.status === 'completed').length
taskCounts.expired = allTasks.value.filter(t => t.status === 'expired').length
} }
} catch (error: any) { } catch (error: any) {
console.error('加载任务列表失败:', error) console.error('加载任务列表失败:', error)
@@ -382,10 +489,62 @@ const formatDeadline = (deadline?: string) => {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
/**
* 加载选项数据(团队、成员、课程)
*/
const loadOptions = async () => {
try {
// 并行加载
const [teamsRes, usersRes, coursesRes] = await Promise.all([
getTeamList({ page: 1, page_size: 100 }).catch(() => null),
getUserList({ page: 1, page_size: 500 }).catch(() => null),
getCourseList().catch(() => null)
])
// 处理团队数据
if (teamsRes?.code === 200 && teamsRes.data) {
const teamsData = teamsRes.data.items || teamsRes.data
teamOptions.value = Array.isArray(teamsData) ? teamsData : []
}
// 处理成员数据
if (usersRes?.code === 200 && usersRes.data) {
const usersData = usersRes.data.items || usersRes.data
memberOptions.value = Array.isArray(usersData) ? usersData : []
}
// 处理课程数据
if (coursesRes?.code === 200 && coursesRes.data) {
const coursesData = coursesRes.data.items || coursesRes.data
courseOptions.value = Array.isArray(coursesData) ? coursesData : []
}
} catch (error) {
console.error('加载选项数据失败:', error)
}
}
/**
* 重置表单
*/
const resetForm = () => {
taskForm.title = ''
taskForm.description = ''
taskForm.priority = '中'
taskForm.assignType = 'all'
taskForm.teams = []
taskForm.members = []
taskForm.courses = []
taskForm.deadline = ''
taskForm.requirements = ['mustComplete']
isEditMode.value = false
editingTaskId.value = null
}
/** /**
* 创建任务 * 创建任务
*/ */
const createTask = () => { const createTask = () => {
resetForm()
createDialogVisible.value = true createDialogVisible.value = true
} }
@@ -398,7 +557,7 @@ const handleAssignTypeChange = () => {
} }
/** /**
* 提交创建任务 * 提交创建/编辑任务
*/ */
const handleCreateTask = async () => { const handleCreateTask = async () => {
if (!formRef.value) return if (!formRef.value) return
@@ -408,34 +567,55 @@ const handleCreateTask = async () => {
createLoading.value = true createLoading.value = true
try { try {
// 优先级中文转英文映射
const priorityMap: Record<string, string> = {
'高': 'high',
'中': 'medium',
'低': 'low'
}
// 构建请求数据 // 构建请求数据
const taskData = { const taskData = {
title: taskForm.title, title: taskForm.title,
description: taskForm.description, description: taskForm.description,
priority: taskForm.priority.toLowerCase(), priority: priorityMap[taskForm.priority] || 'medium',
deadline: taskForm.deadline, deadline: taskForm.deadline,
course_ids: taskForm.courses, course_ids: taskForm.courses,
user_ids: taskForm.assignType === 'all' ? [] : taskForm.members, user_ids: taskForm.assignType === 'all' ? [] : taskForm.members,
requirements: { requirements: {
mustComplete: taskForm.requirements.includes('mustComplete'), mustComplete: taskForm.requirements.includes('mustComplete'),
allowRetake: taskForm.requirements.includes('allowRetake') mustPass: taskForm.requirements.includes('mustPass'),
mustPractice: taskForm.requirements.includes('mustPractice')
} }
} }
const res = await createTaskApi(taskData) let res
if (isEditMode.value && editingTaskId.value) {
// 编辑模式
res = await updateTask(editingTaskId.value, {
title: taskData.title,
description: taskData.description,
priority: taskData.priority,
deadline: taskData.deadline
})
} else {
// 创建模式
res = await createTaskApi(taskData)
}
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('任务创建成功') ElMessage.success(isEditMode.value ? '任务更新成功' : '任务创建成功')
createDialogVisible.value = false createDialogVisible.value = false
formRef.value?.resetFields() resetForm()
// 刷新数据 // 刷新数据
await loadTaskStats() await loadTaskStats()
await loadTasks() await loadTasks()
} else { } else {
ElMessage.error(res.message || '创建任务失败') ElMessage.error(res.message || (isEditMode.value ? '更新任务失败' : '创建任务失败'))
} }
} catch (error: any) { } catch (error: any) {
console.error('创建任务失败:', error) console.error(isEditMode.value ? '更新任务失败:' : '创建任务失败:', error)
ElMessage.error(error.message || '创建任务失败') ElMessage.error(error.message || (isEditMode.value ? '更新任务失败' : '创建任务失败'))
} finally { } finally {
createLoading.value = false createLoading.value = false
} }
@@ -446,48 +626,113 @@ const handleCreateTask = async () => {
/** /**
* 查看详情 * 查看详情
*/ */
const viewDetail = (task: any) => { const viewDetail = async (task: Task) => {
ElMessage.info(`查看任务详情:${task.title}`) detailLoading.value = true
detailDialogVisible.value = true
try {
const res = await getTaskDetail(task.id)
if (res.code === 200 && res.data) {
currentTaskDetail.value = res.data
} else {
currentTaskDetail.value = task
}
} catch (error) {
console.error('获取任务详情失败:', error)
currentTaskDetail.value = task
} finally {
detailLoading.value = false
}
} }
/** /**
* 发送提醒 * 发送提醒
*/ */
const sendReminder = (_task: any) => { const sendReminder = async (task: Task) => {
ElMessageBox.confirm( try {
`确定要向未完成的成员发送任务提醒吗?`, await ElMessageBox.confirm(
'发送提醒', `确定要向未完成的成员发送任务提醒吗?`,
{ '发送提醒',
confirmButtonText: '确定', {
cancelButtonText: '取消', confirmButtonText: '确定',
type: 'info' cancelButtonText: '取消',
type: 'info'
}
)
const res = await sendTaskReminder(task.id)
if (res.code === 200) {
ElMessage.success(res.message || '提醒发送成功')
} else {
ElMessage.error(res.message || '发送提醒失败')
} }
).then(() => { } catch (error: any) {
ElMessage.success('提醒发送成功') if (error !== 'cancel') {
}).catch(() => {}) console.error('发送提醒失败:', error)
ElMessage.error(error.message || '发送提醒失败')
}
}
} }
/** /**
* 编辑任务 * 编辑任务
*/ */
const editTask = async (task: Task) => { const editTask = async (task: Task) => {
// 这里可以打开编辑对话框填充task数据 isEditMode.value = true
// 简化实现:直接提示 editingTaskId.value = task.id
ElMessage.info(`编辑任务功能开发中:${task.title}`)
// TODO: 实现完整的编辑功能 // 填充表单数据
taskForm.title = task.title
taskForm.description = task.description || ''
taskForm.priority = task.priority === 'high' ? '高' : task.priority === 'low' ? '低' : '中'
taskForm.deadline = task.deadline || ''
taskForm.assignType = 'all'
taskForm.teams = []
taskForm.members = []
taskForm.courses = []
// 解析 requirements
if (task.requirements) {
taskForm.requirements = []
if (task.requirements.mustComplete) taskForm.requirements.push('mustComplete')
if (task.requirements.mustPass) taskForm.requirements.push('mustPass')
if (task.requirements.mustPractice) taskForm.requirements.push('mustPractice')
}
createDialogVisible.value = true
} }
/** /**
* 复制任务 * 复制任务
*/ */
const copyTask = (task: any) => { const copyTask = async (task: Task) => {
ElMessage.success(`已复制任务:${task.title}`) resetForm()
// 填充表单数据(标题添加"副本"后缀)
taskForm.title = `${task.title} (副本)`
taskForm.description = task.description || ''
taskForm.priority = task.priority === 'high' ? '高' : task.priority === 'low' ? '低' : '中'
taskForm.deadline = '' // 截止时间需要重新设置
// 解析 requirements
if (task.requirements) {
taskForm.requirements = []
if (task.requirements.mustComplete) taskForm.requirements.push('mustComplete')
if (task.requirements.mustPass) taskForm.requirements.push('mustPass')
if (task.requirements.mustPractice) taskForm.requirements.push('mustPractice')
}
isEditMode.value = false
editingTaskId.value = null
createDialogVisible.value = true
ElMessage.info('已复制任务内容,请修改后保存')
} }
/** /**
* 结束任务 * 结束任务
*/ */
const endTask = async (_task: Task) => { const endTask = async (task: Task) => {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'确定要结束这个任务吗?结束后将不能再修改。', '确定要结束这个任务吗?结束后将不能再修改。',
@@ -498,8 +743,22 @@ const endTask = async (_task: Task) => {
type: 'warning' type: 'warning'
} }
) )
ElMessage.success('任务已结束')
} catch {} const res = await updateTask(task.id, { status: 'completed' })
if (res.code === 200) {
ElMessage.success('任务已结束')
// 刷新数据
await loadTaskStats()
await loadTasks()
} else {
ElMessage.error(res.message || '结束任务失败')
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('结束任务失败:', error)
ElMessage.error(error.message || '结束任务失败')
}
}
} }
/** /**
@@ -534,11 +793,30 @@ const deleteTaskItem = async (task: Task) => {
} }
} }
/**
* 优先级英文转中文映射
*/
const priorityToChineseMap: Record<string, string> = {
'high': '高',
'medium': '中',
'low': '低'
}
/**
* 获取中文优先级显示
*/
const getPriorityLabel = (priority: string) => {
return priorityToChineseMap[priority] || priority
}
/** /**
* 获取任务标签类型 * 获取任务标签类型
*/ */
const getTaskTagType = (priority: string) => { const getTaskTagType = (priority: string) => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
'high': 'danger',
'medium': 'warning',
'low': 'info',
'高': 'danger', '高': 'danger',
'中': 'warning', '中': 'warning',
'低': 'info' '低': 'info'
@@ -558,8 +836,11 @@ const getProgressColor = (percentage: number) => {
// 组件挂载时加载数据 // 组件挂载时加载数据
onMounted(async () => { onMounted(async () => {
await loadTaskStats() await Promise.all([
await loadTasks() loadTaskStats(),
loadTasks(),
loadOptions()
])
}) })
</script> </script>
@@ -744,6 +1025,60 @@ onMounted(async () => {
} }
} }
// 任务详情弹窗样式
.task-detail-content {
.detail-section {
margin-bottom: 24px;
h4 {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.progress-info {
.progress-text {
margin-top: 8px;
font-size: 14px;
color: #666;
text-align: center;
}
}
.course-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.course-tag {
margin: 0;
}
}
.requirements-list {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 14px;
color: #666;
.el-icon {
color: #67c23a;
}
}
}
}
}
// 响应式 // 响应式
@media (max-width: 768px) { @media (max-width: 768px) {
.assignment-center-container { .assignment-center-container {

View File

@@ -611,7 +611,7 @@
:on-remove="handleFileRemove" :on-remove="handleFileRemove"
:before-upload="beforeUpload" :before-upload="beforeUpload"
multiple multiple
accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.csv,.vtt,.properties" accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.doc,.pptx,.ppt,.csv,.vtt,.properties"
> >
<el-icon class="el-icon--upload"><upload-filled /></el-icon> <el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> <div class="el-upload__text">
@@ -619,7 +619,7 @@
</div> </div>
<template #tip> <template #tip>
<div class="el-upload__tip"> <div class="el-upload__tip">
支持格式TXTMarkdownMDXPDFHTMLExcelWordCSVVTTProperties<br> 支持格式TXTMarkdownMDXPDFHTMLExcelWordPPTCSVVTTProperties<br>
单个文件不超过 15MB 单个文件不超过 15MB
</div> </div>
</template> </template>

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,14 @@
<div class="ability-radar card"> <div class="ability-radar card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">能力评估</h3> <h3 class="card-title">能力评估</h3>
<el-button type="primary" size="small" @click="analyzeSmartBadgeData" :loading="analyzing"> <!-- AI智能工牌分析仅对学员开放 -->
<el-button
v-if="userInfo.role === 'trainee'"
type="primary"
size="small"
@click="analyzeSmartBadgeData"
:loading="analyzing"
>
<el-icon><TrendCharts /></el-icon> <el-icon><TrendCharts /></el-icon>
AI 分析智能工牌数据 AI 分析智能工牌数据
</el-button> </el-button>
@@ -302,7 +309,7 @@
<p class="empty-description"> <p class="empty-description">
{{ analyzing ? '正在分析您的智能工牌数据,为您推荐最适合的课程' : '暂无智能工牌数据,请先使用智能工牌记录对话' }} {{ analyzing ? '正在分析您的智能工牌数据,为您推荐最适合的课程' : '暂无智能工牌数据,请先使用智能工牌记录对话' }}
</p> </p>
<el-button v-if="!analyzing" type="primary" @click="analyzeSmartBadgeData"> <el-button v-if="!analyzing && userInfo.role === 'trainee'" type="primary" @click="analyzeSmartBadgeData">
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
重新分析 重新分析
</el-button> </el-button>
@@ -490,8 +497,12 @@ const loadGrowthPath = async () => {
growthPathLoading.value = true growthPathLoading.value = true
try { try {
const response = await getGrowthPath() const response = await getGrowthPath()
// 兼容两种响应格式:直接返回数据 或 { code, data } 格式
if (response.code === 200 && response.data) { if (response.code === 200 && response.data) {
growthPathData.value = response.data growthPathData.value = response.data
} else if (response.id && response.stages) {
// API 直接返回数据对象
growthPathData.value = response
} }
} catch (error) { } catch (error) {
console.error('加载成长路径失败:', error) console.error('加载成长路径失败:', error)

View File

@@ -2,118 +2,134 @@
<div class="practice-records-container"> <div class="practice-records-container">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">陪练记录</h1> <h1 class="page-title">陪练记录</h1>
<div class="header-actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
<el-button type="primary" @click="refreshData">
<el-icon class="el-icon--left"><Refresh /></el-icon>
刷新数据
</el-button>
</div>
</div> </div>
<!-- 陪练统计概览 --> <!-- 陪练统计概览 -->
<div class="stats-overview"> <div class="stats-section">
<div class="stat-card card" v-for="stat in practiceStats" :key="stat.label"> <div class="stat-card card">
<div class="stat-icon" :style="{ backgroundColor: stat.bgColor }"> <el-statistic title="总陪练次数" :value="practiceStatsData.totalCount">
<el-icon :size="32" :color="stat.color"> <template #suffix>
<component :is="stat.icon" /> <span style="font-size: 14px"></span>
</el-icon> </template>
</div> </el-statistic>
<div class="stat-content"> </div>
<div class="stat-value">{{ stat.value }}</div> <div class="stat-card card">
<div class="stat-label">{{ stat.label }}</div> <el-statistic title="平均评分" :value="practiceStatsData.avgScore" :precision="1">
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'" v-if="stat.trend !== 0"> <template #suffix>
<el-icon><component :is="stat.trend > 0 ? 'Top' : 'Bottom'" /></el-icon> <span style="font-size: 14px"></span>
{{ Math.abs(stat.trend) }}% </template>
</div> </el-statistic>
</div> </div>
<div class="stat-card card">
<el-statistic title="总陪练时长" :value="practiceStatsData.totalHours" :precision="1">
<template #suffix>
<span style="font-size: 14px">小时</span>
</template>
</el-statistic>
</div>
<div class="stat-card card">
<el-statistic title="本月进步" :value="practiceStatsData.monthImprovement">
<template #prefix>+</template>
<template #suffix>%</template>
</el-statistic>
</div> </div>
</div> </div>
<!-- 筛选区域 --> <!-- 搜索和筛选 -->
<div class="filter-section card"> <div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form"> <div class="filter-toolbar">
<el-form-item label="关键词"> <!-- 搜索框 -->
<div class="search-box">
<el-input <el-input
v-model="filterForm.keyword" v-model="filterForm.keyword"
placeholder="搜索陪练内容或场景" placeholder="搜索陪练内容或场景..."
clearable clearable
@input="handleRealTimeSearch" @input="handleRealTimeSearch"
style="width: 200px" class="search-input"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon class="search-icon"><Search /></el-icon>
</template> </template>
</el-input> </el-input>
</el-form-item> </div>
<el-form-item label="陪练场景">
<!-- 筛选项 -->
<div class="filter-items">
<el-select <el-select
v-model="filterForm.scene" v-model="filterForm.scene"
placeholder="全部场景" placeholder="陪练场景"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon">🎯</span>
</template>
<el-option label="客户咨询" value="customer_consultation" /> <el-option label="客户咨询" value="customer_consultation" />
<el-option label="美容护理" value="beauty_care" /> <el-option label="美容护理" value="beauty_care" />
<el-option label="产品介绍" value="product_introduction" /> <el-option label="产品介绍" value="product_introduction" />
<el-option label="问题处理" value="problem_handling" /> <el-option label="问题处理" value="problem_handling" />
<el-option label="服务礼仪" value="service_etiquette" /> <el-option label="服务礼仪" value="service_etiquette" />
</el-select> </el-select>
</el-form-item>
<el-form-item label="陪练结果">
<el-select <el-select
v-model="filterForm.result" v-model="filterForm.result"
placeholder="全部结果" placeholder="陪练结果"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon"></span>
</template>
<el-option label="优秀" value="excellent" /> <el-option label="优秀" value="excellent" />
<el-option label="良好" value="good" /> <el-option label="良好" value="good" />
<el-option label="一般" value="average" /> <el-option label="一般" value="average" />
<el-option label="需改进" value="needs_improvement" /> <el-option label="需改进" value="needs_improvement" />
</el-select> </el-select>
</el-form-item>
<el-form-item label="时间周期">
<el-select <el-select
v-model="filterForm.timePeriod" v-model="filterForm.timePeriod"
placeholder="全部时间" placeholder="时间周期"
clearable clearable
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 120px" class="filter-select"
> >
<template #prefix>
<span class="select-prefix-icon">📅</span>
</template>
<el-option label="最近一周" value="week" /> <el-option label="最近一周" value="week" />
<el-option label="最近一月" value="month" /> <el-option label="最近一月" value="month" />
<el-option label="最近三月" value="quarter" /> <el-option label="最近三月" value="quarter" />
<el-option label="自定义" value="custom" /> <el-option label="自定义" value="custom" />
</el-select> </el-select>
</el-form-item>
<el-form-item v-if="filterForm.timePeriod === 'custom'" label="自定义时间">
<el-date-picker <el-date-picker
v-if="filterForm.timePeriod === 'custom'"
v-model="customDateRange" v-model="customDateRange"
type="daterange" type="daterange"
range-separator="" range-separator="~"
start-placeholder="开始日期" start-placeholder="开始"
end-placeholder="结束日期" end-placeholder="结束"
@change="handleRealTimeSearch" @change="handleRealTimeSearch"
style="width: 240px" class="date-picker"
format="MM/DD"
value-format="YYYY-MM-DD"
/> />
</el-form-item> </div>
<el-form-item>
<el-button @click="handleReset"> <!-- 重置按钮 -->
<el-icon class="el-icon--left"><Refresh /></el-icon> <el-button
重置 v-if="hasActiveFilters"
</el-button> @click="handleReset"
</el-form-item> class="reset-btn"
</el-form> type="info"
plain
>
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<!-- 当前筛选条件显示 --> <!-- 当前筛选条件显示 -->
<div v-if="hasActiveFilters" class="filter-tags"> <div v-if="hasActiveFilters" class="filter-tags">
@@ -123,6 +139,7 @@
closable closable
@close="clearKeyword" @close="clearKeyword"
type="primary" type="primary"
effect="light"
> >
关键词{{ filterForm.keyword }} 关键词{{ filterForm.keyword }}
</el-tag> </el-tag>
@@ -283,8 +300,15 @@
</div> </div>
<!-- 对话记录 --> <!-- 对话记录 -->
<div class="conversation-replay"> <div class="conversation-replay" v-loading="replayLoading">
<div class="conversation-list"> <!-- 空状态提示 -->
<el-empty
v-if="!replayLoading && (!currentRecord.conversation || currentRecord.conversation.length === 0)"
description="暂无对话记录"
/>
<!-- 对话列表 -->
<div class="conversation-list" v-else>
<div <div
v-for="(message, index) in currentRecord.conversation" v-for="(message, index) in currentRecord.conversation"
:key="index" :key="index"
@@ -342,6 +366,7 @@ const total = ref(0)
// 弹窗状态 // 弹窗状态
const replayDialogVisible = ref(false) const replayDialogVisible = ref(false)
const replayLoading = ref(false)
const currentRecord = ref<any>(null) const currentRecord = ref<any>(null)
// 筛选表单 // 筛选表单
@@ -373,6 +398,17 @@ interface PracticeStat {
// 陪练统计数据 // 陪练统计数据
const practiceStats = ref<PracticeStat[]>([]) const practiceStats = ref<PracticeStat[]>([])
// 简化的统计数据(用于 el-statistic 组件)
const practiceStatsData = computed(() => {
const stats = practiceStats.value
return {
totalCount: parseInt(stats.find(s => s.label === '总陪练次数')?.value || '0'),
avgScore: parseFloat(stats.find(s => s.label === '平均评分')?.value || '0'),
totalHours: parseFloat(stats.find(s => s.label === '总陪练时长')?.value?.replace('h', '') || '0'),
monthImprovement: parseInt(stats.find(s => s.label === '本月进步')?.value?.replace('+', '').replace('%', '') || '0')
}
})
// 陪练记录数据(直接使用后端返回的已筛选、已分页的数据) // 陪练记录数据(直接使用后端返回的已筛选、已分页的数据)
const recordsList = ref([]) const recordsList = ref([])
@@ -608,9 +644,32 @@ const viewPracticeReport = (record: any) => {
/** /**
* 回放陪练对话 * 回放陪练对话
*/ */
const replayPractice = (record: any) => { const replayPractice = async (record: any) => {
currentRecord.value = record try {
replayDialogVisible.value = true // 先设置基本信息并打开弹窗
currentRecord.value = { ...record, conversation: [] }
replayDialogVisible.value = true
replayLoading.value = true
// 调用 API 获取对话详情
const response: any = await practiceApi.getPracticeReport(record.sessionId)
if (response.code === 200 && response.data?.analysis?.dialogue_review) {
// 转换对话数据格式
const dialogueReview = response.data.analysis.dialogue_review
currentRecord.value.conversation = dialogueReview.map((item: any) => ({
role: item.speaker === 'user' ? 'user' : 'ai',
content: item.content,
timestamp: item.time || '',
feedback: item.comment || ''
}))
}
} catch (error: any) {
console.error('获取对话详情失败:', error)
ElMessage.error('获取对话详情失败,请稍后重试')
} finally {
replayLoading.value = false
}
} }
/** /**
@@ -738,95 +797,143 @@ const loadRecords = async () => {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.page-header { .page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
.page-title { .page-title {
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: #333; color: #1f2937;
}
.header-actions {
display: flex;
gap: 12px;
} }
} }
.stats-overview { // 统计区域 - 参考错题分析风格
.stats-section {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
.stat-card { .stat-card {
padding: 24px; text-align: center;
display: flex; transition: transform 0.2s ease, box-shadow 0.2s ease;
align-items: center;
gap: 20px;
.stat-icon { &:hover {
width: 64px; transform: translateY(-2px);
height: 64px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-content {
flex: 1;
.stat-value {
font-size: 32px;
font-weight: 700;
color: #333;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.up {
color: #67c23a;
}
&.down {
color: #f56c6c;
}
}
} }
} }
} }
// 筛选区域 - 参考错题分析现代风格
.filter-section { .filter-section {
padding: 20px; padding: 16px 20px;
margin-bottom: 20px;
.filter-form { .filter-toolbar {
.el-form-item { display: flex;
margin-bottom: 0; align-items: center;
gap: 12px;
flex-wrap: wrap;
.search-box {
flex: 0 0 280px;
.search-input {
:deep(.el-input__wrapper) {
border-radius: 20px;
background: #f5f7fa;
box-shadow: none;
border: 1px solid transparent;
padding: 4px 16px;
transition: all 0.3s ease;
&:hover, &:focus-within {
background: #fff;
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}
}
.search-icon {
color: #909399;
font-size: 16px;
}
}
}
.filter-items {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex: 1;
.filter-select {
width: 130px;
:deep(.el-select__wrapper) {
border-radius: 8px;
background: #f5f7fa;
box-shadow: none;
border: 1px solid transparent;
transition: all 0.2s ease;
min-height: 36px;
&:hover {
background: #fff;
border-color: #dcdfe6;
}
&.is-focused {
background: #fff;
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
}
.select-prefix-icon {
font-size: 14px;
margin-right: 4px;
}
}
.date-picker {
:deep(.el-input__wrapper) {
border-radius: 8px;
background: #f5f7fa;
box-shadow: none;
border: 1px solid transparent;
&:hover {
background: #fff;
border-color: #dcdfe6;
}
}
}
}
.reset-btn {
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
.el-icon {
margin-right: 4px;
}
} }
} }
.filter-tags { .filter-tags {
margin-top: 16px; margin-top: 14px;
padding-top: 16px; padding-top: 14px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -834,34 +941,54 @@ const loadRecords = async () => {
gap: 8px; gap: 8px;
.filter-label { .filter-label {
color: #666; color: #606266;
font-size: 14px; font-size: 13px;
margin-right: 8px; font-weight: 500;
}
.el-tag {
border-radius: 16px;
padding: 0 12px;
height: 28px;
line-height: 26px;
font-size: 12px;
} }
.clear-all-btn { .clear-all-btn {
margin-left: 8px; margin-left: auto;
font-size: 12px;
} }
} }
.search-result-info { .search-result-info {
margin-top: 12px; margin-top: 14px;
padding-top: 12px; padding-top: 14px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
.result-count { .result-count {
color: #666; color: #606266;
font-size: 14px; font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
strong { strong {
color: #409eff; color: #409eff;
font-weight: 600; font-weight: 600;
font-size: 18px;
} }
} }
.filter-hint { .filter-hint {
color: #e6a23c; color: #e6a23c;
font-size: 12px; font-size: 12px;
background: #fdf6ec;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
} }
} }
} }
@@ -891,6 +1018,26 @@ const loadRecords = async () => {
justify-content: center; justify-content: center;
margin-top: 24px; margin-top: 24px;
} }
// 表格行悬浮效果
:deep(.el-table__row) {
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f7fa;
}
}
// 操作按钮样式优化
:deep(.el-button--link) {
padding: 4px 8px;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
border-radius: 4px;
}
}
} }
.replay-content { .replay-content {
@@ -1001,25 +1148,33 @@ const loadRecords = async () => {
// 响应式设计 // 响应式设计
@media (max-width: 768px) { @media (max-width: 768px) {
.practice-records-container { .practice-records-container {
.page-header { .stats-section {
flex-direction: column; grid-template-columns: repeat(2, 1fr);
align-items: flex-start; gap: 12px;
gap: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
} }
.stats-overview { .filter-section {
grid-template-columns: 1fr; .filter-toolbar {
} flex-direction: column;
align-items: stretch;
.filter-form { .search-box {
.el-form-item { flex: 1;
display: block; width: 100%;
margin-bottom: 16px !important;
.search-input {
width: 100%;
}
}
.filter-items {
width: 100%;
.filter-select {
flex: 1;
min-width: 100px;
}
}
} }
} }
@@ -1032,4 +1187,12 @@ const loadRecords = async () => {
} }
} }
} }
@media (max-width: 480px) {
.practice-records-container {
.stats-section {
grid-template-columns: 1fr;
}
}
}
</style> </style>