Gqingci 6 днів тому
батько
коміт
9042a60fac

+ 1 - 0
package.json

@@ -30,6 +30,7 @@
     "axios": "1.13.1",
     "crypto-js": "4.2.0",
     "echarts": "5.6.0",
+    "element-china-area-data": "^5.0.0",
     "element-plus": "2.11.7",
     "file-saver": "2.0.5",
     "highlight.js": "11.11.1",

+ 180 - 0
src/api/main/training/index.ts

@@ -0,0 +1,180 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { TrainingVO, TrainingQuery, TrainingForm, TrainingUploadResult, TrainingParticipantVO, TrainingLearnRecordVO } from './types';
+
+/**
+ * 查询培训列表
+ * @param query
+ * @returns {*}
+ */
+export const listTraining = (query: TrainingQuery): AxiosPromise<TrainingVO[]> => {
+  return request({
+    url: '/main/training/list',
+    method: 'get',
+    params: query
+  });
+};
+
+/**
+ * 查询线下培训列表
+ * @param query
+ * @returns {*}
+ */
+export const listOfflineTraining = (query: TrainingQuery): AxiosPromise<TrainingVO[]> => {
+  return request({
+    url: '/main/training/offline/list',
+    method: 'get',
+    params: query
+  });
+};
+
+/**
+ * 查询培训详细
+ * @param id
+ * @returns {*}
+ */
+export const getTraining = (id: string | number): AxiosPromise<TrainingVO> => {
+  return request({
+    url: '/main/training/' + id,
+    method: 'get'
+  });
+};
+
+/**
+ * 查询线下培训详细
+ * @param id
+ * @returns {*}
+ */
+export const getOfflineTraining = (id: string | number): AxiosPromise<TrainingVO> => {
+  return request({
+    url: '/main/training/offline/' + id,
+    method: 'get'
+  });
+};
+
+export const listTrainingLearnRecords = (id: string | number, query: PageQuery): AxiosPromise<TrainingLearnRecordVO[]> => {
+  return request({
+    url: '/main/training/' + id + '/learn-records',
+    method: 'get',
+    params: query
+  });
+};
+
+export const listOfflineTrainingParticipants = (id: string | number, query: PageQuery): AxiosPromise<TrainingParticipantVO[]> => {
+  return request({
+    url: '/main/training/offline/' + id + '/participants',
+    method: 'get',
+    params: query
+  });
+};
+
+export const exportTrainingLearnRecords = (id: string | number, studentIds: Array<string | number>) => {
+  return request({
+    url: '/main/training/' + id + '/learn-records/export',
+    method: 'post',
+    data: studentIds,
+    responseType: 'blob'
+  });
+};
+
+export const exportOfflineTrainingParticipants = (id: string | number, studentIds: Array<string | number>) => {
+  return request({
+    url: '/main/training/offline/' + id + '/participants/export',
+    method: 'post',
+    data: studentIds,
+    responseType: 'blob'
+  });
+};
+
+/**
+ * 新增培训
+ * @param data
+ * @returns {*}
+ */
+export const addTraining = (data: TrainingForm) => {
+  return request({
+    url: '/main/training',
+    method: 'post',
+    data: data
+  });
+};
+
+/**
+ * 培训文件上传
+ * @param file
+ * @returns {*}
+ */
+export const uploadTrainingFile = (file: File): AxiosPromise<TrainingUploadResult> => {
+  const formData = new FormData();
+  formData.append('file', file);
+  return request({
+    url: '/main/training/upload',
+    method: 'post',
+    data: formData
+  });
+};
+
+/**
+ * 修改培训
+ * @param data
+ * @returns {*}
+ */
+export const updateTraining = (data: TrainingForm) => {
+  return request({
+    url: '/main/training',
+    method: 'put',
+    data: data
+  });
+};
+
+/**
+ * 删除培训
+ * @param id
+ * @returns {*}
+ */
+export const delTraining = (id: string | number | Array<string | number>) => {
+  return request({
+    url: '/main/training/' + id,
+    method: 'delete'
+  });
+};
+
+/**
+ * 删除线下培训
+ * @param id
+ * @returns {*}
+ */
+export const delOfflineTraining = (id: string | number) => {
+  return request({
+    url: '/main/training/offline/' + id,
+    method: 'delete'
+  });
+};
+
+/**
+ * 更新培训状态(上架/下架)
+ * @param id
+ * @param status
+ * @returns {*}
+ */
+export const updateTrainingStatus = (id: string | number, status: number) => {
+  return request({
+    url: '/main/training/updateStatus',
+    method: 'put',
+    data: { id, status }
+  });
+};
+
+/**
+ * 更新线下培训状态(上架/下架)
+ * @param id
+ * @param status
+ * @returns {*}
+ */
+export const updateOfflineTrainingStatus = (id: string | number, status: number) => {
+  return request({
+    url: '/main/training/offline/updateStatus',
+    method: 'put',
+    data: { id, status }
+  });
+};

+ 104 - 0
src/api/main/training/types.ts

@@ -0,0 +1,104 @@
+export interface TrainingVideoVO {
+  id?: string | number;
+  trainingId?: string | number;
+  name?: string;
+  ossId?: string | number;
+  fileUrl?: string;
+  fileSize?: string;
+  fileType?: string;
+  duration?: string;
+  sortOrder?: number;
+}
+
+export interface TrainingUploadResult {
+  ossId?: string | number;
+  fileName?: string;
+  url?: string;
+  originalName?: string;
+}
+
+export interface TrainingParticipantVO {
+  studentId?: string | number;
+  name?: string;
+  mobile?: string;
+  avatar?: string | number;
+  avatarUrl?: string;
+  enrollTime?: string;
+  checkInTime?: string;
+}
+
+export interface TrainingLearnRecordVO {
+  studentId?: string | number;
+  name?: string;
+  mobile?: string;
+  avatar?: string | number;
+  avatarUrl?: string;
+  learnedTime?: number;
+  remainingTime?: number;
+  progress?: number;
+  finishTime?: string;
+  lastLearnTime?: string;
+}
+
+export interface TrainingVO {
+  id: string | number;
+  trainingType: string;
+  name: string;
+  description?: string;
+  thumbnail?: string | number;
+  thumbnailUrl?: string;
+  jobType?: string;
+  jobLevel?: string;
+  job?: string;
+  sortOrder?: number;
+  status: number;
+  duration?: string;
+  publishTime?: string;
+  city?: string;
+  area?: string;
+  addressDetail?: string;
+  trainingStartTime?: string;
+  trainingEndTime?: string;
+  applyStartTime?: string;
+  applyEndTime?: string;
+  organizer?: string;
+  tags?: string;
+  remark?: string;
+  createTime?: string;
+  learnCount?: number;
+  participantCount?: number;
+  videoList?: TrainingVideoVO[];
+}
+
+export interface TrainingQuery extends PageQuery {
+  trainingType?: string;
+  name?: string;
+  status?: number;
+  jobType?: string;
+  jobLevel?: string;
+}
+
+export interface TrainingForm {
+  id?: string | number;
+  trainingType?: string;
+  name?: string;
+  description?: string;
+  thumbnail?: string | number;
+  jobType?: string;
+  jobLevel?: string;
+  job?: string;
+  sortOrder?: number;
+  status?: number;
+  duration?: string;
+  city?: string;
+  area?: string;
+  addressDetail?: string;
+  trainingStartTime?: string;
+  trainingEndTime?: string;
+  applyStartTime?: string;
+  applyEndTime?: string;
+  organizer?: string;
+  tags?: string;
+  remark?: string;
+  videoList?: TrainingVideoVO[];
+}

