Files
012-kaopeilian/frontend/src/views/manager/edit-course.vue
yuliang_guo 696b48e97a
Some checks failed
continuous-integration/drone/push Build is failing
fix: 优化资源重复的错误提示
1. 后端 course_service.py:
   - 课程名重复时返回 existing_id 和 existing_name
   - 成长路径名重复时返回详细信息

2. 前端 edit-course.vue:
   - 处理409冲突错误,提供跳转到已存在课程的选项

3. 前端 errorHandler.ts:
   - 添加409错误的处理逻辑
   - 添加冲突错误工具函数

4. 前端 position-management.vue, user-management.vue:
   - 改进错误消息提取,显示更详细的错误信息
2026-01-29 17:04:15 +08:00

3107 lines
93 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="edit-course-container">
<div class="page-header">
<h1 class="page-title">{{ isEdit ? '编辑课程' : '创建课程' }}</h1>
<div class="header-actions">
<el-button @click="handleBack">
<el-icon class="el-icon--left"><Back /></el-icon>
返回
</el-button>
<el-button type="primary" @click="handleSave">
<el-icon class="el-icon--left"><Check /></el-icon>
保存
</el-button>
</div>
</div>
<div class="course-content">
<el-tabs v-model="activeTab">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
<el-form ref="basicFormRef" :model="courseForm" :rules="rules" label-width="120px">
<el-form-item label="课程名称" prop="name">
<el-input v-model="courseForm.name" placeholder="请输入课程名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="课程分类" prop="category">
<el-select v-model="courseForm.category" placeholder="请选择课程分类">
<el-option label="技术类" value="technology" />
<el-option label="管理类" value="management" />
<el-option label="业务类" value="business" />
<el-option label="通用类" value="general" />
</el-select>
</el-form-item>
<el-form-item label="课程描述" prop="description">
<el-input
v-model="courseForm.description"
type="textarea"
placeholder="请输入课程描述"
:rows="4"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="课程状态" prop="status">
<el-radio-group v-model="courseForm.status">
<el-radio value="draft">草稿</el-radio>
<el-radio value="published">已发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="允许下载资料">
<el-switch
v-model="courseForm.allow_download"
active-text="允许"
inactive-text="禁止"
/>
<span class="form-help" style="margin-left: 12px; color: #909399; font-size: 12px;">
开启后学员可以下载此课程的学习资料
</span>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 学习资料与知识点管理 -->
<el-tab-pane label="学习资料与知识点" name="materials">
<div class="materials-section">
<div class="section-header">
<h3>学习资料与知识点管理</h3>
<div class="header-actions">
<el-button type="primary" size="small" @click="uploadMaterial">
<el-icon class="el-icon--left"><Upload /></el-icon>
上传资料
</el-button>
<el-button size="small" @click="addKnowledgeManual">
<el-icon class="el-icon--left"><Plus /></el-icon>
手动添加知识点
</el-button>
<el-button
size="small"
type="warning"
@click="reanalyzeAllMaterials"
:loading="reanalyzingAll"
:disabled="materialList.length === 0"
>
<el-icon class="el-icon--left"><Refresh /></el-icon>
重新分析
</el-button>
<!-- 播课功能暂时关闭 -->
<el-button
v-if="false"
size="small"
:type="broadcastInfo.has_broadcast ? 'success' : 'primary'"
@click="generateBroadcast"
:disabled="!isEdit"
>
<el-icon class="el-icon--left"><Microphone /></el-icon>
{{ broadcastInfo.has_broadcast ? '重新生成播课' : '生成播课' }}
</el-button>
</div>
</div>
<!-- 播课信息提示播课功能暂时关闭 -->
<el-alert
v-if="false"
type="success"
:closable="true"
style="margin-bottom: 20px"
>
播课已生成生成时间{{ new Date(broadcastInfo.generated_at || '').toLocaleString('zh-CN') }}
</el-alert>
<el-table :data="materialList" style="width: 100%" v-if="materialList.length > 0" row-class-name="material-row">
<el-table-column type="expand">
<template #default="scope">
<div class="material-knowledge-points">
<div class="kp-stats">
<h4>关联知识点 ({{ scope.row.knowledgePoints?.length || 0 }})</h4>
<el-tag v-if="scope.row.status === 'completed'" type="success" size="small">
<el-icon><CircleCheck /></el-icon> AI已分析
</el-tag>
<el-tag v-else-if="scope.row.status === 'analyzing'" type="warning" size="small">
<el-icon class="is-loading"><Loading /></el-icon> AI分析中...
</el-tag>
<el-tag v-else type="info" size="small">
<el-icon><Warning /></el-icon> 待分析
</el-tag>
</div>
<div class="knowledge-points-grid">
<div class="kp-grid-container">
<transition-group name="kp-list">
<div
v-for="(kp, index) in scope.row.knowledgePoints"
:key="kp.id"
class="knowledge-point-card"
@click="viewKnowledgeDetail(kp)"
>
<div class="kp-card-header">
<span class="kp-number">{{ index + 1 }}</span>
<el-tag size="small" :type="getKnowledgeTypeTag(kp.type)">{{ kp.type }}</el-tag>
<el-tag size="small" :type="isAISource(kp.source) ? 'success' : 'info'">
{{ formatKnowledgeSource(kp.source) }}
</el-tag>
</div>
<h5 class="kp-title">{{ kp.name }}</h5>
<p class="kp-summary">{{ kp.description }}</p>
<div class="kp-relation" v-if="kp.topic_relation">
<small>关系{{ kp.topic_relation }}</small>
</div>
<div class="kp-card-actions">
<el-button
link
type="primary"
size="small"
@click.stop="editKnowledgePoint(scope.row, kp)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
link
type="danger"
size="small"
@click.stop="deleteKnowledgePoint(scope.row, kp)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
</transition-group>
<!-- 优化后的添加知识点按钮 -->
<div
v-if="(scope.row.knowledgePoints?.length || 0) < 20"
class="knowledge-point-card add-card"
@click="addKnowledgePointToMaterial(scope.row)"
>
<el-icon :size="18" class="add-icon"><Plus /></el-icon>
<span class="add-text">添加知识点</span>
</div>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="文件名" min-width="200">
<template #default="scope">
<div class="file-info">
<el-icon><Document /></el-icon>
<span>{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="size" label="大小" width="100">
<template #default="scope">
{{ formatFileSize(scope.row.size) }}
</template>
</el-table-column>
<el-table-column prop="knowledgePoints" label="知识点" width="100">
<template #default="scope">
<el-tag>{{ scope.row.knowledgePoints?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="180" />
<el-table-column label="AI分析" width="120">
<template #default="scope">
<el-button
v-if="scope.row.status === 'pending'"
type="primary"
link
size="small"
@click="analyzeWithAI(scope.row)"
>
<el-icon><MagicStick /></el-icon>
AI拆解
</el-button>
<el-button
v-else-if="scope.row.status === 'completed'"
type="success"
link
size="small"
@click="reAnalyzeWithAI(scope.row)"
>
<el-icon><Refresh /></el-icon>
重新分析
</el-button>
<div v-else>
<el-text type="warning" size="small">
<el-icon class="is-loading"><Loading /></el-icon>
分析中...
</el-text>
<div style="font-size: 10px; color: #909399; margin-top: 2px;">
(可关闭页面)
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="downloadMaterial(scope.row)">
下载
</el-button>
<el-button link type="danger" size="small" @click="deleteMaterial(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无学习资料" />
</div>
</el-tab-pane>
<!-- 岗位分配 -->
<el-tab-pane label="岗位分配" name="positions">
<div class="positions-section">
<div class="section-header">
<h3>岗位分配管理</h3>
</div>
<div class="assignment-stats">
<div class="stat-item required">
<div class="stat-value">{{ courseRequiredPositions.length }}</div>
<div class="stat-label">必修岗位</div>
</div>
<div class="stat-item optional">
<div class="stat-value">{{ courseOptionalPositions.length }}</div>
<div class="stat-label">选修岗位</div>
</div>
<div class="stat-item total">
<div class="stat-value">{{ (courseRequiredPositions.length + courseOptionalPositions.length) }}</div>
<div class="stat-label">已分配岗位</div>
</div>
</div>
<el-tabs v-model="positionTabActive" type="border-card">
<!-- 必修岗位 -->
<el-tab-pane label="必修岗位" name="required">
<div class="position-section">
<div class="section-header">
<h4>必修岗位列表</h4>
<el-button type="danger" size="small" @click="showPositionSelector('required')">
<el-icon><Plus /></el-icon>
添加必修岗位
</el-button>
</div>
<div class="position-cards">
<div v-for="position in courseRequiredPositions" :key="position.id" class="position-card">
<div class="card-header">
<div class="position-name">{{ position.name }}</div>
<el-button link type="danger" size="small" @click="removePositionAssignment(position.id, 'required')">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="card-content">
<div class="position-desc">{{ position.description }}</div>
<div class="position-meta">
<span><el-icon><User /></el-icon> {{ position.memberCount }} </span>
<span><el-icon><Star /></el-icon> 优先级: {{ position.priority || '中' }}</span>
</div>
</div>
</div>
</div>
<el-empty v-if="courseRequiredPositions.length === 0" description="该课程暂无必修岗位" :image-size="80" />
</div>
</el-tab-pane>
<!-- 选修岗位 -->
<el-tab-pane label="选修岗位" name="optional">
<div class="position-section">
<div class="section-header">
<h4>选修岗位列表</h4>
<el-button type="warning" size="small" @click="showPositionSelector('optional')">
<el-icon><Plus /></el-icon>
添加选修岗位
</el-button>
</div>
<div class="position-cards">
<div v-for="position in courseOptionalPositions" :key="position.id" class="position-card">
<div class="card-header">
<div class="position-name">{{ position.name }}</div>
<el-button link type="danger" size="small" @click="removePositionAssignment(position.id, 'optional')">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="card-content">
<div class="position-desc">{{ position.description }}</div>
<div class="position-meta">
<span><el-icon><User /></el-icon> {{ position.memberCount }} </span>
<span><el-icon><Star /></el-icon> 推荐级别: {{ position.recommendLevel || 3 }} </span>
</div>
</div>
</div>
</div>
<el-empty v-if="courseOptionalPositions.length === 0" description="该课程暂无选修岗位" :image-size="80" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-tab-pane>
<!-- 考试设置 -->
<el-tab-pane label="考试设置" name="exam">
<div class="exam-section">
<div class="section-header">
<h3>考试设置</h3>
<p class="section-desc">设置此课程相关考试的题型数量和参数</p>
</div>
<div class="exam-settings-form" v-loading="examSettingsLoading">
<el-form label-width="120px">
<el-form-item label="单选题数量">
<el-input-number v-model="examSettings.singleChoice" :min="0" :max="50" />
<span class="form-help">建议 4 </span>
</el-form-item>
<el-form-item label="多选题数量">
<el-input-number v-model="examSettings.multipleChoice" :min="0" :max="30" />
<span class="form-help">建议 2 </span>
</el-form-item>
<el-form-item label="判断题数量">
<el-input-number v-model="examSettings.trueOrFalse" :min="0" :max="20" />
<span class="form-help">建议 1 </span>
</el-form-item>
<el-form-item label="填空题数量">
<el-input-number v-model="examSettings.fillInBlank" :min="0" :max="10" />
<span class="form-help">建议 2 </span>
</el-form-item>
<el-form-item label="问答题数量">
<el-input-number v-model="examSettings.essay" :min="0" :max="10" />
<span class="form-help">建议 1 </span>
</el-form-item>
<el-form-item label="考试时长">
<el-input-number v-model="examSettings.duration" :min="10" :max="180" />
<span class="form-help">单位分钟</span>
</el-form-item>
<el-form-item label="难度系数">
<el-slider v-model="examSettings.difficulty" :min="1" :max="5" show-stops />
<span class="form-help">1-简单 3-中等 5-困难</span>
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="examSettings.enabled" />
<span class="form-help">关闭后学员无法参加考试</span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveExamSettings">
保存设置
</el-button>
<el-button @click="resetExamSettings">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<div class="current-settings">
<h4>当前设置</h4>
<div class="settings-preview">
<div class="preview-item">
<span class="label">总题数</span>
<span class="value">{{ totalQuestions }} </span>
</div>
<div class="preview-item">
<span class="label">考试时长</span>
<span class="value">{{ examSettings.duration }} 分钟</span>
</div>
<div class="preview-item">
<span class="label">难度系数</span>
<span class="value">{{ getDifficultyText(examSettings.difficulty) }}</span>
</div>
<div class="preview-item">
<span class="label">状态</span>
<el-tag :type="examSettings.enabled ? 'success' : 'info'" size="small">
{{ examSettings.enabled ? '已启用' : '已关闭' }}
</el-tag>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 编辑知识点弹窗 -->
<el-dialog
v-model="knowledgeEditDialogVisible"
:title="isEditKnowledge ? '编辑知识点' : '添加知识点'"
width="700px"
>
<el-form :model="knowledgeForm" label-width="100px">
<el-form-item label="知识点标题" prop="title">
<el-input v-model="knowledgeForm.title" placeholder="请输入知识点标题" />
</el-form-item>
<el-form-item label="知识点内容" prop="content">
<el-input
v-model="knowledgeForm.content"
type="textarea"
placeholder="请输入知识点内容"
:rows="6"
/>
</el-form-item>
<el-form-item label="主题关系" prop="topic_relation">
<el-input v-model="knowledgeForm.topic_relation" placeholder="描述与主题的关系" />
</el-form-item>
<el-form-item label="知识点类型" prop="type">
<el-select v-model="knowledgeForm.type" placeholder="请选择类型">
<el-option label="理论知识" value="理论知识" />
<el-option label="诊断设计" value="诊断设计" />
<el-option label="操作步骤" value="操作步骤" />
<el-option label="沟通话术" value="沟通话术" />
<el-option label="案例分析" value="案例分析" />
<el-option label="注意事项" value="注意事项" />
<el-option label="技巧方法" value="技巧方法" />
<el-option label="客诉处理" value="客诉处理" />
</el-select>
</el-form-item>
<el-form-item label="关联资料" prop="material_id">
<el-select v-model="knowledgeForm.material_id" placeholder="请选择关联资料" clearable>
<el-option
v-for="material in materialList"
:key="material.id"
:label="material.name"
:value="material.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="knowledgeEditDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveKnowledgeForm">保存</el-button>
</template>
</el-dialog>
<!-- 知识点详情查看弹窗 -->
<el-dialog
v-model="knowledgeDetailDialogVisible"
title="知识点详情"
width="800px"
class="knowledge-detail-dialog"
>
<div class="knowledge-detail" v-if="currentKnowledge">
<div class="detail-header">
<div class="title-section">
<h3>{{ currentKnowledge.title }}</h3>
<el-tag :type="getKnowledgeTypeTag(currentKnowledge.type)" size="large">
{{ getTypeLabel(currentKnowledge.type) }}
</el-tag>
</div>
</div>
<div class="detail-content">
<div class="content-section">
<h4>内容描述</h4>
<div class="content-text">{{ currentKnowledge.content }}</div>
</div>
</div>
<div class="detail-meta">
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="来源资料">
<el-tag :type="isAISource(currentKnowledge.source) ? 'success' : 'info'">
{{ formatKnowledgeSource(currentKnowledge.source) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ currentKnowledge.createTime || '2024-03-20' }}
</el-descriptions-item>
<el-descriptions-item label="知识点类型">
<el-tag :type="getKnowledgeTypeTag(currentKnowledge.type)">
{{ getTypeLabel(currentKnowledge.type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag type="success">已完成</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="knowledgeDetailDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="editFromDetail">
<el-icon class="el-icon--left"><Edit /></el-icon>
编辑知识点
</el-button>
</div>
</template>
</el-dialog>
<!-- 岗位选择器弹窗 -->
<el-dialog
v-model="positionSelectorVisible"
:title="`选择${assignmentType === 'required' ? '必修' : '选修'}岗位`"
width="700px"
>
<div class="position-selector-content">
<div class="selector-header">
<el-input
v-model="positionSearchText"
placeholder="搜索岗位名称"
style="width: 300px"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="available-positions">
<div
v-for="position in filteredAvailablePositions"
:key="position.id"
class="position-option"
:class="{ selected: selectedPositions.includes(position.id) }"
@click="togglePositionSelection(position.id)"
>
<div class="position-info">
<div class="position-name">{{ position.name }}</div>
<div class="position-desc">{{ position.description }}</div>
<div class="position-meta">
<span><el-icon><User /></el-icon> {{ position.memberCount }} </span>
<span><el-icon><OfficeBuilding /></el-icon> {{ position.parentName || '顶级部门' }}</span>
</div>
</div>
<div class="selection-indicator">
<el-icon v-if="selectedPositions.includes(position.id)"><Check /></el-icon>
</div>
</div>
</div>
<div class="selector-footer">
<div class="selected-info">
已选择 {{ selectedPositions.length }} 个岗位
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelPositionSelection">取消</el-button>
<el-button type="primary" @click="confirmPositionSelection" :disabled="selectedPositions.length === 0">
确认选择
</el-button>
</div>
</template>
</el-dialog>
<!-- 文件上传弹窗 -->
<el-dialog
v-model="materialDialogVisible"
title="上传学习资料"
width="700px"
@close="handleUploadDialogClose"
>
<el-upload
ref="uploadRef"
class="upload-demo"
drag
action="#"
:auto-upload="false"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:before-upload="beforeUpload"
multiple
accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.csv,.vtt,.properties"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持格式TXTMarkdownMDXPDFHTMLExcelWordCSVVTTProperties<br>
单个文件不超过 15MB
</div>
</template>
</el-upload>
<template #footer>
<div class="dialog-footer">
<el-button @click="materialDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmUpload" :disabled="fileList.length === 0">
确认上传
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, ElLoading, UploadFile, UploadFiles } from 'element-plus'
import { UploadFilled, Refresh, Microphone } from '@element-plus/icons-vue'
import { courseApi, positionApi } from '@/api/course'
import { broadcastApi } from '@/api/broadcast'
import type { BroadcastInfo } from '@/types/broadcast'
import request from '@/utils/http'
const router = useRouter()
const route = useRoute()
// 是否为编辑模式
const isEdit = computed(() => !!route.params.id)
const courseId = computed(() => route.params.id as string)
// 当前标签页
const activeTab = ref('basic')
// 弹窗控制
const knowledgeEditDialogVisible = ref(false)
const materialDialogVisible = ref(false)
const knowledgeDetailDialogVisible = ref(false)
const isEditKnowledge = ref(false)
// 播课功能相关
const broadcastInfo = reactive<BroadcastInfo>({
has_broadcast: false,
mp3_url: undefined,
generated_at: undefined
})
// 文件列表
const fileList = ref<any[]>([])
// 上传组件引用
const uploadRef = ref()
// 表单数据
const courseForm = reactive({
name: '',
category: '',
description: '',
status: 'draft',
allow_download: false,
})
// 岗位分配相关
const positionTabActive = ref('required')
const positionSelectorVisible = ref(false)
const positionSearchText = ref('')
const assignmentType = ref<'required' | 'optional'>('required')
const selectedPositions = ref<number[]>([])
const courseRequiredPositions = ref<any[]>([])
const courseOptionalPositions = ref<any[]>([])
const availablePositions = ref<any[]>([])
// 岗位选择器相关计算属性
const filteredAvailablePositions = computed(() => {
let filtered = availablePositions.value
// 排除已分配的岗位
const assignedIds = [
...courseRequiredPositions.value.map((p: any) => p.id),
...courseOptionalPositions.value.map((p: any) => p.id)
]
filtered = filtered.filter((position: any) => !assignedIds.includes(position.id))
// 按关键词搜索
if (positionSearchText.value.trim()) {
const keyword = positionSearchText.value.toLowerCase()
filtered = filtered.filter((position: any) =>
position.name.toLowerCase().includes(keyword) ||
position.description.toLowerCase().includes(keyword)
)
}
return filtered
})
// 考试设置相关
const examSettingsLoading = ref(false)
const examSettings = reactive({
singleChoice: 4,
multipleChoice: 2,
trueOrFalse: 1,
fillInBlank: 2,
essay: 1,
duration: 10,
difficulty: 3,
enabled: true
})
// 总题数计算
const totalQuestions = computed(() => {
return examSettings.singleChoice + examSettings.multipleChoice + examSettings.trueOrFalse + examSettings.fillInBlank + examSettings.essay
})
// 知识点表单
const knowledgeForm = reactive({
id: '',
title: '',
content: '',
type: 'concept',
source: '',
topic_relation: '',
material_id: null as number | null
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入课程名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择课程分类', trigger: 'change' }
],
description: [
{ required: true, message: '请输入课程描述', trigger: 'blur' },
{ min: 10, max: 200, message: '长度在 10 到 200 个字符', trigger: 'blur' }
]
}
// 资料列表
const materialList = ref<any[]>([])
// 重新分析状态
const reanalyzingAll = ref(false)
// 知识点列表
const knowledgeList = ref<any[]>([])
// 当前查看/编辑的知识点
const currentKnowledge = ref<any>(null)
const currentMaterial = ref<any>(null)
/**
* 初始化页面
*/
onMounted(() => {
if (isEdit.value) {
loadCourseData()
fetchBroadcastInfo()
}
})
/**
* 页面卸载时清理轮询
*/
/**
* 加载课程数据
*/
const loadCourseData = async () => {
try {
// 加载课程基本信息
const courseRes = await courseApi.get(Number(courseId.value))
if (courseRes.code === 200 && courseRes.data) {
const course = courseRes.data
courseForm.name = course.name
courseForm.category = course.category
courseForm.description = course.description || ''
courseForm.status = course.status
courseForm.allow_download = course.allow_download || false
}
// 加载考试设置
examSettingsLoading.value = true
try {
const examRes = await courseApi.getExamSettings(Number(courseId.value))
if (examRes.code === 200) {
if (examRes.data) {
// 如果有数据,使用后端返回的数据
const settings = examRes.data
examSettings.singleChoice = settings.single_choice_count || 0
examSettings.multipleChoice = settings.multiple_choice_count || 0
examSettings.trueOrFalse = settings.true_false_count || 0
examSettings.fillInBlank = settings.fill_blank_count || 0
examSettings.essay = settings.essay_count || 0
examSettings.duration = settings.duration_minutes || 10
examSettings.difficulty = settings.difficulty_level || 3
examSettings.enabled = settings.is_enabled !== undefined ? settings.is_enabled : true
} else {
// 如果没有数据(新课程),使用默认值
examSettings.singleChoice = 4
examSettings.multipleChoice = 2
examSettings.trueOrFalse = 1
examSettings.fillInBlank = 2
examSettings.essay = 1
examSettings.duration = 10
examSettings.difficulty = 3
examSettings.enabled = true
}
}
} catch (error) {
console.error('加载考试设置失败:', error)
ElMessage.error('加载考试设置失败')
} finally {
examSettingsLoading.value = false
}
// 加载岗位分配
const positionsRes = await courseApi.getPositions(Number(courseId.value))
if (positionsRes.code === 200 && positionsRes.data) {
const positions = positionsRes.data
courseRequiredPositions.value = positions.filter((p: any) => p.course_type === 'required').map((p: any) => ({
id: p.position_id,
name: p.position_name || '',
description: p.position_description || '',
memberCount: p.member_count || 0,
priority: p.priority || 0
}))
courseOptionalPositions.value = positions.filter((p: any) => p.course_type === 'optional').map((p: any) => ({
id: p.position_id,
name: p.position_name || '',
description: p.position_description || '',
memberCount: p.member_count || 0,
recommendLevel: 3
}))
}
// 加载课程资料(真实接口)
const materialRes = await courseApi.getMaterials(Number(courseId.value))
if (materialRes.code === 200) {
const list = materialRes.data || []
materialList.value = []
// 逐个加载每个资料的知识点
for (const m of list) {
const material = {
id: m.id,
name: m.name,
size: m.file_size,
uploadTime: m.created_at?.replace('T', ' ').slice(0, 16) || '',
status: 'completed',
knowledgePoints: [] as any[]
}
// 加载资料关联的知识点
try {
const kpRes = await courseApi.getMaterialKnowledgePoints(m.id, Number(courseId.value))
if (kpRes.code === 200 && kpRes.data) {
material.knowledgePoints = kpRes.data.map((kp: any) => ({
id: kp.id,
name: kp.name,
title: kp.name, // 兼容旧字段
description: kp.description || '',
content: kp.description || '', // 兼容旧字段
type: kp.type,
source: kp.source,
topic_relation: kp.topic_relation,
material_id: kp.material_id,
created_at: kp.created_at,
createTime: kp.created_at?.replace('T', ' ').slice(0, 16) || ''
}))
}
} catch (error) {
console.error(`加载资料 ${m.id} 的知识点失败:`, error)
}
materialList.value.push(material)
}
}
// 汇总所有知识点
updateKnowledgeList()
} catch (error: any) {
console.error('加载课程数据失败:', error)
ElMessage.error(error.response?.data?.message || '加载课程数据失败')
}
}
/**
* 更新知识点列表
*/
const updateKnowledgeList = () => {
const allKnowledgePoints: any[] = []
materialList.value.forEach(material => {
if (material.knowledgePoints && material.knowledgePoints.length > 0) {
material.knowledgePoints.forEach((kp: any) => {
allKnowledgePoints.push({
...kp,
source: material.name
})
})
}
})
knowledgeList.value = allKnowledgePoints
}
/**
* 返回
*/
const handleBack = () => {
router.back()
}
/**
* 保存课程
*/
const handleSave = async () => {
try {
// 验证基本信息表单
const basicFormRef = (window as any).$refs?.basicFormRef
if (basicFormRef) {
const valid = await basicFormRef.validate()
if (!valid) {
activeTab.value = 'basic'
return
}
}
const courseData = {
name: courseForm.name,
category: courseForm.category,
description: courseForm.description,
status: courseForm.status,
allow_download: courseForm.allow_download
}
if (isEdit.value) {
// 更新课程
const res = await courseApi.update(Number(courseId.value), courseData)
if (res.code === 200) {
ElMessage.success('更新课程成功')
} else {
throw new Error(res.message || '更新失败')
}
} else {
// 创建课程
const res = await courseApi.create(courseData)
if (res.code === 200) {
ElMessage.success('创建课程成功')
// 获取新创建的课程ID用于后续操作
const newCourseId = res.data?.id
if (newCourseId) {
// 跳转到编辑页面
router.replace(`/manager/edit-course/${newCourseId}`)
return
}
} else {
throw new Error(res.message || '创建失败')
}
}
router.push('/manager/course-management')
} catch (error: any) {
console.error('保存课程失败:', error)
// 处理课程名重复的409冲突错误
const status = error?.status || error?.response?.status
const detail = error?.detail?.detail || error?.response?.data?.detail?.detail
if (status === 409 && detail?.existing_id) {
// 课程名重复,提供跳转选项
ElMessageBox.confirm(
`课程名称"${courseForm.name}"已存在,您可以点击下方按钮查看已有课程。`,
'课程名称重复',
{
confirmButtonText: '查看已有课程',
cancelButtonText: '修改名称',
type: 'warning'
}
).then(() => {
// 跳转到已存在的课程
router.push(`/manager/edit-course/${detail.existing_id}`)
}).catch(() => {
// 用户选择修改名称,聚焦到名称输入框
activeTab.value = 'basic'
})
} else {
// 其他错误
const message = error?.detail?.message || error?.message || '保存课程失败'
ElMessage.error(message)
}
}
}
/**
* 查询播课信息
*/
const fetchBroadcastInfo = async () => {
if (!isEdit.value) return
try {
const res: any = await broadcastApi.getInfo(Number(courseId.value))
const code = res.data?.code || res.code
const data = res.data?.data || res.data
if (code === 200 && data) {
Object.assign(broadcastInfo, data)
}
} catch (error) {
console.error('查询播课信息失败:', error)
}
}
/**
* 生成播课
*/
const generateBroadcast = async () => {
if (!isEdit.value) {
ElMessage.warning('请先保存课程后再生成播课')
return
}
try {
await ElMessageBox.confirm(
'生成播课音频将在后台进行,完成后会自动更新。',
'确认生成',
{
confirmButtonText: '开始生成',
cancelButtonText: '取消',
type: 'info'
}
)
} catch {
return
}
try {
const res: any = await broadcastApi.generate(Number(courseId.value))
const code = res.data?.code || res.code
const message = res.data?.message || res.message
if (code === 200) {
ElMessage.success({
message: '播课生成已启动,完成后会自动更新',
duration: 3000
})
} else {
throw new Error(message || '启动生成失败')
}
} catch (error: any) {
console.error('启动生成播课失败:', error)
ElMessage.error(error.message || '启动生成失败,请稍后重试')
}
}
/**
* 上传资料
*/
const uploadMaterial = () => {
fileList.value = []
materialDialogVisible.value = true
}
/**
* 处理文件选择变化
*/
const handleFileChange = (_file: UploadFile, files: UploadFiles) => {
fileList.value = files
}
/**
* 处理文件移除
*/
const handleFileRemove = (_file: UploadFile, files: UploadFiles) => {
fileList.value = files
}
/**
* 文件上传前的校验
*/
const beforeUpload = (file: UploadFile) => {
const isLt15M = file.size! / 1024 / 1024 < 15
if (!isLt15M) {
ElMessage.error('上传文件大小不能超过 15MB!')
return false
}
return true
}
/**
* 处理上传对话框关闭
*/
const handleUploadDialogClose = () => {
fileList.value = []
}
/**
* 确认上传
*/
const confirmUpload = async () => {
if (fileList.value.length === 0) {
ElMessage.warning('请选择要上传的文件')
return
}
if (!isEdit.value) {
ElMessage.warning('请先保存课程基本信息后再上传资料')
return
}
try {
const loading = ElLoading.service({
lock: true,
text: '正在上传文件...',
background: 'rgba(0, 0, 0, 0.7)'
})
// 逐个上传文件
for (const file of fileList.value) {
if (file.raw) {
try {
// 调用上传接口
console.log('开始上传文件:', file.name, '大小:', file.size)
const uploadRes = await request.upload(
`/api/v1/upload/course/${courseId.value}/materials`,
file.raw
)
console.log('文件上传响应:', uploadRes)
if (uploadRes.code === 200 && uploadRes.data) {
// 创建课程资料记录
console.log('文件上传成功,响应数据:', uploadRes)
// 后端返回的数据直接在 uploadRes.data 中
const materialData = {
name: file.name,
description: '',
file_url: uploadRes.data.file_url,
file_type: uploadRes.data.file_type,
file_size: uploadRes.data.file_size
}
console.log('准备创建资料记录:', materialData)
// 验证必要字段
if (!materialData.file_url) {
throw new Error('文件URL为空无法创建资料记录')
}
if (!materialData.file_type) {
throw new Error('文件类型为空,无法创建资料记录')
}
const res = await courseApi.addMaterial(Number(courseId.value), materialData)
console.log('创建资料记录响应:', res)
if (res.code === 200) {
// 添加到材料列表
const newMaterial = {
id: res.data.id,
name: res.data.name,
size: res.data.file_size,
uploadTime: new Date().toLocaleString(),
status: 'pending', // 待AI分析
knowledgePoints: [],
file_url: res.data.file_url,
file_type: res.data.file_type
}
materialList.value.push(newMaterial)
console.log('资料记录创建成功:', newMaterial)
ElMessage.success(`文件 ${file.name} 上传成功`)
// 上传成功后自动启动AI分析
setTimeout(async () => {
const material = materialList.value.find(m => m.id === newMaterial.id)
if (material) {
console.log('自动启动AI知识点分析:', material.id, material.name)
await analyzeWithAI(material)
}
}, 1000)
} else {
console.error('创建资料记录失败 - res:', res)
throw new Error(res.message || '创建资料记录失败')
}
} else {
console.error('文件上传失败 - uploadRes:', uploadRes)
const errorMsg = uploadRes.data?.message || uploadRes.message || '文件上传失败'
throw new Error(errorMsg)
}
} catch (error: any) {
console.error('上传过程出错:', error)
console.error('错误详情:', {
message: error.message,
response: error.response,
data: error.response?.data
})
// 优先显示后端返回的详细错误信息
let errorMsg = '上传失败'
if (error.response?.data?.detail) {
errorMsg = error.response.data.detail
} else if (error.message) {
errorMsg = error.message
}
ElMessage.error(`文件 ${file.name} 上传失败: ${errorMsg}`)
}
}
}
loading.close()
ElMessage.success('文件上传成功,正在后台分析知识点...')
materialDialogVisible.value = false
fileList.value = []
} catch (error: any) {
console.error('上传失败:', error)
ElMessage.error(error.message || '文件上传失败')
}
}
/**
* 删除资料
*/
const deleteMaterial = async (material: any) => {
try {
await ElMessageBox.confirm(
`确定要删除资料"${material.name}"吗?相关的知识点也会被删除。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const res = await courseApi.deleteMaterial(Number(courseId.value), material.id)
if (res.code === 200 && res.data) {
const index = materialList.value.findIndex(m => m.id === material.id)
if (index > -1) {
materialList.value.splice(index, 1)
updateKnowledgeList()
}
ElMessage.success('删除成功')
} else {
throw new Error(res.message || '删除失败')
}
} catch (e) {}
}
/**
* 下载资料
*/
const downloadMaterial = (material: any) => {
ElMessage.success(`开始下载:${material.name}`)
}
/**
* 编辑资料中的知识点
*/
const editKnowledgePoint = (material: any, kp: any) => {
currentMaterial.value = material
isEditKnowledge.value = true
knowledgeForm.id = kp.id
knowledgeForm.title = kp.name || kp.title
knowledgeForm.content = kp.description || kp.content
knowledgeForm.type = kp.type || '理论知识'
knowledgeForm.topic_relation = kp.topic_relation || ''
knowledgeForm.material_id = kp.material_id || material.id
knowledgeEditDialogVisible.value = true
}
/**
* 保存知识点表单
*/
const saveKnowledgeForm = async () => {
if (!knowledgeForm.title || !knowledgeForm.content) {
ElMessage.warning('请填写完整信息')
return
}
try {
if (isEditKnowledge.value) {
// 编辑模式:更新知识点
const updateData = {
name: knowledgeForm.title,
description: knowledgeForm.content,
type: knowledgeForm.type,
source: 0, // 手动编辑默认为手动添加
topic_relation: knowledgeForm.topic_relation,
material_id: knowledgeForm.material_id || currentMaterial.value?.id
}
const res = await courseApi.updateKnowledgePoint(Number(knowledgeForm.id), updateData)
if (res.code === 200) {
// 更新本地数据
if (currentMaterial.value) {
const index = currentMaterial.value.knowledgePoints.findIndex((k: any) => k.id === knowledgeForm.id)
if (index > -1) {
currentMaterial.value.knowledgePoints[index] = {
...currentMaterial.value.knowledgePoints[index],
name: knowledgeForm.title,
description: knowledgeForm.content,
type: knowledgeForm.type,
topic_relation: knowledgeForm.topic_relation
}
}
}
ElMessage.success('编辑成功')
}
} else {
// 添加模式:创建新知识点
if (!courseId.value) {
ElMessage.warning('请先保存课程基本信息')
return
}
// 创建知识点
const createData = {
name: knowledgeForm.title,
description: knowledgeForm.content,
type: knowledgeForm.type,
source: 0, // 手动添加默认为0
topic_relation: knowledgeForm.topic_relation,
material_id: knowledgeForm.material_id || currentMaterial.value?.id
}
const res = await courseApi.createKnowledgePoint(Number(courseId.value), createData)
if (res.code === 200 && res.data) {
// 直接更新本地显示数据
if (currentMaterial.value && res.data.material_id) {
if (!currentMaterial.value.knowledgePoints) {
currentMaterial.value.knowledgePoints = []
}
// 添加新知识点到当前资料
const displayKnowledge = {
id: res.data.id,
name: res.data.name,
title: res.data.name, // 兼容旧字段
description: res.data.description || '',
content: res.data.description || '', // 兼容旧字段
type: res.data.type,
source: res.data.source,
topic_relation: res.data.topic_relation,
material_id: res.data.material_id,
created_at: res.data.created_at,
createTime: res.data.created_at?.replace('T', ' ').slice(0, 16) || ''
}
currentMaterial.value.knowledgePoints.push(displayKnowledge)
}
ElMessage.success('添加成功')
}
}
updateKnowledgeList()
knowledgeEditDialogVisible.value = false
// 重置表单
knowledgeForm.id = ''
knowledgeForm.title = ''
knowledgeForm.content = ''
knowledgeForm.type = '理论知识'
knowledgeForm.topic_relation = ''
knowledgeForm.material_id = null
} catch (error: any) {
console.error('保存知识点失败:', error)
ElMessage.error(error.response?.data?.message || '保存知识点失败')
}
}
/**
* 删除资料中的知识点
*/
const deleteKnowledgePoint = async (material: any, kp: any) => {
try {
await ElMessageBox.confirm(
`确定要删除知识点"${kp.title}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 先移除资料与知识点的关联
if (material.id !== 'manual') {
const res = await courseApi.removeMaterialKnowledgePoint(material.id, kp.id)
if (res.code === 200) {
const index = material.knowledgePoints.findIndex((k: any) => k.id === kp.id)
if (index > -1) {
material.knowledgePoints.splice(index, 1)
updateKnowledgeList()
}
ElMessage.success('删除成功')
} else {
throw new Error(res.message || '删除失败')
}
} else {
// 如果是未关联的知识点,直接删除知识点
const res = await courseApi.deleteKnowledgePoint(kp.id)
if (res.code === 200) {
const index = material.knowledgePoints.findIndex((k: any) => k.id === kp.id)
if (index > -1) {
material.knowledgePoints.splice(index, 1)
updateKnowledgeList()
}
ElMessage.success('删除成功')
} else {
throw new Error(res.message || '删除失败')
}
}
} catch (e: any) {
if (e !== 'cancel') {
console.error('删除知识点失败:', e)
ElMessage.error(e.message || '删除知识点失败')
}
}
}
/**
* 添加知识点到资料
*/
const addKnowledgePointToMaterial = (material: any) => {
currentMaterial.value = material
isEditKnowledge.value = false
knowledgeForm.id = ''
knowledgeForm.title = ''
knowledgeForm.content = ''
knowledgeForm.type = 'concept'
knowledgeForm.source = material.name
knowledgeEditDialogVisible.value = true
}
/**
* 查看知识点详情
*/
const viewKnowledgeDetail = (kp: any) => {
currentKnowledge.value = kp
knowledgeDetailDialogVisible.value = true
}
/**
* 从详情页编辑
*/
const editFromDetail = () => {
const kp = currentKnowledge.value
knowledgeDetailDialogVisible.value = false
// 找到对应的资料
let material = null
for (const m of materialList.value) {
if (m.knowledgePoints?.some((k: any) => k.id === kp.id)) {
material = m
break
}
}
if (material) {
editKnowledgePoint(material, kp)
}
}
/**
* AI分析知识点
*/
const analyzeWithAI = async (material: any) => {
if (!courseId.value) {
ElMessage.warning('请先保存课程基本信息')
return
}
// 直接调用真实的重新分析逻辑,不显示确认对话框
try {
material.status = 'analyzing'
ElMessage.info('AI正在分析文件内容提取知识点...')
const res = await request.post(`/api/v1/courses/${courseId.value}/materials/${material.id}/analyze`, {}, {
timeout: 180000 // 为AI分析设置180秒超时
})
if (res.code === 200) {
console.log('AI分析API响应:', res)
// 检查实际的分析状态
const status = res.data?.status
const workflowRunId = res.data?.workflow_run_id
if (status === 'failed') {
// 后端分析失败
material.status = 'failed'
ElMessage.error('AI分析失败' + (res.data?.error || '未知错误'))
} else if (status === 'completed' || status === 'succeeded') {
// 分析完成,重新加载知识点
const kpRes = await courseApi.getMaterialKnowledgePoints(material.id, Number(courseId.value))
if (kpRes.code === 200 && kpRes.data) {
material.knowledgePoints = kpRes.data.map((kp: any) => ({
id: kp.id,
name: kp.name,
title: kp.name,
description: kp.description || '',
content: kp.description || '',
type: kp.type,
source: kp.source,
topic_relation: kp.topic_relation,
material_id: kp.material_id,
created_at: kp.created_at,
createTime: kp.created_at?.replace('T', ' ').slice(0, 16) || ''
}))
updateKnowledgeList()
}
material.status = 'completed'
ElMessage.success('AI分析完成')
} else if (status === 'started' && workflowRunId) {
// 分析已启动,显示成功消息
material.status = 'completed'
ElMessage.success('AI分析已启动请稍后刷新页面查看结果')
} else {
material.status = 'completed'
ElMessage.warning('AI分析状态未知请刷新页面查看结果')
}
} else {
throw new Error(res.message || 'AI分析启动失败')
}
} catch (error: any) {
material.status = 'failed'
console.error('AI分析失败:', error)
ElMessage.error(error.message || 'AI分析失败')
}
}
/**
* 重新分析知识点
*/
const reAnalyzeWithAI = async (material: any) => {
try {
await ElMessageBox.confirm(
'重新分析将覆盖现有的知识点,是否继续?',
'重新分析确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 调用真实的重新分析API
material.status = 'analyzing'
const res = await request.post(`/api/v1/courses/${courseId.value}/materials/${material.id}/analyze`, {}, {
timeout: 180000 // 为重新分析设置180秒超时
})
{
// axios 实际返回 AxiosResponse真实数据在 res.data 下
const api = (res as any)?.data ?? res
const code: number = api?.code ?? (res as any)?.code ?? 0
const payload = api?.data ?? {}
const status: string = payload?.status || 'unknown'
const statusText = statusToCN(status)
const ok = code === 200
if (ok && status === 'succeeded') {
const kpRes = await courseApi.getMaterialKnowledgePoints(material.id, Number(courseId.value))
if (kpRes.code === 200 && kpRes.data) {
material.knowledgePoints = kpRes.data.map((kp: any) => ({
id: kp.id,
name: kp.name,
title: kp.name,
description: kp.description || '',
content: kp.description || '',
type: kp.type,
source: kp.source,
topic_relation: kp.topic_relation,
material_id: kp.material_id,
created_at: kp.created_at,
createTime: kp.created_at?.replace('T', ' ').slice(0, 16) || ''
}))
updateKnowledgeList()
}
material.status = 'completed'
ElMessage.success(statusText)
} else if (ok && status === 'running') {
material.status = 'analyzing'
ElMessage.info(statusText)
} else if (ok && status === 'failed') {
material.status = 'failed'
ElMessage.error(`${statusText}${payload?.error ? '' + payload.error : ''}`)
} else if (ok && status === 'stopped') {
material.status = 'completed'
ElMessage.warning(statusText)
} else {
// 业务失败或未知情况
console.warn('重新分析业务失败,使用状态兜底展示:', res)
if (status === 'succeeded') {
const kpRes = await courseApi.getMaterialKnowledgePoints(material.id, Number(courseId.value))
if (kpRes.code === 200 && kpRes.data) {
material.knowledgePoints = kpRes.data.map((kp: any) => ({
id: kp.id,
name: kp.name,
title: kp.name,
description: kp.description || '',
content: kp.description || '',
type: kp.type,
source: kp.source,
topic_relation: kp.topic_relation,
material_id: kp.material_id,
created_at: kp.created_at,
createTime: kp.created_at?.replace('T', ' ').slice(0, 16) || ''
}))
updateKnowledgeList()
}
material.status = 'completed'
ElMessage.success(statusText)
} else if (status === 'running') {
material.status = 'analyzing'
ElMessage.info(statusText)
} else if (status === 'failed') {
material.status = 'failed'
ElMessage.error(`${statusText}${payload?.error ? '' + payload.error : ''}`)
} else if (status === 'stopped') {
material.status = 'completed'
ElMessage.warning(statusText)
} else {
material.status = 'completed'
ElMessage.error(api?.message || '启动重新分析失败')
}
}
}
} catch (error: any) {
if (error !== 'cancel') {
material.status = 'completed'
console.error('重新分析失败:', error)
ElMessage.error(error.message || '重新分析失败')
}
}
}
/**
* 手动添加知识点
*/
const addKnowledgeManual = () => {
currentMaterial.value = null
isEditKnowledge.value = false
knowledgeForm.id = ''
knowledgeForm.title = ''
knowledgeForm.content = ''
knowledgeForm.type = 'concept'
knowledgeForm.source = ''
knowledgeEditDialogVisible.value = true
}
/**
* 重新分析所有资料
*/
const reanalyzeAllMaterials = async () => {
if (materialList.value.length === 0) {
ElMessage.warning('暂无资料需要分析')
return
}
try {
await ElMessageBox.confirm(
'重新分析将重新提取所有资料的知识点,这可能需要一些时间,是否继续?',
'重新分析确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
reanalyzingAll.value = true
// 立即将所有资料状态设为分析中
materialList.value.forEach(material => {
material.status = 'analyzing'
})
// 跳过批量API直接为每个资料调用单个分析完全复用成功逻辑
console.log('批量分析:为每个资料触发重新分析...')
ElMessage.success('重新分析任务已启动,请稍后查看结果')
let successCount = 0
const totalCount = materialList.value.length
// 简化逻辑:直接调用每个资料的重新分析函数
console.log('批量分析:逐个触发资料重新分析...')
for (let i = 0; i < materialList.value.length; i++) {
const material = materialList.value[i]
try {
console.log(`[${i + 1}/${materialList.value.length}] 触发资料 ${material.name} 的重新分析...`)
// 设置状态
material.status = 'analyzing'
// 直接调用分析APIstreaming模式会等待完成
const res = await request.post(`/api/v1/courses/${courseId.value}/materials/${material.id}/analyze`, {}, {
timeout: 180000 // 为批量分析设置180秒超时
})
// 兼容 AxiosResponse 与裸数据两种返回结构
const api = (res as any)?.data ?? res
const code: number = api?.code ?? (res as any)?.code ?? 0
const payload = api?.data ?? {}
const status: string = payload?.status || 'unknown'
const statusText = statusToCN(status)
const ok = code === 200
if (ok) {
if (status === 'succeeded') {
const kpRes = await courseApi.getMaterialKnowledgePoints(material.id, Number(courseId.value))
if (kpRes.code === 200 && kpRes.data) {
material.knowledgePoints = kpRes.data.map((kp: any) => ({
id: kp.id,
name: kp.name,
title: kp.name,
description: kp.description || '',
content: kp.description || '',
type: kp.type,
source: kp.source,
topic_relation: kp.topic_relation,
material_id: kp.material_id,
created_at: kp.created_at,
createTime: kp.created_at?.replace('T', ' ').slice(0, 16) || ''
}))
updateKnowledgeList()
}
material.status = 'completed'
console.log(`✅ 资料 ${material.name} ${statusText}`)
} else if (status === 'running') {
material.status = 'analyzing'
console.log(`⏳ 资料 ${material.name} ${statusText}`)
} else if (status === 'failed') {
material.status = 'failed'
console.log(`❌ 资料 ${material.name} ${statusText}:`, payload?.error)
} else if (status === 'stopped') {
material.status = 'completed'
console.log(`⏹️ 资料 ${material.name} ${statusText}`)
} else {
material.status = 'completed'
console.log(`⚠️ 资料 ${material.name} ${statusText}`)
}
successCount++
} else {
console.warn(`资料 ${material.name} 业务失败,使用状态兜底展示:`, res)
if (status === 'succeeded') {
const kpRes = await courseApi.getMaterialKnowledgePoints(material.id, Number(courseId.value))
if (kpRes.code === 200 && kpRes.data) {
material.knowledgePoints = kpRes.data.map((kp: any) => ({
id: kp.id,
name: kp.name,
title: kp.name,
description: kp.description || '',
content: kp.description || '',
type: kp.type,
source: kp.source,
topic_relation: kp.topic_relation,
material_id: kp.material_id,
created_at: kp.created_at,
createTime: kp.created_at?.replace('T', ' ').slice(0, 16) || ''
}))
updateKnowledgeList()
}
material.status = 'completed'
console.log(`✅ 资料 ${material.name} ${statusText}`)
} else if (status === 'running') {
material.status = 'analyzing'
console.log(`⏳ 资料 ${material.name} ${statusText}`)
} else if (status === 'failed') {
material.status = 'failed'
console.log(`❌ 资料 ${material.name} ${statusText}:`, payload?.error)
} else if (status === 'stopped') {
material.status = 'completed'
console.log(`⏹️ 资料 ${material.name} ${statusText}`)
} else {
material.status = 'failed'
console.log(`⚠️ 资料 ${material.name} ${statusText}`)
}
}
} catch (error) {
console.error(`❌ 资料 ${material.name} 分析启动异常:`, error)
material.status = 'failed'
}
// 每个资料之间添加延迟,避免并发问题
if (i < materialList.value.length - 1) {
await new Promise(resolve => setTimeout(resolve, 2000)) // 2秒延迟
}
}
console.log(`批量分析完成:${successCount}/${totalCount} 个资料已启动`)
if (successCount > 0) {
ElMessage.success(`${successCount}个资料重新分析已启动,请查看各资料状态`)
} else {
ElMessage.error('所有资料重新分析启动失败')
}
} catch (error: any) {
if (error !== 'cancel') {
// 恢复所有资料状态
materialList.value.forEach(material => {
material.status = 'completed'
})
console.error('重新分析失败:', error)
ElMessage.error(error.message || '重新分析失败')
}
} finally {
reanalyzingAll.value = false
}
}
// 将后端返回的工作流状态转中文
const statusToCN = (status: string): string => {
switch ((status || '').toLowerCase()) {
case 'running':
return '分析中'
case 'succeeded':
return '分析完成'
case 'failed':
return '分析失败'
case 'stopped':
return '分析已停止'
default:
return '状态未知'
}
}
/**
* 格式化文件大小
*/
const formatFileSize = (size: number) => {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(2) + ' MB'
} else {
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
}
/**
* 获取知识点类型标签样式
*/
const getKnowledgeTypeTag = (type: string) => {
const typeMap: Record<string, string> = {
'理论知识': 'primary', // 蓝色 - 基础理论
'诊断设计': 'success', // 绿色 - 专业设计
'操作步骤': 'warning', // 橙色 - 操作流程
'沟通话术': 'warning', // 橙色 - 沟通技巧(亮眼)
'案例分析': 'danger', // 红色 - 案例学习
'注意事项': '', // 默认色 - 重要提醒
'技巧方法': 'success', // 绿色 - 实用技巧
'客诉处理': 'danger', // 红色 - 紧急处理
// 兼容旧类型
'concept': 'primary',
'procedure': 'success',
'case': 'warning',
'notice': 'danger',
'skill': 'info'
}
return typeMap[type] || 'info'
}
/**
* 获取知识点类型标签文本
*/
const getTypeLabel = (type: string) => {
const typeMap: Record<string, string> = {
'理论知识': '理论知识',
'诊断设计': '诊断设计',
'操作步骤': '操作步骤',
'沟通话术': '沟通话术',
'案例分析': '案例分析',
'注意事项': '注意事项',
'技巧方法': '技巧方法',
'客诉处理': '客诉处理',
// 兼容旧类型
'concept': '概念定义',
'procedure': '操作步骤',
'case': '案例分析',
'notice': '注意事项',
'skill': '技巧方法'
}
return typeMap[type] || type
}
/**
* 判断来源是否为AI分析
* 兼容数值/字符串1/'1'/'ai'/'AI分析'
*/
const isAISource = (source: any): boolean => {
try {
if (source === null || source === undefined) return false
if (typeof source === 'number') return source === 1
const s = String(source).trim().toLowerCase()
return s === '1' || s === 'ai' || s === 'ai分析'
} catch {
return false
}
}
/**
* 格式化来源显示
* - 1/'1'/'ai' => 'AI分析'
* - 0/'0' => '手动'
* - 其他字符串 => 原样(如资料名)
* - 其他 => '手动添加'
*/
const formatKnowledgeSource = (source: any): string => {
try {
if (isAISource(source)) return 'AI分析'
if (source === 0) return '手动'
const s = (source ?? '').toString().trim()
if (s === '0') return '手动'
if (s) return s
return '手动添加'
} catch {
return '手动添加'
}
}
// 注释掉重复的函数定义,使用后面更完整的版本
// showPositionSelector 在第1089行有更完整的定义
// removePositionAssignment 在第1139行有相同的定义
/**
* 保存考试设置
*/
const saveExamSettings = async () => {
if (!isEdit.value) {
ElMessage.warning('请先保存课程基本信息')
return
}
try {
const settingsData = {
single_choice_count: examSettings.singleChoice,
multiple_choice_count: examSettings.multipleChoice,
true_false_count: examSettings.trueOrFalse,
fill_blank_count: examSettings.fillInBlank,
essay_count: examSettings.essay,
duration_minutes: examSettings.duration,
difficulty_level: examSettings.difficulty,
is_enabled: examSettings.enabled
}
const res = await courseApi.saveExamSettings(Number(courseId.value), settingsData)
if (res.code === 200) {
ElMessage.success('考试设置已保存')
// 保存后主动刷新一次,确保展示为后端真实数据(真落库、真查库)
try {
const fresh = await courseApi.getExamSettings(Number(courseId.value))
if (fresh.code === 200) {
if (fresh.data) {
// 有数据时,使用后端返回的数据
const s = fresh.data
examSettings.singleChoice = s.single_choice_count || 0
examSettings.multipleChoice = s.multiple_choice_count || 0
examSettings.trueOrFalse = s.true_false_count || 0
examSettings.fillInBlank = s.fill_blank_count || 0
examSettings.essay = s.essay_count || 0
examSettings.duration = s.duration_minutes || 10
examSettings.difficulty = s.difficulty_level || 3
examSettings.enabled = s.is_enabled !== undefined ? s.is_enabled : true
} else {
// 后端返回null时使用刚保存的数据保持当前表单状态
console.log('后端返回null保持当前表单数据')
}
}
} catch (e) {
// 刷新失败不影响本次保存提示
console.warn('刷新考试设置失败', e)
}
} else {
throw new Error(res.message || '保存失败')
}
} catch (error: any) {
console.error('保存考试设置失败:', error)
ElMessage.error(error.message || '保存考试设置失败')
}
}
/**
* 重置考试设置
*/
const resetExamSettings = () => {
examSettings.singleChoice = 4
examSettings.multipleChoice = 2
examSettings.trueOrFalse = 1
examSettings.fillInBlank = 2
examSettings.essay = 1
examSettings.duration = 10
examSettings.difficulty = 3
examSettings.enabled = true
ElMessage.info('考试设置已重置')
}
/**
* 获取难度文本
*/
const getDifficultyText = (difficulty: number) => {
const difficultyMap: Record<number, string> = {
1: '简单',
2: '较简单',
3: '中等',
4: '较难',
5: '困难'
}
return difficultyMap[difficulty] || '中等'
}
/**
* 显示岗位选择器
*/
const showPositionSelector = (type: 'required' | 'optional') => {
assignmentType.value = type
selectedPositions.value = []
positionSearchText.value = ''
loadAvailablePositions()
positionSelectorVisible.value = true
}
/**
* 切换岗位选择
*/
const togglePositionSelection = (positionId: number) => {
const index = selectedPositions.value.indexOf(positionId)
if (index > -1) {
selectedPositions.value.splice(index, 1)
} else {
selectedPositions.value.push(positionId)
}
}
/**
* 取消岗位选择
*/
const cancelPositionSelection = () => {
positionSelectorVisible.value = false
selectedPositions.value = []
}
/**
* 确认岗位选择
*/
const confirmPositionSelection = async () => {
if (!isEdit.value) {
ElMessage.warning('请先保存课程基本信息')
positionSelectorVisible.value = false
return
}
try {
const assignments = selectedPositions.value.map(positionId => ({
position_id: positionId,
course_type: assignmentType.value,
priority: 0
}))
const res = await courseApi.assignPositions(Number(courseId.value), assignments)
if (res.code === 200) {
ElMessage.success(`已添加 ${selectedPositions.value.length}${assignmentType.value === 'required' ? '必修' : '选修'}岗位`)
// 重新加载岗位分配列表
await loadCoursePositions()
} else {
throw new Error(res.message || '分配失败')
}
} catch (error: any) {
console.error('岗位分配失败:', error)
ElMessage.error(error.message || '岗位分配失败')
}
positionSelectorVisible.value = false
selectedPositions.value = []
}
/**
* 移除岗位分配
*/
const removePositionAssignment = async (positionId: number, type: 'required' | 'optional') => {
if (!isEdit.value) {
return
}
try {
const res = await courseApi.removePosition(Number(courseId.value), positionId)
if (res.code === 200) {
ElMessage.success(`已移除${type === 'required' ? '必修' : '选修'}岗位`)
// 重新加载岗位分配列表
await loadCoursePositions()
} else {
throw new Error(res.message || '移除失败')
}
} catch (error: any) {
console.error('移除岗位分配失败:', error)
ElMessage.error(error.message || '移除岗位分配失败')
}
}
/**
* 加载课程岗位分配列表
*/
const loadCoursePositions = async () => {
if (!isEdit.value) {
return
}
try {
const res = await courseApi.getPositions(Number(courseId.value))
if (res.code === 200 && res.data) {
const positions = res.data
courseRequiredPositions.value = positions.filter((p: any) => p.course_type === 'required').map((p: any) => ({
id: p.position_id,
name: p.position_name || '',
description: p.position_description || '',
memberCount: p.member_count || 0,
priority: p.priority || 0
}))
courseOptionalPositions.value = positions.filter((p: any) => p.course_type === 'optional').map((p: any) => ({
id: p.position_id,
name: p.position_name || '',
description: p.position_description || '',
memberCount: p.member_count || 0,
recommendLevel: 3
}))
}
} catch (error: any) {
console.error('加载岗位分配列表失败:', error)
}
}
/**
* 加载可用岗位
*/
const loadAvailablePositions = async () => {
try {
const res = await positionApi.list({ page: 1, size: 100 })
if (res.code === 200 && res.data) {
availablePositions.value = res.data.items.map((item: any) => ({
id: item.id,
name: item.name,
description: item.description || '',
memberCount: item.member_count || 0,
parentName: item.parent_name || '顶级部门'
}))
}
} catch (error: any) {
console.error('加载岗位列表失败:', error)
ElMessage.error('加载岗位列表失败')
}
}
</script>
<style lang="scss" scoped>
.edit-course-container {
padding: 20px;
max-height: 100vh;
overflow-y: auto;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
background: white;
padding: 20px 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.course-content {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
}
// 资料知识点样式
.material-knowledge-points {
padding: 20px;
background: #f9fafb;
border-radius: 8px;
}
// 知识点卡片网格样式
.kp-stats {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
}
.knowledge-points-grid {
.kp-grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
// transition-group 内部的网格布局
.kp-list-group {
display: contents; // 让 transition-group 的子元素直接参与父级 grid 布局
}
}
.knowledge-point-card {
background: white;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
min-height: 140px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #409eff;
}
// 优化添加卡片样式 - 更小更协调
&.add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 90px; // 减小高度
border: 1px dashed #dcdfe6;
background: #fafbfc;
&:hover {
border-color: #409eff;
background: #f0f7ff;
.add-icon {
color: #409eff;
}
.add-text {
color: #409eff;
}
}
.add-icon {
color: #c0c4cc;
margin-bottom: 4px;
transition: color 0.3s;
}
.add-text {
color: #c0c4cc;
font-size: 12px;
transition: color 0.3s;
}
}
.kp-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.kp-number {
width: 20px;
height: 20px;
background: #409eff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
}
}
.kp-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kp-summary {
font-size: 12px;
color: #666;
line-height: 1.4;
height: 34px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2; /* 标准属性 */
-webkit-box-orient: vertical;
margin-bottom: 10px;
}
.kp-card-actions {
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
&:hover .kp-card-actions {
opacity: 1;
}
}
}
// 知识点列表动画
.kp-list-enter-active,
.kp-list-leave-active {
transition: all 0.3s ease;
}
.kp-list-enter-from {
opacity: 0;
transform: scale(0.8);
}
.kp-list-leave-to {
opacity: 0;
transform: scale(0.8);
}
.kp-list-move {
transition: transform 0.3s ease;
}
// 修复 transition-group 布局问题
:deep(.kp-grid-container > span) {
display: contents;
}
// 文件信息样式
.file-info {
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #667eea;
}
}
// 材料行高亮效果
:deep(.material-row) {
&:hover {
background-color: #f5f7fa;
}
}
// 加载动画
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.is-loading {
animation: spin 1s linear infinite;
}
}
// 知识点详情弹窗样式优化
:deep(.knowledge-detail-dialog) {
.el-dialog__header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px 8px 0 0;
padding: 20px 24px;
.el-dialog__title {
color: white;
font-size: 18px;
font-weight: 600;
}
.el-dialog__close {
color: white;
font-size: 18px;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 16px 24px;
background: #f8f9fa;
border-radius: 0 0 8px 8px;
}
}
.knowledge-detail {
.detail-header {
margin-bottom: 24px;
.title-section {
display: flex;
align-items: center;
gap: 16px;
h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
}
}
.detail-content {
margin-bottom: 24px;
.content-section {
h4 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.content-text {
font-size: 14px;
line-height: 1.6;
color: #666;
background: #f8f9fa;
padding: 16px;
border-radius: 6px;
border-left: 4px solid #409eff;
}
}
}
.detail-meta {
background: #ffffff;
border-radius: 8px;
border: 1px solid #e4e7ed;
:deep(.el-descriptions) {
.el-descriptions__header {
margin-bottom: 16px;
.el-descriptions__title {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
.el-descriptions__body {
.el-descriptions__table {
.el-descriptions__cell {
padding: 12px 16px;
&.is-bordered-label {
background: #fafafa;
font-weight: 500;
}
}
}
}
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
// 响应式
@media (max-width: 768px) {
.edit-course-container {
padding: 10px;
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
}
.knowledge-points-grid {
.kp-grid-container {
grid-template-columns: 1fr;
}
}
}
}
// 封面管理样式
.cover-section {
.section-header {
margin-bottom: 24px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.cover-management {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
.current-cover-section {
h4 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.current-cover-display {
display: flex;
gap: 16px;
img {
width: 200px;
height: 150px;
object-fit: cover;
border-radius: 8px;
border: 2px solid #e4e7ed;
}
.cover-info {
flex: 1;
p {
margin-bottom: 8px;
font-size: 14px;
color: #666;
strong {
color: #333;
}
}
}
}
}
.upload-cover-section {
h4 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.cover-uploader {
.upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
transition: all 0.3s;
&:hover {
border-color: #409eff;
background: #f5f7fa;
}
.el-icon--upload {
font-size: 32px;
color: #c0c4cc;
margin-bottom: 12px;
}
.el-upload__text {
color: #606266;
font-size: 14px;
em {
color: #409eff;
font-style: normal;
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
}
.new-cover-preview {
position: relative;
width: 100%;
height: 150px;
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
&:hover {
opacity: 1;
}
}
}
}
.cover-settings {
margin-top: 16px;
}
}
}
}
// 岗位分配样式
.positions-section {
.section-header {
margin-bottom: 24px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.assignment-stats {
display: flex;
gap: 16px;
margin-bottom: 24px;
.stat-item {
flex: 1;
text-align: center;
padding: 16px;
border-radius: 8px;
&.required {
background: rgba(245, 108, 108, 0.1);
.stat-value {
color: #f56c6c;
}
}
&.optional {
background: rgba(230, 162, 60, 0.1);
.stat-value {
color: #e6a23c;
}
}
&.total {
background: rgba(64, 158, 255, 0.1);
.stat-value {
color: #409eff;
}
}
.stat-value {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #666;
}
}
}
.position-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h4 {
font-size: 14px;
font-weight: 600;
color: #333;
margin: 0;
}
}
.position-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
.position-card {
padding: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.position-name {
font-weight: 600;
color: #333;
}
}
.card-content {
.position-desc {
font-size: 12px;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.position-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: #999;
span {
display: flex;
align-items: center;
gap: 2px;
}
}
}
}
}
}
}
// 考试设置样式
.exam-section {
.section-header {
margin-bottom: 24px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.section-desc {
font-size: 14px;
color: #666;
margin: 0;
}
}
.exam-settings-form {
margin-bottom: 24px;
.form-help {
margin-left: 12px;
font-size: 12px;
color: #999;
}
}
.current-settings {
h4 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.settings-preview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
.preview-item {
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
.label {
font-size: 14px;
color: #666;
}
.value {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
}
}
}
// 岗位选择器样式
.position-selector-content {
.selector-header {
margin-bottom: 20px;
}
.available-positions {
max-height: 400px;
overflow-y: auto;
border: 1px solid #e4e7ed;
border-radius: 8px;
.position-option {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #f5f7fa;
}
&.selected {
background: rgba(64, 158, 255, 0.1);
border-color: #409eff;
}
&:last-child {
border-bottom: none;
}
.position-info {
flex: 1;
.position-name {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.position-desc {
font-size: 12px;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.position-meta {
display: flex;
gap: 16px;
span {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
}
}
.selection-indicator {
width: 24px;
height: 24px;
border: 2px solid #ddd;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #409eff;
.el-icon {
border-color: #409eff;
background: #409eff;
color: white;
}
}
}
}
.selector-footer {
margin-top: 16px;
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
text-align: center;
.selected-info {
font-size: 14px;
color: #666;
}
}
}
// 文件上传弹窗样式
.upload-demo {
:deep(.el-upload) {
width: 100%;
.el-upload-dragger {
width: 100%;
height: 180px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.el-icon--upload {
font-size: 67px;
color: #c0c4cc;
margin-bottom: 16px;
}
}
}
:deep(.el-upload-list) {
margin-top: 20px;
.el-upload-list__item {
transition: all 0.3s;
&:hover {
background-color: #f5f7fa;
}
}
}
}
// ========== iOS Safari 弹窗兼容样式 ==========
// 修复苹果手机上弹窗底部按钮被遮挡的问题
@media (max-width: 768px) {
// 岗位选择器弹窗的移动端适配
.position-selector-content {
.available-positions {
max-height: 50vh; // 减小高度,留出底部空间
}
}
}
// 全局 el-dialog 移动端底部安全区域适配
:deep(.el-dialog) {
@media (max-width: 768px) {
width: 92% !important;
max-width: none !important;
margin: 5vh auto !important; // 减小顶部边距
max-height: 85vh !important; // 限制最大高度
display: flex;
flex-direction: column;
.el-dialog__body {
flex: 1;
overflow-y: auto;
max-height: 55vh;
padding-bottom: 16px;
}
.el-dialog__footer {
flex-shrink: 0;
padding: 16px 20px;
// iOS safe-area 底部适配
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(16px + constant(safe-area-inset-bottom, 0px)); // 兼容旧版iOS
background: #fff;
border-top: 1px solid #ebeef5;
border-radius: 0 0 8px 8px;
.el-button {
min-height: 44px; // iOS 推荐的最小点击区域
font-size: 16px;
}
}
}
}
// 针对 iPhone X 及以上的刘海屏适配
@supports (padding-bottom: env(safe-area-inset-bottom)) {
:deep(.el-dialog__footer) {
@media (max-width: 768px) {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}
}
</style>