Compare commits

...

13 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 14:06:22 +08:00
28 changed files with 2911 additions and 2027 deletions

View File

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

View File

@@ -41,7 +41,7 @@ UPLOAD_DIR=uploads
COZE_OAUTH_CLIENT_ID=1114009328887 COZE_OAUTH_CLIENT_ID=1114009328887
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
COZE_PRACTICE_BOT_ID=7560643598174683145 COZE_PRACTICE_BOT_ID=7602204855037591602
# Dify 工作流 API Key 配置 # Dify 工作流 API Key 配置
# 01-知识点分析 # 01-知识点分析

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,6 +87,14 @@ class GrowthPathNode(BaseModel, SoftDeleteMixin):
Integer, default=7, nullable=False, comment="预计学习天数" 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 growth_path: Mapped["GrowthPath"] = relationship( # noqa: F821
"GrowthPath", back_populates="nodes" "GrowthPath", back_populates="nodes"

View File

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

View File

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

View File

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

View File

@@ -95,6 +95,8 @@ class GrowthPathService:
is_required=node_data.is_required, is_required=node_data.is_required,
prerequisites=node_data.prerequisites, prerequisites=node_data.prerequisites,
estimated_days=node_data.estimated_days, estimated_days=node_data.estimated_days,
position_x=node_data.position_x,
position_y=node_data.position_y,
) )
db.add(node) db.add(node)
@@ -147,6 +149,8 @@ class GrowthPathService:
is_required=node_data.is_required, is_required=node_data.is_required,
prerequisites=node_data.prerequisites, prerequisites=node_data.prerequisites,
estimated_days=node_data.estimated_days, estimated_days=node_data.estimated_days,
position_x=node_data.position_x,
position_y=node_data.position_y,
) )
db.add(node) db.add(node)
@@ -222,6 +226,8 @@ class GrowthPathService:
"is_required": node.is_required, "is_required": node.is_required,
"prerequisites": node.prerequisites, "prerequisites": node.prerequisites,
"estimated_days": node.estimated_days, "estimated_days": node.estimated_days,
"position_x": node.position_x,
"position_y": node.position_y,
"created_at": node.created_at, "created_at": node.created_at,
"updated_at": node.updated_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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -379,6 +379,8 @@ const menuConfig = [
// 获取菜单路由 // 获取菜单路由
const menuRoutes = computed(() => { const menuRoutes = computed(() => {
const userRole = authManager.getUserRole()
// 仅保留当前用户可访问的菜单项和启用的功能 // 仅保留当前用户可访问的菜单项和启用的功能
const filterChildren = (children: any[] = []) => const filterChildren = (children: any[] = []) =>
children.filter((child: any) => { children.filter((child: any) => {
@@ -389,7 +391,22 @@ const menuRoutes = computed(() => {
return true return true
}) })
// 根据角色预过滤顶级菜单
const roleMenuFilter = (route: any): boolean => {
// 管理者中心:仅 admin 和 manager 可见
if (route.path === '/manager') {
return userRole === 'admin' || userRole === 'manager'
}
// 系统管理:仅 admin 可见
if (route.path === '/admin') {
return userRole === 'admin'
}
// 数据分析:所有登录用户可见(但子菜单会进一步过滤)
return true
}
return menuConfig return menuConfig
.filter(roleMenuFilter) // 先按角色过滤顶级菜单
.map((route: any) => { .map((route: any) => {
const next = { ...route } const next = { ...route }
if (route.children && route.children.length > 0) { if (route.children && route.children.length > 0) {
@@ -398,8 +415,10 @@ const menuRoutes = computed(() => {
return next return next
}) })
.filter((route: any) => { .filter((route: any) => {
// 有子菜单至少一个可访问 // 有子菜单的必须至少一个可访问的子项
if (route.children && route.children.length > 0) return true if (route.children !== undefined) {
return route.children.length > 0
}
// 无子菜单时检查自身路径 // 无子菜单时检查自身路径
return authManager.canAccessRoute(route.path) return authManager.canAccessRoute(route.path)
}) })

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,14 @@
<div class="ability-radar card"> <div class="ability-radar card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">能力评估</h3> <h3 class="card-title">能力评估</h3>
<el-button type="primary" size="small" @click="analyzeSmartBadgeData" :loading="analyzing"> <!-- AI智能工牌分析仅对学员开放 -->
<el-button
v-if="userInfo.role === 'trainee'"
type="primary"
size="small"
@click="analyzeSmartBadgeData"
:loading="analyzing"
>
<el-icon><TrendCharts /></el-icon> <el-icon><TrendCharts /></el-icon>
AI 分析智能工牌数据 AI 分析智能工牌数据
</el-button> </el-button>
@@ -302,7 +309,7 @@
<p class="empty-description"> <p class="empty-description">
{{ analyzing ? '正在分析您的智能工牌数据,为您推荐最适合的课程' : '暂无智能工牌数据,请先使用智能工牌记录对话' }} {{ analyzing ? '正在分析您的智能工牌数据,为您推荐最适合的课程' : '暂无智能工牌数据,请先使用智能工牌记录对话' }}
</p> </p>
<el-button v-if="!analyzing" type="primary" @click="analyzeSmartBadgeData"> <el-button v-if="!analyzing && userInfo.role === 'trainee'" type="primary" @click="analyzeSmartBadgeData">
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
重新分析 重新分析
</el-button> </el-button>

View File

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