3141 lines
94 KiB
Vue
3141 lines
94 KiB
Vue
<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>
|
||
<el-button
|
||
type="primary"
|
||
:plain="!isAllSelected"
|
||
@click="toggleSelectAll"
|
||
>
|
||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||
</el-button>
|
||
</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,.doc,.pptx,.ppt,.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">
|
||
支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、PPT、CSV、VTT、Properties<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 isAllSelected = computed(() => {
|
||
if (filteredAvailablePositions.value.length === 0) return false
|
||
return filteredAvailablePositions.value.every(
|
||
(p: any) => selectedPositions.value.includes(p.id)
|
||
)
|
||
})
|
||
|
||
// 全选/取消全选
|
||
const toggleSelectAll = () => {
|
||
if (isAllSelected.value) {
|
||
// 取消全选:移除当前筛选结果中的所有岗位
|
||
const filteredIds = filteredAvailablePositions.value.map((p: any) => p.id)
|
||
selectedPositions.value = selectedPositions.value.filter(
|
||
id => !filteredIds.includes(id)
|
||
)
|
||
} else {
|
||
// 全选:添加当前筛选结果中的所有岗位
|
||
const filteredIds = filteredAvailablePositions.value.map((p: any) => p.id)
|
||
const newSelection = new Set([...selectedPositions.value, ...filteredIds])
|
||
selectedPositions.value = Array.from(newSelection)
|
||
}
|
||
}
|
||
|
||
// 考试设置相关
|
||
const examSettingsLoading = ref(false)
|
||
const examSettings = reactive({
|
||
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'
|
||
|
||
// 直接调用分析API,streaming模式会等待完成
|
||
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 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
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>
|
||
|