浏览代码

新增AI实验课模块(课程管理、权限、前端等all)

liyanbo 4 月之前
父节点
当前提交
a33430d545

+ 61 - 0
src/api/aiCourse/aiCourse/index.ts

@@ -0,0 +1,61 @@
+import request from '@/config/axios'
+
+// 课程 VO
+export interface AiCourseVO {
+  id: number // 课程d
+  acName: string // 课程名称
+  acContentType: "all", // 课程内容类型
+  acContent: string, // 课程内容
+
+  aiCourseInfo: string // 简介
+  aiCourseUserImage: string // 人物图标
+  aiCourseUserDirection: number // 人物朝向
+  aiCourseTileSize: string // 地图方格尺寸
+  aiCourseStartPoint: string // 地图开始坐标
+  aiCourseBackground: string // 地图背景图
+  aiCourseEndPoint: string // 地图结束坐标
+  aiCourseWalkablePoints: string // 地图可行走坐标
+  aiCourseSpecialBlocks: string // 特殊方块
+
+  acIsInspect: false // 课程是否有检查
+  acType: number // 课程类型
+  acTypeName: string // 课程类型
+  acLabel: string // 课程标签
+  acOrder: number // 课程排序
+  acStatus: "0" // 课程状态
+  tenantId: Number // 租户id
+}
+
+// 课程 API
+export const AiCourseApi = {
+  // 查询课程分页
+  getAiCoursePage: async (params: any) => {
+    return await request.get({ url: `/aiCourse/course/page`, params })
+  },
+
+  // 查询课程详情
+  getAiCourse: async (id: number) => {
+    return await request.get({ url: `/aiCourse/course/get?id=` + id })
+  },
+
+  // 新增课程
+  createAiCourse: async (data: AiCourseVO) => {
+    return await request.post({ url: `/aiCourse/course/create`, data })
+  },
+
+  // 修改课程
+  updateAiCourse: async (data: AiCourseVO) => {
+    return await request.put({ url: `/aiCourse/course/update`, data })
+  },
+
+  // 删除课程
+  deleteAiCourse: async (id: number) => {
+    return await request.delete({ url: `/aiCourse/course/delete?id=` + id })
+  },
+
+  // 导出课程 Excel
+  exportAiCourse: async (params) => {
+    return await request.download({ url: `/aiCourse/course/export-excel`, params })
+  },
+
+}

+ 56 - 0
src/api/aiCourse/aiCourseConfig/index.ts

@@ -0,0 +1,56 @@
+import request from '@/config/axios'
+
+// 课程配置 VO
+export interface AiCourseConfigVO {
+  id: number // 课程配置id
+  ccCourseId: number // 课程id
+  courseName: string // 课程名称
+  ccTime: number // 课程暂停时长
+  ccQuestId: number // 试题id
+  questContent: string // 试题内容
+  ccAnswerJudge: string // 是否显示答案, // 选择的试题id
+  ccQuestSource: undefined // 问题呈现类型
+  ccQuestContent: undefined  // 问题内容
+  ccQuestOption: undefined  // 问题选项
+  ccAiAnswer: undefined  // AI答案
+  ccAnswer: undefined  // 答案
+  tenantId: Number // 租户id
+}
+
+// 课程配置 API
+export const AiCourseConfigApi = {
+  // 查询课程配置分页
+  getCourseConfigPage: async (params: any) => {
+    return await request.get({ url: `/aiCourse/course-config/page`, params })
+  },
+
+  // 查询课程配置详情
+  getCourseConfig: async (id: number) => {
+    return await request.get({ url: `/aiCourse/course-config/get?id=` + id })
+  },
+
+  // 查询课程配置试题详情
+  getCourseConfigQuestion: async (ccCourseId: number) => {
+    return await request.get({ url: `/aiCourse/course-config/getConfigQuest?ccCourseId=` + ccCourseId })
+  },
+
+  // 新增课程配置
+  createCourseConfig: async (data: AiCourseConfigVO) => {
+    return await request.post({ url: `/aiCourse/course-config/create`, data })
+  },
+
+  // 修改课程配置
+  updateCourseConfig: async (data: AiCourseConfigVO) => {
+    return await request.put({ url: `/aiCourse/course-config/update`, data })
+  },
+
+  // 删除课程配置
+  deleteCourseConfig: async (id: number) => {
+    return await request.delete({ url: `/aiCourse/course-config/delete?id=` + id })
+  },
+
+  // 导出课程配置 Excel
+  exportCourseConfig: async (params) => {
+    return await request.download({ url: `/aiCourse/course-config/export-excel`, params })
+  }
+}

+ 54 - 0
src/api/aiCourse/aiCourseType/index.ts

@@ -0,0 +1,54 @@
+import request from '@/config/axios'
+
+// 课程-类型 VO
+export interface AiCourseTypeVO {
+  id: number // 课程类型id
+  ctType: string, // 课程类型名称
+  ctTypeNode: undefined, // 课程类型节点
+  ctTypeSort: undefined, // 课程类型排序
+  ctParentId: number, // 课程类型父级id
+  ctTypeDescribe: string, // 课程类型描述
+  tenantId: Number // 租户id
+}
+
+// 课程-类型 API
+export const AiCourseTypeApi = {
+  // 查询课程-类型列表
+  getAiCourseTypeList: async (params) => {
+    return await request.get({ url: `/aiCourse/course-type/list`, params })
+  },
+  // 查询课程-类型列表
+  getAiCourseTypeSimpleList: async (params) => {
+    return await request.get({ url: `/aiCourse/course-type/simple-list`, params })
+  },
+
+  // 查询课程-类型详情
+  getAiCourseType: async (id: number) => {
+    return await request.get({ url: `/aiCourse/course-type/get?id=` + id })
+  },
+
+  // 新增课程-类型
+  createAiCourseType: async (data: AiCourseTypeVO) => {
+    return await request.post({ url: `/aiCourse/course-type/create`, data })
+  },
+
+  // 修改课程-类型
+  updateAiCourseType: async (data: AiCourseTypeVO) => {
+    return await request.put({ url: `/aiCourse/course-type/update`, data })
+  },
+
+  // 删除课程-类型
+  deleteAiCourseType: async (id: number) => {
+    return await request.delete({ url: `/aiCourse/course-type/delete?id=` + id })
+  },
+
+  // 导出课程-类型 Excel
+  exportAiCourseType: async (params) => {
+    return await request.download({ url: `/aiCourse/course-type/export-excel`, params })
+  },
+
+  // 获取aiCourse课程列表
+  getAiCourseTypeTree: async () => {
+    return await request.get({ url: `/bjdxWeb/aiCourse/getAiCourseTypeTree`})
+  }
+}

+ 10 - 0
src/api/system/permission/index.ts

@@ -26,6 +26,11 @@ export interface PermissionAssignRoleBlocklyScopeReqVO {
   dataScopeBlocklyIds: number[]
 }
 
