Compare commits

...

50 Commits

Author SHA1 Message Date
yuliang_guo
e7202a6244 fix(practice): 修复分析报告重复插入错误
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 15:38:53 +08:00
yuliang_guo
c6f64de4cc fix(mistakes): 修复错题掌握状态不返回的问题
Some checks failed
continuous-integration/drone/push Build is failing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 15:31:23 +08:00
yuliang_guo
724e3e1073 fix(practice): 获取报告时自动生成不存在的报告
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 15:03:54 +08:00
yuliang_guo
b02f249166 fix(practice): 修复结束会话接口 DetachedInstanceError
All checks were successful
continuous-integration/drone/push Build is passing
- 将 ORM 对象转换为 PracticeSessionResponse 后再返回
- 添加 COZE_WORKSPACE_ID 配置到 .env.ex

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 14:56:36 +08:00
yuliang_guo
8f2bd92ee0 Merge branch 'test'
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-04 12:34:36 +08:00
yuliang_guo
205ae6aa4e feat(course): 岗位选择器添加全选按钮
- 在选择岗位弹窗中添加全选/取消全选功能
- 支持对当前筛选结果进行全选操作

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 12:34:28 +08:00
yuliang_guo
2de394ccae feat: 添加租户部署脚本
- 添加 deploy-tenant.sh 用于标准化租户后端部署
- 自动挂载 secrets 目录解决 Coze OAuth 问题
- 禁用 Watchtower 避免配置被覆盖
2026-02-04 10:32:36 +08:00
yuliang_guo
3bc9304fa9 chore: trigger rebuild [all]
Some checks are pending
continuous-integration/drone/push Build is running
2026-02-03 18:32:59 +08:00
yuliang_guo
a2ba73e33d fix: 增加 Node.js 内存限制避免构建 OOM
Some checks are pending
continuous-integration/drone/push Build is running
2026-02-03 18:02:57 +08:00
yuliang_guo
bb669ef422 fix: npm install 添加 --include=dev 确保安装 vite
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-03 17:56:42 +08:00
yuliang_guo
44beaa164d fix: 前端构建添加 --target production 参数
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-03 17:53:05 +08:00
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
49 changed files with 4510 additions and 2431 deletions

View File