+ 372 - 66
src/views/system/training/TrainForm.vue

@@ -1,10 +1,10 @@
 <template>
   <div class="train-form bg-white p-6 rounded h-full overflow-y-auto">
     <div class="form-breadcrumb mb-4 text-gray-400 text-xs">
-      课程管理 / 培训课程 / {{ initialData ? '编辑' : '新增' }}{{ type === 'video' ? '视频' : '线下' }}培训
+      课程管理 / 培训课程 / {{ initialData ? '编辑' : '新增' }}{{ type === 'video' ? '视频' : type === 'live' ? '直播' : '线下' }}培训
     </div>
     <div class="form-header mb-8">
-      <div class="text-xl font-bold">{{ initialData ? '编辑' : '新增' }}{{ type === 'video' ? '视频培训' : '线下培训' }}</div>
+      <div class="text-xl font-bold">{{ initialData ? '编辑' : '新增' }}{{ type === 'video' ? '视频培训' : type === 'live' ? '直播培训' : '线下培训' }}</div>
     </div>
 
     <div class="form-wrapper border-2 border-blue-500 rounded-lg p-10 bg-white relative">
@@ -23,13 +23,23 @@
         <template v-if="type === 'video'">
           <el-form-item label="培训封面" prop="thumbnail" required>
             <div class="thumbnail-uploader">
+              <input
+                ref="thumbnailInputRef"
+                type="file"
+                accept=".jpg,.jpeg,.png"
+                class="hidden"
+                @change="handleThumbnailFileChange"
+              />
               <el-upload
+                ref="thumbnailUploadRef"
                 class="avatar-uploader"
                 action="#"
                 :auto-upload="false"
                 :show-file-list="false"
+                accept=".jpg,.jpeg,.png"
+                @click="handleUploadThumbnail"
               >
-                <img v-if="form.thumbnail" :src="form.thumbnail" class="avatar-preview" />
+                <img v-if="form.thumbnailUrl" :src="form.thumbnailUrl" class="avatar-preview" />
                 <el-icon v-else class="avatar-uploader-icon"><i-ep-plus /></el-icon>
               </el-upload>
               <div class="upload-hint text-gray-400 text-xs mt-2">
@@ -49,7 +59,14 @@
           </el-form-item>
 
           <el-form-item label="课程类型" prop="videoList">
-            <el-button type="primary" size="small" @click="handleUploadVideo">
+            <input
+              ref="videoInputRef"
+              type="file"
+              accept=".mp4,.avi,.wmv,.mov,.flv,.rmvb,.3gp,.m4v,.mkv"
+              class="hidden"
+              @change="handleVideoFileChange"
+            />
+            <el-button type="primary" size="small" :loading="videoUploading" @click="handleUploadVideo">
               <el-icon class="mr-1"><i-ep-plus /></el-icon>
               上传视频
             </el-button>
@@ -65,8 +82,8 @@
                   </div>
                 </template>
               </el-table-column>
-              <el-table-column label="视频大小" prop="size" width="100" />
-              <el-table-column label="视频类型" prop="type" width="80" />
+              <el-table-column label="视频大小" prop="fileSize" width="100" />
+              <el-table-column label="视频类型" prop="fileType" width="80" />
               <el-table-column label="视频时长" prop="duration" width="100" />
               <el-table-column label="操作" width="80" align="center">
                 <template #default="scope">
@@ -91,26 +108,35 @@
 
         <div class="flex flex-wrap items-center">
           <el-form-item label="岗位级别" prop="jobLevel" required>
-            <el-select v-model="form.jobLevel" multiple collapse-tags placeholder="请选择" style="width: 180px">
-              <el-option label="A1" value="A1" />
-              <el-option label="A2" value="A2" />
-              <el-option label="B1" value="B1" />
-              <el-option label="B2" value="B2" />
+            <el-select v-model="form.jobLevel" placeholder="请选择" style="width: 180px">
+              <el-option
+                v-for="item in jobLevelOptions"
+                :key="item.value"
+                :label="item.label"
+                :value="item.value"
+              />
             </el-select>
           </el-form-item>
           <el-form-item label="岗位" prop="job" class="ml-4">
-            <el-select v-model="form.job" placeholder="审计" style="width: 180px">
-              <el-option label="审计" value="审计" />
-              <el-option label="会计" value="会计" />
+            <el-select v-model="form.job" placeholder="请选择" style="width: 180px" filterable>
+              <el-option
+                v-for="item in jobOptions"
+                :key="item.value"
+                :label="item.label"
+                :value="item.value"
+              />
             </el-select>
           </el-form-item>
         </div>
 
         <el-form-item label="岗位类型" prop="jobType" required>
           <el-select v-model="form.jobType" placeholder="全职" style="width: 180px">
-            <el-option label="全职" value="全职" />
-            <el-option label="兼职" value="兼职" />
-            <el-option label="实习" value="实习" />
+            <el-option
+              v-for="item in jobTypeOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
           </el-select>
         </el-form-item>
 
@@ -121,16 +147,18 @@
         </template>
 
         <template v-if="type === 'offline'">
-          <el-form-item label="培训地址" prop="address" required>
-            <div class="flex gap-2 mb-2">
-              <el-select v-model="form.city" placeholder="上海市" style="width: 120px">
-                <el-option label="上海市" value="shanghai" />
-              </el-select>
-              <el-select v-model="form.area" placeholder="黄浦区" style="width: 120px">
-                <el-option label="黄浦区" value="huangpu" />
-              </el-select>
+          <el-form-item label="培训地址" prop="addressDetail" required>
+            <div class="address-container">
+              <el-cascader
+                v-model="form.region"
+                :options="regionOptions"
+                :props="regionProps"
+                clearable
+                placeholder="省 / 市 / 区"
+                class="region-cascader"
+              />
+              <el-input v-model="form.addressDetail" placeholder="请输入具体地址" class="address-detail-input" />
             </div>
-            <el-input v-model="form.addressDetail" placeholder="请输入具体地址" style="width: 400px" />
           </el-form-item>
 
           <el-form-item label="培训时间" prop="timeRange" required>
@@ -164,7 +192,7 @@
           <el-form-item label="标签" prop="tags">
             <div class="flex items-center gap-2">
               <el-tag
-                v-for="tag in form.tags"
+                v-for="tag in tagList"
                 :key="tag"
                 closable
                 @close="handleCloseTag(tag)"