+export interface PermissionAssignRoleAiCourseScopeReqVO {
+  roleId: number
+  dataScopeAiCourseIds: number[]
+}
+
 // 查询角色拥有的菜单权限
 export const getRoleMenuList = async (roleId: number) => {
   return await request.get({ url: '/system/permission/list-role-menus?roleId=' + roleId })
@@ -51,6 +56,11 @@ export const assignRoleBlocklyScope = async (data: PermissionAssignRoleBlocklySc
   return await request.post({ url: '/system/permission/assign-role-blockly-scope', data })
 }
 
+// 赋予角色Ai实验课课程权限
+export const assignRoleAiCourseScope = async (data: PermissionAssignRoleAiCourseScopeReqVO) => {
+  return await request.post({ url: '/system/permission/assign-role-aiCourse-scope', data })
+}
+
 // 查询用户拥有的角色数组
 export const getUserRoleList = async (userId: number) => {
   return await request.get({ url: '/system/permission/list-user-roles?userId=' + userId })

+ 1 - 0
src/api/system/role/index.ts

@@ -11,6 +11,7 @@ export interface RoleVO {
   dataScopeDeptIds: number[]
   dataScopeCourseIds: number[]
   dataScopeBlocklyIds: number[]
+  dataScopeAiCourseIds: number[]
   createTime: Date
 }
 

+ 1 - 0
src/types/auto-components.d.ts

@@ -145,6 +145,7 @@ declare module 'vue' {
     PropertiesPanel: typeof import('./../components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue')['default']
     Qrcode: typeof import('./../components/Qrcode/src/Qrcode.vue')['default']
     ReceiveTask: typeof import('./../components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue')['default']
+    RoleAiCoursePermissionForm: typeof import('./../views/system/role/RoleAiCoursePermissionForm.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterNode: typeof import('./../components/SimpleProcessDesignerV2/src/nodes/RouterNode.vue')['default']
     RouterNodeConfig: typeof import('./../components/SimpleProcessDesignerV2/src/nodes-config/RouterNodeConfig.vue')['default']

+ 1387 - 0
src/views/aiCourse/aiCourse/AiCourseForm.vue

@@ -0,0 +1,1387 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="1000px">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="大纲课程" prop="bcType">
+        <el-tree-select
+          v-model="formData.bcType"
+          :data="bcTypeTree"
+          :props="{...defaultProps,
+            label: (node) => `${node.ctTypeNode === undefined ? node.ctType : node.ctTypeSort + '、' + node.ctType}`,
+            disabled: (node) => node.ctTypeNode === undefined || node.ctTypeNode === '0'
+            }"
+          placeholder="请选择课程类型"
+          :default-expand-all="true"
+        />
+      </el-form-item>
+      <el-form-item label="课程名称" prop="bcName">
+        <el-input v-model="formData.bcName" placeholder="请输入课程名称" />
+      </el-form-item>
+
+      <el-form-item label="课程标签" prop="bcLabel">
+        <el-select
+          v-model="formData.bcLabel"
+          placeholder="请选择课程标签"
+          @change="()=>{
+            if(!formData.bcName){
+              const selectedDict = getStrDictOptions(DICT_TYPE.BLOCKLY_COURSE_LABEL).find(
+                dict => dict.value === formData.bcLabel
+              );
+              if(selectedDict){
+                formData.bcName = selectedDict.label;
+              }
+            }
+           }"
+          clearable
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.BLOCKLY_COURSE_LABEL)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="内容类型" prop="bcContentType">
+        <el-segmented v-model="formData.bcContentType" :options="getStrDictOptions(DICT_TYPE.COURSE_COUTNET_TYPE)" />
+      </el-form-item>
+
+      <el-form-item v-if="formData.bcContentType === 'all'" label="课程内容" prop="bcContent">
+        <Editor v-model="formData.bcContent" height="150px" />
+      </el-form-item>
+
+      <el-form-item v-if="formData.bcContentType === 'text'" label="纯文本" prop="bcContent">
+        <Editor v-model="formData.bcContent" height="150px"  />
+      </el-form-item>
+
+      <el-form-item v-if="formData.bcContentType === 'image'" label="课程图片集" prop="bcContent">
+        <UploadImgs v-model="formData.bcContent"
+                    @upload-progress="handleUploadProgress"
+                    @upload-start="handleUploadStart"
+                    @upload-complete="handleUploadComplete"/>
+      </el-form-item>
+
+      <el-form-item v-if="formData.bcContentType === 'music'" label="课程音频" prop="bcContent">
+        <UploadMusic v-model="formData.bcContent"
+                     @upload-progress="handleUploadProgress"
+                     @upload-start="handleUploadStart"
+                     @upload-complete="handleUploadComplete"/>
+      </el-form-item>
+
+      <el-form-item v-if="formData.bcContentType === 'video'" label="课程视频" prop="bcContent">
+        <UploadVideo
+          v-model="formData.bcContent"
+          @upload-progress="handleUploadProgress"
+          @upload-start="handleUploadStart"
+          @upload-complete="handleUploadComplete"
+        />
+      </el-form-item>
+
+      <el-form-item v-if="formData.bcContentType === 'ppt'" label="课程PPT" prop="bcContent">
+        <UploadFile v-model="formData.bcContent" :fileType="['ppt','pptx']" :fileSize="50"
+                    @upload-progress="handleUploadProgress"
+                    @upload-start="handleUploadStart"
+                    @upload-complete="handleUploadComplete"/>
+      </el-form-item>
+
+      <!-- 上传进度条 -->
+      <el-form-item>
+        <div v-if="isUploading" class="uploadProgress">
+          <el-progress :percentage="uploadProgress" />
+          <div class="text-xs text-gray-500 text-right mt-1">{{ uploadProgress }}% 已上传</div>
+        </div>
+      </el-form-item>
+
+      <template v-if="formData.bcContentType === 'blockly'">
+        <el-form-item label="Blockly配置" prop="blocklyConfig">
+          <el-button type="primary" @click="openBlocklyConfigDialog">配置Blockly数据</el-button>
+          <div v-if="hasBlocklyConfig" class="mt-2 text-sm text-green-600">
+            ✓ Blockly数据已配置
+          </div>
+          <div v-else class="mt-2 text-sm text-red-600">
+            ⚠ Blockly数据未配置
+          </div>
+        </el-form-item>
+      </template>
+
+      <template v-else>
+        <el-form-item label="课程是否有检查" prop="bcIsInspect">
+          <el-radio-group v-model="formData.bcIsInspect">
+            <el-radio
+              v-for="dict in getStrDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+              :key="dict.value"
+              :label="dict.value"
+            >
+              {{ dict.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </template>
+
+      <el-form-item label="课程排序" prop="bcOrder">
+        <el-input-number v-model="formData.bcOrder" placeholder="请输入课程排序" class="!w-1/1" />
+      </el-form-item>
+
+      <el-form-item label="课程状态" prop="bcStatus">
+        <el-radio-group v-model="formData.bcStatus">
+          <el-radio
+            v-for="dict in getStrDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading || isUploading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+
+  <!-- Blockly配置弹框 -->
+  <Dialog :title="blocklyConfigTitle" v-model="blocklyConfigVisible" width="1250px" >
+    <el-form
+      ref="blocklyConfigFormRef"
+      :model="formData"
+      :rules="blocklyConfigRules"
+      label-width="130px"
+    >
+
+      <el-form-item label="Blockly信息" prop="blocklyInfo">
+        <Editor v-model="formData.blocklyInfo" height="150px" />
+      </el-form-item>
+
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="人物图标" prop="blocklyUserImage">
+            <UploadImg v-model="formData.blocklyUserImage" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="地图背景图" prop="blocklyBackground">
+            <UploadImg v-model="formData.blocklyBackground" />
+          </el-form-item>
+        </el-col>
+
+        <el-col :span="24">
+          <el-form-item label="地图方格尺寸" required>
+            <div class="coordinate-group">
+              X:
+              <el-input-number
+                v-model="blocklyTileX"
+                placeholder="横向数量"
+                :min="1"
+                :step="1"
+                style="width: 120px"
+              />
+              Y:
+              <el-input-number
+                v-model="blocklyTileY"
+                placeholder="纵向数量"
+                :min="1"
+                :step="1"
+                style="width: 120px"
+              />
+            </div>
+          </el-form-item>
+        </el-col>
+
+        <!-- 路线配置 -->
+        <el-col :span="24">
+          <el-form-item label="路线配置" required>
+            <el-button type="primary" @click="addRoute" style="margin-bottom: 12px;">新增路线</el-button>
+            <div class="route-config-container">
+              <div v-for="(route, index) in blocklyRoutes" :key="route.id" class="route-config-item">
+                <div class="route-row">
+                  <div class="route-title">
+                    <span>路线 {{ index + 1 }}</span>
+                  </div>
+                  
+                  <div class="route-fields">
+
+                    <!-- 人物朝向 -->
+                    <div class="route-field">
+                      <span class="field-label">人物朝向:</span>
+                      <el-radio-group v-model="route.direction" class="direction-radio">
+                        <el-radio-button label="上" :value="0" />
+                        <el-radio-button label="右" :value="1" />
+                        <el-radio-button label="下" :value="2" />
+                        <el-radio-button label="左" :value="3" />
+                      </el-radio-group>
+                    </div>
+
+                    <!-- 开始坐标 -->
+                    <div class="route-field">
+                      <span class="field-label">开始坐标:</span>
+                      <div class="coordinate-group">
+                        <el-input-number
+                          v-model="route.startPoint.x"
+                          placeholder="X"
+                          :min="1"
+                          :max="blocklyTileX"
+                          :step="1"
+                          class="coordinate-input"
+                          style="width: 100px"
+                        />
+                        <el-input-number
+                          v-model="route.startPoint.y"
+                          placeholder="Y"
+                          :min="1"
+                          :max="blocklyTileY"
+                          :step="1"
+                          class="coordinate-input"
+                          style="width: 100px"
+                        />
+                      </div>
+                    </div>
+                    
+                    <!-- 结束坐标 -->
+                    <div class="route-field">
+                      <span class="field-label">结束坐标:</span>
+                      <div class="coordinate-group">
+                        <el-input-number
+                          v-model="route.endPoint.x"
+                          placeholder="X"
+                          :min="1"
+                          :max="blocklyTileX"
+                          :step="1"
+                          class="coordinate-input"
+                          style="width: 100px"
+                        />
+                        <el-input-number
+                          v-model="route.endPoint.y"
+                          placeholder="Y"
+                          :min="1"
+                          :max="blocklyTileY"
+                          :step="1"
+                          class="coordinate-input"
+                          style="width: 100px"
+                        />
+                      </div>
+                    </div>
+                  </div>
+                  
+                  <!-- 删除按钮 -->
+                  <div class="route-delete-btn">
+                    <el-button
+                      type="danger"
+                      size="small"
+                      @click="deleteRoute(route.id)"
+                      :disabled="blocklyRoutes.length <= 1"
+                      plain
+                    >
+                      删除
+                    </el-button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <!-- 地图可行走坐标 - 动态矩阵显示 -->
+      <el-form-item label="地图方格配置" required>
+        <el-row type="flex" style="width: 100%;">
+          <!-- 左侧方格矩阵 -->
+          <el-col :span="10">
+            <div class="map-grid-container">
+              <div
+                class="map-grid"
+                :style="{
+                  gridTemplateColumns: `repeat(${blocklyTileX || 6}, 1fr)`,
+                  gridTemplateRows: `repeat(${blocklyTileY || 5}, 1fr)`,
+                  backgroundImage: formData.blocklyBackground ? `url('${formData.blocklyBackground}')` : 'none',
+                  backgroundSize: 'cover',
+                  backgroundPosition: 'center'
+                }"
+              >
+                <div
+                  v-for="(cell, index) in blocklyGridCells"
+                  :key="index"
+                  class="grid-cell"
+                  :class="{
+                      'walkable': isBlocklyWalkable(cell.x, cell.y),
+                      'selected': selectedBlocklyPoint && selectedBlocklyPoint.x === cell.x && selectedBlocklyPoint.y === cell.y,
+                      'has-icon': getBlocklyPointByXY(cell.x, cell.y)?.img
+                    }"
+                  @click="selectBlocklyCell(cell)"
+                >
+                  <div class="cell-coordinate">{{ cell.x }},{{ cell.y }}</div>
+                  <div v-if="getBlocklyPointByXY(cell.x, cell.y)?.img" class="cell-icon">
+                    <img :src="getBlocklyPointByXY(cell.x, cell.y).img" alt="图标" />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </el-col>
+
+          <!-- 右侧配置项 -->
+          <el-col :span="14">
+            <div class="point-config-container" v-if="selectedBlocklyPoint">
+              <el-card class="config-card" shadow="hover">
+                <template #header>
+                  <div class="card-header">
+                    <span>坐标配置</span>
+                    <el-tag size="small" type="info">{{ selectedBlocklyPoint.x }},{{ selectedBlocklyPoint.y }}</el-tag>
+                  </div>
+                </template>
+                <el-form
+                  label-width="100px"
+                  :model="selectedBlocklyPoint"
+                  class="config-form"
+                  size="large"
+                >
+
+                  <!-- 第一行:是否可行走 -->
+                  <div class="config-row">
+                    <el-form-item label="是否可行走" class="config-item">
+                      <el-switch
+                        v-model="selectedBlocklyPoint.walkable"
+                        active-text="是"
+                        inactive-text="否"
+                        size="large"
+                      />
+                    </el-form-item>
+                  </div>
+
+                  <template v-if="selectedBlocklyPoint.walkable">
+                    <!-- 第二行:地图类型 -->
+                    <div class="config-row" style="display: flex; flex-wrap: wrap; gap: 20px;">
+                      <el-form-item label="地图类型" class="config-item"
+                        :style="(selectedBlocklyPoint.type === 'task' || selectedBlocklyPoint.type === 'item' || selectedBlocklyPoint.type === 'mark') ? 'width: calc(50% - 20px);' : 'width: 100%;'">
+                        <el-select
+                          v-model="selectedBlocklyPoint.type"
+                          placeholder="选择地图类型"
+                          style="width: 100%"
+                          clearable
+                          size="large"
+                        >
+                          <el-option
+                            v-for="dict in getStrDictOptions(DICT_TYPE.AI_BLOCKLY_MAP_TYPE)"
+                            :key="dict.value"
+                            :label="dict.label"
+                            :value="dict.value"
+                          />
+                        </el-select>
+                      </el-form-item>
+
+                      <el-form-item
+                        label="必须拾取"
+                        class="config-item"
+                        v-if="selectedBlocklyPoint.type === 'item'"
+                        style="width: calc(50% - 20px);"
+                      >
+                        <el-switch
+                          v-model="selectedBlocklyPoint.must"
+                          active-text="是"
+                          inactive-text="否"
+                          size="large"
+                        />
+                      </el-form-item>
+                      <el-form-item
+                          label="限制通行"
+                          class="config-item"
+                          v-if="selectedBlocklyPoint.type === 'task'"
+                          style="width: calc(50% - 20px);"
+                      >
+                        <el-switch
+                            v-model="selectedBlocklyPoint.noPassing"
+                            active-text="是"
+                            inactive-text="否"
+                            size="large"
+                        />
+                      </el-form-item>
+
+                      <el-form-item
+                          label="标记标签"
+                          class="config-item"
+                          v-if="selectedBlocklyPoint.type === 'mark'"
+                          style="width: calc(50% - 20px);"
+                      >
+                        <el-select
+                            v-model="selectedBlocklyPoint.mark"
+                            placeholder="标记标签"
+                            clearable
+                            size="large"
+                        >
+                          <el-option
+                              v-for="dict in getStrDictOptions(DICT_TYPE.BLOCKLY_MAP_MARK)"
+                              :key="dict.value"
+                              :label="dict.label"
+                              :value="dict.value"
+                              @click="()=>{selectedBlocklyPoint.img = dict.cssClass}"
+                          />
+                        </el-select>
+                      </el-form-item>
+                    </div>
+
+                    <!-- 第三行:图标上传 -->
+                    <div class="config-row" style="display: flex; flex-wrap: wrap; gap: 20px;">
+                      <el-form-item label="初始图标" class="config-item"
+                        :style="selectedBlocklyPoint.type === 'task' ? 'width: calc(50% - 20px);' : 'width: 100%;'">
+                        <div class="icon-upload-wrapper">
+                          <UploadImg
+                            :disabled="selectedBlocklyPoint.type === 'mark'"
+                            v-model="selectedBlocklyPoint.img"
+                            title="点击上传初始图标"
+                            class="custom-upload"
+                          />
+                          <div v-if="selectedBlocklyPoint.img" class="upload-tip">已上传</div>
+                        </div>
+                      </el-form-item>
+
+                      <el-form-item
+                        label="完成图标"
+                        class="config-item"
+                        v-if="selectedBlocklyPoint.type && selectedBlocklyPoint.type === 'task'"
+                        style="width: calc(50% - 20px);"
+                      >
+                        <div class="icon-upload-wrapper">
+                          <UploadImg
+                            v-model="selectedBlocklyPoint.endImg"
+                            title="点击上传完成图标"
+                            class="custom-upload"
+                          />
+                          <div v-if="selectedBlocklyPoint.endImg" class="upload-tip">已上传</div>
+                        </div>
+                      </el-form-item>
+
+                      <!-- 完成动画字段 -->
+                      <el-form-item
+                        label="完成动画"
+                        class="config-item"
+                        v-if="selectedBlocklyPoint.type && selectedBlocklyPoint.type === 'task'"
+                      >
+                        <div class="icon-upload-wrapper">
+                          <UploadVideo
+                            v-model="selectedBlocklyPoint.finishAnimation"
+                            title="点击上传完成动画"
+                            class="custom-upload"
+                            @upload-progress="handleBlocklyUploadProgress"
+                            @upload-start="handleBlocklyUploadStart"
+                            @upload-complete="handleBlocklyUploadComplete"
+                          />
+                          <div v-if="selectedBlocklyPoint.finishAnimation" class="upload-tip">已上传</div>
+                          <!-- Blockly配置弹框中的视频上传进度条 -->
+                          <div v-if="blocklyIsUploading" class="uploadProgress">
+                            <el-progress :percentage="blocklyUploadProgress" />
+                            <div class="text-xs text-gray-500 text-right mt-1">{{ blocklyUploadProgress }}% 已上传</div>
+                          </div>
+                        </div>
+                      </el-form-item>
+                    </div>
+
+                    <el-form-item label="提示语" class="form-item-full" v-if="selectedBlocklyPoint.type && selectedBlocklyPoint.type !== 'task'">
+                      <el-input
+                        v-model="selectedBlocklyPoint.tip"
+                        placeholder="输入初始提示语"
+                        style="width: 100%"
+                        show-word-limit
+                        maxlength="100"
+                        type="textarea"
+                        :rows="2"
+                      />
+                    </el-form-item>
+
+                    <el-form-item label="未完成提示语" class="form-item-full" v-if="selectedBlocklyPoint.type && selectedBlocklyPoint.type === 'task'">
+                      <el-input
+                        v-model="selectedBlocklyPoint.unfinishedTip"
+                        placeholder="输入未完成提示语"
+                        style="width: 100%"
+                        show-word-limit
+                        maxlength="100"
+                        type="textarea"
+                        :rows="2"
+                      />
+                    </el-form-item>
+                    <el-form-item label="完成提示语" class="form-item-full" v-if="selectedBlocklyPoint.type && selectedBlocklyPoint.type === 'task'">
+                      <el-input
+                        v-model="selectedBlocklyPoint.finishedTip"
+                        placeholder="输入完成提示语"
+                        style="width: 100%"
+                        show-word-limit
+                        maxlength="100"
+                        type="textarea"
+                        :rows="2"
+                      />
+                    </el-form-item>
+                  </template>
+                </el-form>
+              </el-card>
+            </div>
+
+            <div v-else class="no-selection">
+              <el-empty description="请点击左侧方格进行配置" />
+            </div>
+          </el-col>
+        </el-row>
+      </el-form-item>
+
+      <el-form-item label="特殊方块" prop="blocklySpecialBlocks">
+        <el-select
+          v-model="formData.blocklySpecialBlocks"
+          placeholder="请选择特殊方块"
+          multiple
+          clearable
+          style="width: 100%"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.BLOCKLY_MAP_SPECIAL)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="saveBlocklyConfig" type="primary" :disabled="blocklyIsUploading">确 定</el-button>
+      <el-button @click="blocklyConfigVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getStrDictOptions, DICT_TYPE } from '@/utils/dict'
+import { BlocklyApi, BlocklyVO } from '@/api/blockly/blockly'
+import { defaultProps, handleTree } from '@/utils/tree'
+import { BlocklyTypeApi } from '@/api/blockly/blocklyType'
+
+/** 课程 表单 */
+defineOptions({ name: 'BlocklyForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+
+// Blockly配置弹框相关状态
+const blocklyConfigVisible = ref(false)
+const blocklyConfigTitle = ref('配置Blockly数据')
+const blocklyConfigFormRef = ref() // Blockly配置表单Ref
+
+// Blockly配置相关响应式数据
+const blocklyTileX = ref<number>(6) // 横向数量,默认5
+const blocklyTileY = ref<number>(5) // 纵向数量,默认5
+
+// 选中的点
+const selectedBlocklyPoint = ref<BlocklyWalkablePoint | null>(null)
+
+// 路线ID计数器
+const routeIdCounter = ref(1)
+
+// 可行走点接口定义
+interface BlocklyWalkablePoint {
+  x: number
+  y: number
+  walkable: boolean
+  type: string
+  must: boolean
+  mark: String
+  noPassing: boolean
+  img: string
+  endImg: string
+  finishAnimation: string
+  tip: string
+  unfinishedTip: string
+  finishedTip: string
+}
+
+// 路线接口定义
+interface BlocklyRoute {
+  id: number
+  direction: number
+  startPoint: { x: number, y: number }
+  endPoint: { x: number, y: number }
+}
+
+// 可行走点数组
+const blocklyWalkablePoints = ref<BlocklyWalkablePoint[]>([])
+
+// 路线数组
+const blocklyRoutes = ref<BlocklyRoute[]>([])
+
+const formData = ref({
+  id: undefined,
+  bcName: undefined,
+  bcContentType: undefined,
+  bcContent: undefined,
+
+  blocklyInfo: undefined,
+  blocklyUserImage: 'https://learn-ai.com.cn/admin-api/infra/file/29/get/20251107/user_1762504554550.png',
+  blocklyTileSize: undefined,
+  blocklyBackground: undefined,
+  blocklyWalkablePoints: undefined,
+  blocklySpecialBlocks: undefined,
+  blocklyRouteList: undefined,
+
+  bcIsInspect: "false",
+  bcType: undefined,
+  bcLabel: undefined,
+  bcOrder: undefined,
+  bcStatus: "0",
+  tenantId: undefined,
+})
+
+// 添加上传进度相关的状态
+const uploadProgress = ref(0)
+const isUploading = ref(false)
+
+// 为Blockly配置弹框中的完成动画视频上传添加独立的进度状态
+const blocklyUploadProgress = ref(0)
+const blocklyIsUploading = ref(false)
+
+// 所有格子数据
+const blocklyGridCells = computed(() => {
+  const cells = []
+  for (let y = 1; y <= (blocklyTileY.value || 6); y++) {
+    for (let x = 1; x <= (blocklyTileX.value || 5); x++) {
+      cells.push({ x, y })
+    }
+  }
+  return cells
+})
+
+// 计算属性:检查是否已配置Blockly数据
+const hasBlocklyConfig = computed(() => {
+  return formData.value.blocklyUserImage &&
+    formData.value.blocklyBackground &&
+    blocklyRoutes.value.length > 0 &&
+    blocklyRoutes.value.every(route =>
+      route.startPoint.x !== undefined &&
+      route.startPoint.y !== undefined &&
+      route.endPoint.x !== undefined &&
+      route.endPoint.y !== undefined
+    ) &&
+    blocklyTileX.value !== undefined &&
+    blocklyTileY.value !== undefined
+})
+
+const formRules = reactive({
+  bcType: [{ required: true, message: '课程类型不能为空', trigger: 'blur' }],
+  bcName: [{ required: true, message: '课程名称不能为空', trigger: 'blur' }],
+  bcLabel: [{ required: true, message: '课程标签不能为空', trigger: 'blur' }],
+  bcOrder: [{ required: true, message: '课程排序不能为空', trigger: 'blur' }],
+  // 当内容类型为blockly时,需要验证blockly配置
+  blocklyConfig: [{
+    validator: (rule: any, value: any, callback: any) => {
+      if (formData.value.bcContentType === 'blockly' && !hasBlocklyConfig.value) {
+        callback(new Error('请配置Blockly数据'))
+      } else {
+        callback()
+      }
+    },
+    trigger: 'blur'
+  }]
+})
+
+// Blockly配置表单验证规则
+const blocklyConfigRules = reactive({
+  blocklyUserImage: [{ required: true, message: '人物图标不能为空', trigger: 'blur' }],
+  blocklyBackground: [{ required: true, message: '地图背景图不能为空', trigger: 'blur' }]
+  // blocklyInfo: [{ required: true, message: 'Blockly信息不能为空', trigger: 'blur' }]
+})
+
+const formRef = ref() // 表单 Ref
+const bcTypeTree = ref() // 树形结构
+
+/** 获取指定坐标的点配置 */
+const getBlocklyPointByXY = (x: number, y: number): BlocklyWalkablePoint | undefined => {
+  return blocklyWalkablePoints.value.find(point => point.x === x && point.y === y)
+}
+
+/** 判断指定坐标是否可行走 */
+const isBlocklyWalkable = (x: number, y: number): boolean => {
+  const point = getBlocklyPointByXY(x, y)
+  return point ? point.walkable : false
+}
+
+/** 选择格子 */
+const selectBlocklyCell = (cell: { x: number, y: number }) => {
+  // 查找是否已有该点的配置
+  let point = getBlocklyPointByXY(cell.x, cell.y)
+
+  // 如果没有,创建新的配置
+  if (!point) {
+    point = {
+      x: cell.x,
+      y: cell.y,
+      walkable: false,
+      type: '',
+      must: false,
+      mark: '',
+      noPassing: false,
+      img: '',
+      endImg: '',
+      finishAnimation: '',
+      tip: '',
+      unfinishedTip: '',
+      finishedTip: ''
+    }
+    blocklyWalkablePoints.value.push(point)
+  }
+
+  selectedBlocklyPoint.value = point
+}
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+
+  // 新增时,绑定类型
+  if (type === 'create' && id) {
+    // 确保 bcType 为正确的 id 类型
+    formData.value.bcType = id ? Number(id) : undefined
+  }
+  // 修改时,设置数据
+      if (type === 'update' && id) {
+        formLoading.value = true
+        try {
+          const blocklyData = await BlocklyApi.getBlockly(id)
+          formData.value = {
+            ...formData.value,
+            ...blocklyData,
+            bcContent: blocklyData.bcType === "image" ? blocklyData.bcContent?.split(',') : blocklyData.bcContent,
+            bcInfo: blocklyData.bcInfo || ''
+          }
+          // 确保 bcType 为正确的 id 类型
+          formData.value.bcType = blocklyData.bcType ? Number(blocklyData.bcType) : undefined
+
+          // 处理回显数据 - 解析路线JSON
+          if (blocklyData.blocklyRouteList) {
+            try {
+              blocklyRoutes.value = JSON.parse(blocklyData.blocklyRouteList)
+              routeIdCounter.value = Math.max(...blocklyRoutes.value.map(route => route.id)) + 1
+            } catch (e) {
+              console.error('解析路线数据失败:', e)
+              // 如果没有路线数据,使用旧的单路线数据
+              blocklyRoutes.value = []
+              if (blocklyData.blocklyStartPoint && blocklyData.blocklyEndPoint) {
+                try {
+                  const startPoint = JSON.parse(blocklyData.blocklyStartPoint)
+                  const endPoint = JSON.parse(blocklyData.blocklyEndPoint)
+                  blocklyRoutes.value.push({
+                    id: routeIdCounter.value++,
+                    direction: blocklyData.blocklyUserDirection || 0,
+                    startPoint: startPoint,
+                    endPoint: endPoint
+                  })
+                } catch (e) {
+                  console.error('解析旧路线数据失败:', e)
+                  blocklyRoutes.value = [{
+                    id: routeIdCounter.value++,
+                    direction: 0,
+                    startPoint: { x: undefined, y: undefined },
+                    endPoint: { x: undefined, y: undefined }
+                  }]
+                }
+              } else {
+                blocklyRoutes.value = [{
+                  id: routeIdCounter.value++,
+                  direction: 0,
+                  startPoint: { x: undefined, y: undefined },
+                  endPoint: { x: undefined, y: undefined }
+                }]
+              }
+            }
+          } else {
+            // 兼容旧数据结构
+            blocklyRoutes.value = []
+            if (blocklyData.blocklyStartPoint && blocklyData.blocklyEndPoint) {
+              try {
+                const startPoint = JSON.parse(blocklyData.blocklyStartPoint)
+                const endPoint = JSON.parse(blocklyData.blocklyEndPoint)
+                blocklyRoutes.value.push({
+                  id: routeIdCounter.value++,
+                  direction: blocklyData.blocklyUserDirection || 0,
+                  startPoint: startPoint,
+                  endPoint: endPoint
+                })
+              } catch (e) {
+                console.error('解析旧路线数据失败:', e)
+                blocklyRoutes.value = [{
+                  id: routeIdCounter.value++,
+                  direction: 0,
+                  startPoint: { x: undefined, y: undefined },
+                  endPoint: { x: undefined, y: undefined }
+                }]
+              }
+            } else {
+              blocklyRoutes.value = [{
+                id: routeIdCounter.value++,
+                direction: 0,
+                startPoint: { x: undefined, y: undefined },
+                endPoint: { x: undefined, y: undefined }
+              }]
+            }
+          }
+
+      // 解析地图尺寸
+      if (blocklyData.blocklyTileSize) {
+        try {
+          const point = JSON.parse(blocklyData.blocklyTileSize)
+          blocklyTileX.value = point.x
+          blocklyTileY.value = point.y
+        } catch (e) {
+          console.error('解析地图尺寸失败:', e)
+        }
+      }
+
+      // 解析地图可行走点
+      if (blocklyData.blocklyWalkablePoints) {
+        try {
+          const points = JSON.parse(blocklyData.blocklyWalkablePoints)
+          blocklyWalkablePoints.value = points.map((p: any) => ({
+            x: p.x,
+            y: p.y,
+            walkable: true,
+            type: p.type || '',
+            must: p.must || false,
+            mark: p.mark || '',
+            noPassing: p.noPassing || false,
+            img: p.img || '',
+            endImg: p.endImg || '',
+            finishAnimation: p.finishAnimation || '',
+            tip: p.tip || '',
+            unfinishedTip: p.unfinishedTip || '',
+            finishedTip: p.finishedTip || ''
+          }))
+        } catch (e) {
+          console.error('解析地图可行走坐标失败:', e)
+          blocklyWalkablePoints.value = []
+        }
+      }
+
+      // 处理 blocklySpecialBlocks 字段,将字符串转换为数组
+      if (blocklyData.blocklySpecialBlocks) {
+        formData.value.blocklySpecialBlocks = blocklyData.blocklySpecialBlocks.split(',')
+      }
+    } finally {
+      formLoading.value = false
+    }
+  }
+
+  await getbcTypeTree()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 打开Blockly配置弹框 */
+const openBlocklyConfigDialog = () => {
+  blocklyConfigVisible.value = true
+}
+
+// 添加新路线
+const addRoute = () => {
+  blocklyRoutes.value.push({
+    id: routeIdCounter.value++,
+    direction: 0,
+    startPoint: { x: undefined, y: undefined },
+    endPoint: { x: undefined, y: undefined }
+  })
+}
+
+// 删除路线
+const deleteRoute = (routeId: number) => {
+  if (blocklyRoutes.value.length <= 1) {
+    message.warning('不能删除最后一条路线')
+    return
+  }
+  const index = blocklyRoutes.value.findIndex(route => route.id === routeId)
+  if (index > -1) {
+    blocklyRoutes.value.splice(index, 1)
+  }
+}
+
+/** 保存Blockly配置 */
+const saveBlocklyConfig = async () => {
+  // 坐标数据验证
+  if (blocklyRoutes.value.length === 0) {
+    message.error('至少需要配置一条路线')
+    return
+  }
+
+  for (let i = 0; i < blocklyRoutes.value.length; i++) {
+    const route = blocklyRoutes.value[i]
+    if (route.startPoint.x === undefined || route.startPoint.y === undefined) {
+      message.error(`路线 ${i + 1} 的开始坐标不能为空`)
+      return
+    }
+    if (route.endPoint.x === undefined || route.endPoint.y === undefined) {
+      message.error(`路线 ${i + 1} 的结束坐标不能为空`)
+      return
+    }
+  }
+
+  if (blocklyTileX.value === undefined || blocklyTileY.value === undefined) {
+    message.error('地图方格尺寸不能为空')
+    return
+  }
+
+  // 校验Blockly配置表单
+  await blocklyConfigFormRef.value.validate()
+
+  // 设置坐标JSON
+  formData.value.blocklyTileSize = JSON.stringify({ x: blocklyTileX.value, y: blocklyTileY.value })
+  formData.value.blocklyRouteList = JSON.stringify(blocklyRoutes.value)
+
+  // 只保存可行走的点
+  const walkableOnlyPoints = blocklyWalkablePoints.value.filter(point => point.walkable)
+  formData.value.blocklyWalkablePoints = JSON.stringify(walkableOnlyPoints)
+
+  blocklyConfigVisible.value = false
+  message.success('Blockly配置已更改!')
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+
+  // 特殊校验:当内容类型为blockly时,需要验证blockly配置
+  if (formData.value.bcContentType === 'blockly' && !hasBlocklyConfig.value) {
+    message.error('请先配置Blockly数据')
+    return
+  }
+
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = { ...formData.value } as unknown as BlocklyVO
+    if (data.bcContentType === 'image') {
+      data.bcContent = data.bcContent?.join(',')
+    }
+    // 将 blocklySpecialBlocks 数组转换为字符串
+    if (data.blocklySpecialBlocks && Array.isArray(data.blocklySpecialBlocks)) {
+      data.blocklySpecialBlocks = data.blocklySpecialBlocks.join(',')
+    }
+    if (formType.value === 'create') {
+      await BlocklyApi.createBlockly(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await BlocklyApi.updateBlockly(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 外层表单上传进度处理方法
+const handleUploadProgress = (progress: number) => {
+  uploadProgress.value = progress
+}
+
+const handleUploadStart = () => {
+  isUploading.value = true
+  uploadProgress.value = 0
+}
+
+const handleUploadComplete = () => {
+  isUploading.value = false
+  uploadProgress.value = 100
+}
+
+// Blockly配置弹框中完成动画视频上传的进度处理方法
+const handleBlocklyUploadProgress = (progress: number) => {
+  blocklyUploadProgress.value = progress
+}
+
+const handleBlocklyUploadStart = () => {
+  blocklyIsUploading.value = true
+  blocklyUploadProgress.value = 0
+}
+
+const handleBlocklyUploadComplete = () => {
+  blocklyIsUploading.value = false
+  blocklyUploadProgress.value = 100
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    bcName: undefined,
+    bcContentType: undefined,
+    bcContent: undefined,
+
+    blocklyInfo: undefined,
+    blocklyUserImage: 'https://learn-ai.com.cn/admin-api/infra/file/29/get/20251107/user_1762504554550.png',
+    blocklyTileSize: undefined,
+    blocklyBackground: undefined,
+    blocklyWalkablePoints: undefined,
+    blocklySpecialBlocks: undefined,
+    blocklyRouteList: undefined,
+
+    bcIsInspect: "false",
+    bcType: undefined,
+    bcLabel: undefined,
+    bcOrder: undefined,
+    bcStatus: "0",
+    tenantId: undefined,
+  }
+
+  // 重置Blockly配置相关数据
+  blocklyTileX.value = 6
+  blocklyTileY.value = 5
+  blocklyWalkablePoints.value = []
+  selectedBlocklyPoint.value = null
+
+  // 重置路线数据
+  blocklyRoutes.value = [{
+    id: routeIdCounter.value++,
+    direction: 0,
+    startPoint: { x: undefined, y: undefined },
+    endPoint: { x: undefined, y: undefined }
+  }]
+
+  // 重置上传进度状态
+  uploadProgress.value = 0
+  isUploading.value = false
+  blocklyUploadProgress.value = 0
+  blocklyIsUploading.value = false
+
+  formRef.value?.resetFields()
+}
+/** 获得课程-类型树 */
+const getbcTypeTree = async () => {
+  bcTypeTree.value = []
+  const data = await BlocklyTypeApi.getBlocklyTypeSimpleList()
+  const root: Tree = { id: 0, ctType: '课程类型', children: [] }
+  root.children = handleTree(data, 'id', 'ctParentId')
+  bcTypeTree.value.push(root)
+}
+</script>
+
+<style scoped>
+/* 大纲课程树选择器样式 */
+:deep(.el-tree-select__dropdown .el-tree-node__content) {
+  font-size: 14px;
+}
+
+/* 上传进度条样式 */
+.uploadProgress {
+  width: 85%;
+  margin-top: 10px;
+}
+
+/* 地图坐标输入组样式 */
+.coordinate-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+/* 地图网格容器样式 */
+.map-grid-container {
+  padding: 10px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  background-color: #f5f7fa;
+  height: 400px;
+  overflow: auto;
+}
+
+.map-grid {
+  display: grid;
+  gap: 1px;
+  background-color: #dcdfe6;
+  border-radius: 4px;
+  padding: 1px;
+}
+
+.grid-cell {
+  aspect-ratio: 1;
+  background-color: rgba(255, 255, 255, 0.5);
+  position: relative;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.grid-cell:hover {
+  background-color: rgba(0, 150, 136, 0.2);
+}
+
+.grid-cell.walkable {
+  background-color: rgba(240, 249, 235, 0.8);
+  border: 3px solid rgb(103, 194, 58);
+}
+
+.grid-cell.selected {
+  background-color: rgba(64, 158, 255, 0.5);
+  box-shadow: 0 0 0 2px #409eff;
+  z-index: 1;
+}
+
+.cell-coordinate {
+  position: absolute;
+  top: 2px;
+  left: 2px;
+  background-color: rgba(255, 255, 255, 0.7);
+  padding: 1px 3px;
+  border-radius: 3px;
+  height: 15px;
+  line-height: 12px;
+}
+
+.cell-icon {
+  max-width: 80%;
+  max-height: 80%;
+}
+
+.cell-icon img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+}
+
+/* 右侧配置区域样式 - 修复高度显示问题 */
+.point-config-container {
+  padding: 10px;
+  //height: 400px;
+}
+
+.config-card {
+  height: calc(100% - 20px);
+  display: flex;
+  flex-direction: column;
+}
+
+.config-card .el-card__body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 15px;
+}
+
+.config-form {
+  width: 100%;
+  max-height: 400px;
+  overflow-y: auto;
+}
+
+.config-card {
+margin-bottom: 0;
+border-radius: 8px;
+}
+
+.card-header {
+display: flex;
+justify-content: space-between;
+align-items: center;
+font-weight: bold;
+color: #303133;
+}
+
+/* 配置项行间距 */
+.config-row {
+  margin-bottom: 15px;
+}
+
+/* 配置项样式 */
+.config-item {
+  margin-bottom: 0;
+}
+
+/* 表单项全宽样式 */
+.form-item-full {
+  margin-bottom: 15px;
+}
+
+/* 路线配置容器 */
+.route-config-container {
+  margin-top: 10px;
+  width: 100%;
+}
+
+/* 路线配置项样式 */
+.route-config-item {
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  padding: 12px;
+  margin-bottom: 12px;
+  background-color: #ffffff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+.route-config-item:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  border-color: #409eff;
+  transform: translateY(-1px);
+}
+
+/* 路线行样式 */
+.route-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.route-row:last-child {
+  margin-bottom: 0;
+}
+
+/* 路线标题样式 */
+.route-title {
+  display: inline-flex;
+  align-items: center;
+  height: 28px;
+  font-weight: 600;
+  font-size: 13px;
+  color: #409eff;
+  background-color: #ecf5ff;
+  padding: 0 10px;
+  border-radius: 4px;
+  border-left: 2px solid #409eff;
+  margin-right: 15px;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+/* 路线字段容器 */
+.route-fields {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+  gap: 20px;
+}
+
+/* 路线字段样式 */
+.route-field {
+  display: flex;
+  align-items: center;
+}
+
+/* 字段标签样式 */
+.field-label {
+  font-size: 13px;
+  color: #606266;
+  margin-right: 8px;
+  font-weight: 500;
+}
+
+/* 坐标组样式 */
+.coordinate-group {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+/* 坐标输入框样式 */
+.coordinate-input {
+  width: 60px;
+}
+
+/* 方向单选按钮样式 */
+.direction-radio {
+  display: flex;
+  align-items: center;
+  border-radius: 4px;
+  overflow: hidden;
+  border: 1px solid #dcdfe6;
+}
+
+.direction-radio .el-radio-button__inner {
+  padding: 4px 10px;
+  font-size: 12px;
+  border-radius: 0;
+  border: none;
+  transition: all 0.2s;
+}
+
+.direction-radio .el-radio-button__orig-radio:checked + .el-radio-button__inner {
+  background-color: #409eff;
+  border-color: #409eff;
+}
+
+.direction-radio .el-radio-button:first-child .el-radio-button__inner {
+  border-radius: 3px 0 0 3px;
+}
+
+.direction-radio .el-radio-button:last-child .el-radio-button__inner {
+  border-radius: 0 3px 3px 0;
+}
+
+/* 删除按钮样式 */
+.route-delete-btn {
+  margin-left: auto;
+}
+
+/* 响应式调整 */
+@media (max-width: 768px) {
+  .route-row {
+    flex-wrap: wrap;
+  }
+  
+  .route-fields {
+    width: 100%;
+    margin-top: 8px;
+  }
+  
+  .route-delete-btn {
+    margin-left: 0;
+    margin-top: 8px;
+  }
+}
+
+/* 路线头部样式 - 保留用于兼容性 */
+.route-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  font-weight: bold;
+  color: #303133;
+}
+
+/* 图标上传包装器 */
+.icon-upload-wrapper {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+/* 上传提示 */
+.upload-tip {
+  font-size: 12px;
+  color: #67c23a;
+  margin-top: 5px;
+}
+
+/* 无选择状态样式 */
+.no-selection {
+  height: 400px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f5f7fa;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+}
+
+/* 自定义上传组件样式 */
+.custom-upload {
+  margin-bottom: 5px;
+}
+</style>

+ 426 - 0
src/views/aiCourse/aiCourse/index.vue

@@ -0,0 +1,426 @@
+<template>
+  <el-row :gutter="20">
+    <!-- 左侧课程类型树 -->
+    <el-col :span="5" :xs="24">
+      <ContentWrap class="h-1/1 custom-scrollbar" style="overflow-x: auto; overflow-y: auto; height: calc(100vh - 100px);">
+
+        <!-- 搜索条件框 -->
+        <div class="mb-20px">
+          <el-input
+            v-model="treeSearchValue"
+            placeholder="搜索课程类型"
+            prefix-icon="ep:search"
+            clearable
+            @input="handleTreeSearch"
+            class="!w-full"
+          />
+        </div>
+
+        <!-- 课程类型树形选择器 -->
+        <el-tree
+          v-model="queryParams.acType"
+          :data="filteredCourseTypeTree"
+          :props="{
+            label: (node) => `${node.ctTypeNode === '0' || node.ctTypeNode === undefined ? node.ctType : node.ctTypeSort + '、' + node.ctType}`,
+            children: 'children',
+            value: 'id'
+          }"
+          :default-expand-all="true"
+          node-key="id"
+          highlight-current
+          @node-click="handleCourseTypeClick"
+        />
+      </ContentWrap>
+    </el-col>
+    <el-col :span="19" :xs="24">
+      <!-- 搜索工作栏 -->
+      <ContentWrap>
+        <el-form
+          class="-mb-15px"
+          :model="queryParams"
+          ref="queryFormRef"
+          :inline="true"
+          label-width="90px"
+        >
+          <el-form-item label="内容类型" prop="acContentType">
+            <el-select
+              v-model="queryParams.acContentType"
+              placeholder="请选择内容类型"
+              clearable
+              class="!w-240px"
+              @change="handleQuery"
+            >
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.COURSE_COUTNET_TYPE)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="课程名称" prop="acName">
+            <el-input
+              v-model="queryParams.acName"
+              placeholder="请输入课程名称"
+              clearable
+              @keyup.enter="handleQuery"
+              class="!w-240px"
+            />
+          </el-form-item>
+          <el-form-item label="是否有检查" prop="acIsInspect">
+            <el-select
+              v-model="queryParams.acIsInspect"
+              placeholder="请选择是否有检查"
+              clearable
+              class="!w-240px"
+              @change="handleQuery"
+            >
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="课程标签" prop="acLabel">
+            <el-select
+              v-model="queryParams.acLabel"
+              placeholder="请选择课程标签"
+              clearable
+              class="!w-240px"
+              @change="handleQuery"
+            >
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.BLOCKLY_COURSE_LABEL)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="课程状态" prop="acStatus">
+            <el-select
+              v-model="queryParams.acStatus"
+              placeholder="请选择课程状态"
+              clearable
+              class="!w-240px"
+              @change="handleQuery"
+            >
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+            <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+            <el-button
+              type="primary"
+              plain
+              @click="openForm('create',queryParams.acType)"
+              v-hasPermi="['aiCourse:course:create']"
+            >
+              <Icon icon="ep:plus" class="mr-5px" /> 新增
+            </el-button>
+            <el-button
+              type="success"
+              plain
+              @click="handleExport"
+              :loading="exportLoading"
+              v-hasPermi="['aiCourse:course:export']"
+            >
+              <Icon icon="ep:download" class="mr-5px" /> 导出
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </ContentWrap>
+
+      <!-- 列表 -->
+      <ContentWrap>
+        <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+          <el-table-column label="课程类型" align="center" prop="acTypeName" />
+          <el-table-column label="课程名称" align="center" prop="acName" />
+          <el-table-column label="内容类型" align="center" prop="acContentType" >
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.COURSE_COUTNET_TYPE" :value="scope.row.acContentType" />
+            </template>
+          </el-table-column>
+          <el-table-column label="课程内容" align="center" prop="acContent" width="120px">
+            【预览查看】
+          </el-table-column>
+          <el-table-column label="是否有检查" align="center" prop="acIsInspect">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.acIsInspect" />
+            </template>
+          </el-table-column>
+          <el-table-column label="课程标签" align="center" prop="acLabel">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.BLOCKLY_COURSE_LABEL" :value="scope.row.acLabel" />
+            </template>
+          </el-table-column>
+          <el-table-column label="课程排序" align="center" prop="acOrder"/>
+          <el-table-column label="课程状态" align="center" prop="acStatus">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.acStatus" />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" align="center" min-width="180px">
+            <template #default="scope">
+              <el-button
+                link
+                type="primary"
+                v-if="scope.row.tenantId == getTenantId()"
+                @click="openForm('update', scope.row.id)"
+                v-hasPermi="['aiCourse:course:update']"
+              >
+                编辑
+              </el-button>
+              <el-button
+                v-if="scope.row.acContentType == 'video'"
+                link
+                type="success"
+                title="仅视频课程可配置"
+                @click="handleConfig(scope.row.id,scope.row.acName,scope.row.tenantId)"
+              >
+                配置
+              </el-button>
+              <el-button
+                link
+                type="danger"
+                v-if="scope.row.tenantId == getTenantId()"
+                @click="handleDelete(scope.row.id)"
+                v-hasPermi="['aiCourse:course:delete']"
+              >
+                删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <!-- 分页 -->
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </ContentWrap>
+    </el-col>
+  </el-row>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <AiCourseForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getStrDictOptions, DICT_TYPE } from '@/utils/dict'
+import download from '@/utils/download'
+import { AiCourseApi, AiCourseVO } from '@/api/aiCourse/aiCourse'
+import AiCourseForm from './AiCourseForm.vue'
+import { useRouter } from 'vue-router'
+import { handleTree } from '@/utils/tree'
+import { AiCourseTypeApi } from '@/api/aiCourse/aiCourseType'
+import { getTenantId } from '@/utils/auth'
+
+/** 课程 列表 */
+defineOptions({ name: 'AiCourse' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<AiCourseVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const treeSearchValue = ref('') // 树形搜索值
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  acName: undefined,
+  acContentType: undefined,
+  acContent: undefined,
+
+  blocklyInfo: undefined,
+  blocklyUserImage: undefined,
+  blocklyUserDirection: undefined,
+  blocklyStartPoint: undefined,
+  blocklyBackground: undefined,
+  blocklyEndPoint: undefined,
+  blocklyWalkablePoints: undefined,
+
+  acIsInspect: undefined,
+  ctTypeNode: "1",//课程类型
+  acType: undefined,
+  acTypeName: undefined,
+  acLabel: undefined,
+  acOrder: undefined,
+  acStatus: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+const acTypeTree = ref() // 原始树形结构
+const filteredCourseTypeTree = computed(() => {
+  if (!treeSearchValue.value) {
+    return acTypeTree.value
+  }
+
+  const filterTree = (tree, searchText) => {
+    return tree.map(node => {
+      const newNode = { ...node }
+      // 检查节点是否匹配搜索文本
+      const isMatch = newNode.ctType && newNode.ctType.toLowerCase().includes(searchText.toLowerCase())
+
+      // 如果有子节点,递归过滤
+      if (newNode.children && newNode.children.length > 0) {
+        const filteredChildren = filterTree(newNode.children, searchText)
+        if (filteredChildren.length > 0 || isMatch) {
+          newNode.children = filteredChildren
+          return newNode
+        }
+      } else if (isMatch) {
+        // 没有子节点但节点本身匹配
+        return newNode
+      }
+
+      // 如果既没有匹配的子节点也不匹配自身,则返回null
+      return null
+    }).filter(node => node !== null) // 过滤掉null值
+  }
+
+  return filterTree([...acTypeTree.value], treeSearchValue.value)
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await AiCourseApi.getAiCoursePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = async () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 处理课程类型节点点击 */
+const handleCourseTypeClick = (data) => {
+  queryParams.acType = data.id
+  handleQuery()
+}
+
+/** 处理树搜索 */
+const handleTreeSearch = () => {
+  // 搜索时不需要自动查询列表,只过滤树节点
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  queryParams.ctTypeNode = "1"
+  queryParams.acType = undefined
+  treeSearchValue.value = ''
+  getacTypeTree().then(() => {
+    handleQuery()
+  })
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await AiCourseApi.deleteAiCourse(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 配置按钮操作 */
+const router = useRouter()
+const handleConfig = (id: number, acName: string, tenantId: number) => {
+  router.push({
+    path: '/aiCourse/course-config',
+    query: { acId: id, acName: acName, tenantId: tenantId }
+  })
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await AiCourseApi.exportAiCourse(queryParams)
+    download.excel(data, '课程.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 获得课程-类型树 */
+const getacTypeTree = async (filterCtTypeNode = "1") => {
+  acTypeTree.value = []
+  const data = await AiCourseTypeApi.getAiCourseTypeSimpleList()
+
+  let filteredData = data
+  if (filterCtTypeNode !== undefined) {
+    filteredData = data.filter(item => item.ctTypeNode === '0' || item.ctTypeNode === filterCtTypeNode)
+  }
+  const root = { id: 0, ctType: '课程类型', children: [] }
+  root.children = handleTree(filteredData, 'id', 'ctParentId')
+  acTypeTree.value = [root]
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+  getacTypeTree()
+})
+</script>
+
+<style scoped>
+/* 优化滚动条样式 */
+:deep(.custom-scrollbar) {
+  scrollbar-width: thin;
+  scrollbar-color: #c0c4cc #f0f2f5;
+}
+
+/* 兼容WebKit浏览器(Chrome、Safari等) */
+:deep(.custom-scrollbar::-webkit-scrollbar) {
+  width: 3px;
+  height: 3px;
+}
+
+:deep(.custom-scrollbar::-webkit-scrollbar-track) {
+  background: #f0f2f5;
+  border-radius: 3px;
+}
+
+:deep(.custom-scrollbar::-webkit-scrollbar-thumb) {
+  background: #c0c4cc;
+  border-radius: 3px;
+  transition: background 0.3s;
+}
+
+:deep(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
+  background: #909399;
+}
+</style>

+ 348 - 0
src/views/aiCourse/aiCourseConfig/AiCourseConfigForm.vue

@@ -0,0 +1,348 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="1000px">
+    <!-- 顶部:课程配置表单 -->
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="课程名称" prop="ccCourseId">
+        <el-input v-model="formData.courseName" placeholder="请输入课程名称" :disabled="true" />
+        <el-input v-model="formData.ccCourseId" type="hidden" />
+        <!-- 隐藏真实课程id -->
+      </el-form-item>
+
+      <el-row>
+        <el-col :span="16">
+          <el-form-item label="课程暂停时长" required>
+            <div style="display: flex; align-items: center; gap: 10px;">
+              <el-input-number v-model="formData.ccHours" :min="0" :step="1" step-strictly :precision="0" style="width: 140px;">
+                <template #suffix><span>时</span></template>
+              </el-input-number>
+              <el-input-number v-model="formData.ccMinutes" :min="0" :max="59" :step="1" step-strictly :precision="0" style="width: 140px;">
+                <template #suffix><span>分</span></template>
+              </el-input-number>
+              <el-input-number v-model="formData.ccSeconds" :min="0" :max="59" :step="1" step-strictly :precision="0" style="width: 140px;">
+                <template #suffix><span>秒</span></template>
+              </el-input-number>
+            </div>
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="是否显示答案" prop="ccAnswerJudge" >
+            <el-radio-group v-model="formData.ccAnswerJudge">
+              <el-radio
+                v-for="dict in getStrDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                :key="dict.value"
+                :label="dict.value"
+              >{{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <br/>
+          <el-form-item label="问题呈现类型" prop="ccQuestSource" required>
+            <el-segmented v-model="formData.ccQuestSource" :options="getStrDictOptions(DICT_TYPE.COURSE_QUEST_SHOW_TYPE)" />
+          </el-form-item>
+        </el-col>
+
+        <el-col :span="24" v-if="formData.ccQuestSource === '1'" required>
+          <el-form-item label="试题内容" prop="ccQuestContent">
+            <Editor v-model="formData.ccQuestContent" height="150px" />
+          </el-form-item>
+
+          <el-form-item label="试题选项" prop="ccQuestOption">
+            <el-input v-model="formData.ccQuestOption" placeholder="请输入试题选项( 示例:多个选项用英文,分割 )" />
+          </el-form-item>
+
+          <el-form-item label="答  案" prop="ccAnswer">
+            <el-input v-model="formData.ccAnswer" placeholder="请输入答案" />
+          </el-form-item>
+
+          <el-form-item label="Ai问题提示" prop="ccAiQuestTip">
+            <el-input v-model="formData.ccAiQuestTip" placeholder="请输入Ai问题提示(多条请使用英文,豆包分割)" />
+          </el-form-item>
+
+          <el-form-item label="Ai答案" prop="ccAiAnswer">
+            <el-input v-model="formData.ccAiAnswer" placeholder="请输入Ai答案" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <div v-if="formData.ccQuestSource === '2'">
+        <ContentWrap>
+          <!-- 搜索工作栏 -->
+          <el-form
+            class="-mb-15px"
+            :model="questQueryParams"
+            ref="queryFormRef"
+            :inline="true"
+            label-width="68px"
+          >
+            <el-form-item label="试题类型" prop="cqQuestType">
+              <el-select
+                v-model="questQueryParams.cqQuestType"
+                placeholder="请选择试题类型"
+                clearable
+                class="!w-240px"
+              >
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.COURSE_QUESTION_TYPE)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="试题内容" prop="cqQuestion">
+              <el-input
+                v-model="questQueryParams.cqQuestion"
+                placeholder="请输入试题内容"
+                clearable
+                @keyup.enter="loadQuestionList"
+                class="!w-240px"
+              />
+            </el-form-item>
+            <el-form-item>
+              <el-button @click="loadQuestionList"
+              ><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button
+              >
+              <el-button @click="resetQuestionQuery"
+              ><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button
+              >
+            </el-form-item>
+          </el-form>
+        </ContentWrap>
+
+        <!-- 列表 -->
+        <ContentWrap>
+          <el-table
+            v-loading="questionLoading"
+            :data="questionList"
+            :stripe="true"
+            :show-overflow-tooltip="true"
+          >
+            <el-table-column label="选择" width="80">
+              <template #default="scope">
+                <el-radio
+                  v-model="formData.ccQuestId"
+                  :label="scope.row.id"
+                  @change="handleQuestionSelect(scope.row)"
+                />
+              </template>
+            </el-table-column>
+            <el-table-column label="试题内容" align="center" prop="cqQuestion" >
+              <template #default="scope">
+                <div v-html="scope.row.cqQuestion"></div>
+              </template>
+            </el-table-column>
+            <el-table-column label="试题解析" align="center" prop="cqQuestAnalysis" >
+              <template #default="scope">
+                <div v-html="scope.row.cqQuestAnalysis"></div>
+              </template>
+            </el-table-column>
+            <el-table-column label="试题类型" align="center" prop="cqQuestType" >
+              <template #default="scope">
+                <dict-tag :type="DICT_TYPE.COURSE_QUESTION_TYPE" :value="scope.row.cqQuestType" />
+              </template>
+            </el-table-column>
+          </el-table>
+          <!-- 分页 -->
+          <Pagination
+            :total="questionTotal"
+            v-model:page="questQueryParams.pageNo"
+            v-model:limit="questQueryParams.pageSize"
+            @pagination="loadQuestionList"
+          />
+        </ContentWrap>
+      </div>
+    </el-form>
+
+    <!-- 底部按钮 -->
+    <template #footer>
+<!--      <el-button @click="submitForm" type="primary" :disabled="formLoading">同步内容</el-button>-->
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { getStrDictOptions, DICT_TYPE } from '@/utils/dict'
+import { BlocklyConfigApi, BlocklyConfigVO} from '@/api/blockly/blocklyConfig'
+import { CourseQuestionApi} from '@/api/bjdx/coursequestion'
+
+
+
+/** 合并后的课程配置与试题选择表单 */
+defineOptions({ name: 'BlocklyConfigWithQuestionForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+
+// 原课程配置相关状态
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formData = ref({
+  id: undefined,
+  ccCourseId: undefined, // 课程id(隐藏字段)
+  courseName: undefined, // 课程名称(显示用)
+  ccTime: 0, // 暂停时长总秒数(用于提交)
+  ccHours: 0, // 小时
+  ccMinutes: 0, // 分钟
+  ccSeconds: 0, // 秒
+  ccAnswerJudge: undefined, // 是否显示答案(必填)
+  ccQuestId: undefined, // 选择的试题id
+  ccQuestSource: '0', // 问题呈现类型
+  ccQuestContent: undefined,  // 问题内容
+  ccQuestOption: undefined,  // 问题选项
+  ccAiQuestTip: undefined,  // AI问题提示
+  ccAiAnswer: undefined,  // AI答案
+  ccAnswer: undefined,  // 答案
+  tenantId: undefined,
+})
+const formRules = reactive({
+  ccHours: [{ required: true, message: t('common.required'), trigger: 'blur' }],
+  ccMinutes: [{ required: true, message: t('common.required'), trigger: 'blur' }],
+  ccSeconds: [{ required: true, message: t('common.required'), trigger: 'blur' }],
+  ccQuestContent: [{ required: true, message: t('common.required'), trigger: 'change' }]
+})
+const formRef = ref()
+
+// 试题列表相关状态
+const questionLoading = ref(false)
+const questionList = ref<any[]>([])
+const queryParams = reactive({
+  cqQuestType: undefined,
+  cqQuestion: undefined
+})
+const selectedQuestIds = ref<number[]>([]) // 记录选中的试题id
+
+/** 打开弹窗(新增参数:courseId, courseName) */
+const open = async (type: string, id?: number, courseId?: number, courseName?: string) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await BlocklyConfigApi.getCourseConfig(id)
+      formData.value = data
+      // 转换秒数为时分秒
+      const totalSeconds = data.ccTime || 0
+      formData.value.ccHours = Math.floor(totalSeconds / 3600)
+      formData.value.ccMinutes = Math.floor((totalSeconds % 3600) / 60)
+      formData.value.ccSeconds = totalSeconds % 60
+    } finally {
+      formLoading.value = false
+    }
+  }
+
+  // 初始化课程信息
+  formData.value.ccCourseId = courseId
+  formData.value.courseName = courseName
+
+  // 加载试题列表
+  await loadQuestionList()
+}
+
+/** 新增试题分页相关状态 */
+const questionTotal = ref(0) // 总条数
+
+const questQueryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  cqQuestion: undefined,
+  cqQuestAnalysis: undefined,
+  cqQuestType: undefined,
+  cqQuestAnswerId: undefined
+})
+
+/** 加载试题列表(修改分页参数传递) */
+const loadQuestionList = async () => {
+  questionLoading.value = true
+  try {
+    const res = await CourseQuestionApi.getCourseQuestionPage(questQueryParams)
+    questionList.value = res.list
+    questionTotal.value = res.total // 更新总条数
+  } finally {
+    questionLoading.value = false
+  }
+}
+
+/** 重置试题筛选 */
+const resetQuestionQuery = () => {
+  questQueryParams.cqQuestType = undefined
+  questQueryParams.cqQuestion = undefined
+  loadQuestionList()
+}
+
+/** 处理试题选择 */
+const handleQuestionSelect = (row: any) => {
+  formData.value.ccQuestId = row.id // 同步到表单数据
+}
+
+
+/** 提交表单(修改:包含ccQuestId) */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    // 计算总秒数
+    const ccTime = formData.value.ccHours * 3600 + formData.value.ccMinutes * 60 + formData.value.ccSeconds
+    formData.value.ccTime = ccTime
+    
+    const data = formData.value as unknown as BlocklyConfigVO
+    if (formType.value === 'create') {
+      await BlocklyConfigApi.createCourseConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await BlocklyConfigApi.updateCourseConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单(重置试题相关状态) */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    ccCourseId: undefined,
+    courseName: undefined,
+    ccTime: 0,
+    ccHours: 0,
+    ccMinutes: 0,
+    ccSeconds: 0,
+    ccAnswerJudge: undefined,
+    ccQuestId: undefined, // 选择的试题id
+    ccQuestSource: '0', // 问题呈现类型
+    ccQuestContent: undefined,  // 问题内容
+    ccQuestOption: undefined,  // 问题选项
+    ccAiQuestTip: undefined,  // AI问题提示
+    ccAiAnswer: undefined,  // AI答案
+    ccAnswer: undefined,  // 答案,
+    tenantId: undefined,
+  }
+  formRef.value?.resetFields()
+  selectedQuestIds.value = []
+  questionList.value = []
+}
+
+defineExpose({ open })
+</script>

+ 267 - 0
src/views/aiCourse/aiCourseConfig/index.vue

@@ -0,0 +1,267 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="100px"
+    >
+      <el-form-item label="课程名称" prop="ccCourseId">
+        <el-input
+          v-model="queryParams.courseName"
+          placeholder="请输入课程id"
+          clearable
+          disabled
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="试题名称" prop="ccQuest">
+        <el-input
+          v-model="queryParams.questContent"
+          placeholder="请输入试题名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="课程暂停时长" prop="ccTime">
+        <el-input
+          v-model="queryParams.ccTime"
+          placeholder="请输入课程暂停时长"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="是否显示答案" prop="ccAnswerJudge">
+        <el-select
+          v-model="queryParams.ccAnswerJudge"
+          placeholder="请选择是否显示答案"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          v-if="tenantId == getTenantId()"
+          @click="openForm('create', null, queryParams.ccCourseId, queryParams.courseName)"
+          v-hasPermi="['aiCourse:course-config:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['aiCourse:course-config:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <!--      <el-table-column label="课程配置id" align="center" prop="id" />-->
+      <el-table-column label="课程名称" align="center" prop="courseName" />
+      <el-table-column label="课程暂停时长" align="center">
+        <template #default="scope">
+          {{ formatDuration(scope.row.ccTime) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="暂停类型" align="center" prop="ccQuestSource">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COURSE_QUEST_SHOW_TYPE" :value="scope.row.ccQuestSource" />
+        </template>
+      </el-table-column>
+      <el-table-column label="试题内容" align="center" prop="ccQuestContent">
+        <template #default="scope">
+          <div v-html="scope.row.ccQuestContent"></div>
+        </template>
+      </el-table-column>
+      <el-table-column label="试题选项" align="center" prop="ccQuestOption" />
+      <el-table-column label="试题答案" align="center" prop="ccAnswer" />
+      <el-table-column label="Ai答案" align="center" prop="ccAiAnswer" />
+      <el-table-column label="是否显示答案" align="center" prop="ccAnswerJudge">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.ccAnswerJudge" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            v-if="scope.row.tenantId == getTenantId()"
+            @click="openForm('update', scope.row.id, queryParams.ccCourseId, queryParams.courseName)"
+            v-hasPermi="['aiCourse:course-config:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            v-if="scope.row.tenantId == getTenantId()"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['aiCourse:course-config:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <AiCourseConfigForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import download from '@/utils/download'
+import { useRoute } from 'vue-router'
+import { AiCourseConfigApi, CourseConfigVO } from '@/api/aiCourse/aiCourseConfig'
+import AiCourseConfigForm from './AiCourseConfigForm.vue'
+import { getTenantId } from '@/utils/auth'
+import { ElButton } from 'element-plus'
+
+/** 课程配置 列表 */
+defineOptions({ name: 'AiCourseConfig' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<CourseConfigVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  ccCourseId: undefined,
+  courseName: undefined,
+  ccTime: undefined,
+  ccQuestId: undefined,
+  questContent: undefined,
+  ccAnswerJudge: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+const route = useRoute()
+const tenantId = ref()
+
+/** 格式化时长为中文格式 */
+const formatDuration = (seconds: number) => {
+  if (!seconds || seconds < 0) {
+    return '0秒'
+  }
+  const hours = Math.floor(seconds / 3600)
+  const minutes = Math.floor((seconds % 3600) / 60)
+  const secs = seconds % 60
+  
+  let result = ''
+  if (hours > 0) {
+    result += `${hours}小时`
+  }
+  if (minutes > 0) {
+    result += `${minutes}分钟`
+  }
+  if (secs > 0 || result === '') {
+    result += `${secs}秒`
+  }
+  return result
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    queryParams.ccCourseId = route.query.acId
+    queryParams.courseName = route.query.acName
+    tenantId.value = route.query.tenantId
+
+    console.log(queryParams)
+    const data = await AiCourseConfigApi.getCourseConfigPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number, courseId?: number, courseName?: string) => {
+  console.log(type, id, courseId, courseName)
+  formRef.value.open(type, id, courseId, courseName)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await AiCourseConfigApi.deleteCourseConfig(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await AiCourseConfigApi.exportCourseConfig(queryParams)
+    download.excel(data, '课程配置.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 198 - 0
src/views/aiCourse/aiCourseType/AiCourseTypeForm.vue

@@ -0,0 +1,198 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="父级类型" prop="ctParentId">
+        <el-tree-select
+          :disabled="formType === 'update'"
+          v-model="formData.ctParentId"
+          :data="blocklyTypeTree"
+          :props="{...defaultProps,
+            label: (node) => `${node.ctType}${node.ctTypeNode === '0' ? '(主题)' : '(类型)'}`,
+            disabled: (node) => node.id !== 0 && node.ctTypeNode !== '0'
+            }"
+          :default-expanded-keys="[0]"
+          check-strictly
+          default-expand-all
+          placeholder="请选择课程父级类型"
+          @change="handleParentChange"
+        />
+      </el-form-item>
+      <el-form-item label="课程类型节点" prop="ctTypeNode">
+        <el-segmented 
+          :disabled="formType === 'update'"
+          v-model="formData.ctTypeNode"
+          :options="segmentedOptions"
+        />
+      </el-form-item>
+      <el-form-item label="课程类型名称" prop="ctType">
+        <el-input v-model="formData.ctType" placeholder="请输入课程类型名称" />
+      </el-form-item>
+      <el-form-item label="课程封面" prop="ctTypeImage">
+        <UploadImg v-model="formData.ctTypeImage" />
+      </el-form-item>
+      <el-form-item label="课程类型描述" prop="ctTypeDescribe">
+        <el-input
+          type="textarea"
+          v-model="formData.ctTypeDescribe"
+          placeholder="请输入课程类型描述"
+        />
+      </el-form-item>
+      <el-form-item label="课程类型排序" prop="ctTypeSort">
+        <el-input-number v-model="formData.ctTypeSort" :step="1" step-strictly placeholder="请输入课程类型排序"
+                 class="!w-100%"/>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { BlocklyTypeApi, BlocklyTypeVO } from '@/api/blockly/blocklyType'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+/** 课程-类型 表单 */
+defineOptions({ name: 'BlocklyTypeForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  ctType: undefined,
+  ctTypeImage: undefined,
+  ctTypeNode: 0,
+  ctTypeSort: undefined,
+  ctParentId: 0,
+  ctTypeDescribe: undefined,
+  tenantId: undefined,
+})
+const formRules = reactive({
+  ctParentId: [{ required: true, message: '父级节点不能为空', trigger: 'blur' }],
+  ctTypeNode: [{ required: true, message: '节点类型不能为空', trigger: 'blur' }],
+  ctType: [{ required: true, message: '类型名称不能为空', trigger: 'blur' }],
+  ctTypeSort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
+})
+const formRef = ref() // 表单 Ref
+const blocklyTypeTree = ref() // 树形结构
+
+// 计算属性:判断父级ID是否为根节点(0)
+const isRootParent = computed(() => {
+  return formData.value.ctParentId === 0 || formData.value.ctParentId === '0'
+})
+
+// 分段控件选项(根据父级ID动态配置禁用状态)
+const segmentedOptions = computed(() => {
+  return [
+    { label: '主题', value: '0', disabled: !isRootParent.value },  // 非根节点时禁用主题
+    { label: '类型', value: '1', disabled: isRootParent.value },   // 根节点时禁用类型
+  ]
+})
+
+// 处理父级类型变更
+const handleParentChange = () => {
+  if (isRootParent.value) {
+    // 当父级是根节点时,强制设置为主题类型
+    formData.value.ctTypeNode = '0'
+  } else {
+    // 当父级不是根节点时,如果当前类型是主题,则改为类型
+    if (formData.value.ctTypeNode === '0' || formData.value.ctTypeNode === 0) {
+      formData.value.ctTypeNode = '1'
+    }
+  }
+}
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number, parentId?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  if (parentId) {
+    formData.value.ctParentId = parentId
+    // 根据父级ID设置默认的类型节点
+    if (parentId !== 0 && parentId !== '0') {
+      formData.value.ctTypeNode = '1' // 非根节点时默认类型
+    } else {
+      formData.value.ctTypeNode = '0' // 根节点时默认主题
+    }
+  }
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await BlocklyTypeApi.getBlocklyType(id)
+      // 确保类型与父级ID匹配
+      if (isRootParent.value && formData.value.ctTypeNode !== '0') {
+        formData.value.ctTypeNode = '0'
+      } else if (!isRootParent.value && formData.value.ctTypeNode === '0') {
+        formData.value.ctTypeNode = '1'
+      }
+    } finally {
+      formLoading.value = false
+    }
+  }
+  await getBlocklyTypeTree()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as BlocklyTypeVO
+    if (formType.value === 'create') {
+      await BlocklyTypeApi.createBlocklyType(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await BlocklyTypeApi.updateBlocklyType(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    ctType: undefined,
+    ctTypeNode: 0,
+    ctTypeSort: undefined,
+    ctParentId: 0,
+    ctTypeDescribe: undefined,
+    tenantId: undefined,
+  }
+  formRef.value?.resetFields()
+}
+
+/** 获得课程-类型树 */
+const getBlocklyTypeTree = async () => {
+  blocklyTypeTree.value = []
+  const data = await BlocklyTypeApi.getBlocklyTypeSimpleList()
+
+  const filteredData = data.filter(item => item.ctTypeNode === '0')
+  const root: Tree = { id: 0, ctType: '课程类型', children: [] }
+  root.children = handleTree(filteredData, 'id', 'ctParentId')
+  blocklyTypeTree.value.push(root)
+}
+</script>

+ 234 - 0
src/views/aiCourse/aiCourseType/index.vue

@@ -0,0 +1,234 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="100px"
+    >
+      <el-form-item label="课程类型名称" prop="ctType">
+        <el-input
+          v-model="queryParams.ctType"
+          placeholder="请输入课程类型名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="课程类型节点" prop="ctTypeNode">
+        <el-select
+          v-model="queryParams.ctTypeNode"
+          placeholder="请选择类型节点类型"
+          clearable
+          class="!w-240px"
+          @change="handleQuery"
+        >
+          <el-option label="主题" value="0" />
+          <el-option label="类型" value="1" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="课程类型描述" prop="ctTypeDescribe">
+        <el-input
+          v-model="queryParams.ctTypeDescribe"
+          placeholder="请输入课程类型描述"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['aiCourse:course-type:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['aiCourse:course-type:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+        <el-button type="danger" plain @click="toggleExpandAll">
+          <Icon icon="ep:sort" class="mr-5px" /> 展开/折叠
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+      row-key="id"
+      :default-expand-all="isExpandAll"
+      v-if="refreshTable"
+    >
+      <el-table-column label="课程类型名称" align="center" prop="ctType" />
+      <el-table-column label="节点类型" align="center" prop="ctTypeNode" >
+        <template #default="scope">
+          <el-tag type="info" v-if="scope.row.ctTypeNode === '0'">主题</el-tag>
+          <el-tag type="warning" v-else>类型</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="封面" align="center" prop="ctTypeImage" >
+        <template #default="scope">
+          <img :src="scope.row.ctTypeImage" class="w-150px"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="课程类型描述" align="center" prop="ctTypeDescribe" />
+      <el-table-column label="排序" align="center" prop="ctTypeSort" />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            v-if="scope.row.tenantId == getTenantId()"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['aiCourse:course-type:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            v-if="scope.row.tenantId == getTenantId() && scope.row.ctTypeNode == '0'"
+            @click="openForm('create', undefined, scope.row.id)"
+            v-hasPermi="['aiCourse:course-type:create']"
+          >
+            新增
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            v-if="scope.row.tenantId == getTenantId()"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['aiCourse:course-type:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <AiCourseTypeForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { handleTree } from '@/utils/tree'
+import download from '@/utils/download'
+import { AiCourseTypeApi, AiCourseTypeVO } from '@/api/aiCourse/aiCourseType'
+import AiCourseTypeForm from './AiCourseTypeForm.vue'
+import { ElButton } from 'element-plus'
+import { getTenantId } from '@/utils/auth'
+
+/** 课程-类型 列表 */
+defineOptions({ name: 'AiCourseType' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<AiCourseTypeVO[]>([]) // 列表的数据
+const queryParams = reactive({
+  ctType: undefined,
+  ctTypeNode: undefined,
+  ctParentId: undefined,
+  ctTypeDescribe: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await AiCourseTypeApi.getAiCourseTypeList(queryParams)
+    list.value = handleTree(data, 'id', 'ctParentId')
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number, parentId?: number) => {
+  formRef.value.open(type, id, parentId)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await AiCourseTypeApi.deleteAiCourseType(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await AiCourseTypeApi.exportAiCourseType(queryParams)
+    download.excel(data, '课程-类型.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 展开/折叠操作 */
+const isExpandAll = ref(true) // 是否展开,默认全部展开
+const refreshTable = ref(true) // 重新渲染表格状态
+const toggleExpandAll = async () => {
+  refreshTable.value = false
+  isExpandAll.value = !isExpandAll.value
+  await nextTick()
+  refreshTable.value = true
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 0 - 1
src/views/blockly/blocklyType/BlocklyTypeForm.vue

@@ -134,7 +134,6 @@ const open = async (type: string, id?: number, parentId?: number) => {
     try {
       formData.value = await BlocklyTypeApi.getBlocklyType(id)
       // 确保类型与父级ID匹配
-      debugger
       if (isRootParent.value && formData.value.ctTypeNode !== '0') {
         formData.value.ctTypeNode = '0'
       } else if (!isRootParent.value && formData.value.ctTypeNode === '0') {

+ 257 - 0
src/views/system/role/RoleAiCoursePermissionForm.vue

@@ -0,0 +1,257 @@
+<template>
+  <!-- 增加弹窗宽度从800px到1200px -->
+  <Dialog v-model="dialogVisible" title="课程数据权限" width="800">
+    <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="120px">
+      <!-- 使用el-row和el-col让角色名称和角色标识在一排显示 -->
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="角色名称">
+            <el-tag>{{ formData.name }}</el-tag>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="角色标识">
+            <el-tag>{{ formData.code }}</el-tag>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-divider content-position="left">AI实验课数据权限</el-divider>
+      <br/>
+
+      <!-- AI实验课权限 -->
+      <el-form-item label="AI实验课权限">
+        <el-card class="w-full h-300px !overflow-y-scroll" shadow="never">
+          <template #header>
+            全选/全不选:
+            <el-switch
+              v-model="generalTreeNodeAll"
+              active-text="是"
+              inactive-text="否"
+              inline-prompt
+              @change="handleCheckedGeneralTreeNodeAll()"
+            />
+            全部展开/折叠:
+            <el-switch
+              v-model="generalDeptExpand"
+              active-text="展开"
+              inactive-text="折叠"
+              inline-prompt
+              @change="handleCheckedGeneralTreeExpand"
+            />
+            父子联动:
+            <el-switch v-model="generalCheckStrictly" active-text="是" inactive-text="否" inline-prompt />
+          </template>
+          <el-tree
+            ref="generalTreeRef"
+            :check-strictly="!generalCheckStrictly"
+            :data="generalAiCourseOptions"
+            :props="defaultProps"
+            default-expand-all
+            empty-text="加载中,请稍后"
+            node-key="id"
+            show-checkbox
+          />
+        </el-card>
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { defaultProps } from '@/utils/tree'
+import * as RoleApi from '@/api/system/role'
+import { AiCourseTypeApi } from '@/api/aiCourse/aiCourseType'
+import * as PermissionApi from '@/api/system/permission'
+
+defineOptions({ name: 'SystemRoleAiCoursePermissionForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = reactive({
+  id: undefined,
+  name: '',
+  code: '',
+  dataScopeAiCourseIds: []
+})
+const formRef = ref() // 表单 Ref
+
+// AI实验课相关变量
+const generalAiCourseOptions = ref<any[]>([]) // AI实验课树形结构(仅包含AI实验课)
+const generalDeptExpand = ref(true) // AI实验课展开/折叠
+const generalTreeRef = ref() // AI实验课菜单树组件 Ref
+const generalTreeNodeAll = ref(false) // AI实验课全选/全不选
+const generalCheckStrictly = ref(true) // AI实验课是否严格模式
+
+/** 打开弹窗 */
+const open = async (row: RoleApi.RoleVO) => {
+  dialogVisible.value = true
+  resetForm()
+
+  // 加载课程列表
+  const allCourses = await AiCourseTypeApi.getAiCourseTypeTree()
+
+  // 确保AI实验课只有子节点
+  generalAiCourseOptions.value = filterCoursesByType(allCourses, "aiCourseType")
+
+  // 设置数据
+  formData.id = row.id
+  formData.name = row.name
+  formData.code = row.code
+  await nextTick()
+
+  // 需要在 DOM 渲染完成后,再设置选中状态
+  if (row.dataScopeAiCourseIds && row.dataScopeAiCourseIds.length > 0) {
+    row.dataScopeAiCourseIds.forEach((courseId: number): void => {
+      // 尝试在两个树中都设置选中状态,实际只会在对应树中找到匹配的节点
+      generalTreeRef.value?.setChecked(courseId, true, false)
+    })
+  }
+}
+
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/**
+ * 根据课程类型筛选课程,并保留完整的树结构
+ * @param courses 原始课程树数据
+ * @param type 课程类型
+ * @returns 筛选后的课程树数据
+ */
+function filterCoursesByType(courses: any[], type: string): any[] {
+  // 深拷贝原始数据,避免修改原始数组
+  const filteredCourses = JSON.parse(JSON.stringify(courses))
+
+  // 递归处理树结构,只保留指定类型的节点及其父节点
+  function filterTreeNodes(nodes: any[]): any[] {
+    return nodes
+      .map(node => {
+        // 复制当前节点
+        const newNode = { ...node }
+
+        // 递归处理子节点
+        if (node.children && node.children.length > 0) {
+          const filteredChildren = filterTreeNodes(node.children)
+          if (filteredChildren.length > 0) {
+            newNode.children = filteredChildren
+            return newNode
+          }
+        }
+
+        // 如果当前节点是指定类型,或者有符合条件的子节点,则保留
+        if (node.type === type) {
+          return newNode
+        }
+
+        return null
+      })
+      .filter(node => node !== null)
+  }
+
+  return filterTreeNodes(filteredCourses)
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  formLoading.value = true
+  try {
+    // 获取所有选中的节点
+    const generalCheckedKeys = generalTreeRef.value?.getCheckedKeys(false) || []
+    const allCheckedKeys = [...generalCheckedKeys]
+
+    // 过滤出只包含子节点的ID
+    const allAiCourseOptions = [...generalAiCourseOptions.value]
+    const childNodeKeys = filterChildNodeKeys(allCheckedKeys, allAiCourseOptions)
+
+    const data = {
+      roleId: formData.id,
+      dataScopeAiCourseIds: childNodeKeys
+    }
+
+    console.log('所有选中的节点:', allCheckedKeys)
+    console.log('只提交子节点:', childNodeKeys)
+    await PermissionApi.assignRoleAiCourseScope(data)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/**
+ * 过滤只包含子节点的ID
+ * @param checkedKeys 所有选中的节点ID
+ * @param treeData 树结构数据
+ * @returns 只包含子节点的ID数组
+ */
+function filterChildNodeKeys(checkedKeys: number[], treeData: any[]): number[] {
+  // 创建一个Map存储所有节点ID及其是否为叶子节点的状态
+  const nodeMap = new Map<number, boolean>()
+
+  // 递归遍历树结构,标记每个节点是否为叶子节点
+  function traverseTree(nodes: any[]) {
+    nodes.forEach(node => {
+
+      nodeMap.set(node.id, node.type === "aiCourseType")
+
+      // 递归处理子节点
+      if (node.children && node.children.length > 0) {
+        traverseTree(node.children)
+      }
+    })
+  }
+
+  // 执行遍历
+  traverseTree(treeData)
+
+  // 过滤出只包含叶子节点的ID
+  return checkedKeys.filter(key => {
+    // 如果节点存在于nodeMap中且是叶子节点,则保留
+    return nodeMap.has(key) && nodeMap.get(key)
+  })
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  // 重置AI实验课选项
+  generalTreeNodeAll.value = false
+  generalDeptExpand.value = true
+  generalCheckStrictly.value = true
+  generalTreeRef.value?.setCheckedNodes([])
+
+  // 重置表单
+  formData.id = undefined
+  formData.name = ''
+  formData.code = ''
+  formData.dataScopeAiCourseIds = []
+
+  formRef.value?.resetFields()
+}
+
+/** AI实验课全选/全不选 */
+const handleCheckedGeneralTreeNodeAll = () => {
+  generalTreeRef.value?.setCheckedNodes(generalTreeNodeAll.value ? generalAiCourseOptions.value : [])
+}
+
+/** AI实验课展开/折叠全部 */
+const handleCheckedGeneralTreeExpand = () => {
+  const nodes = generalTreeRef.value?.store.nodesMap
+  if (nodes) {
+    for (let node in nodes) {
+      if (nodes[node].expanded === generalDeptExpand.value) {
+        continue
+      }
+      nodes[node].expanded = generalDeptExpand.value
+    }
+  }
+}
+</script>

+ 19 - 0
src/views/system/role/index.vue

@@ -155,6 +155,16 @@
           >
             blockly权限
           </el-button>
+          <el-button
+            v-hasPermi="['system:permission:assign-role-data-scope']"
+            link
+            preIcon="ep:coin"
+            title="AI实验课权限"
+            type="primary"
+            @click="openAiCoursePermissionForm(scope.row)"
+          >
+            AI实验课权限
+          </el-button>
           <el-button
             v-hasPermi="['system:role:delete']"
             link
@@ -185,6 +195,8 @@
   <RoleCoursePermissionForm ref="coursePermissionFormRef" @success="getList" />
   <!-- 表单弹窗:blockly课程权限 -->
   <RoleBlocklyPermissionForm ref="blocklyPermissionFormRef" @success="getList" />
+  <!-- 表单弹窗:AI实验课权限 -->
+  <RoleAiCoursePermissionForm ref="aiCoursePermissionFormRef" @success="getList" />
 </template>
 <script lang="ts" setup>
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
@@ -196,6 +208,7 @@ import RoleAssignMenuForm from './RoleAssignMenuForm.vue'
 import RoleDataPermissionForm from './RoleDataPermissionForm.vue'
 import RoleCoursePermissionForm from './RoleCoursePermissionForm.vue'
 import RoleBlocklyPermissionForm from './RoleBlocklyPermissionForm.vue'
+import RoleAiCoursePermissionForm from './RoleAiCoursePermissionForm.vue'
 
 
 defineOptions({ name: 'SystemRole' })
@@ -259,6 +272,12 @@ const openCoursePermissionForm = async (row: RoleApi.RoleVO) => {
   coursePermissionFormRef.value.open(row)
 }
 
+/** AI实验课权限操作 */
+const aiCoursePermissionFormRef = ref()
+const openAiCoursePermissionForm = async (row: RoleApi.RoleVO) => {
+  aiCoursePermissionFormRef.value.open(row)
+}
+
 /** blockly权限操作 */
 const blocklyPermissionFormRef = ref()
 const openBlocklyPermissionForm = async (row: RoleApi.RoleVO) => {