@@ -1,8 +1,8 @@
kind: pipeline
type: docker
name: deploy-test
name: build-and-push-test
# 测试环境test 分支触发,部署到 kpl
# 测试环境test 分支触发,构建并推送到 ACR
trigger:
branch:
- test
@@ -10,64 +10,61 @@ trigger:
- push
steps:
- name: sync-code
image: appleboy/drone-ssh
settings:
host: 120.79.247.16
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 5m
script:
- echo "=== 测试环境 同步代码 ==="
- cd /root/aiedu
- git fetch cicd
- git checkout test 2>/dev/null || git checkout -b test cicd/test
- git reset --hard cicd/test
- echo "代码同步完成"
# 构建并推送后端镜像
- name: build-push-backend
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:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- 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
settings:
host: 120.79.247.16
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 10m
script:
- echo "=== 测试环境 编译前端到 dist-test ==="
- cd /root/aiedu/frontend
- npm install --silent
- npm run build
- rm -rf /root/aiedu/dist-test/*
- cp -r dist/* /root/aiedu/dist-test/
- echo "前端编译完成 dist-test"
# 构建并推送前端镜像
- name: build-push-frontend
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:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd frontend
- docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:test -f Dockerfile --target production --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
image: appleboy/drone-ssh
settings:
host: 120.79.247.16
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 "=== 测试环境部署完成 ==="
volumes:
- name: docker-sock
host:
path: /var/run/docker.sock
---
kind: pipeline
type: docker
name: deploy-staging
name: build-and-push-staging
# 预生产环境staging 分支触发,部署到 aiedu
# 预生产环境staging 分支触发
trigger:
branch:
- staging
@@ -75,71 +72,53 @@ trigger:
- push
steps:
- name: sync-code
image: appleboy/drone-ssh
settings:
host: 120.79.247.16
username: root
password:
from_secret: prod_ssh_password
port: 22
command_timeout: 5m
script:
- echo "=== 预生产 同步代码 ==="
- cd /root/aiedu
- git fetch cicd
- git checkout staging 2>/dev/null || git checkout -b staging cicd/staging
- git reset --hard cicd/staging
- echo "代码同步完成"
- name: build-push-backend
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:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd backend
- docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-backend:staging -f Dockerfile .
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:staging
- name: build-frontend-staging
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-staging ==="
- cd /root/aiedu/frontend
- npm install --silent
- npm run build
- rm -rf /root/aiedu/dist-staging/*
- cp -r dist/* /root/aiedu/dist-staging/
- echo "前端编译完成 dist-staging"
- name: build-push-frontend
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:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd frontend
- docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:staging -f Dockerfile --target production --build-arg VITE_API_BASE_URL=https://aiedu.ireborn.com.cn .
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:staging
- 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: 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 "=== 预生产部署完成 ==="
volumes:
- name: docker-sock
host:
path: /var/run/docker.sock
---
kind: pipeline
type: docker
name: deploy-prod
name: build-and-push-prod
# 生产环境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" - 默认部署所有租户
#
# 生产环境main 分支触发
trigger:
branch:
- main
@@ -147,107 +126,47 @@ trigger:
- push
steps:
- name: sync-code-to-server
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 "=== 生产 同步代码 ==="
- cd /root/aiedu
- 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
- name: build-push-backend
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:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd backend
- pip install flake8 -q
- flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics || true
- echo "Python lint completed"
- docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-backend:main -f Dockerfile .
- docker tag $DOCKER_REGISTRY/ireborn/kaopeilian-backend:main $DOCKER_REGISTRY/ireborn/kaopeilian-backend:latest
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:main
- docker push $DOCKER_REGISTRY/ireborn/kaopeilian-backend:latest
- name: frontend-check
image: node:18-alpine
- name: build-push-frontend
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:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- cd frontend
- echo "Frontend check completed"
- docker build -t $DOCKER_REGISTRY/ireborn/kaopeilian-frontend:main -f Dockerfile --target production --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

View File

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

View File

@@ -43,9 +43,18 @@ RUN pip install --upgrade pip && \
# 复制应用代码
COPY app/ ./app/
# 复制启动脚本
COPY start.sh ./start.sh
RUN chmod +x ./start.sh
# 创建上传目录和日志目录
RUN mkdir -p uploads logs
# 默认环境变量可通过docker-compose或环境变量覆盖
ENV WORKERS=4 \
RELOAD=false \
TIMEOUT_KEEP_ALIVE=600
# 暴露端口
EXPOSE 8000
@@ -53,5 +62,5 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
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="权限不足,需要管理员权限"
)
# 用户统计
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天的新增用户
thirty_days_ago = datetime.now() - timedelta(days=30)

View File

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

View File

@@ -734,14 +734,27 @@ async def end_practice_session(
except Exception:
pass
# 将 ORM 对象转换为响应格式,避免 DetachedInstanceError
session_data = PracticeSessionResponse(
id=session.id,
session_id=session.session_id,
user_id=session.user_id,
scene_id=session.scene_id,
scene_name=session.scene_name or "",
scene_type=session.scene_type,
conversation_id=session.conversation_id,
start_time=session.start_time,
end_time=session.end_time,
duration_seconds=session.duration_seconds or 0,
turns=session.turns or 0,
status=session.status,
created_at=session.created_at
)
return ResponseModel(
code=200,
message="会话已结束",
data={
"session": session,
"exp_result": exp_result,
"new_badges": new_badges
}
data=session_data
)
except HTTPException:
@@ -815,19 +828,35 @@ async def analyze_practice_session(
# 解析分析结果
analysis_result = analysis_data.get("analysis", {})
# 保存分析报告
report = PracticeReport(
session_id=session_id,
total_score=analysis_result.get("total_score"),
score_breakdown=analysis_result.get("score_breakdown"),
ability_dimensions=analysis_result.get("ability_dimensions"),
dialogue_review=analysis_result.get("dialogue_annotations"),
suggestions=analysis_result.get("suggestions"),
workflow_run_id=f"{v2_result.ai_provider}_{v2_result.ai_latency_ms}ms",
task_id=None
# 检查报告是否已存在
existing_report = await db.execute(
select(PracticeReport).where(PracticeReport.session_id == session_id)
)
report = existing_report.scalar_one_or_none()
if report:
# 更新现有报告
report.total_score = analysis_result.get("total_score")
report.score_breakdown = analysis_result.get("score_breakdown")
report.ability_dimensions = analysis_result.get("ability_dimensions")
report.dialogue_review = analysis_result.get("dialogue_annotations")
report.suggestions = analysis_result.get("suggestions")
report.workflow_run_id = f"{v2_result.ai_provider}_{v2_result.ai_latency_ms}ms"
logger.info(f"更新现有分析报告: session_id={session_id}")
else:
# 创建新报告
report = PracticeReport(
session_id=session_id,
total_score=analysis_result.get("total_score"),
score_breakdown=analysis_result.get("score_breakdown"),
ability_dimensions=analysis_result.get("ability_dimensions"),
dialogue_review=analysis_result.get("dialogue_annotations"),
suggestions=analysis_result.get("suggestions"),
workflow_run_id=f"{v2_result.ai_provider}_{v2_result.ai_latency_ms}ms",
task_id=None
)
db.add(report)
db.add(report)
await db.commit()
logger.info(f"分析报告已保存: session_id={session_id}, total_score={report.total_score}")
@@ -883,7 +912,53 @@ async def get_practice_report(
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="分析报告不存在,请先生成报告")
# 报告不存在,自动生成
logger.info(f"报告不存在,自动生成: session_id={session_id}")
# 查询对话历史
result = await db.execute(
select(PracticeDialogue).where(
PracticeDialogue.session_id == session_id
).order_by(PracticeDialogue.sequence)
)
dialogue_list = result.scalars().all()
if not dialogue_list:
raise HTTPException(status_code=404, detail="没有对话记录,无法生成报告")
# 构建对话历史
dialogue_history = [
{"role": "user" if d.speaker == "user" else "assistant", "content": d.content}
for d in dialogue_list
]
# 调用分析服务
from app.services.ai.practice_analysis_service import PracticeAnalysisService
import json
practice_analysis_service = PracticeAnalysisService()
analysis_result = await practice_analysis_service.analyze(dialogue_history, db=db)
if not analysis_result.success:
raise HTTPException(status_code=500, detail=f"分析失败: {analysis_result.error}")
analysis_data = analysis_result.to_dict()
# 保存报告
report = PracticeReport(
session_id=session_id,
total_score=analysis_data.get("overall_score", 0),
score_breakdown=analysis_data.get("score_breakdown", []),
ability_dimensions=analysis_data.get("ability_dimensions", []),
dialogue_review=analysis_data.get("dialogue_review", []),
suggestions=analysis_data.get("suggestions", []),
summary=analysis_data.get("summary", ""),
raw_response=json.dumps(analysis_data, ensure_ascii=False)
)
db.add(report)
await db.commit()
await db.refresh(report)
logger.info(f"报告自动生成成功: session_id={session_id}, 总分={report.total_score}")
# 3. 查询完整对话记录(从数据库)
result = await db.execute(

View File

@@ -1,6 +1,8 @@
"""
文件预览API
提供课程资料的在线预览功能
支持MinIO和本地文件系统两种存储后端
"""
import logging
from pathlib import Path
@@ -15,6 +17,7 @@ from app.core.config import settings
from app.models.user import User
from app.models.course import CourseMaterial
from app.services.document_converter import document_converter
from app.services.storage_service import storage_service
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -81,10 +84,12 @@ def get_preview_type(file_ext: str) -> str:
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获取本地文件路径
支持MinIO和本地文件系统。如果文件在MinIO中会先下载到本地缓存。
Args:
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:
# 移除 /static/uploads/ 前缀
if file_url.startswith('/static/uploads/'):
relative_path = file_url.replace('/static/uploads/', '')
full_path = Path(settings.UPLOAD_PATH) / relative_path
return full_path
object_name = file_url.replace('/static/uploads/', '')
# 使用storage_service获取文件路径自动处理MinIO下载
return await storage_service.get_file_path(object_name)
return None
except Exception:
except Exception as e:
logger.error(f"获取文件路径失败: {e}")
return None
@@ -158,7 +164,7 @@ async def get_material_preview(
# 根据预览类型处理
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():
try:
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:
# 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():
converted_url = document_converter.convert_excel_to_html(
str(file_path),
@@ -200,7 +206,7 @@ async def get_material_preview(
elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext):
# 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():
# 执行转换
converted_url = document_converter.convert_to_pdf(

View File

@@ -43,13 +43,20 @@ async def create_task(
# 构建响应
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(
data=TaskResponse(
id=task.id,
title=task.title,
description=task.description,
priority=task.priority.value,
status=task.status.value,
priority=priority_val,
status=status_val,
creator_id=task.creator_id,
deadline=task.deadline,
requirements=task.requirements,
@@ -58,7 +65,7 @@ async def create_task(
updated_at=task.updated_at,
courses=courses,
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(
status: Optional[str] = Query(None, description="任务状态筛选"),
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),
current_user: User = Depends(require_admin_or_manager)
):
@@ -77,26 +84,33 @@ async def get_tasks(
# 构建响应
items = []
for task in tasks:
# 加载关联数据
task_detail = await task_service.get_task_detail(db, task.id)
if task_detail:
courses = [link.course.name for link in task_detail.course_links]
items.append(TaskResponse(
id=task.id,
title=task.title,
description=task.description,
priority=task.priority.value,
status=task.status.value,
creator_id=task.creator_id,
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_detail.assignments),
completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed")
))
# 安全获取枚举值
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
# 使用已加载的关联数据(通过 selectinload
courses = [link.course.name for link in task.course_links] if task.course_links else []
completed_count = sum(
1 for a in task.assignments
if (a.status.value if hasattr(a.status, 'value') else a.status) == "completed"
) if task.assignments else 0
items.append(TaskResponse(
id=task.id,
title=task.title,
description=task.description,
priority=priority_val,
status=status_val,
creator_id=task.creator_id,
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(
data=PaginatedResponse.create(

View File

@@ -28,10 +28,17 @@ async def get_accessible_team_member_ids(
current_user: User,
db: AsyncSession
) -> List[int]:
"""获取用户可访问的团队成员ID列表"""
"""获取用户可访问的团队成员ID列表(只返回未删除的用户)"""
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)
return [row[0] for row in result.all()]
else:
@@ -44,9 +51,15 @@ async def get_accessible_team_member_ids(
if not team_ids:
return []
# 2. 查询这些团队的所有成员
stmt = select(UserTeam.user_id).where(
UserTeam.team_id.in_(team_ids)
# 2. 查询这些团队的所有成员(过滤已删除用户)
stmt = select(UserTeam.user_id).join(
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()
result = await db.execute(stmt)
return [row[0] for row in result.all()]

View File

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

View File

@@ -47,20 +47,23 @@ async def get_current_user_statistics(
获取当前用户学习统计
返回字段:
- learningDays: 学习天数(按陪练会话开始日期去重
- learningDays: 学习天数(从注册日期到今天的天数至少为1
- totalHours: 学习总时长小时取整到1位小数
- practiceQuestions: 练习题数(答题记录条数汇总)
- averageScore: 平均成绩已提交考试的平均分保留1位小数
- examsCompleted: 已完成考试数量
"""
try:
from datetime import date
user_id = current_user.id
# 学习天数:按会话开始日期去重
learning_days_stmt = select(func.count(func.distinct(func.date(TrainingSession.start_time)))).where(
TrainingSession.user_id == user_id
)
learning_days = (await db.scalar(learning_days_stmt)) or 0
# 学习天数:从注册日期到今天的天数至少为1天
if current_user.created_at:
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 = max(1, learning_days) # 确保至少为1
else:
learning_days = 1
# 总时长(小时)
total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where(

View File

@@ -107,6 +107,14 @@ class Settings(BaseSettings):
import os
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_API_BASE: Optional[str] = Field(default="https://api.coze.cn")
COZE_WORKSPACE_ID: Optional[str] = Field(default=None)

View File

@@ -188,6 +188,46 @@ async def http_exception_handler(request: Request, exc: HTTPException):
)
# 数据库唯一约束冲突处理 (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)
async def global_exception_handler(request: Request, exc: Exception):

View File

@@ -87,6 +87,14 @@ class GrowthPathNode(BaseModel, SoftDeleteMixin):
Integer, default=7, nullable=False, comment="预计学习天数"
)
# 画布位置(用于可视化编辑器)
position_x: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0, comment="画布X坐标"
)
position_y: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0, comment="画布Y坐标"
)
# 关联关系
growth_path: Mapped["GrowthPath"] = relationship( # noqa: F821
"GrowthPath", back_populates="nodes"

View File

@@ -42,7 +42,7 @@ class PaginationParams(BaseModel):
"""分页参数"""
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
def offset(self) -> int:

View File

@@ -28,6 +28,8 @@ class NodeBase(BaseModel):
is_required: bool = Field(True, description="是否必修")
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
estimated_days: int = Field(7, description="预计学习天数")
position_x: Optional[int] = Field(0, description="画布X坐标")
position_y: Optional[int] = Field(0, description="画布Y坐标")
# =====================================================

View File

@@ -43,13 +43,16 @@ class CozeService:
def __init__(self):
self.client = get_coze_client()
self.bot_config = get_bot_config()
self.workspace_id = get_workspace_id()
# 内存中的会话存储(生产环境应使用 Redis
self._sessions: Dict[str, CozeSession] = {}
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(
self, request: CreateSessionRequest
) -> CreateSessionResponse:
@@ -211,12 +214,15 @@ class CozeService:
def _get_bot_id_by_type(self, session_type: SessionType) -> str:
"""根据会话类型获取 Bot ID"""
mapping = {
SessionType.COURSE_CHAT: self.bot_config["course_chat"],
SessionType.TRAINING: self.bot_config["training"],
SessionType.EXAM: self.bot_config["exam"],
# 将 SessionType 枚举映射到配置字符串
type_mapping = {
SessionType.COURSE_CHAT: "course_chat",
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]:
"""获取会话"""

View File

@@ -8,6 +8,7 @@
- 写入数据库
提供稳定可靠的知识点分析能力。
支持MinIO和本地文件系统两种存储后端。
"""
import logging
@@ -20,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.exceptions import ExternalServiceError
from app.schemas.course import KnowledgePointCreate
from app.services.storage_service import storage_service
from .ai_service import AIService, AIResponse
from .llm_json_parser import parse_with_fallback, clean_llm_output
@@ -92,8 +94,8 @@ class KnowledgeAnalysisServiceV2:
f"file_url: {file_url}"
)
# 1. 解析文件路径
file_path = self._resolve_file_path(file_url)
# 1. 解析文件路径支持MinIO和本地文件系统
file_path = await self._resolve_file_path(file_url)
if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
@@ -160,11 +162,20 @@ class KnowledgeAnalysisServiceV2:
)
raise ExternalServiceError(f"知识点分析失败: {e}")
def _resolve_file_path(self, file_url: str) -> Path:
"""解析文件 URL 为本地路径"""
async def _resolve_file_path(self, file_url: str) -> Path:
"""
解析文件 URL 为本地路径
支持MinIO和本地文件系统。如果文件在MinIO中会先下载到本地缓存。
"""
if file_url.startswith(STATIC_UPLOADS_PREFIX):
relative_path = file_url.replace(STATIC_UPLOADS_PREFIX, '')
return Path(self.upload_path) / relative_path
object_name = file_url.replace(STATIC_UPLOADS_PREFIX, '')
# 使用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('/'):
# 绝对路径
return Path(file_url)

View File

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

View File

@@ -42,101 +42,164 @@ class DashboardService:
Returns:
企业级数据概览
"""
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
try:
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
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)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee')
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
exam_avg_score = round(exam_row[2] or 0, 1)
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
total_users = result.scalar() or 0
# 7. 满分人数
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
)
perfect_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
try:
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
except Exception as e:
logger.warning(f"获取今日活跃用户失败: {e}")
today_active = 0
# 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today)
)
today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
# 3. 本周活跃用户
try:
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
except Exception as e:
logger.warning(f"获取本周活跃用户失败: {e}")
week_active = 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()
}
# 4. 本月活跃用户
try:
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
except Exception as e:
logger.warning(f"获取本月活跃用户失败: {e}")
month_active = 0
# 5. 总学习时长(小时)
practice_hours = 0
training_hours = 0
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]]:
"""
@@ -313,35 +376,40 @@ class DashboardService:
"""
activities = []
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
rows = result.all()
try:
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
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 = "奖章"
for exp, user in rows:
activity_type = "学习"
description = exp.description or ""
if "考试" in description:
activity_type = "考试"
elif "签到" in description:
activity_type = "签到"
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": exp.description,
"exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
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

View File

@@ -238,7 +238,7 @@ class EmployeeSyncService:
logger.info(f"创建岗位: {position_name} (ID: {position.id})")
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]:
"""
创建用户
@@ -246,14 +246,14 @@ class EmployeeSyncService:
employee_data: 员工数据
Returns:
用户对象或None如果创建失败
(用户对象, 状态): 状态为 'created'/'existing'/'restored'/'skipped'
"""
phone = employee_data.get('phone')
full_name = employee_data.get('full_name')
if not phone:
logger.warning(f"员工 {full_name} 没有手机号,跳过")
return None
return None, 'skipped'
# 检查用户是否已存在(通过手机号,包括已软删除的)
stmt = select(User).where(User.phone == phone)
@@ -270,15 +270,15 @@ class EmployeeSyncService:
if dingtalk_id:
existing_user.dingtalk_id = dingtalk_id
logger.info(f"恢复软删除用户: {phone} ({full_name})")
return existing_user
return existing_user, 'restored'
# 如果用户已存在但没有dingtalk_id则更新
dingtalk_id = employee_data.get('dingtalk_id')
if dingtalk_id and not existing_user.dingtalk_id:
existing_user.dingtalk_id = dingtalk_id
logger.info(f"更新用户 {phone} 的钉钉ID: {dingtalk_id}")
logger.info(f"用户已存在: {phone} ({full_name})")
return existing_user
logger.debug(f"用户已存在: {phone} ({full_name})")
return existing_user, 'existing'
# 生成邮箱
email = self.generate_email(phone, employee_data.get('email'))
@@ -315,7 +315,7 @@ class EmployeeSyncService:
await self.db.flush()
logger.info(f"创建用户: {phone} ({full_name}) - 角色: {role}")
return user
return user, 'created'
async def sync_employees(self) -> Dict[str, Any]:
"""
@@ -331,6 +331,9 @@ class EmployeeSyncService:
stats = {
'total_employees': 0,
'users_created': 0,
'users_existing': 0,
'users_restored': 0,
'users_departed': 0,
'users_skipped': 0,
'teams_created': 0,
'positions_created': 0,
@@ -351,12 +354,18 @@ class EmployeeSyncService:
for employee in employees:
try:
# 创建用户
user = await self.create_user(employee)
user, status = await self.create_user(employee)
if not user:
stats['users_skipped'] += 1
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')
@@ -400,7 +409,29 @@ class EmployeeSyncService:
stats['errors'].append(error_msg)
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()
logger.info("✅ 数据库事务已提交")
@@ -414,12 +445,15 @@ class EmployeeSyncService:
stats['end_time'] = datetime.now()
stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds()
# 4. 输出统计信息
# 5. 输出统计信息
logger.info("=" * 60)
logger.info("同步完成统计")
logger.info("=" * 60)
logger.info(f"员工: {stats['total_employees']}")
logger.info(f"创建用户: {stats['users_created']}")
logger.info(f"钉钉在职员工: {stats['total_employees']}")
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['duration']:.2f}")
@@ -577,7 +611,7 @@ class EmployeeSyncService:
"""
增量同步员工数据
- 新增钉钉有但系统没有的员工
- 删除系统有但钉钉没有的员工(物理删除)
- 删除系统有但钉钉没有的员工(删除)
- 跳过两边都存在的员工(不做任何修改)
Returns:
@@ -600,8 +634,22 @@ class EmployeeSyncService:
try:
# 1. 获取钉钉在职员工数据
dingtalk_employees = await self.fetch_employees_from_dingtalk()
# 使用手机号和钉钉ID双重匹配
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和已软删除的
stmt = select(User).where(
@@ -611,16 +659,26 @@ class EmployeeSyncService:
result = await self.db.execute(stmt)
system_users = result.scalars().all()
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_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
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. 新增员工
for employee in dingtalk_employees:
@@ -630,16 +688,18 @@ class EmployeeSyncService:
try:
# 创建用户
user = await self.create_user(employee)
user, status = await self.create_user(employee)
if not user:
continue
stats['added_count'] += 1
stats['added_users'].append({
'full_name': user.full_name,
'phone': user.phone,
'role': user.role
})
# 只有真正创建的才计入新增
if status == 'created':
stats['added_count'] += 1
stats['added_users'].append({
'full_name': user.full_name,
'phone': user.phone,
'role': user.role
})
# 创建部门团队
department = employee.get('department')
@@ -684,53 +744,53 @@ class EmployeeSyncService:
stats['errors'].append(error_msg)
continue
# 5. 删除离职员工(物理删除)
# 先flush之前的新增操作,避免与删除操作冲突
# 5. 处理离职员工(删除)
# 先flush之前的新增操作
await self.db.flush()
# 收集需要删除的用户ID
users_to_delete = []
for user in system_users:
if user.phone and user.phone in phones_to_delete:
# 如果跳过删除,则不处理
if skip_delete:
logger.info("⚠️ 由于安全检查未通过,跳过离职员工处理")
else:
# 标记离职员工需要手机号和钉钉ID都不在钉钉列表中才删除
for user in system_users:
# 双重保护确保不删除admin
if user.username == 'admin' or user.role == 'admin':
logger.warning(f"⚠️ 跳过删除管理员账户: {user.username}")
continue
users_to_delete.append({
'id': user.id,
'full_name': user.full_name,
'phone': user.phone,
'username': user.username
})
# 检查用户是否在钉钉列表中手机号或钉钉ID匹配任一即视为在职
in_dingtalk = False
if user.phone and user.phone in dingtalk_phones:
in_dingtalk = True
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:
continue # 在钉钉中,跳过
# 先清理关联数据(外键约束)
await self._cleanup_user_related_data(user_id)
# 额外安全检查:如果钉钉没有返回有效数据,不删除
if len(dingtalk_phones) == 0 and len(dingtalk_ids) == 0:
logger.warning(f"⚠️ 钉钉数据为空,跳过删除用户: {user.full_name}")
continue
# 用SQL直接删除用户避免ORM的级联操作冲突
await self.db.execute(
text("DELETE FROM users WHERE id = :user_id"),
{"user_id": user_id}
)
try:
# 软删除:标记为离职
user.is_active = False
user.is_deleted = True
stats['deleted_users'].append({
'full_name': user_info['full_name'],
'phone': user_info['phone'],
'username': user_info['username']
})
stats['deleted_count'] += 1
logger.info(f"🗑️ 删除离职员工: {user_info['full_name']} ({user_info['phone']})")
stats['deleted_users'].append({
'full_name': user.full_name,
'phone': user.phone,
'username': user.username
})
stats['deleted_count'] += 1
logger.info(f"🚪 标记离职员工: {user.full_name} ({user.phone})")
except Exception as e:
error_msg = f"删除员工 {user_info['full_name']} 失败: {str(e)}"
logger.error(error_msg)
stats['errors'].append(error_msg)
continue
except Exception as e:
error_msg = f"处理离职员工 {user.full_name} 失败: {str(e)}"
logger.error(error_msg)
stats['errors'].append(error_msg)
continue
# 6. 提交所有更改
await self.db.commit()
@@ -751,7 +811,7 @@ class EmployeeSyncService:
logger.info("增量同步完成统计")
logger.info("=" * 60)
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['duration']:.2f}")

View File

@@ -309,6 +309,7 @@ class MistakeService:
ExamMistake.question_type,
ExamMistake.knowledge_point_id,
KnowledgePoint.name.label("knowledge_point_name"),
ExamMistake.mastery_status,
ExamMistake.created_at
).select_from(ExamMistake).join(
Exam, ExamMistake.exam_id == Exam.id
@@ -339,6 +340,7 @@ class MistakeService:
"question_type": row.question_type,
"knowledge_point_id": row.knowledge_point_id,
"knowledge_point_name": row.knowledge_point_name,
"mastery_status": row.mastery_status,
"created_at": row.created_at
})

View File

@@ -95,6 +95,8 @@ class GrowthPathService:
is_required=node_data.is_required,
prerequisites=node_data.prerequisites,
estimated_days=node_data.estimated_days,
position_x=node_data.position_x,
position_y=node_data.position_y,
)
db.add(node)
@@ -147,6 +149,8 @@ class GrowthPathService:
is_required=node_data.is_required,
prerequisites=node_data.prerequisites,
estimated_days=node_data.estimated_days,
position_x=node_data.position_x,
position_y=node_data.position_y,
)
db.add(node)
@@ -222,6 +226,8 @@ class GrowthPathService:
"is_required": node.is_required,
"prerequisites": node.prerequisites,
"estimated_days": node.estimated_days,
"position_x": node.position_x,
"position_y": node.position_y,
"created_at": node.created_at,
"updated_at": node.updated_at,
})

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 sqlalchemy import select, func, and_, case
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.course import Course
from app.schemas.task import TaskCreate, TaskUpdate, TaskStatsResponse
@@ -44,7 +44,14 @@ class TaskService(BaseService[Task]):
db.add(assignment)
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
async def get_tasks(
@@ -62,6 +69,12 @@ class TaskService(BaseService[Task]):
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)
if status:
@@ -71,7 +84,7 @@ class TaskService(BaseService[Task]):
# 分页
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
tasks = result.scalars().all()
tasks = result.unique().scalars().all()
return tasks, total

View File

@@ -31,6 +31,9 @@ PyMySQL==1.1.0
httpx==0.27.2
aiofiles==23.2.1
# 对象存储MinIO
minio>=7.2.0
# 日志
structlog==23.2.0

View File

@@ -1,64 +1,38 @@
#!/bin/bash
# 统一启动脚本 - 根据环境变量自动配置
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 默认配置
HOST=${HOST:-0.0.0.0}
PORT=${PORT:-8000}
WORKERS=${WORKERS:-1}
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}"
python_version=$(python3 --version 2>&1)
if [[ $? -eq 0 ]]; then
echo -e "${GREEN}$python_version${NC}"
# 构建启动命令
CMD="uvicorn app.main:app --host $HOST --port $PORT --timeout-keep-alive $TIMEOUT_KEEP_ALIVE"
if [ "$RELOAD" = "true" ]; then
# 开发模式启用热重载不支持多workers
CMD="$CMD --reload --reload-dir /app/app"
echo "Mode: Development (hot reload enabled)"
else
echo -e "${RED}✗ Python3未安装${NC}"
exit 1
# 生产模式多workers
CMD="$CMD --workers $WORKERS"
echo "Mode: Production ($WORKERS workers)"
fi
# 检查虚拟环境
if [ ! -d "venv" ]; then
echo -e "${YELLOW}创建虚拟环境...${NC}"
python3 -m venv venv
fi
echo ""
echo "Executing: $CMD"
echo ""
# 激活虚拟环境
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
exec $CMD

View File

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

View File

@@ -0,0 +1,218 @@
#!/bin/bash
# ================================================================
# 租户后端部署脚本
# 用于创建/更新租户的后端容器,确保包含所有必要的挂载
#
# 使用方法:
# ./deploy-tenant.sh <tenant_code> [image_tag]
#
# 示例:
# ./deploy-tenant.sh ex main # 部署 ex 租户,使用 main 镜像
# ./deploy-tenant.sh hua # 部署 hua 租户,默认 main 镜像
# ./deploy-tenant.sh all # 部署所有租户
#
# 注意:
# - 此脚本需要在服务器 120.79.247.16 上执行
# - 确保 secrets 目录存在:/data/prod-envs/secrets/coze_private_key.pem
# ================================================================
set -e
# 配置
IMAGE_REGISTRY="crpi-na6dit5kd0bonqed.cn-guangzhou.personal.cr.aliyuncs.com/ireborn/kaopeilian-backend"
ENV_DIR="/root/aiedu/kaopeilian-backend"
DATA_DIR="/data/prod-envs"
SECRETS_DIR="/data/prod-envs/secrets"
# 租户配置(端口映射)
declare -A TENANT_PORTS=(
["hua"]="8010"
["yy"]="8011"
["hl"]="8012"
["xy"]="8013"
["fw"]="8014"
["ex"]="8015"
["cxw"]="8016"
)
# 所有租户列表
ALL_TENANTS=("hua" "yy" "hl" "xy" "fw" "ex" "cxw")
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 检查前置条件
check_prerequisites() {
log_info "检查前置条件..."
# 检查 Docker
if ! docker info > /dev/null 2>&1; then
log_error "Docker 未运行"
exit 1
fi
# 检查 secrets 目录
if [ ! -f "${SECRETS_DIR}/coze_private_key.pem" ]; then
log_error "私钥文件不存在: ${SECRETS_DIR}/coze_private_key.pem"
exit 1
fi
log_info "前置条件检查通过"
}
# 部署单个租户
deploy_tenant() {
local tenant=$1
local image_tag=${2:-main}
local port=${TENANT_PORTS[$tenant]}
if [ -z "$port" ]; then
log_error "未知租户: $tenant"
return 1
fi
local container_name="${tenant}-backend"
local env_file="${ENV_DIR}/.env.${tenant}"
local image="${IMAGE_REGISTRY}:${image_tag}"
log_info "=========================================="
log_info "部署租户: ${tenant}"
log_info "容器名: ${container_name}"
log_info "端口: ${port}:8000"
log_info "镜像: ${image}"
log_info "=========================================="
# 检查环境变量文件
if [ ! -f "$env_file" ]; then
log_error "环境变量文件不存在: $env_file"
return 1
fi
# 创建必要的目录
mkdir -p "${DATA_DIR}/uploads-${tenant}"
mkdir -p "${DATA_DIR}/logs-${tenant}"
# 拉取最新镜像
log_info "拉取镜像..."
docker pull "$image" || {
log_warn "拉取镜像失败,使用本地镜像"
}
# 停止并删除旧容器
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
log_info "停止旧容器..."
docker stop "$container_name" 2>/dev/null || true
docker rm "$container_name" 2>/dev/null || true
fi
# 创建新容器
log_info "创建新容器..."
docker run -d \
--name "$container_name" \
--restart unless-stopped \
--env-file "$env_file" \
-e TZ=Asia/Shanghai \
-e PYTHONPATH=/app \
-e WORKERS=4 \
-e RELOAD=false \
-p "${port}:8000" \
-v "${DATA_DIR}/uploads-${tenant}:/app/uploads:rw" \
-v "${DATA_DIR}/logs-${tenant}:/app/logs:rw" \
-v "${SECRETS_DIR}:/app/secrets:ro" \
-v /etc/localtime:/etc/localtime:ro \
-v /etc/timezone:/etc/timezone:ro \
--network kaopeilian-network \
--label "com.centurylinklabs.watchtower.enable=false" \
"$image"
# 连接到 prod-network
log_info "连接网络..."
docker network connect prod-network "$container_name" 2>/dev/null || true
# 等待容器启动
log_info "等待容器启动..."
sleep 5
# 检查容器状态
local status=$(docker inspect "$container_name" --format '{{.State.Status}}' 2>/dev/null)
if [ "$status" = "running" ]; then
log_info "${container_name} 部署成功"
# 验证 secrets 挂载
if docker exec "$container_name" ls /app/secrets/coze_private_key.pem > /dev/null 2>&1; then
log_info "✓ secrets 挂载验证成功"
else
log_warn "✗ secrets 挂载验证失败"
fi
else
log_error "${container_name} 启动失败,状态: $status"
docker logs "$container_name" --tail 20
return 1
fi
}
# 部署所有租户
deploy_all_tenants() {
local image_tag=${1:-main}
log_info "部署所有租户 (镜像标签: $image_tag)"
for tenant in "${ALL_TENANTS[@]}"; do
deploy_tenant "$tenant" "$image_tag"
echo ""
done
log_info "所有租户部署完成"
}
# 显示帮助
show_help() {
echo "用法: $0 <tenant_code|all> [image_tag]"
echo ""
echo "租户代码:"
echo " hua - 华尔倍丽"
echo " yy - 杨扬宠物"
echo " hl - 武汉禾丽"
echo " xy - 芯颜定制"
echo " fw - 飞沃"
echo " ex - 恩喜成都总院"
echo " cxw - 崔曦文"
echo " all - 所有租户"
echo ""
echo "镜像标签 (可选, 默认: main):"
echo " main - 生产环境"
echo " staging - 预发布环境"
echo " test - 测试环境"
echo ""
echo "示例:"
echo " $0 ex main # 部署 ex 租户"
echo " $0 all # 部署所有租户"
}
# 主函数
main() {
local tenant=${1:-}
local image_tag=${2:-main}
if [ -z "$tenant" ] || [ "$tenant" = "-h" ] || [ "$tenant" = "--help" ]; then
show_help
exit 0
fi
check_prerequisites
if [ "$tenant" = "all" ]; then
deploy_all_tenants "$image_tag"
else
deploy_tenant "$tenant" "$image_tag"
fi
}
main "$@"

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 |
| Redis | 10 |
| MySQL | 4 |
| MinIO | 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. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新
2. **后端独立**:每个租户有独立的后端容器和数据库
@@ -198,7 +286,7 @@ docker ps --format 'table {{.Names}}\t{{.Status}}'
---
## 、Git 仓库配置
## 、Git 仓库配置
```bash
# 查看远程仓库

View File

@@ -28,7 +28,8 @@ COPY package*.json ./
RUN npm config set registry https://registry.npmmirror.com
# 安装所有依赖包括开发依赖跳过husky
RUN npm install --silent --ignore-scripts
# 注意NODE_ENV=production 会跳过 devDependencies所以用 --include=dev
RUN npm install --silent --ignore-scripts --include=dev
# 复制源代码
COPY . .
@@ -43,7 +44,8 @@ RUN echo "======================================" && \
echo "======================================"
# 构建应用使用环境变量中的API地址
RUN npm run build
# 增加 Node.js 内存限制避免 OOM
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
# ============================================
# 生产阶段 - 轻量级nginx镜像

View File

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

View File

@@ -167,6 +167,8 @@ export interface CreateGrowthPathNode {
is_required: boolean
prerequisites?: number[]
estimated_days: number
position_x?: number // 画布X坐标
position_y?: number // 画布Y坐标
}
// 创建成长路径请求

View File

@@ -10,6 +10,21 @@ import { loadingManager } from '@/utils/loadingManager'
// 模拟延迟,使体验更真实
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
interface ExtendedRequestInit extends RequestInit {
transformRequest?: Array<(data: any, headers?: any) => any>
@@ -108,24 +123,84 @@ class Request {
} catch (error) {
// 处理HTTP错误
const errorInfo = handleHttpError(error)
// 401 统一处理:清理本地状态并跳转登录
try {
const status = (errorInfo as any)?.status || (error as any)?.status
if (status === 401) {
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'
const status = (errorInfo as any)?.status || (error as any)?.status
// 401 处理先尝试刷新Token失败后再跳转登录
if (status === 401 && !url.includes('/auth/refresh') && !url.includes('/auth/login')) {
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
// 如果已经在刷新中,等待刷新完成后重试
if (isRefreshing) {
return new Promise<ApiResponse<T>>((resolve, reject) => {
subscribeTokenRefresh((newToken: string) => {
// 使用新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) {
// 认证处理过程中的异常不应影响主流程,但需要记
console.error('[Auth] 处理401错误时发生异常:', authError)
// 无refresh_token或刷新失败清理状态并跳转登
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
} finally {
if (showLoading) {

View File

@@ -112,4 +112,3 @@ export function deleteTask(id: number): Promise<ResponseModel<void>> {
export function sendTaskReminder(id: number): Promise<ResponseModel<void>> {
return http.post(`/api/v1/manager/tasks/${id}/remind`)
}

View File

@@ -379,6 +379,8 @@ const menuConfig = [
// 获取菜单路由
const menuRoutes = computed(() => {
const userRole = authManager.getUserRole()
// 仅保留当前用户可访问的菜单项和启用的功能
const filterChildren = (children: any[] = []) =>
children.filter((child: any) => {
@@ -389,7 +391,22 @@ const menuRoutes = computed(() => {
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
.filter(roleMenuFilter) // 先按角色过滤顶级菜单
.map((route: any) => {
const next = { ...route }
if (route.children && route.children.length > 0) {
@@ -398,8 +415,10 @@ const menuRoutes = computed(() => {
return next
})
.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)
})

View File

@@ -55,11 +55,31 @@ export function setupRouterGuard(router: Router) {
}
})
// 路由错误处理
// 路由错误处理 - 处理懒加载组件失败
router.onError((error) => {
console.error('Router error:', error)
ElMessage.error('路由加载失败')
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('页面加载失败,请刷新重试')
}
})
}

View File

@@ -345,11 +345,20 @@ const triggerSync = async () => {
const response = await request.post('/api/v1/employee-sync/sync')
if (response.success) {
const data = response.data
ElMessage.success(
`同步完成!共处理 ${data.total_employees || 0} 名员工,` +
`创建 ${data.users_created || 0} 个账号,` +
`跳过 ${data.users_skipped || 0}`
)
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 || '同步失败')
}

View File

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

View File

@@ -58,93 +58,113 @@
<!-- 搜索和筛选 -->
<div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="关键词">
<div class="filter-toolbar">
<!-- 搜索框 -->
<div class="search-box">
<el-input
v-model="filterForm.keyword"
placeholder="搜索错题内容或知识点"
placeholder="搜索错题内容或知识点..."
clearable
@input="handleRealTimeSearch"
style="width: 200px"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
<el-icon class="search-icon"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="题目类型">
</div>
<!-- 筛选项 -->
<div class="filter-items">
<el-select
v-model="filterForm.type"
placeholder="全部类型"
placeholder="题目类型"
clearable
@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="multiple" />
<el-option label="判断题" value="judge" />
<el-option label="填空题" value="fill" />
<el-option label="简答题" value="essay" />
</el-select>
</el-form-item>
<el-form-item label="难度等级">
<el-select
v-model="filterForm.difficulty"
placeholder="全部难度"
placeholder="难度等级"
clearable
@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="medium" />
<el-option label="困难" value="hard" />
</el-select>
</el-form-item>
<el-form-item label="掌握状态">
<el-select
v-model="filterForm.status"
placeholder="全部状态"
placeholder="掌握状态"
clearable
@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="mastered" />
<el-option label="需巩固" value="review" />
</el-select>
</el-form-item>
<el-form-item label="时间周期">
<el-select
v-model="filterForm.timePeriod"
placeholder="全部时间"
placeholder="时间周期"
clearable
@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="month" />
<el-option label="最近三月" value="quarter" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item v-if="filterForm.timePeriod === 'custom'" label="自定义时间">
<el-date-picker
v-if="filterForm.timePeriod === 'custom'"
v-model="customDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator="~"
start-placeholder="开始"
end-placeholder="结束"
@change="handleRealTimeSearch"
style="width: 240px"
class="date-picker"
format="MM/DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleReset">
<el-icon class="el-icon--left"><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 重置按钮 -->
<el-button
v-if="hasActiveFilters"
@click="handleReset"
class="reset-btn"
type="info"
plain
>
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<!-- 当前筛选条件显示 -->
<div v-if="hasActiveFilters" class="filter-tags">
@@ -259,19 +279,29 @@
</div>
<div class="card-content">
<div class="question-content">
<h4 class="question-title">{{ mistake.title }}</h4>
<div class="answer-comparison">
<div class="answer-item wrong">
<span class="answer-label">你的答案</span>
<span class="answer-value">{{ mistake.yourAnswer }}</span>
<div class="question-content">
<h4 class="question-title">{{ mistake.title }}</h4>
<div class="answer-comparison">
<div class="answer-item wrong">
<div class="answer-header">
<span class="answer-icon wrong-icon"></span>
<span class="answer-label">你的答案</span>
</div>
<div class="answer-item correct">
<span class="answer-label">正确答案</span>
<span class="answer-value">{{ mistake.correctAnswer }}</span>
<div class="answer-body">
<span class="answer-value">{{ formatAnswer(mistake.yourAnswer) || '未作答' }}</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 class="mistake-meta">
<div class="detail-item">
@@ -292,10 +322,22 @@
</div>
<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 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>
</div>
@@ -329,14 +371,28 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column label="操作" width="220">
<template #default="scope">
<el-button link type="primary" size="small" @click="viewDetail(scope.row)">
查看解析
</el-button>
<el-button link type="success" size="small" @click="markMastered(scope.row)">
标记已掌握
</el-button>
<div class="table-actions">
<el-button
type="primary"
size="small"
@click="viewDetail(scope.row)"
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>
</el-table-column>
</el-table>
@@ -376,7 +432,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
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 type { MistakeListItem, MistakesStatisticsResponse } from '@/api/exam'
@@ -527,7 +583,7 @@ const loadMistakes = async () => {
id: item.id,
type: item.question_type || 'single',
difficulty: 'medium', // 暂无此字段
masteryStatus: 'unmastered', // 暂无此字段
masteryStatus: item.mastery_status || 'unmastered',
title: item.question_content,
yourAnswer: item.user_answer,
correctAnswer: item.correct_answer,
@@ -550,19 +606,38 @@ const loadMistakes = async () => {
*/
const viewDetail = (mistake: any) => {
ElMessageBox.alert(
h('div', { class: 'mistake-detail' }, [
h('h4', '题目'),
h('p', mistake.title),
h('h4', '你的答案'),
h('p', { style: 'color: #f56c6c' }, mistake.yourAnswer || '未作答'),
h('h4', '正确答案'),
h('p', { style: 'color: #67c23a' }, mistake.correctAnswer),
h('h4', '知识点'),
h('p', mistake.knowledge)
h('div', { class: 'mistake-detail-content' }, [
// 题目区域
h('div', { class: 'detail-section question-section' }, [
h('div', { class: 'section-label' }, '📝 题目内容'),
h('div', { class: 'section-content question-text' }, mistake.title)
]),
// 答案对比区域
h('div', { class: 'detail-section answers-section' }, [
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'
}
)
@@ -790,6 +865,33 @@ const getStatusTagType = (status: string) => {
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(() => {
loadStatistics()
@@ -826,15 +928,105 @@ console.log('错题分析页面已加载')
}
.filter-section {
.filter-form {
.el-form-item {
margin-bottom: 0;
padding: 16px 20px;
.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 {
margin-top: 16px;
padding-top: 16px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
@@ -842,42 +1034,62 @@ console.log('错题分析页面已加载')
gap: 8px;
.filter-label {
color: #666;
font-size: 14px;
margin-right: 8px;
color: #606266;
font-size: 13px;
font-weight: 500;
}
.el-tag {
border-radius: 16px;
padding: 0 12px;
height: 28px;
line-height: 26px;
font-size: 12px;
}
.clear-all-btn {
margin-left: 8px;
margin-left: auto;
font-size: 12px;
}
}
.search-result-info {
margin-top: 12px;
padding-top: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
.result-count {
color: #666;
color: #606266;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
strong {
color: #409eff;
font-weight: 600;
font-size: 18px;
}
}
.filter-hint {
color: #e6a23c;
font-size: 12px;
background: #fdf6ec;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
.category-info {
color: #666;
font-size: 14px;
color: #606266;
font-size: 13px;
background: #f0f9eb;
padding: 4px 12px;
border-radius: 16px;
strong {
color: #67c23a;
@@ -989,6 +1201,21 @@ console.log('错题分析页面已加载')
.mistake-table {
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 {
@@ -1036,32 +1263,90 @@ console.log('错题分析页面已加载')
.answer-comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
gap: 16px;
margin-bottom: 16px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
.answer-item {
padding: 8px 12px;
border-radius: 6px;
border-radius: 12px;
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 {
background: #fef0f0;
border: 1px solid #fcdede;
background: linear-gradient(135deg, #fff5f5 0%, #ffe8e8 100%);
border: 1px solid #ffccc7;
.answer-header {
background: linear-gradient(90deg, #ff7875 0%, #ff9c9c 100%);
}
.answer-icon {
background: #fff;
color: #ff4d4f;
}
}
&.correct {
background: #f0f9ff;
border: 1px solid #b3d8ff;
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
border: 1px solid #b7eb8f;
.answer-header {
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
}
.answer-icon {
background: #fff;
color: #52c41a;
}
}
.answer-label {
font-size: 12px;
color: #666;
margin-right: 8px;
.answer-header {
display: flex;
align-items: center;
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 {
font-weight: 500;
color: #333;
.answer-body {
padding: 14px 16px;
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 {
padding: 12px 16px;
background: #fafafa;
padding: 14px 16px;
background: linear-gradient(135deg, #f8f9fa 0%, #f0f2f5 100%);
border-top: 1px solid #e4e7ed;
display: flex;
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;
}
}
}
}
}
@@ -1114,3 +1441,180 @@ 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>

View File

@@ -4,7 +4,15 @@
<div class="welcome-card card">
<div class="welcome-content">
<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 class="welcome-image">
<el-icon :size="120" color="#667eea">
@@ -13,8 +21,8 @@
</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-icon" :style="{ backgroundColor: stat.bgColor }">
<el-icon :size="24" :color="stat.color">
@@ -24,7 +32,7 @@
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</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">
<component :is="stat.trend > 0 ? 'Top' : 'Bottom'" />
</el-icon>
@@ -48,8 +56,8 @@
</div>
</div>
<!-- 最近考试 -->
<div class="recent-exams">
<!-- 最近考试 - 仅学员显示 -->
<div class="recent-exams" v-if="userRole === 'trainee'">
<h2 class="section-title">最近考试</h2>
<div v-if="recentExams.length > 0" class="exam-list">
<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 userName = computed(() => currentUser.value?.full_name || currentUser.value?.username || '用户')
const userRole = computed(() => authManager.getUserRole())
const learningDays = ref(0)
// 统计数据
@@ -156,37 +165,64 @@ const loadStatistics = async () => {
}
}
// 快捷操作
const quickActions = ref([
// 快捷操作配置(包含角色限制)
const allQuickActions = [
{
title: '智能工牌分析',
desc: 'AI能力评估与成长路径规划',
icon: 'TrendCharts',
color: '#e6a23c',
path: '/trainee/growth-path'
path: '/trainee/growth-path',
roles: ['trainee'] // 仅学员可见
},
{
title: '课程中心',
desc: '查看可用课程',
icon: 'Collection',
color: '#67c23a',
path: '/trainee/course-center'
path: '/trainee/course-center',
roles: ['trainee', 'manager', 'admin'] // 所有角色可见
},
{
title: '查分中心',
desc: '查看成绩和分析报告',
icon: 'DataAnalysis',
color: '#409eff',
path: '/trainee/score-report'
path: '/trainee/score-report',
roles: ['trainee'] // 仅学员可见
},
{
title: 'AI陪练',
desc: '智能陪练系统',
icon: 'ChatLineRound',
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[]>([])

View File

@@ -28,22 +28,22 @@
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="进行中" name="ongoing">
<span slot="label">
进行中 <el-badge :value="12" class="tab-badge" />
进行中 <el-badge :value="taskCounts.ongoing" class="tab-badge" v-if="taskCounts.ongoing > 0" />
</span>
</el-tab-pane>
<el-tab-pane label="待开始" name="pending">
<span slot="label">
待开始 <el-badge :value="5" class="tab-badge" />
待开始 <el-badge :value="taskCounts.pending" class="tab-badge" v-if="taskCounts.pending > 0" />
</span>
</el-tab-pane>
<el-tab-pane label="已完成" name="completed">
<span slot="label">
已完成 <el-badge :value="28" class="tab-badge" />
已完成 <el-badge :value="taskCounts.completed" class="tab-badge" v-if="taskCounts.completed > 0" />
</span>
</el-tab-pane>
<el-tab-pane label="已过期" name="expired">
<span slot="label">
已过期 <el-badge :value="3" class="tab-badge" />
已过期 <el-badge :value="taskCounts.expired" class="tab-badge" v-if="taskCounts.expired > 0" />
</span>
</el-tab-pane>
</el-tabs>
@@ -55,7 +55,7 @@
<div class="task-title-section">
<h3 class="task-title">{{ task.title }}</h3>
<el-tag :type="getTaskTagType(task.priority)" size="small">
{{ task.priority }}
{{ getPriorityLabel(task.priority) }}
</el-tag>
</div>
<el-dropdown trigger="click">
@@ -383,6 +383,14 @@ const taskStats = ref([
// 任务列表数据
const allTasks = ref<Task[]>([])
// 各状态任务数量
const taskCounts = reactive({
ongoing: 0,
pending: 0,
completed: 0,
expired: 0
})
// 任务表单
const taskForm = reactive({
title: '',
@@ -417,9 +425,6 @@ const rules = reactive<FormRules>({
// 根据当前标签页筛选的任务列表
const taskList = computed(() => {
if (activeTab.value === 'ongoing') {
return allTasks.value
}
return allTasks.value.filter(task => task.status === activeTab.value)
})
@@ -442,15 +447,20 @@ const loadTaskStats = async () => {
}
/**
* 加载任务列表
* 加载任务列表(加载所有任务,前端筛选)
*/
const loadTasks = async () => {
loading.value = true
try {
const status = activeTab.value === 'ongoing' ? 'ongoing' : activeTab.value
const res = await getTasks({ status })
// 不传status参数获取所有任务
const res = await getTasks({ page_size: 500 })
if (res.code === 200 && res.data) {
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) {
console.error('加载任务列表失败:', error)
@@ -557,11 +567,18 @@ const handleCreateTask = async () => {
createLoading.value = true
try {
// 优先级中文转英文映射
const priorityMap: Record<string, string> = {
'高': 'high',
'中': 'medium',
'低': 'low'
}
// 构建请求数据
const taskData = {
title: taskForm.title,
description: taskForm.description,
priority: taskForm.priority.toLowerCase(),
priority: priorityMap[taskForm.priority] || 'medium',
deadline: taskForm.deadline,
course_ids: taskForm.courses,
user_ids: taskForm.assignType === 'all' ? [] : taskForm.members,
@@ -776,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 typeMap: Record<string, string> = {
'high': 'danger',
'medium': 'warning',
'low': 'info',
'高': 'danger',
'中': 'warning',
'低': 'info'

View File

@@ -552,6 +552,13 @@
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
:plain="!isAllSelected"
@click="toggleSelectAll"
>
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
</div>
<div class="available-positions">
@@ -719,6 +726,30 @@ const filteredAvailablePositions = computed(() => {
return filtered
})
// 是否全选
const isAllSelected = computed(() => {
if (filteredAvailablePositions.value.length === 0) return false
return filteredAvailablePositions.value.every(
(p: any) => selectedPositions.value.includes(p.id)
)
})
// 全选/取消全选
const toggleSelectAll = () => {
if (isAllSelected.value) {
// 取消全选:移除当前筛选结果中的所有岗位
const filteredIds = filteredAvailablePositions.value.map((p: any) => p.id)
selectedPositions.value = selectedPositions.value.filter(
id => !filteredIds.includes(id)
)
} else {
// 全选:添加当前筛选结果中的所有岗位
const filteredIds = filteredAvailablePositions.value.map((p: any) => p.id)
const newSelection = new Set([...selectedPositions.value, ...filteredIds])
selectedPositions.value = Array.from(newSelection)
}
}
// 考试设置相关
const examSettingsLoading = ref(false)
const examSettings = reactive({
@@ -2920,6 +2951,9 @@ const loadAvailablePositions = async () => {
// 岗位选择器样式
.position-selector-content {
.selector-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,14 @@
<div class="ability-radar card">
<div class="card-header">
<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>
AI 分析智能工牌数据
</el-button>
@@ -302,7 +309,7 @@
<p class="empty-description">
{{ analyzing ? '正在分析您的智能工牌数据,为您推荐最适合的课程' : '暂无智能工牌数据,请先使用智能工牌记录对话' }}
</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-button>

View File

@@ -2,118 +2,134 @@
<div class="practice-records-container">
<div class="page-header">
<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 class="stats-overview">
<div class="stat-card card" v-for="stat in practiceStats" :key="stat.label">
<div class="stat-icon" :style="{ backgroundColor: stat.bgColor }">
<el-icon :size="32" :color="stat.color">
<component :is="stat.icon" />
</el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'" v-if="stat.trend !== 0">
<el-icon><component :is="stat.trend > 0 ? 'Top' : 'Bottom'" /></el-icon>
{{ Math.abs(stat.trend) }}%
</div>
</div>
<div class="stats-section">
<div class="stat-card card">
<el-statistic title="总陪练次数" :value="practiceStatsData.totalCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</el-statistic>
</div>
<div class="stat-card card">
<el-statistic title="平均评分" :value="practiceStatsData.avgScore" :precision="1">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</el-statistic>
</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 class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="关键词">
<div class="filter-toolbar">
<!-- 搜索框 -->
<div class="search-box">
<el-input
v-model="filterForm.keyword"
placeholder="搜索陪练内容或场景"
placeholder="搜索陪练内容或场景..."
clearable
@input="handleRealTimeSearch"
style="width: 200px"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
<el-icon class="search-icon"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="陪练场景">
</div>
<!-- 筛选项 -->
<div class="filter-items">
<el-select
v-model="filterForm.scene"
placeholder="全部场景"
placeholder="陪练场景"
clearable
@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="beauty_care" />
<el-option label="产品介绍" value="product_introduction" />
<el-option label="问题处理" value="problem_handling" />
<el-option label="服务礼仪" value="service_etiquette" />
</el-select>
</el-form-item>
<el-form-item label="陪练结果">
<el-select
v-model="filterForm.result"
placeholder="全部结果"
placeholder="陪练结果"
clearable
@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="good" />
<el-option label="一般" value="average" />
<el-option label="需改进" value="needs_improvement" />
</el-select>
</el-form-item>
<el-form-item label="时间周期">
<el-select
v-model="filterForm.timePeriod"
placeholder="全部时间"
placeholder="时间周期"
clearable
@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="month" />
<el-option label="最近三月" value="quarter" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item v-if="filterForm.timePeriod === 'custom'" label="自定义时间">
<el-date-picker
v-if="filterForm.timePeriod === 'custom'"
v-model="customDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator="~"
start-placeholder="开始"
end-placeholder="结束"
@change="handleRealTimeSearch"
style="width: 240px"
class="date-picker"
format="MM/DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleReset">
<el-icon class="el-icon--left"><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 重置按钮 -->
<el-button
v-if="hasActiveFilters"
@click="handleReset"
class="reset-btn"
type="info"
plain
>
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<!-- 当前筛选条件显示 -->
<div v-if="hasActiveFilters" class="filter-tags">
@@ -123,6 +139,7 @@
closable
@close="clearKeyword"
type="primary"
effect="light"
>
关键词{{ filterForm.keyword }}
</el-tag>
@@ -283,8 +300,15 @@
</div>
<!-- 对话记录 -->
<div class="conversation-replay">
<div class="conversation-list">
<div class="conversation-replay" v-loading="replayLoading">
<!-- 空状态提示 -->
<el-empty
v-if="!replayLoading && (!currentRecord.conversation || currentRecord.conversation.length === 0)"
description="暂无对话记录"
/>
<!-- 对话列表 -->
<div class="conversation-list" v-else>
<div
v-for="(message, index) in currentRecord.conversation"
:key="index"
@@ -342,6 +366,7 @@ const total = ref(0)
// 弹窗状态
const replayDialogVisible = ref(false)
const replayLoading = ref(false)
const currentRecord = ref<any>(null)
// 筛选表单
@@ -373,6 +398,17 @@ interface 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([])
@@ -608,9 +644,32 @@ const viewPracticeReport = (record: any) => {
/**
* 回放陪练对话
*/
const replayPractice = (record: any) => {
currentRecord.value = record
replayDialogVisible.value = true
const replayPractice = async (record: any) => {
try {
// 先设置基本信息并打开弹窗
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;
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 {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
color: #1f2937;
}
}
.stats-overview {
// 统计区域 - 参考错题分析风格
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 24px;
.stat-card {
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
.stat-icon {
width: 64px;
height: 64px;
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;
}
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
}
}
// 筛选区域 - 参考错题分析现代风格
.filter-section {
padding: 20px;
margin-bottom: 20px;
padding: 16px 20px;
.filter-form {
.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 {
margin-top: 16px;
padding-top: 16px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
@@ -834,34 +941,54 @@ const loadRecords = async () => {
gap: 8px;
.filter-label {
color: #666;
font-size: 14px;
margin-right: 8px;
color: #606266;
font-size: 13px;
font-weight: 500;
}
.el-tag {
border-radius: 16px;
padding: 0 12px;
height: 28px;
line-height: 26px;
font-size: 12px;
}
.clear-all-btn {
margin-left: 8px;
margin-left: auto;
font-size: 12px;
}
}
.search-result-info {
margin-top: 12px;
padding-top: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
.result-count {
color: #666;
color: #606266;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
strong {
color: #409eff;
font-weight: 600;
font-size: 18px;
}
}
.filter-hint {
color: #e6a23c;
font-size: 12px;
background: #fdf6ec;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
}
}
@@ -891,6 +1018,26 @@ const loadRecords = async () => {
justify-content: center;
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 {
@@ -1001,25 +1148,33 @@ const loadRecords = async () => {
// 响应式设计
@media (max-width: 768px) {
.practice-records-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
.stats-section {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stats-overview {
grid-template-columns: 1fr;
}
.filter-section {
.filter-toolbar {
flex-direction: column;
align-items: stretch;
.filter-form {
.el-form-item {
display: block;
margin-bottom: 16px !important;
.search-box {
flex: 1;
width: 100%;
.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>