@@ -198,8 +226,13 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, nextTick } from 'vue';
-import type { FormInstance, FormRules } from 'element-plus';
+import { ref, reactive, computed, onMounted, nextTick } from 'vue';
+import { ElMessage } from 'element-plus';
+import { regionData } from 'element-china-area-data';
+import type { FormInstance, FormRules, UploadInstance } from 'element-plus';
+import { uploadTrainingFile } from '../../../api/main/training/index';
+import { listPosition } from '../../../api/main/position/index';
+import { getDicts } from '../../../api/system/dict/data/index';
 
 const props = defineProps<{
   initialData?: any;
@@ -210,70 +243,272 @@ const emit = defineEmits(['cancel', 'submit']);
 
 const formRef = ref<FormInstance>();
 const form = reactive({
-  id: '',
+  id: undefined as any,
   name: '',
-  thumbnail: '',
+  thumbnail: undefined as any,
+  thumbnailUrl: '',
   description: '',
   videoList: [] as any[],
-  jobLevel: [] as string[],
+  jobLevel: '',
   job: '',
   jobType: '',
   sortOrder: '',
-  city: 'shanghai',
-  area: 'huangpu',
+  city: '上海市',
+  area: '黄浦区',
+  region: ['上海市', '上海市', '黄浦区'] as string[],
   addressDetail: '',
-  timeRange: [],
-  applyRange: [],
+  timeRange: [] as string[],
+  applyRange: [] as string[],
   organizer: '',
-  tags: [] as string[],
-  status: true
+  tags: '',
+  status: 1
 });
 
-const regionOptions = [
-  {
-    value: 'shanghai',
-    label: '上海市',
-    children: [
-      { value: 'huangpu', label: '黄浦区' },
-      { value: 'xuhui', label: '徐汇区' }
-    ]
-  }
-];
+/** 将 element-china-area-data 的数字编码转为文字作为 value */
+const convertRegionToLabelValue = (data: any[]): any[] => {
+  return data.map(item => ({
+    value: item.label,
+    label: item.label,
+    children: item.children ? convertRegionToLabelValue(item.children) : undefined
+  }));
+};
+
+const regionOptions = convertRegionToLabelValue(regionData);
+
+const regionProps = {
+  checkStrictly: false,
+  emitPath: true
+};
+
+/** 标签列表(从逗号分隔字符串解析) */
+const tagList = computed({
+  get: () => form.tags ? form.tags.split(',').filter(Boolean) : [],
+  set: (val: string[]) => { form.tags = val.join(','); }
+});
 
 const inputTagVisible = ref(false);
 const inputTagValue = ref('');
 const inputTagRef = ref();
+const thumbnailInputRef = ref<HTMLInputElement>();
+const thumbnailUploadRef = ref<UploadInstance>();
+const videoInputRef = ref<HTMLInputElement>();
+const thumbnailUploading = ref(false);
+const videoUploading = ref(false);
+const jobLevelOptions = ref<{ label: string; value: string }[]>([]);
+const jobTypeOptions = ref<{ label: string; value: string }[]>([]);
+const jobOptions = ref<{ label: string; value: string }[]>([]);
 
 const rules = reactive<FormRules>({
   name: [{ required: true, message: '请输入培训名称', trigger: 'blur' }],
-  thumbnail: [{ required: true, message: '请上传培训封面', trigger: 'change' }],
   description: [{ required: true, message: '请输入描述', trigger: 'blur' }],
-  videoList: [{ required: true, type: 'array', min: 1, message: '请上传视频', trigger: 'change' }],
-  jobLevel: [{ required: true, type: 'array', min: 1, message: '请选择岗位级别', trigger: 'change' }],
+  jobLevel: [{ required: true, message: '请选择岗位级别', trigger: 'change' }],
   job: [{ required: true, message: '请选择岗位', trigger: 'change' }],
   jobType: [{ required: true, message: '请选择岗位类型', trigger: 'change' }],
-  address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
+  thumbnail: [{ required: true, message: '请上传培训封面', trigger: 'change' }],
+  videoList: [{ required: true, type: 'array', min: 1, message: '请至少上传一个视频', trigger: 'change' }],
+  addressDetail: [{ required: true, message: '请输入地址', trigger: 'blur' }],
   timeRange: [{ required: true, type: 'array', min: 2, message: '请选择培训时间', trigger: 'change' }],
   applyRange: [{ required: true, type: 'array', min: 2, message: '请选择报名时间', trigger: 'change' }]
 });
 
-onMounted(() => {
+const formatFileSize = (size: number) => {
+  if (size >= 1024 * 1024 * 1024) {
+    return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
+  }
+  if (size >= 1024 * 1024) {
+    return `${(size / 1024 / 1024).toFixed(1)}MB`;
+  }
+  if (size >= 1024) {
+    return `${(size / 1024).toFixed(1)}KB`;
+  }
+  return `${size}B`;
+};
+
+const formatDuration = (totalSeconds: number) => {
+  const hours = Math.floor(totalSeconds / 3600);
+  const minutes = Math.floor((totalSeconds % 3600) / 60);
+  const seconds = totalSeconds % 60;
+  return [hours, minutes, seconds].map((item) => item.toString().padStart(2, '0')).join(':');
+};
+
+const parseDuration = (duration?: string) => {
+  if (!duration) return 0;
+  const parts = duration.split(':').map((item) => Number(item));
+  if (parts.length === 3) {
+    return parts[0] * 3600 + parts[1] * 60 + parts[2];
+  }
+  if (parts.length === 2) {
+    return parts[0] * 60 + parts[1];
+  }
+  return parts[0] || 0;
+};
+
+const calcTotalDuration = (videoList: any[]) => {
+  const totalSeconds = videoList.reduce((sum, item) => sum + parseDuration(item.duration), 0);
+  return totalSeconds > 0 ? formatDuration(totalSeconds) : '';
+};
+
+const getVideoDuration = (file: File) => new Promise<string>((resolve) => {
+  const video = document.createElement('video');
+  const objectUrl = URL.createObjectURL(file);
+  video.preload = 'metadata';
+  video.onloadedmetadata = () => {
+    const duration = Number.isFinite(video.duration) ? Math.round(video.duration) : 0;
+    URL.revokeObjectURL(objectUrl);
+    resolve(formatDuration(duration));
+  };
+  video.onerror = () => {
+    URL.revokeObjectURL(objectUrl);
+    resolve('00:00:00');
+  };
+  video.src = objectUrl;
+});
+
+const loadSelectOptions = async () => {
+  const [jobLevelRes, jobTypeRes, positionRes] = await Promise.all([
+    getDicts('main_position_level'),
+    getDicts('main_position_type'),
+    listPosition({ pageNum: 1, pageSize: 999 })
+  ]);
+  jobLevelOptions.value = (jobLevelRes.data || []).map((item: any) => ({
+    label: item.dictLabel,
+    value: item.dictLabel
+  }));
+  jobTypeOptions.value = (jobTypeRes.data || []).map((item: any) => ({
+    label: item.dictLabel,
+    value: item.dictLabel
+  }));
+  jobOptions.value = (positionRes.rows || []).map((item: any) => ({
+    label: item.postName,
+    value: item.postName
+  }));
+};
+
+onMounted(async () => {
+  await loadSelectOptions();
   if (props.initialData) {
-    Object.assign(form, props.initialData);
-    if (props.type === 'video' && !props.initialData.videoList) {
-      form.videoList = [{ name: '西班牙系列课程1', size: '100.0MB', type: 'MP4', duration: '00:09:00' }];
+    // 编辑模式: 回显数据
+    form.id = props.initialData.id;
+    form.name = props.initialData.name || '';
+    form.thumbnail = props.initialData.thumbnail;
+    form.thumbnailUrl = props.initialData.thumbnailUrl || '';
+    form.description = props.initialData.description || '';
+    form.jobLevel = props.initialData.jobLevel || '';
+    form.job = props.initialData.job || '';
+    form.jobType = props.initialData.jobType || '';
+    form.sortOrder = props.initialData.sortOrder?.toString() || '';
+    form.status = props.initialData.status ?? 1;
+    form.organizer = props.initialData.organizer || '';
+    form.tags = props.initialData.tags || '';
+
+    // 视频列表
+    if (props.initialData.videoList && props.initialData.videoList.length > 0) {
+      form.videoList = props.initialData.videoList.map((v: any) => ({
+        id: v.id,
+        name: v.name,
+        fileSize: v.fileSize,
+        fileType: v.fileType,
+        duration: v.duration,
+        ossId: v.ossId,
+        fileUrl: v.fileUrl,
+        sortOrder: v.sortOrder
+      }));
+    }
+
+    // 线下培训地址
+    form.city = props.initialData.city || '上海市';
+    form.area = props.initialData.area || '黄浦区';
+    form.region = ['上海市', form.city || '上海市', form.area || '黄浦区'];
+    form.addressDetail = props.initialData.addressDetail || '';
+
+    // 时间范围
+    if (props.initialData.trainingStartTime && props.initialData.trainingEndTime) {
+      form.timeRange = [props.initialData.trainingStartTime, props.initialData.trainingEndTime];
     }
-  } else {
-    // Default values for new form
-    if (props.type === 'video') {
-      form.name = '西班牙语0-B2高级直达';
-      form.thumbnail = 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=320&h=160&fit=crop';
+    if (props.initialData.applyStartTime && props.initialData.applyEndTime) {
+      form.applyRange = [props.initialData.applyStartTime, props.initialData.applyEndTime];
     }
   }
 });
 
+const handleUploadThumbnail = () => {
+  thumbnailInputRef.value?.click();
+};
+
+const handleThumbnailFileChange = async (event: Event) => {
+  const target = event.target as HTMLInputElement;
+  const rawFile = target.files?.[0];
+  if (!rawFile) {
+    return;
+  }
+  if (!['image/jpeg', 'image/png'].includes(rawFile.type)) {
+    ElMessage.error('请上传 JPG、PNG 格式图片');
+    target.value = '';
+    return;
+  }
+  if (rawFile.size / 1024 / 1024 > 10) {
+    ElMessage.error('图片不能超过10M');
+    target.value = '';
+    return;
+  }
+  thumbnailUploading.value = true;
+  try {
+    const res = await uploadTrainingFile(rawFile);
+    form.thumbnail = res.data?.ossId;
+    form.thumbnailUrl = res.data?.url || URL.createObjectURL(rawFile);
+    formRef.value?.clearValidate('thumbnail');
+    ElMessage.success('封面上传成功');
+  } catch (error) {
+    ElMessage.error('封面上传失败');
+  } finally {
+    target.value = '';
+    thumbnailUploading.value = false;
+  }
+};
+
 const handleUploadVideo = () => {
-  form.videoList.push({ name: '新增视频' + (form.videoList.length + 1), size: '50.0MB', type: 'MP4', duration: '00:05:00' });
+  videoInputRef.value?.click();
+};
+
+const handleVideoFileChange = async (event: Event) => {
+  const target = event.target as HTMLInputElement;
+  const rawFile = target.files?.[0];
+  if (!rawFile) {
+    return;
+  }
+  const extension = rawFile.name.split('.').pop()?.toLowerCase() || '';
+  const allowTypes = ['mp4', 'avi', 'wmv', 'mov', 'flv', 'rmvb', '3gp', 'm4v', 'mkv'];
+  if (!allowTypes.includes(extension)) {
+    ElMessage.error('视频格式不支持');
+    target.value = '';
+    return;
+  }
+  if (rawFile.size / 1024 / 1024 / 1024 > 10) {
+    ElMessage.error('视频不能超过10G');
+    target.value = '';
+    return;
+  }
+  videoUploading.value = true;
+  try {
+    const duration = await getVideoDuration(rawFile);
+    const res = await uploadTrainingFile(rawFile);
+    form.videoList.push({
+      name: res.data?.originalName || res.data?.fileName || rawFile.name,
+      fileSize: formatFileSize(rawFile.size),
+      fileType: extension.toUpperCase(),
+      duration,
+      ossId: res.data?.ossId,
+      fileUrl: res.data?.url,
+      sortOrder: form.videoList.length + 1
+    });
+    formRef.value?.clearValidate('videoList');
+    ElMessage.success('视频上传成功');
+  } catch (error) {
+    ElMessage.error('视频上传失败');
+  } finally {
+    target.value = '';
+    videoUploading.value = false;
+  }
 };
 
 const removeVideo = (index: number) => {
@@ -281,7 +516,8 @@ const removeVideo = (index: number) => {
 };
 
 const handleCloseTag = (tag: string) => {
-  form.tags = form.tags.filter(t => t !== tag);
+  const list = tagList.value.filter(t => t !== tag);
+  tagList.value = list;
 };
 
 const showInputTag = () => {
@@ -293,7 +529,8 @@ const showInputTag = () => {
 
 const handleInputTagConfirm = () => {
   if (inputTagValue.value) {
-    form.tags.push(inputTagValue.value);
+    const list = [...tagList.value, inputTagValue.value];
+    tagList.value = list;
   }
   inputTagVisible.value = false;
   inputTagValue.value = '';
@@ -303,7 +540,59 @@ const handleSubmit = async () => {
   if (!formRef.value) return;
   await formRef.value.validate((valid) => {
     if (valid) {
-      emit('submit', { ...form });
+      if (props.type === 'offline') {
+        const [province, cityLevel, district] = form.region || [];
+        // 合并省+市到 city(直辖市跳过"市辖区")
+        const mergedCity = cityLevel && !cityLevel.includes('市辖区')
+          ? `${province}${cityLevel}`
+          : province;
+        form.city = mergedCity || '上海市';
+        form.area = district || '';
+      }
+      // 构造提交数据
+      const submitData: any = {
+        id: form.id,
+        name: form.name,
+        description: form.description,
+        thumbnail: form.thumbnail,
+        jobLevel: form.jobLevel,
+        job: form.job,
+        jobType: form.jobType,
+        status: form.status
+      };
+
+      if (props.type === 'video') {
+        submitData.sortOrder = form.sortOrder ? parseInt(form.sortOrder) : 0;
+        submitData.duration = calcTotalDuration(form.videoList);
+        submitData.videoList = form.videoList.map((v, i) => ({
+          id: v.id,
+          name: v.name,
+          ossId: v.ossId,
+          fileUrl: v.fileUrl,
+          fileSize: v.fileSize,
+          fileType: v.fileType,
+          duration: v.duration,
+          sortOrder: i + 1
+        }));
+      }
+
+      if (props.type === 'offline') {
+        submitData.city = form.city;
+        submitData.area = form.area;
+        submitData.addressDetail = form.addressDetail;
+        submitData.organizer = form.organizer;
+        submitData.tags = form.tags;
+        if (form.timeRange && form.timeRange.length === 2) {
+          submitData.trainingStartTime = form.timeRange[0];
+          submitData.trainingEndTime = form.timeRange[1];
+        }
+        if (form.applyRange && form.applyRange.length === 2) {
+          submitData.applyStartTime = form.applyRange[0];
+          submitData.applyEndTime = form.applyRange[1];
+        }
+      }
+
+      emit('submit', submitData);
     }
   });
 };
@@ -390,10 +679,27 @@ const handleSubmit = async () => {
     }
   }
 
+  .address-container {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    width: 100%;
+    max-width: 720px;
+
+    .region-cascader {
+      width: 240px;
+      flex-shrink: 0;
+    }
+
+    .address-detail-input {
+      width: 400px;
+      flex: 1;
+    }
+  }
+
   :deep(.el-form-item__label) {
     color: #4e5969;
     font-weight: 500;
   }
 }
 </style>
-

+ 165 - 97
src/views/system/training/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="app-container training-page">
-    <div v-if="!showForm" class="flex gap-4 h-full main-wrapper">
+    <div v-if="!showForm && !showLearnData" class="flex gap-4 h-full main-wrapper">
       <!-- 左侧分类侧边栏 -->
       <div class="category-sidebar bg-white rounded p-4 flex flex-col shrink-0">
         <div class="sidebar-header mb-4">
@@ -11,7 +11,7 @@
             v-for="item in categories"
             :key="item.id"
             :class="['category-item', activeCategory === item.id ? 'active' : '']"
-            @click="activeCategory = item.id"
+            @click="switchCategory(item.id)"
           >
             <div class="flex items-center gap-2 overflow-hidden">
               <el-icon v-if="item.id === 'video'"><i-ep-video-play /></el-icon>
@@ -27,7 +27,7 @@
       <div class="flex-1 bg-white rounded p-4 flex flex-col overflow-hidden content-area">
         <div class="header-bar flex justify-between items-center mb-4">
           <el-input
-            v-model="queryParams.keyword"
+            v-model="queryParams.name"
             placeholder="请输入"
             style="width: 320px"
             clearable
@@ -48,7 +48,7 @@
         <el-table
           v-if="activeCategory === 'video'"
           v-loading="loading"
-          :data="videoList"
+          :data="tableData"
           style="width: 100%"
           header-cell-class-name="custom-header"
           class="custom-table flex-1"
@@ -59,7 +59,10 @@
             <template #default="scope">
               <div class="flex items-center">
                 <div class="video-thumb mr-3 shrink-0">
-                  <img :src="scope.row.thumbnail" alt="" class="w-full h-full object-cover" />
+                  <img v-if="scope.row.thumbnailUrl" :src="scope.row.thumbnailUrl" alt="" class="w-full h-full object-cover" />
+                  <div v-else class="w-full h-full flex items-center justify-center bg-gray-100 text-gray-400">
+                    <el-icon><i-ep-picture /></el-icon>
+                  </div>
                   <div class="play-overlay">
                     <el-icon><i-ep-video-play /></el-icon>
                   </div>
@@ -72,12 +75,17 @@
           <el-table-column label="岗位等级" prop="jobLevel" width="100" />
           <el-table-column label="学习记录" width="100" align="center">
             <template #default="scope">
-              <el-link type="primary" :underline="false" @click="handleData(scope.row)">{{ scope.row.learnCount }}</el-link>
+              <el-link type="primary" :underline="false" @click="handleData(scope.row)">{{ scope.row.learnCount || 0 }}</el-link>
             </template>
           </el-table-column>
           <el-table-column label="课程状态" width="100" align="center">
             <template #default="scope">
-              <el-switch v-model="scope.row.status" />
+              <el-switch
+                v-model="scope.row.status"
+                :active-value="1"
+                :inactive-value="0"
+                @change="handleStatusChange(scope.row)"
+              />
             </template>
           </el-table-column>
           <el-table-column label="时长" prop="duration" width="100" />
@@ -87,7 +95,7 @@
               <div class="operation-links">
                 <el-link type="primary" :underline="false" @click="handleData(scope.row)">数据</el-link>
                 <el-link type="primary" :underline="false" @click="handleEdit(scope.row)">编辑</el-link>
-                <el-link type="primary" :underline="false" @click="handleOffline(scope.row)">下架</el-link>
+                <el-link type="primary" :underline="false" @click="handleToggleStatus(scope.row)">{{ scope.row.status === 1 ? '下架' : '上架' }}</el-link>
                 <el-link type="primary" :underline="false" class="delete-link" @click="handleDelete(scope.row)">删除</el-link>
               </div>
             </template>
@@ -98,7 +106,7 @@
         <el-table
           v-if="activeCategory === 'offline'"
           v-loading="loading"
-          :data="offlineList"
+          :data="tableData"
           style="width: 100%"
           header-cell-class-name="custom-header"
           class="custom-table flex-1"
@@ -108,16 +116,29 @@
           <el-table-column label="培训名称" prop="name" min-width="180" />
           <el-table-column label="岗位类型" prop="jobType" width="120" />
           <el-table-column label="岗位等级" prop="jobLevel" width="100" />
-          <el-table-column label="时间" prop="time" min-width="240" />
-          <el-table-column label="地点" prop="location" min-width="150" />
+          <el-table-column label="时间" min-width="240">
+            <template #default="scope">
+              {{ formatTimeRange(scope.row.trainingStartTime, scope.row.trainingEndTime) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="地点" min-width="150">
+            <template #default="scope">
+              {{ [scope.row.city, scope.row.area, scope.row.addressDetail].filter(Boolean).join('') }}
+            </template>
+          </el-table-column>
           <el-table-column label="状态" width="100" align="center">
             <template #default="scope">
-              <el-switch v-model="scope.row.status" />
+              <el-switch
+                v-model="scope.row.status"
+                :active-value="1"
+                :inactive-value="0"
+                @change="handleStatusChange(scope.row)"
+              />
             </template>
           </el-table-column>
           <el-table-column label="参与人数" width="100" align="center">
             <template #default="scope">
-              <el-link type="primary" :underline="false" @click="handleData(scope.row)">{{ scope.row.participantCount }}</el-link>
+              <span>{{ scope.row.participantCount || 0 }}</span>
             </template>
           </el-table-column>
           <el-table-column label="操作" width="150" fixed="right">
@@ -134,7 +155,7 @@
         <el-table
           v-if="activeCategory === 'live'"
           v-loading="loading"
-          :data="liveList"
+          :data="tableData"
           style="width: 100%"
           header-cell-class-name="custom-header"
           class="custom-table flex-1"
@@ -145,7 +166,12 @@
           <el-table-column label="岗位等级" prop="jobLevel" width="120" />
           <el-table-column label="状态" width="100" align="center">
             <template #default="scope">
-              <el-switch v-model="scope.row.status" />
+              <el-switch
+                v-model="scope.row.status"
+                :active-value="1"
+                :inactive-value="0"
+                @change="handleStatusChange(scope.row)"
+              />
             </template>
           </el-table-column>
           <el-table-column label="操作" width="220" fixed="right">
@@ -164,7 +190,7 @@
           <span class="total-text mr-4">共 {{ total }} 条</span>
           <span class="page-size-select mr-4">
             显示
-            <el-select v-model="queryParams.pageSize" size="small" style="width: 70px">
+            <el-select v-model="queryParams.pageSize" size="small" style="width: 70px" @change="handleSizeChange">
               <el-option :label="10" :value="10" />
               <el-option :label="20" :value="20" />
               <el-option :label="50" :value="50" />
@@ -198,23 +224,27 @@
       v-else-if="showLearnData"
       :train-id="currentTrain?.id"
       :train-name="currentTrain?.name"
+      :train-type="currentTrain?.trainingType"
       @back="showLearnData = false"
     />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue';
+import { ref, reactive, onMounted, watch } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import TrainForm from './TrainForm.vue';
 import learnData from './learnData.vue';
+import { listTraining, listOfflineTraining, getTraining, getOfflineTraining, addTraining, updateTraining, delTraining, delOfflineTraining, updateTrainingStatus, updateOfflineTrainingStatus } from '@/api/main/training';
+import type { TrainingVO, TrainingForm } from '@/api/main/training/types';
 
 const loading = ref(false);
-const total = ref(243);
+const total = ref(0);
 const activeCategory = ref('video');
 const showForm = ref(false);
 const showLearnData = ref(false);
-const currentTrain = ref(null);
+const currentTrain = ref<TrainingVO | null>(null);
+const tableData = ref<TrainingVO[]>([]);
 
 const categories = [
   { id: 'video', name: '视频培训', icon: 'VideoPlay' },
@@ -223,108 +253,132 @@ const categories = [
 ];
 
 const queryParams = reactive({
-  pageNum: 11,
+  pageNum: 1,
   pageSize: 10,
-  keyword: ''
+  name: '',
+  trainingType: 'video'
 });
 
-// 视频培训模拟数据
-const videoList = ref([
-  {
-    id: '34234',
-    name: '西班牙语0-B2高级直达',
-    thumbnail: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=100&h=60&fit=crop',
-    jobType: '全职',
-    jobLevel: 'A1',
-    learnCount: 300,
-    status: true,
-    duration: '20.34',
-    publishTime: '2020/09/08 10:45'
-  },
-  {
-    id: '34234',
-    name: '西班牙语0-B2高级直达',
-    thumbnail: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=100&h=60&fit=crop',
-    jobType: '全职',
-    jobLevel: 'A1',
-    learnCount: 300,
-    status: true,
-    duration: '20.34',
-    publishTime: '2020/09/08 10:45'
-  }
-]);
-
-// 线下培训模拟数据
-const offlineList = ref([
-  {
-    id: '34234',
-    name: '审计岗位A1',
-    jobType: '全职',
-    jobLevel: 'A1',
-    time: '2025-03-21 10:00——2025-03-22 12:00',
-    location: '上海市XXXXX',
-    status: true,
-    participantCount: 12
-  }
-]);
-
-// 直播培训模拟数据
-const liveList = ref([
-  {
-    name: '审计岗位A1',
-    jobType: '全职',
-    jobLevel: 'A1',
-    status: true
-  }
-]);
-
-const getList = () => {
+/** 查询列表 */
+const getList = async () => {
   loading.value = true;
-  setTimeout(() => {
+  try {
+    queryParams.trainingType = activeCategory.value;
+    const res = activeCategory.value === 'offline'
+      ? await listOfflineTraining(queryParams)
+      : await listTraining(queryParams);
+    tableData.value = res.rows || [];
+    total.value = res.total || 0;
+  } catch (e) {
+    console.error('查询培训列表失败', e);
+  } finally {
     loading.value = false;
-  }, 300);
+  }
+};
+
+/** 切换分类 */
+const switchCategory = (id: string) => {
+  activeCategory.value = id;
+  queryParams.pageNum = 1;
+  queryParams.name = '';
+  getList();
 };
 
+/** 搜索 */
 const handleQuery = () => {
   queryParams.pageNum = 1;
   getList();
 };
 
-const handleSizeChange = () => getList();
+const handleSizeChange = () => {
+  queryParams.pageNum = 1;
+  getList();
+};
 const handleCurrentChange = () => getList();
 
+/** 新增 */
 const handleAdd = () => {
   currentTrain.value = null;
   showForm.value = true;
 };
 
-const handleEdit = (row: any) => {
-  currentTrain.value = { ...row };
-  showForm.value = true;
+/** 编辑 */
+const handleEdit = async (row: TrainingVO) => {
+  try {
+    const res = activeCategory.value === 'offline'
+      ? await getOfflineTraining(row.id)
+      : await getTraining(row.id);
+    currentTrain.value = res.data;
+    showForm.value = true;
+  } catch (e) {
+    // 如果详情接口报错,用列表数据兜底
+    currentTrain.value = { ...row };
+    showForm.value = true;
+  }
 };
 
-const handleFormSubmit = (formData: any) => {
-  if (currentTrain.value) {
-    ElMessage.success('修改成功');
-  } else {
-    ElMessage.success('创建成功');
+/** 表单提交 */
+const handleFormSubmit = async (formData: TrainingForm) => {
+  try {
+    if (formData.id) {
+      await updateTraining(formData);
+      ElMessage.success('修改成功');
+    } else {
+      formData.trainingType = activeCategory.value;
+      await addTraining(formData);
+      ElMessage.success('创建成功');
+    }
+    showForm.value = false;
+    getList();
+  } catch (e) {
+    console.error('提交失败', e);
   }
-  showForm.value = false;
-  getList();
 };
 
-const handleData = (row: any) => {
+/** 数据/学习记录 */
+const handleData = (row: TrainingVO) => {
   showLearnData.value = true;
   currentTrain.value = row;
 };
 
-const handleOffline = (row: any) => {
-  ElMessageBox.confirm('确定下架该课程吗?', '提示', { type: 'warning' }).then(() => {
-    ElMessage.success('下架成功');
-  });
+/** Switch 状态变更 */
+const handleStatusChange = async (row: TrainingVO) => {
+  try {
+    if (row.trainingType === 'offline') {
+      await updateOfflineTrainingStatus(row.id, row.status);
+    } else {
+      await updateTrainingStatus(row.id, row.status);
+    }
+    ElMessage.success(row.status === 1 ? '上架成功' : '下架成功');
+    getList();
+  } catch (e) {
+    // 恢复到之前的状态
+    row.status = row.status === 1 ? 0 : 1;
+    console.error('状态更新失败', e);
+  }
 };
 
-const handleDelete = (row: any) => {
+/** 操作列的上架/下架按钮 */
+const handleToggleStatus = (row: TrainingVO) => {
+  const action = row.status === 1 ? '下架' : '上架';
+  ElMessageBox.confirm(`确定${action}该课程吗?`, '提示', { type: 'warning' }).then(async () => {
+    const newStatus = row.status === 1 ? 0 : 1;
+    try {
+      if (row.trainingType === 'offline') {
+        await updateOfflineTrainingStatus(row.id, newStatus);
+      } else {
+        await updateTrainingStatus(row.id, newStatus);
+      }
+      ElMessage.success(`${action}成功`);
+      getList();
+    } catch (e) {
+      console.error(`${action}失败`, e);
+    }
+  }).catch(() => {});
+};
+
+/** 删除 */
+const handleDelete = (row: TrainingVO) => {
   ElMessageBox.confirm(
     '删除后将无法恢复,确定删除吗?',
     '删除培训',
@@ -334,16 +388,32 @@ const handleDelete = (row: any) => {
       type: 'warning',
       buttonSize: 'default'
     }
-  ).then(() => {
-    ElMessage.success('删除成功');
-    getList();
+  ).then(async () => {
+    try {
+      if (row.trainingType === 'offline') {
+        await delOfflineTraining(row.id);
+      } else {
+        await delTraining(row.id);
+      }
+      ElMessage.success('删除成功');
+      getList();
+    } catch (e) {
+      console.error('删除失败', e);
+    }
   }).catch(() => {});
 };
 
-const handleCopy = (row: any) => {
+/** 复制测评 */
+const handleCopy = (row: TrainingVO) => {
   ElMessage.success('测评链接已复制');
 };
 
+/** 格式化时间范围 */
+const formatTimeRange = (start?: string, end?: string) => {
+  if (!start || !end) return '';
+  return `${start} —— ${end}`;
+};
+
 onMounted(() => {
   getList();
 });
@@ -506,5 +576,3 @@ onMounted(() => {
   }
 }
 </style>
-
-

+ 140 - 77
src/views/system/training/learnData.vue

@@ -2,13 +2,13 @@
   <div class="app-container learn-data-page bg-white p-6 rounded">
     <div class="header-bar flex justify-between items-center mb-6">
       <div class="flex items-center">
-        <el-button link @click="handleBack" class="mr-4">
+        <el-button link @click="handleBack" class="mr-4 text-gray-600" style="width:60px; height: 30px;">
           <el-icon><i-ep-arrow-left /></el-icon>
           返回
         </el-button>
-        <span class="font-bold text-lg">学习数据</span>
+        <span class="font-bold text-lg text-gray-800">学习数据</span>
       </div>
-      <el-button type="primary" plain @click="handleExport">批量导出</el-button>
+      <el-button type="default" :disabled="selectedIds.length === 0" @click="handleExport">批量导出</el-button>
     </div>
 
     <el-table
@@ -23,159 +23,200 @@
       <el-table-column label="用户信息" min-width="200">
         <template #default="scope">
           <div class="flex items-center">
-            <el-avatar :size="32" :src="scope.row.avatar" class="mr-3" />
-            <div class="user-info">
-              <div class="name font-medium text-blue-500 cursor-pointer">{{ scope.row.name }}</div>
-              <div class="mobile text-gray-400 text-xs">{{ scope.row.mobile }}</div>
+            <el-avatar :size="36" :src="scope.row.avatarUrl || scope.row.avatar" class="mr-3 shrink-0" />
+            <div class="user-info flex flex-col justify-center">
+              <div class="name font-medium text-blue-500 cursor-pointer text-sm mb-1">{{ scope.row.name }}</div>
+              <div class="mobile text-gray-500 text-xs">{{ scope.row.mobile }}</div>
             </div>
           </div>
         </template>
       </el-table-column>
-      <el-table-column label="已学习时长 (h)" prop="learnedTime" width="150" align="center" />
-      <el-table-column label="剩余时长 (h)" prop="remainingTime" width="150" align="center" />
-      <el-table-column label="学习进度" min-width="200">
+      <el-table-column v-if="!isOffline" label="已学习时长 (分钟)" width="150" align="center">
+        <template #default="scope">{{ formatMinutes(scope.row.learnedTime) }}</template>
+      </el-table-column>
+      <el-table-column v-if="!isOffline" label="剩余时长 (分钟)" width="150" align="center">
+        <template #default="scope">{{ formatMinutes(scope.row.remainingTime) }}</template>
+      </el-table-column>
+      <el-table-column v-if="!isOffline" label="学习进度" min-width="200">
         <template #default="scope">
           <div class="flex items-center gap-3">
-            <el-progress :percentage="scope.row.progress" :stroke-width="8" color="#67c23a" class="flex-1" />
-            <span class="text-xs text-gray-400">{{ scope.row.progress }}%</span>
+            <el-progress 
+              :percentage="calculateProgress(scope.row)" 
+              :stroke-width="8" 
+              color="#67c23a" 
+              class="flex-1" 
+            />
           </div>
         </template>
       </el-table-column>
-      <el-table-column label="完成时间" prop="finishTime" width="180" align="center" />
-      <el-table-column label="上次学习时间" prop="lastLearnTime" width="180" align="center" />
+      <el-table-column v-if="!isOffline" label="完成时间" prop="finishTime" width="180" align="center" />
+      <el-table-column v-if="!isOffline" label="上次学习时间" prop="lastLearnTime" width="180" align="center" />
+      <el-table-column v-if="isOffline" label="报名时间" prop="enrollTime" width="180" align="center" />
+      <el-table-column v-if="isOffline" label="签到时间" prop="checkInTime" width="180" align="center">
+        <template #default="scope">
+          {{ scope.row.checkInTime || '-' }}
+        </template>
+      </el-table-column>
     </el-table>
 
     <!-- 分页 -->
-    <div class="pagination-wrapper mt-6 flex justify-between items-center text-gray-500 text-sm">
-      <div class="flex-1">共 {{ total }} 条记录 第 {{ queryParams.pageNum }} / {{ Math.ceil(total / queryParams.pageSize) }} 页</div>
-      <div class="flex items-center">
+    <div class="pagination-wrapper mt-8 text-gray-500 text-sm">
+      <div class="page-total text-gray-400 mb-4 text-center">
+        共 {{ total }} 条记录 第 {{ queryParams.pageNum }} / {{ Math.max(1, Math.ceil(total / queryParams.pageSize)) }} 页
+      </div>
+      <div class="pagination-center">
         <el-pagination
-          v-model:current-page="queryParams.pageNum"
-          v-model:page-size="queryParams.pageSize"
+          :current-page="queryParams.pageNum"
+          :page-size="queryParams.pageSize"
           :total="total"
           background
-          layout="prev, pager, next, sizes"
+          layout="prev, pager, next, sizes, jumper"
           :page-sizes="[10, 20, 50, 100]"
-          class="mr-4"
           @size-change="handleSizeChange"
           @current-change="handleCurrentChange"
         />
-        <div class="flex items-center gap-2">
-          <span>跳至</span>
-          <el-input v-model="jumpPage" size="small" style="width: 50px" @keyup.enter="handleJump" />
-          <span>页</span>
-        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue';
+import { computed, ref, reactive, onMounted, watch } from 'vue';
+import FileSaver from 'file-saver';
 import { ElMessage } from 'element-plus';
+import { listTrainingLearnRecords, listOfflineTrainingParticipants, exportTrainingLearnRecords, exportOfflineTrainingParticipants } from '../../../api/main/training/index';
+import type { TrainingParticipantVO, TrainingLearnRecordVO } from '../../../api/main/training/types';
 
 const props = defineProps<{
   trainId?: string | number;
   trainName?: string;
+  trainType?: string;
 }>();
 
 const emit = defineEmits(['back']);
 
 const loading = ref(false);
-const total = ref(400);
+const total = ref(0);
+const isOffline = computed(() => props.trainType === 'offline');
+const selectedIds = ref<Array<string | number>>([]);
 
 const queryParams = reactive({
   pageNum: 1,
   pageSize: 10
 });
 
-const jumpPage = ref(1);
-
-const tableData = ref([
-  {
-    avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
-    name: '王冕',
-    mobile: '18854789000',
-    learnedTime: '400h',
-    remainingTime: '400h',
-    progress: 50,
-    finishTime: '2020/09/08 10:45',
-    lastLearnTime: '2020/09/08 10:45'
-  },
-  {
-    avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
-    name: '王冕',
-    mobile: '18854789000',
-    learnedTime: '400h',
-    remainingTime: '400h',
-    progress: 50,
-    finishTime: '2020/09/08 10:45',
-    lastLearnTime: '2020/09/08 10:45'
-  },
-  {
-    avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
-    name: '王冕',
-    mobile: '18854789000',
-    learnedTime: '400h',
-    remainingTime: '400h',
-    progress: 50,
-    finishTime: '2020/09/08 10:45',
-    lastLearnTime: '2020/09/08 10:45'
-  }
-]);
+const formatMinutes = (value?: number) => {
+  return Number(value || 0);
+};
 
-const handleBack = () => {
-  emit('back');
+const calculateProgress = (row: TrainingLearnRecordVO) => {
+  const learned = Number(row.learnedTime) || 0;
+  const remaining = Number(row.remainingTime) || 0;
+  const totalTime = learned + remaining;
+  if (totalTime === 0) return 0;
+  return Math.max(0, Math.min(100, Math.floor((learned / totalTime) * 100)));
 };
 
-const handleJump = () => {
-  queryParams.pageNum = Number(jumpPage.value);
-  getList();
+const tableData = ref<Array<TrainingParticipantVO | TrainingLearnRecordVO>>([]);
+
+const handleBack = () => {
+  emit('back');
 };
 
-const handleExport = () => {
+const handleExport = async () => {
+  if (!props.trainId || selectedIds.value.length === 0) {
+    ElMessage.warning('请先勾选需要导出的数据');
+    return;
+  }
+  const fileName = `${props.trainName || '学习数据'}_${new Date().getTime()}.xlsx`;
+  const blob = isOffline.value
+    ? await exportOfflineTrainingParticipants(props.trainId, selectedIds.value)
+    : await exportTrainingLearnRecords(props.trainId, selectedIds.value);
+  FileSaver.saveAs(blob as unknown as Blob, fileName);
   ElMessage.success('导出成功');
 };
 
-const handleSelectionChange = (selection: any) => {
-  console.log(selection);
+const handleSelectionChange = (selection: Array<TrainingParticipantVO | TrainingLearnRecordVO>) => {
+  selectedIds.value = selection.map(item => item.studentId!).filter(Boolean);
 };
 
-const handleSizeChange = () => {
+const handleSizeChange = (size: number) => {
+  queryParams.pageSize = size;
+  queryParams.pageNum = 1;
   getList();
 };
 
-const handleCurrentChange = () => {
+const handleCurrentChange = (page: number) => {
+  queryParams.pageNum = page;
   getList();
 };
 
-const getList = () => {
+const getList = async () => {
+  if (!props.trainId) {
+    tableData.value = [];
+    total.value = 0;
+    return;
+  }
   loading.value = true;
-  setTimeout(() => {
+  try {
+    if (isOffline.value && props.trainId) {
+      const res: any = await listOfflineTrainingParticipants(props.trainId, queryParams);
+      tableData.value = (res.rows || []) as TrainingParticipantVO[];
+      total.value = res.total || 0;
+      selectedIds.value = [];
+      return;
+    }
+    const res: any = await listTrainingLearnRecords(props.trainId, queryParams);
+    tableData.value = (res.rows || []).map((item: TrainingLearnRecordVO) => ({
+      ...item,
+      progress: calculateProgress(item)
+    }));
+    total.value = res.total || 0;
+    selectedIds.value = [];
+  } finally {
     loading.value = false;
-  }, 300);
+  }
 };
 
 onMounted(() => {
   getList();
 });
+
+watch(() => [props.trainId, props.trainType, queryParams.pageNum, queryParams.pageSize], () => {
+  getList();
+});
 </script>
 
 <style scoped lang="scss">
 .learn-data-page {
   min-height: calc(100vh - 84px);
+  display: flex;
+  flex-direction: column;
   
+  .header-bar {
+    .el-button {
+      border-color: #dcdfe6;
+      color: #606266;
+    }
+  }
+
   :deep(.custom-header th) {
     background-color: #f7f8fa !important;
     color: #4e5969;
     font-weight: 500;
     padding: 12px 0;
+    border-bottom: 1px solid #ebeef5 !important;
   }
 
   .custom-table {
     :deep(.el-table__inner-wrapper::before) {
       display: none;
     }
+    :deep(.el-table__row) {
+      td {
+        border-bottom: 1px solid #ebeef5;
+      }
+    }
   }
 
   :deep(.el-progress-bar__outer) {
@@ -183,9 +224,31 @@ onMounted(() => {
   }
 
   .pagination-wrapper {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin-top: auto;
+    margin-bottom: 40px;
+
+    .page-total {
+      width: 100%;
+      text-align: left;
+      margin-bottom: -20px;
+    }
+
+    .pagination-center {
+      display: flex;
+      justify-content: center;
+      width: 100%;
+    }
+
     :deep(.el-pagination) {
-      .el-select .el-input {
-        width: 100px;
+      justify-content: center;
+      .el-pagination__sizes {
+        margin-right: 16px;
+      }
+      .el-pagination__jump {
+        margin-left: 16px;
       }
     }
   }