Sfoglia il codice sorgente

Merge remote-tracking branch 'origin/master'

zyy 1 settimana fa
parent
commit
963bf173d0
45 ha cambiato i file con 12728 aggiunte e 1508 eliminazioni
  1. 2 2
      .env.prod-sxtb
  2. 26 0
      src/api/company/withdrawDetail.js
  3. 33 0
      src/api/course/publicCourseWatchStat.js
  4. 60 0
      src/api/live/trainingCampAdmin.js
  5. 111 3
      src/api/watch/deviceInfo.js
  6. 2 1
      src/components/DeviceInfo/AtrialFibrillation.vue
  7. 5 0
      src/components/DeviceInfo/BloodSugar.vue
  8. 13 6
      src/components/DeviceInfo/Bloodoxygen.vue
  9. 5 0
      src/components/DeviceInfo/Heartrate.vue
  10. 8 3
      src/components/DeviceInfo/Pressure.vue
  11. 291 0
      src/components/DeviceInfo/PressureValue.vue
  12. 5 0
      src/components/DeviceInfo/Pulse.vue
  13. 1943 0
      src/components/DeviceInfo/Report.vue
  14. 5 0
      src/components/DeviceInfo/Sleep.vue
  15. 5 0
      src/components/DeviceInfo/Sports.vue
  16. 8 4
      src/components/DeviceInfo/Temperature.vue
  17. 5 0
      src/components/DeviceInfo/Uricacid.vue
  18. 5 0
      src/components/DeviceInfo/Urineketones.vue
  19. 8 1
      src/utils/request.js
  20. 298 0
      src/views/company/companyMoneyLogsDetail/index.vue
  21. 8 2
      src/views/course/courseWatchComment/index.vue
  22. 280 0
      src/views/course/publiccourse/commentSensitive/index.vue
  23. 753 0
      src/views/course/publiccourse/courseQuestionBank/index.vue
  24. 508 0
      src/views/course/publiccourse/courseWatchComment/index.vue
  25. 336 0
      src/views/course/publiccourse/courseWatchLog/index.vue
  26. 515 0
      src/views/course/publiccourse/userCourseCategory/index.vue
  27. 1248 0
      src/views/course/publiccourse/usercourse/index.vue
  28. 3160 0
      src/views/course/publiccourse/videoResource/index.vue
  29. 10 5
      src/views/course/userCourse/index.vue
  30. 5 3
      src/views/course/userCourseCategory/index.vue
  31. 22 5
      src/views/course/videoResource/index.vue
  32. 18 18
      src/views/hisStore/components/productAfterSalesOrder.vue
  33. 230 119
      src/views/live/liveConfig/answer.vue
  34. 1 1
      src/views/live/liveConfig/index.vue
  35. 1222 0
      src/views/live/liveStatistics/LiveStatisticsPanel.vue
  36. 3 1166
      src/views/live/liveStatistics/index.vue
  37. 322 0
      src/views/live/trainingCamp/PeriodWorkspace.vue
  38. 884 0
      src/views/live/trainingCamp/index.vue
  39. 163 0
      src/views/live/trainingCamp/trainingLiveAudit.vue
  40. 34 14
      src/views/watch/deviceInfo/details.vue
  41. 94 47
      src/views/watch/deviceInfo/index.vue
  42. 11 3
      src/views/watch/iot/index.vue
  43. 58 45
      src/views/watch/isBind/index.vue
  44. 4 59
      src/views/watch/isSend/index.vue
  45. 1 1
      vue.config.js

+ 2 - 2
.env.prod-sxtb

@@ -3,9 +3,9 @@ VUE_APP_TITLE =挑宝易购总管理系统
 # 首页菜单标题
 VUE_APP_TITLE_INDEX =挑宝易购
 # 公司名称
-VUE_APP_COMPANY_NAME =西安挑宝益康医药连锁有限公司
+VUE_APP_COMPANY_NAME =陕西和泰惠健科技控股集团有限公司
 # ICP备案号
-VUE_APP_ICP_RECORD =宁ICP备2022001349
+VUE_APP_ICP_RECORD =陕ICP备2025072324
 # ICP网站访问地址
 VUE_APP_ICP_URL =https://beian.miit.gov.cn
 # 网站LOG

+ 26 - 0
src/api/company/withdrawDetail.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+/** 分公司下拉(关键词模糊) */
+export function listWithdrawDetailCompanyOptions(query) {
+  return request({
+    url: '/company/withdrawDetailAdmin/companyOptions',
+    method: 'get',
+    params: query
+  })
+}
+
+export function listWithdrawDetail(query) {
+  return request({
+    url: '/company/withdrawDetailAdmin/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function exportWithdrawDetail(query) {
+  return request({
+    url: '/company/withdrawDetailAdmin/export',
+    method: 'get',
+    params: query
+  })
+}

+ 33 - 0
src/api/course/publicCourseWatchStat.js

@@ -0,0 +1,33 @@
+import request from '@/utils/request'
+
+export function listPublicCourseWatchStatCourseDay(query) {
+  return request({
+    url: '/course/publicCourseWatchStat/courseDay/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function exportPublicCourseWatchStatCourseDay(query) {
+  return request({
+    url: '/course/publicCourseWatchStat/courseDay/export',
+    method: 'get',
+    params: query
+  })
+}
+
+export function listPublicCourseWatchStatCatalog(query) {
+  return request({
+    url: '/course/publicCourseWatchStat/catalog/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function exportPublicCourseWatchStatCatalog(query) {
+  return request({
+    url: '/course/publicCourseWatchStat/catalog/export',
+    method: 'get',
+    params: query
+  })
+}

+ 60 - 0
src/api/live/trainingCampAdmin.js

@@ -0,0 +1,60 @@
+import request from '@/utils/request'
+
+const base = '/live/trainingCamp/admin'
+
+export function listTrainingCamp(query) {
+  return request({ url: base + '/camp/list', method: 'get', params: query })
+}
+
+export function getTrainingCamp(campId, companyId) {
+  return request({ url: base + '/camp/' + campId, method: 'get', params: { companyId } })
+}
+
+export function addTrainingCamp(data) {
+  return request({ url: base + '/camp', method: 'post', data })
+}
+
+export function updateTrainingCamp(data) {
+  return request({ url: base + '/camp', method: 'put', data })
+}
+
+export function delTrainingCamp(campIds, companyId) {
+  return request({ url: base + '/camp/' + campIds, method: 'delete', params: { companyId } })
+}
+
+export function listTrainingPeriod(query) {
+  return request({ url: base + '/period/list', method: 'get', params: query })
+}
+
+export function getTrainingPeriod(periodId, companyId) {
+  return request({ url: base + '/period/' + periodId, method: 'get', params: { companyId } })
+}
+
+export function addTrainingPeriod(data) {
+  return request({ url: base + '/period', method: 'post', data })
+}
+
+export function updateTrainingPeriod(data) {
+  return request({ url: base + '/period', method: 'put', data })
+}
+
+export function delTrainingPeriod(periodIds, companyId) {
+  return request({ url: base + '/period/' + periodIds, method: 'delete', params: { companyId } })
+}
+
+export function listTrainingLive(query) {
+  return request({ url: base + '/live/list', method: 'get', params: query })
+}
+
+export function addTrainingLive(data, companyId) {
+  return request({ url: base + '/live', method: 'post', params: { companyId }, data })
+}
+
+/** 训练营直播间审核列表 */
+export function listTrainingLiveAudit(query) {
+  return request({ url: base + '/live/auditList', method: 'get', params: query })
+}
+
+export function auditTrainingLive(data) {
+  return request({ url: base + '/live/audit', method: 'put', data })
+}

+ 111 - 3
src/api/watch/deviceInfo.js

@@ -69,13 +69,21 @@ export function exportDeviceInfo(query) {
     params: query
   })
 }
+export function exportDeviceIdAndIotInfo(query) {
+  return request({
+    url: '/watch-api/watch/deviceInfo/exportDeviceIdAndIotInfo',
+    method: 'get',
+    params: query
+  })
+}
+
 
   // 查询用户信息
-export function getUser(deviceId) {
+export function getUser(data) {
   return request({
     url: '/watch-api/device/set/up/getUserInfo',
     method: 'get',
-    params: { deviceId } 
+    params:data
   })
 }
 
@@ -104,6 +112,14 @@ export function queryByDate(date,deviceId) {
   })
 }
 
+export function queryHrList(data) {
+  return request({
+    url: `/watch-api/watch/heart/rate/list`,
+    method: 'get',
+    params: data
+  })
+}
+
 //exportHeartDate导出
 export function exportHeartDate(date,deviceId) {
   return request({
@@ -149,6 +165,15 @@ export function queryGlucoseDate(date,deviceId) {
   })
 }
 
+// 根据时间获取血糖数据
+export function queryGlucoseList(data) {
+  return request({
+    url: `/watch-api/watch/blood/glucose/list`,
+    method: 'get',
+    params: data
+  })
+}
+
 // 根据时间获取房颤数据
 export function queryAfDate(date,deviceId) {
   return request({
@@ -167,6 +192,15 @@ export function queryPressure(date,deviceId) {
   })
 }
 
+// 根据时间获取血压数据
+export function queryPressureList(data) {
+  return request({
+    url: `/watch-api/watch/blood/pressure/list`,
+    method: 'get',
+    params:data
+  })
+}
+
 // 根据时间获取温度数据
 export function queryTemperature(date,deviceId) {
   return request({
@@ -194,6 +228,15 @@ export function querySpo2(date,deviceId) {
   })
 }
 
+// 根据时间获取血氧数据
+export function querySpo2List(data) {
+  return request({
+    url: `/watch-api/watch/spo2/data/list`,
+    method: 'get',
+    params: data
+  })
+}
+
 // 根据时间获取睡眠数据
 export function querySleep(date,deviceId) {
   return request({
@@ -203,6 +246,15 @@ export function querySleep(date,deviceId) {
   })
 }
 
+// 根据时间获取睡眠数据
+export function querySleepList(data) {
+  return request({
+    url: `/watch-api/watch/sleep/data/list`,
+    method: 'get',
+    params: data
+  })
+}
+
 // 获取预警数据
 export function queryAlarm(status) {
   return request({
@@ -269,6 +321,15 @@ export function queryUaData(data) {
   })
 }
 
+//获取尿酸数据
+export function queryUaList(data) {
+  return request({
+      url: '/watch-api/watch/third/ua/list',
+      method: 'get',
+      params:data
+  })
+}
+
 //获最新健康数据
 export function queryLastHealthData(data) {
   return request({
@@ -349,4 +410,51 @@ export function queryIotList(data) {
       method: 'get',
       params:data
   })
-}
+}
+
+//查询某个时间段的压力数据 分页
+export function queryFatiguePageByDate(data) {
+  return request({
+      url: '/watch-api/watch/fatigue/page',
+      method: 'get',
+      params:data
+  })
+}
+
+// 根据时间获取压力数据
+export function queryFatigue(date,deviceId) {
+  return request({
+    url: `/watch-api/watch/fatigue/queryByDateAndDeviceId`,
+    method: 'get',
+    params: { date,deviceId } 
+  })
+}
+
+
+//健康周报-步数,卡路里,睡眠等信息
+export function getUserHealthInfoByDeviceId(data) {
+  return request({
+      url: '/watch-api/watch/sport/data/getUserHealthInfoByDeviceId',
+      method: 'get',
+      params:data
+  })
+}
+
+//健康周报-步数,卡路里,睡眠等信息
+export function queryHealthReport(data) {
+  return request({
+      url: '/watch-api/device/queryHealthReport',
+      method: 'get',
+      params:data
+  })
+}
+
+//舌诊
+export function queryTongueList(data) {
+  return request({
+      url: '/watch-api/device/getHealthTongueList',
+      method: 'get',
+      params:data
+  })
+}
+

+ 2 - 1
src/components/DeviceInfo/AtrialFibrillation.vue

@@ -21,7 +21,8 @@
     </div>
     <div class="afib-diagnosis">
       <div class="afib-diagnosis-title">诊断结论:</div>
-      <div class="afib-diagnosis-note">{{conclusion||'没有房颤数据'}}</div>
+      <div class="afib-diagnosis-note">{{conclusion||'今日暂无数据,请选择其他日期'}}</div>
+      <div class="afib-diagnosis-note">散点图: 基于⾮线性混沌理论,依靠计算机快速运算技术,对⻓程RR间期数据进⾏图形表达,⽤于⼼率变异</div>
       <div class="afib-diagnosis-tips">
         注:非医疗诊断,供参考,请以医院确诊结论为准。
       </div>

+ 5 - 0
src/components/DeviceInfo/BloodSugar.vue

@@ -17,6 +17,11 @@
             </el-table-column>
             <el-table-column prop="bloodGlucose" align="center" label="测量结果">
             </el-table-column>
+            <template #empty>
+                <div>
+                    今日暂无数据,请选择其他日期
+                </div>
+            </template>
         </el-table>
 
     </div>

+ 13 - 6
src/components/DeviceInfo/Bloodoxygen.vue

@@ -19,12 +19,19 @@
         <!-- 列表展示 -->
         <div v-show="detailsType">
             <el-table :data="dataList">
-            <el-table-column type="index" label="序号" align="center"/>
-            <el-table-column label="时间" align="center" prop="time" />
-            <el-table-column label="血氧" align="center" prop="avgBoxy" />
+                <el-table-column type="index" label="序号" align="center"/>
+                <el-table-column label="时间" align="center" prop="time" />
+                <el-table-column label="血氧" align="center" prop="avgBoxy" />
+                 <!-- 使用 empty 插槽自定义无数据时的显示内容 -->
+                <template #empty>
+                    <div>
+                        今日暂无数据,请选择其他日期
+                    </div>
+                </template>
             </el-table>
             <pagination v-show="(total>0) && detailsType" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getDataPage"/>
         </div>
+        
     </div>
 </template>
 
@@ -87,9 +94,9 @@ export default {
                 xAxis: {
                     type: "time",
                     splitNumber: 6,
-                    axisLine: {
-                        show: false,
-                    },
+                    // axisLine: {
+                    //     show: false,
+                    // },
                     axisTick: {
                         show: false,
                     },

+ 5 - 0
src/components/DeviceInfo/Heartrate.vue

@@ -65,6 +65,11 @@
         <el-table-column label="最小心率" align="center" prop="minBpm" />
         <el-table-column label="状态" align="center" prop="status" :formatter="statusFormatter"/>
         <el-table-column label="时间" align="center" prop="createTime" />
+        <template #empty>
+            <div>
+                今日暂无数据,请选择其他日期
+            </div>
+        </template>
       </el-table>
       
     </div>

+ 8 - 3
src/components/DeviceInfo/Pressure.vue

@@ -24,6 +24,11 @@
                 <el-table-column label="sbp(mmgh)" align="center" prop="sbp" />
                 <el-table-column label="dbp(mmgh)" align="center" prop="dbp" />
                 <el-table-column label="hr(次/分)" align="center" prop="hr" />
+                <template #empty>
+                <div>
+                    今日暂无数据,请选择其他日期
+                </div>
+            </template>
             </el-table>
             <pagination v-show="(dataListTotal > 0) && detailsType" :total="dataListTotal" :page.sync="tqueryParams.pageNum" :limit.sync="tqueryParams.pageSize" @pagination="getTableList"/>
         </div>
@@ -66,9 +71,9 @@ export default {
                     boundaryGap: false,
                     type: "time",
                     splitNumber: 6,
-                    axisLine: {
-                        show: false,
-                    },
+                    // axisLine: {
+                    //     show: false,
+                    // },
                     axisTick: {
                         show: false,
                     },

+ 291 - 0
src/components/DeviceInfo/PressureValue.vue

@@ -0,0 +1,291 @@
+<template>
+    <div>
+        <div class="box-header">
+            <div class="box-title boldtext">
+                压力 <span style="margin-left: 14px">{{ currentDate }}</span>
+            </div>
+            <div>
+                <!-- <el-button @click="exportData" size="small" style="margin-right: 20px"
+            >导出</el-button
+          > -->
+                <el-date-picker size="small" v-model="currentDatePicker" type="date" placeholder="选择日期"
+                    value-format="yyyy-MM-dd" @change="datePickerchange">
+                </el-date-picker>
+            </div>
+        </div>
+        <div v-show="!detailsType">
+            <div style="min-width: 841px; height: 320px; width: 100%;">
+                <div ref="heartrateChart" style="width: 100%; height: 100%;min-width: 783px;"></div>
+            </div>
+        </div>
+        <!-- 列表展示 -->
+        <div v-show="detailsType">
+            <el-table :data="dataList" style="width: 100%">
+                <el-table-column prop="createTime" label="时间" />
+                <el-table-column prop="pressure" label="压力值" />
+                <template #empty>
+                    <div>
+                        今日暂无数据,请选择其他日期
+                    </div>
+                </template>
+            </el-table>
+            <pagination v-show="(total > 0) && detailsType" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getDataPage"/>
+        </div>
+        
+    </div>
+</template>
+
+<script>
+import * as echarts from "echarts";
+import { queryFatiguePageByDate, queryFatigue} from "@/api/watch/deviceInfo";
+export default {
+    props: {
+        detailsType: { 
+            type: Boolean,
+            default: false
+        },
+        deviceId: {
+            type: String || Number,
+            default: "",
+        },
+    },
+    data() {
+        return {
+            total:0,
+            dataList:[],
+            queryParams:{
+                pageNum: 1,
+                pageSize: 10,
+                deviceId:"",
+                beginTime:"",
+                endTime:""
+            },
+            currentDate: this.parseTime(new Date(), "{y}-{m}-{d}"),
+            currentDatePicker: "",
+            exportLoading: false,
+            chart: null,
+            chartOptions: {
+                tooltip: {
+                    trigger: 'axis'
+                },
+                legend: {},
+                xAxis: {
+                    boundaryGap: false,
+                    type: "time",
+                    splitNumber: 6,
+                    axisTick: {
+                        show: false,
+                    },
+                    splitLine: {
+                        show: false,
+                    },
+                    axisLabel: {
+                        color: "#999999",
+                        margin: 20,
+                        // fontSize: 14,
+                        fontWeight: "bold",
+                        formatter: (value) => {
+                            return this.parseTime(value, "{h}:{i}");
+                        },
+                    }
+                },
+                yAxis: {
+                    type: 'value',
+                    max: 100, // 设置y轴最大值为100
+                    // splitLine: {
+                    //     show: false // 去掉y轴的横线
+                    // }
+                },
+                series: [
+                    {
+                        name: '压力值',
+                        type: 'line',
+                        data: [],
+                        smooth: true
+                    }
+                    
+                ]
+            }
+
+        }
+    },
+    methods: {
+        getDataPage(){
+            this.loading = true;
+            this.queryParams.deviceId = this.deviceId;
+            this.queryParams.beginTime = this.currentDate + " 00:00:00";
+            this.queryParams.endTime = this.currentDate + " 23:59:59";
+            queryFatiguePageByDate(this.queryParams)
+                .then((response) => {
+                if (response.data) {
+                    this.dataList = response.data.rows;
+                    this.total = response.data.total;
+                    this.loading = false;
+                } else {
+                    console.warn("分页数据返回为空或格式不正确:", response);
+                }
+                })
+                .catch((error) => {
+                console.error("获取分页数据失败:", error);
+                });
+        },
+        datePickerchange() {
+            this.currentDate = this.currentDatePicker || this.parseTime(new Date(), "{y}-{m}-{d}");
+            if(this.detailsType){
+                this.getDataPage();
+            } else{
+                this.getDetailsData();
+            }
+        },
+        initChart() {
+            const chartElement = this.$refs.heartrateChart;
+            const sportChartElement = this.$refs.sportHeartrate;
+            if (chartElement) {
+                this.chart = echarts.init(chartElement);
+                this.seChartOptions()
+            }
+            if (sportChartElement) {
+                this.sportChart = echarts.init(sportChartElement);
+                this.sportChart.setOption(this.sportChartOption);
+            }
+        },
+        seChartOptions() {
+            this.chartOptions.xAxis.min = this.currentDate + ' 00:00:00'
+            this.chartOptions.xAxis.max = this.currentDate + ' 23:59:59'
+            this.chart.setOption(this.chartOptions);
+        },
+        // 获取体温折线图
+        getDetailsData() {
+            queryFatigue(this.currentDate, this.deviceId)
+                .then((response) => {
+                    if (response && response.data) {
+                        // console.log("------------------" + JSON.stringify(response.data, null, 2))
+                        const data = response.data.map((item) => (
+                            {
+                                value: [
+                                    new Date(item.createTime).getTime(),
+                                    item.pressure,
+                                ],
+                            }));
+                     
+
+                        // 将数据分别赋值给不同的 series
+                        this.chartOptions.series[0].data = data;
+                        // 更新图表
+                        if (this.detailsType == false) {
+                            this.initChart();
+                        }
+                    } else {
+                        console.warn("数据返回为空或格式不正确:", response);
+                    }
+                })
+                .catch((error) => {
+                    console.error("获取数据失败:", error);
+                });
+        },
+       
+    },
+};
+</script>
+
+<style lang="scss" scoped>
+@mixin u-flex($flexD, $alignI, $justifyC) {
+    display: flex;
+    flex-direction: $flexD;
+    align-items: $alignI;
+    justify-content: $justifyC;
+}
+
+.fz15 {
+    font-size: 15px !important;
+}
+
+.boldtext {
+    font-size: 16px;
+    color: #292929;
+    line-height: 30px;
+    font-weight: 700;
+}
+
+.box-header {
+    padding: 0 15px;
+    @include u-flex(row, center, space-between);
+    height: 50px;
+}
+
+.box-title {
+    color: #292929;
+    font-size: 16px;
+    font-family: PingFang SC-Medium;
+    padding-left: 10px;
+    border-left: 4px solid #2284ff;
+    line-height: 13px;
+}
+
+.heartrate-data {
+    @include u-flex(row, center, space-around);
+    margin-top: 30px;
+
+    &-item {
+        height: 60px;
+        width: 196px;
+        border-radius: 4px;
+        box-shadow: 0 0 10px rgba(1, 24, 54, 0.1);
+        overflow: hidden;
+        @include u-flex(row, center, flex-start);
+
+        img {
+            height: 41px;
+            width: 41px;
+        }
+    }
+
+    &-imgbox {
+        flex-shrink: 0;
+        height: 60px;
+        width: 60px;
+        @include u-flex(row, center, center);
+    }
+
+    &-right {
+        flex: 1;
+        text-align: center;
+        font-size: 13px;
+        color: #606165;
+        font-family: PingFang SC-Medium;
+        background: #fff;
+        height: 60px;
+    }
+
+    &-num {
+        font-size: 22px;
+        color: #2c2c3b;
+        line-height: 38px;
+    }
+}
+
+.heartrate-sport {
+    @include u-flex(row, flex-start, flex-start);
+    margin-top: 20px;
+
+    &-l {
+        width: 490px;
+        flex-shrink: 0;
+    }
+
+    &-r {
+        flex: 1;
+        max-width: 720px;
+    }
+
+    &-lechart {
+        width: 490px;
+        height: 300px;
+    }
+
+    &-rechart {
+        width: 100%;
+        @include u-flex(row, center, center);
+    }
+}
+</style>

+ 5 - 0
src/components/DeviceInfo/Pulse.vue

@@ -70,6 +70,11 @@
           <el-table-column type="index" label="序号" align="center"/>
           <el-table-column label="时间" align="center" prop="time" />
           <el-table-column label="脉搏" align="center" prop="pulseRate" />
+          <template #empty>
+                <div>
+                    今日暂无数据,请选择其他日期
+                </div>
+            </template>
         </el-table>
         <pagination v-show="(dataListTotal > 0) && detailsType" :total="dataListTotal" :page.sync="tqueryParams.pageNum" :limit.sync="tqueryParams.pageSize" @pagination="getTableList"/>
       </div>

+ 1943 - 0
src/components/DeviceInfo/Report.vue

@@ -0,0 +1,1943 @@
+<template>
+    <div class="weekly-report no-scrollbar">
+      <div class="report-header">
+        <div class="report-title">
+          <img class="title-icon" src="@/assets/images/watchApi/title-icon.png" />
+          <span>周报</span>
+        </div>
+        <div class="date-selector">
+          <el-date-picker
+            :picker-options="{ firstDayOfWeek: 1 }"
+            v-model="selectedDate"
+            type="week"
+            format="yyyy 第 WW 周"
+            placeholder="选择周"
+            @change="handleDateChange"
+          ></el-date-picker>
+        </div>
+      </div>
+  
+      <el-card class="report-card">
+        <div class="progress-section">
+          <div class="progress-container">
+            <el-progress type="circle" :percentage="healthIndex" :width="120" color="#FF9F43"></el-progress>
+            <div class="progress-info">
+              <div class="progress-label">健康指数</div>
+            </div>
+            <div class="health-status">{{ healthStatus }}</div>
+            <div class="health-description">{{ healthDesc }}</div>
+          </div>
+          
+          <div class="health-metrics-container">
+            <div class="health-metrics-row">
+              <div class="metric-item">
+                <div class="metric-data">
+                  <div class="metric-value">{{ height }}<span class="metric-unit">cm</span></div>
+                  <div class="metric-label">身高</div>
+                </div>
+                <div class="metric-icon orange">
+                  <i class="el-icon-sort"></i>
+                </div>
+              </div>
+              <div class="metric-item">
+                <div class="metric-data">
+                  <div class="metric-value">{{ weight }}<span class="metric-unit">kg</span></div>
+                <div class="metric-label">体重</div>
+                </div>
+                <div class="metric-icon green">
+                  <i class="el-icon-top"></i>
+                </div>
+              </div>
+            </div>
+            
+            <div class="health-metrics-row">
+              <div class="metric-item">
+                <div class="metric-data">
+                  <div class="metric-value">{{ bmiStatus }}</div>
+                <div class="metric-label">BMI</div>
+                </div>
+                <div class="metric-icon blue">
+                  <i class="el-icon-plus"></i>
+                </div>
+              </div>
+              <div class="metric-item">
+                <div class="metric-data">
+                  <div class="metric-value">{{ caloriesData.value }}<span class="metric-unit">{{ caloriesData.unit }}</span></div>
+                  <div class="metric-label">{{ caloriesData.label }}</div>
+                </div>
+                <div class="metric-icon red">
+                  <i class="el-icon-hot-water"></i>
+                </div>
+              </div>
+            </div>
+            
+            <div class="health-metrics-row">
+              <div class="metric-item">
+                <div class="metric-data">
+                  <div class="metric-value">{{ stepsData.value }}<span class="metric-unit">{{ stepsData.unit }}</span></div>
+                  <div class="metric-label">{{ stepsData.label }}</div>
+                </div>
+                <div class="metric-icon orange">
+                  <i class="el-icon-s-promotion"></i>
+                </div>
+              </div>
+              <div class="metric-item">
+                <div class="metric-data">
+                  <div class="metric-value">{{ sleepCardData.status }}</div>
+                <div class="metric-label">{{ sleepCardData.label }}</div>
+                </div>
+                <div class="metric-icon purple">
+                  <i class="el-icon-moon"></i>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+  
+        <!-- 健康数据 (Health Data) Section -->
+        <div class="health-data-section">
+          <div class="section-title">健康数据</div>
+          
+          <!-- 血糖趋势 (Blood Sugar Trend) -->
+          <div class="trend-chart">
+            <div class="trend-title">血糖趋势</div>
+            <div class="trend-subtitle">单位: mmol/L</div>
+            <div class="line-chart-container" ref="bloodSugarChart"></div>
+            <div class="trend-advice">
+            </div>
+          </div>
+          
+          <!-- 尿酸趋势 (Uric Acid Trend) -->
+          <div class="trend-chart">
+            <div class="trend-title">尿酸趋势</div>
+            <div class="trend-subtitle">单位: μmol/L</div>
+            <div class="bar-chart-container" ref="uricAcidChart"></div>
+            <div class="legend">
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #4CD964;"></span>
+                <span>150~416μmol/L</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #FFCC00;"></span>
+                <span>&lt;150μmol/L</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #FF6B6B;"></span>
+                <span>&gt;416μmol/L</span>
+              </div>
+            </div>
+          </div>
+
+           <div class="trend-chart">
+            <div class="trend-title">血压趋势</div>
+            <div class="trend-subtitle">单位: mmgh</div>
+            <div class="line-chart-container" ref="bloodPressureChart"></div>
+          </div>
+
+           <div class="trend-chart">
+            <div class="trend-title">心率趋势</div>
+            <div class="trend-subtitle">单位: 次/分</div>
+            <div class="line-chart-container" ref="heartRateChart"></div>
+          </div>
+
+           <div class="trend-chart">
+            <div class="trend-title">血氧趋势</div>
+            <div class="trend-subtitle"></div>
+            <div class="line-chart-container" ref="bloodOxygenChart"></div>
+            <div class="legend">
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #4CD964;"></span>
+                <span>&lt;90%</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #FFCC00;"></span>
+                <span>&lt;95%</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #FF6B6B;"></span>
+                <span>其他</span>
+              </div>
+            </div>
+          </div>
+
+           <div class="trend-chart">
+            <div class="trend-title">睡眠趋势</div>
+            <div class="trend-subtitle"></div>
+            <div class="line-chart-container" ref="sleepChart"></div>
+            <div class="legend">
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #8C37E6;"></span>
+                <span>深睡</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #D138CF;"></span>
+                <span>浅睡</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #F88082;"></span>
+                <span>快速眼动</span>
+              </div>
+              <div class="legend-item">
+                <span class="legend-color" style="background-color: #FDBD27;"></span>
+                <span>清醒</span>
+              </div>
+            </div>
+          </div>
+          
+         
+        </div>
+  
+        
+        <div class="health-archive-section">
+          <div class="section-title">健康档案</div>
+          <div class="archive-items">
+            <!-- 健康预测 -->
+            <div class="archive-item">
+              <div class="archive-icon warning">
+                <i class="el-icon-warning-outline"></i>
+              </div>
+              <div class="archive-content">
+                <div class="archive-title">健康预测</div>
+                <div class="archive-text" v-for="(item, idx) in healthArchive" :key="'prediction' + idx">
+                  {{ item.prediction }}
+                </div>
+              </div>
+            </div>
+
+            <!-- 解析 -->
+            <div class="archive-item">
+              <div class="archive-icon analysis">
+                <i class="el-icon-data-analysis"></i>
+              </div>
+              <div class="archive-content">
+                <div class="archive-title">解析</div>
+                <div class="archive-text" v-for="(item, idx) in healthArchive" :key="'analysis' + idx">
+                  {{ item.analysis }}
+                </div>
+              </div>
+            </div>
+
+            <!-- 治疗建议 -->
+            <div class="archive-item">
+              <div class="archive-icon suggestion">
+                <i class="el-icon-first-aid-kit"></i>
+              </div>
+              <div class="archive-content">
+                <div class="archive-title">治疗建议</div>
+                <div class="archive-text" v-for="(item, idx) in healthArchive" :key="'suggestion' + idx">
+                  {{ item.suggestion }}
+                </div>
+              </div>
+            </div>
+
+            <!-- 注意事项 -->
+            <div class="archive-item">
+              <div class="archive-icon attention">
+                <i class="el-icon-info"></i>
+              </div>
+              <div class="archive-content">
+                <div class="archive-title">注意事项</div>
+                <div class="archive-text" v-for="(item, idx) in healthArchive" :key="'precautions' + idx">
+                  {{ item.precautions }}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        
+        <!-- AI舌诊 (AI Tongue Diagnosis) Section -->
+        <div class="ai-diagnosis-section">
+          <div class="section-title">AI舌诊</div>
+          <div class="date-selector-tabs">
+            <div 
+              v-for="date in weekDates" 
+              :key="date"
+              :class="['date-tab', selectedDiagnosisDate === date ? 'active' : '']"
+              @click="selectedDiagnosisDate = date"
+            >
+              {{ date }}
+            </div>
+          </div>
+          
+          <div class="diagnosis-content" v-if="tongueData">
+            <div class="diagnosis-time">检测时间 {{ tongueData.createTime || '未知时间' }}</div>
+            
+            <div class="diagnosis-result">
+              <div class="diagnosis-label">您属于</div>
+              <div class="diagnosis-type">{{ tongueData.typeName || '未知体质' }}</div>
+            </div>
+            
+            <div class="diagnosis-section">
+              <div class="diagnosis-section-title">舌苔特征</div>
+              <div class="diagnosis-feature">
+                <div class="feature-dot"></div>
+                <div class="feature-title">{{ tongueData.taiseName || '未知' }}</div>
+                <div class="feature-description">{{ tongueData.taiseDesc || '未知' }}</div>
+              </div>
+              <div class="diagnosis-feature">
+                <div class="feature-dot"></div>
+                <div class="feature-title">{{ tongueData.shemianName || '未知' }}</div>
+                <div class="feature-description">{{ tongueData.shemianDesc || '未知' }}</div>
+              </div>
+            </div>
+            
+            <div class="diagnosis-section">
+              <!-- <div class="diagnosis-section-title">体质解析</div> -->
+              <div 
+                class="diagnosis-analysis" 
+                v-for="(item, index) in tongueData.typeJsonObj" 
+                :key="index"
+              >
+                <div class="diagnosis-section-title">{{ item.name }}</div>
+                <div class="analysis-content">{{ item.value }}</div>
+              </div>
+            </div>
+          </div>
+          <div class="diagnosis-content" v-else>
+            <div class="no-data">暂无舌诊数据</div>
+          </div>
+        </div>
+      </el-card>
+    </div>
+  </template>
+  
+  <script>
+  import * as echarts from 'echarts';
+  import {getUserHealthInfoByDeviceId,queryHealthReport,queryGlucoseList,queryUaList,queryPressureList,queryHrList,querySpo2List,querySleepList,queryTongueList} from "@/api/watch/deviceInfo";
+  export default {
+    props: {
+        // detailsType: { 
+        //     type: Boolean,
+        //     default: false
+        // },
+        userInfo: {
+          type: Object,
+          default: () => ({})
+        },
+        deviceId: {
+            type: String || Number,
+            default: "",
+        },
+    },
+    name: 'WeeklyReport',
+    data() {
+      return {
+        sleepDefaultInfo: {
+          4: { name: "浅睡", color: "#D138CF", yValue: 7 },
+          3: { name: "深睡", color: "#8C37E6", yValue: 3 },
+          6: { name: "清醒", color: "#FDBD27", yValue: 9 },
+          7: { name: "快速眼动", color: "#F88082", yValue: 12 },
+        },
+        startTime: null,
+        endTime:  null,
+        // 基础数据
+        height: '--',
+        weight: '--',
+        bmi: 0,
+        bmiStatus: '--',
+        healthIndex: 0,
+        healthStatus: '--',
+        healthDesc:"",
+        // 健康指标数据
+        caloriesData: {
+          value: '--',
+          unit: 'kcal',
+          label: '卡路里'
+        },
+        stepsData: {
+          value: '--',
+          unit: '步',
+          label: '步数'
+        },
+        sleepCardData: {
+          status: '--',
+          label: '睡眠'
+        },
+        selectedDate: new Date(),
+        // 健康档案
+        healthArchive: [],
+        selectedDiagnosisDate: '12/27',
+        bloodSugarChart: null,
+        uricAcidChart: null,
+        bloodPressureChart:null,
+        heartRateChart:null,
+        bloodOxygenChart:null,
+        sleepChart:null,
+        bloodSugarData: {
+          values: []
+        },
+       
+        uricAcidData: {
+          values: [
+          ]
+        },
+        bloodPressureData:{},
+        heartRateData:{},
+        bloodOxygenData:{values: []},
+        sleepData:[],
+        tongueData:null
+      };
+    },
+    mounted() {
+      this.$nextTick(() => {
+        this.initBloodSugarChart();
+        this.initBloodPressureChart();
+        this.initUricAcidChart();
+        this.initHeartRateChart();
+        this.initBloodOxygenChart();
+        this.initSleepChart();
+        this.setInitialDiagnosisDate();
+      });
+    },
+    computed: {
+      weekDates() {
+        const dates = [];
+        const startOfWeek = new Date(this.selectedDate);
+        startOfWeek.setDate(this.selectedDate.getDate() - (this.selectedDate.getDay() || 7) + 1); // Set to Monday
+        for (let i = 0; i < 7; i++) {
+          const date = new Date(startOfWeek);
+          date.setDate(startOfWeek.getDate() + i);
+          dates.push(this.formatDate(date));
+        }
+        return dates;
+      }
+    },
+    watch: {
+      userInfo: {
+        handler(newVal) {
+          if (newVal) {
+            this.updateUserInfo();
+          }
+        },
+        immediate: true,
+        deep: true
+      },
+      selectedDate() {
+        this.setInitialDiagnosisDate();
+      },
+      selectedDiagnosisDate(newVal) {
+        if (newVal) {
+          this.getTongueDate();
+        }
+      }
+    },
+    methods: {
+      formatDate(date) {
+        return `${date.getMonth() + 1}/${date.getDate()}`;
+      },
+      setInitialDiagnosisDate() {
+        this.selectedDiagnosisDate = this.weekDates[0] || null;
+      },
+      getWeekDates(date) {
+        const startOfWeek = new Date(date);
+        const endOfWeek = new Date(date);
+        startOfWeek.setDate(date.getDate() - (date.getDay() || 7) + 1); // 如果是周日,getDay() 返回 0,需要加 7
+         // 将日期设置为周一,时间为00:00:00
+        // startOfWeek.setDate(date.getDate() - date.getDay());
+        startOfWeek.setHours(0, 0, 0, 0);
+
+        // 将日期设置为周日,时间为23:59:59
+        endOfWeek.setDate(date.getDate() - (date.getDay() || 7) + 7); // 如果是周日,getDay() 返回 0,需要加 7
+        // endOfWeek.setDate(date.getDate() - date.getDay() + 6);
+        endOfWeek.setHours(23, 59, 59, 999);
+       
+        this.startTime= startOfWeek,
+        this.endTime=  endOfWeek
+      },
+      // 更新基本信息
+      updateUserInfo() {
+        if (this.userInfo) {
+          // 设置身高体重
+          this.height = this.userInfo.height || '--';
+          this.weight = this.userInfo.weight || '--';
+          
+          // 计算BMI
+          this.calculateBMI();}
+      },
+      // 计算BMI
+      calculateBMI() {
+        if (this.height && this.weight && this.height !== '--' && this.weight !== '--') {
+          // BMI = 体重(kg) / 身高(m)²
+          const heightInMeters = parseFloat(this.height) / 100;
+          const weightInKg = parseFloat(this.weight);
+          
+          if (!isNaN(heightInMeters) && !isNaN(weightInKg) && heightInMeters > 0) {
+            this.bmi = (weightInKg / (heightInMeters * heightInMeters)).toFixed(1);
+            
+            // 设置BMI状态
+            if (this.bmi < 18.5) {
+              this.bmiStatus = '偏瘦';
+            } else if (this.bmi >= 18.5 && this.bmi < 24) {
+              this.bmiStatus = '正常';
+            } else if (this.bmi >= 24 && this.bmi < 28) {
+              this.bmiStatus = '超重';
+            } else {
+              this.bmiStatus = '肥胖';
+            }
+          }
+        }
+      },
+      async getSportData() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime),
+            endTime: this.parseTime(this.endTime),
+            userId: this.userInfo.userId
+          };
+          const response = await getUserHealthInfoByDeviceId(params);
+          if(response.data){
+            this.caloriesData.value = response.data.calorie;
+            this.stepsData.value = response.data.step;
+            this.sleepData.status = response.data.sleepStatus;
+          }
+        }catch(error){
+          console.error("获取步数,卡路里,睡眠等信息数据失败:", error);
+        }
+      },
+
+      async getHealthArchive() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime),
+            endTime: this.parseTime(this.endTime),
+          };
+          const response = await queryHealthReport(params);
+          if(response.data){
+            this.healthArchive = response.data.list;
+            this.healthIndex = response.data.score;
+            this.healthStatus = response.data.title;
+            this.healthDesc = response.data.desc;
+
+          }
+        }catch(error){
+          console.error("获取步数,卡路里,睡眠等信息数据失败:", error);
+        }
+      },
+      async getGlucoseDate() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+            endTime: this.parseTime(this.endTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+          };
+          const response = await queryGlucoseList(params);
+          if(response.data && response.data.length > 0){
+            const values = response.data.map(item => {
+              const timestamp = new Date(item.createTime).getTime();
+              return [timestamp, item.bloodGlucose, item.createTime];
+            });
+            this.bloodSugarData = {
+              values: values
+            };
+        
+          } else {
+            this.bloodSugarData = {
+              values: []
+            };
+          }
+        }catch(error){
+          console.error("获取血糖数据失败:", error);
+          this.bloodSugarData = {
+            values: []
+          };
+        }
+      },
+
+      async getUaDate() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+            endTime: this.parseTime(this.endTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+          };
+          const response = await queryUaList(params);
+          if(response.data && response.data.length > 0){
+            const values = response.data.map(item => {
+              const timestamp = new Date(item.createTime).getTime();
+              const val = item.val/10;
+              let color;
+              if (val < 150) {
+                  color = '#FFCC00';
+              } else if (val < 416) {
+                color = '#4CD964';
+              } else {
+                color = '#FF6B6B';
+              }
+              return [timestamp, val, item.createTime,color];
+            });
+            this.uricAcidData.values = values;
+        
+          } else {
+            this.uricAcidChart.values = [];
+          }
+        }catch(error){
+          console.error("获取尿酸数据失败:", error);
+          this.uricAcidChart.values = [];
+        }
+      },
+      async getSleepDate() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+            endTime: this.parseTime(this.endTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+          };
+          const response = await querySleepList(params);
+          if(response.data.sleepSection && response.data.sleepSection.length > 0){
+            this.sleepData = response.data.sleepSection;
+          }
+        }catch(error){
+          console.error("获取睡眠数据失败:", error);
+        }
+      },
+      async querySpo2Data() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+            endTime: this.parseTime(this.endTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+          };
+          const response = await querySpo2List(params);
+          if(response.data.list && response.data.list.length > 0){
+            const values = response.data.list.map(item => {
+              const timestamp = new Date(item.createTime).getTime();
+              const val = item.avgBoxy;
+              let color;
+              if (val < 150) {
+                  color = '#FFCC00';
+              } else if (val < 416) {
+                color = '#4CD964';
+              } else {
+                color = '#FF6B6B';
+              }
+              return [timestamp, val, item.createTime,color];
+            });
+            this.bloodOxygenData.values = values;
+        
+          } else {
+            this.bloodOxygenData.values = [];
+          }
+        }catch(error){
+          console.error("获取尿酸数据失败:", error);
+          this.bloodOxygenData.values = [];
+        }
+      },
+
+      async getHrDate() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+            endTime: this.parseTime(this.endTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+          };
+          const response = await queryHrList(params);
+          if(response.data.list && response.data.list.length > 0){
+            const values = response.data.list.map(item => {
+              const timestamp = new Date(item.createTime).getTime();
+              return [timestamp, (item.maxBpm + item.minBpm)/2, item.createTime];
+            });
+            this.heartRateData = {
+              values: values
+            };
+           
+          } else {
+            this.heartRateData = {
+              values: []
+            };
+          }
+        }catch(error){
+          console.error("获取心率数据失败:", error);
+          this.bloodSugarData = {
+            values: []
+          };
+        }
+      },
+
+      
+      async queryPressureData() {
+        try{
+          const params = {
+            deviceId: this.deviceId,
+            startTime: this.parseTime(this.startTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+            endTime: this.parseTime(this.endTime,'{y}/{m}/{d} {h}:{i}:{s}'),
+          };
+          const response = await queryPressureList(params);
+          if(response.data && response.data.length > 0){
+            
+
+            const sbp = response.data.map((item) => ({
+              value: [
+                new Date(item.createTime).getTime(),
+                item.sbp,
+              ]
+            }));
+            const dbp = response.data.map((item) => ({
+              value: [
+                  new Date(item.createTime).getTime(),
+                  item.dbp,
+              ]
+            }));
+            const hr = response.data.map((item) => ({
+              value: [
+                  new Date(item.createTime).getTime(),
+                  item.hr,
+              ]
+            }));
+            const createTime = response.data.map((item) => ({
+              value: [
+                  item.createTime
+              ]
+            }));
+            this.bloodPressureData = {
+              sbp : sbp,
+              dbp : dbp,
+              hr : hr,
+              createTime:createTime,
+            };
+          } else {
+            this.bloodPressureData = {
+              sbp : [],
+              dbp : [],
+              hr :  [],
+              createTime:[]
+            };
+          }
+        }catch(error){
+          console.error("获取血压数据失败:", error);
+          this.bloodPressureData = {
+            sbp : [],
+            dbp : [],
+            hr :  [],
+            createTime:[]
+          };
+        }
+      },
+
+      formatCreateDay(dateStr) {
+        const [month, day] = dateStr.split('/').map(Number);
+        const year = this.selectedDate.getFullYear();
+        const date = new Date(year, month - 1, day);
+        return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+      },
+
+      async getTongueDate(){
+        if(this.userInfo && this.userInfo.userId){
+          const params = {
+            userId: this.userInfo.userId,
+            createDay: this.formatCreateDay(this.selectedDiagnosisDate),
+          };
+          
+          const response = await queryTongueList(params);
+          if(response && response.rows.length > 0){
+            this.tongueData = response.rows[0]
+            this.tongueData.typeJsonObj = JSON.parse(this.tongueData.typeJson || '[]')
+            console.log(this.tongueData)
+          } else {
+            this.tongueData = null;
+          }
+        }
+      },
+      // 获取周报详情数据
+      async getDetailsData() {
+        try {
+          this.getWeekDates(this.selectedDate);
+          // 获取健康周报-步数,卡路里,睡眠等信息
+          await this.getSportData();
+          await this.getHealthArchive();
+          await this.getGlucoseDate();
+          await this.getUaDate();
+          await this.queryPressureData();
+          await this.getHrDate();
+          await this.querySpo2Data();
+          await this.getSleepDate();
+          await this.getTongueDate();
+          this.updateCharts();
+        } catch (error) {
+          console.error("获取周报数据失败:", error);
+        }
+      },
+
+        
+      handleDateChange(date) {
+        this.selectedDate = date || this.parseTime(new Date(), "{y}-{m}-{d}");
+        this.getDetailsData();
+      },
+     
+      initBloodSugarChart() {
+        const chartDom = this.$refs.bloodSugarChart;
+        this.bloodSugarChart = echarts.init(chartDom);
+        const option = {
+          tooltip: {
+            trigger: 'item',
+            formatter: function(params) {
+              if (!params.data || params.data.length < 3) {
+                return '无数据';
+              }
+              const value = params.data[1];
+              const createTime = params.data[2];
+              return `时间: ${createTime}<br/>血糖: ${value} mmol/L`;
+            }
+          },
+          grid: {
+            left: '3%',
+            right: '4%',
+            bottom: '3%',
+            top: '3%',
+            containLabel: true
+          },
+          xAxis: {
+            type: 'time',
+            boundaryGap: false,
+            splitNumber: 7,
+            min:new Date(this.startTime).getTime(),
+            max:new Date(this.endTime).getTime() + 1,
+            axisLabel: {
+              color: "#999999",
+              margin: 20,
+              // fontSize: 14,
+              fontWeight: "bold",
+              formatter: (value) => {
+                return this.parseTime(value, "{m}/{d}");
+              },
+            },
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          yAxis: {
+            type: 'value',
+            min: 0,
+            max: 12,
+            interval: 2,
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          series: [
+            {
+              name: '血糖',
+              data: this.bloodSugarData.values,
+              type: 'line',
+              smooth: true,
+              symbol: 'circle',
+              symbolSize: 7,
+              itemStyle: {
+                color: '#FF9F43'
+              },
+              lineStyle: {
+                color: '#FF9F43'
+              },
+              areaStyle: {
+                color: {
+                  type: 'linear',
+                  x: 0,
+                  y: 0,
+                  x2: 0,
+                  y2: 1,
+                  colorStops: [
+                    {
+                      offset: 0,
+                      color: 'rgba(255, 159, 67, 0.3)'
+                    },
+                    {
+                      offset: 1,
+                      color: 'rgba(255, 159, 67, 0.1)'
+                    }
+                  ]
+                }
+              }
+            }
+          ]
+        };
+        
+        this.bloodSugarChart.setOption(option);
+        
+        // 响应式调整
+        window.addEventListener('resize', () => {
+          this.bloodSugarChart.resize();
+        });
+      },
+      initBloodPressureChart() {
+        const chartDom = this.$refs.bloodPressureChart;
+        this.bloodPressureChart = echarts.init(chartDom);
+        const option = {
+          tooltip: {
+            trigger: 'axis',
+            formatter: function(params) {
+              if (!params) {
+                return '无数据';
+              }
+              const sbp = params[0].data.value;
+              const dbp = params[1].data.value;
+              const hr = params[2].data.value;
+              const createTime = new Date(sbp[0]).toLocaleString();
+              return `时间: ${createTime}<br/>sbp: ${sbp[1]} mmgh<br/>bdp: ${dbp[1]} mmgh<br/>hr: ${hr[1]} 次/分`;
+            }
+          },
+          legend: {},
+          grid: {
+            left: '3%',
+            right: '4%',
+            bottom: '3%',
+            top: '3%',
+            containLabel: true
+          },
+          xAxis: {
+            type: "time",
+            boundaryGap: false,
+            splitNumber: 7,
+            min:new Date(this.startTime).getTime(),
+            max:new Date(this.endTime).getTime(),
+            axisLabel: {
+              color: "#999999",
+              margin: 20,
+              fontWeight: "bold",
+              formatter: (value) => {
+                return this.parseTime(value, "{m}/{d}");
+              },
+            },
+            axisTick: {
+                show: false,
+            },
+            splitLine: {
+                show: false,
+            },
+          },
+          yAxis: {
+              type: 'value',
+              min: 0,
+              max: 180,
+              interval: 30,
+              axisLabel: {
+                  formatter: '{value}'
+              },
+              axisTick: {
+                show: false,
+              },
+              splitLine: {
+                show: false,
+              },
+          },
+          
+          series: [
+              {
+                  name: 'sbp(mmgh)',
+                  type: "line",
+                  symbol: "none",
+                  data: this.bloodPressureData.sbp
+              },
+              {
+                  name: 'dbp(mmgh)',
+                  type: "line",
+                  symbol: "none",
+                  data: this.bloodPressureData.dbp
+              },
+              {
+                  name: 'hr(次/分)',
+                  type: "line",
+                  symbol: "none",
+                  data: this.bloodPressureData.hr
+              }
+          ]
+          
+        };
+        
+        this.bloodPressureChart.setOption(option);
+        
+        // 响应式调整
+        window.addEventListener('resize', () => {
+          this.bloodPressureChart.resize();
+        });
+      },
+      initUricAcidChart() {
+        const chartDom = this.$refs.uricAcidChart;
+        this.uricAcidChart = echarts.init(chartDom);
+
+        const data = this.uricAcidData.values.map((item, index) => {
+          return {
+            value: [item[0],item[1]],
+            itemStyle: {
+              color: item[3]
+            },
+            time:item[2]
+          };
+        });
+        
+        const option = {
+          tooltip: {
+            trigger: 'item',
+            formatter: function(params) {
+              if (!params.data || params.data.length < 4) {
+                return '无数据';
+              }
+              const value = params.data.value[1];
+              const createTime = params.data.time;
+              return `时间: ${createTime}<br/>尿酸: ${value} μmol/L`;
+            }
+          },
+          grid: {
+            left: '3%',
+            right: '4%',
+            bottom: '3%',
+            top: '3%',
+            containLabel: true
+          },
+          xAxis: {
+            type: 'time',
+            splitNumber: 7,
+            min:new Date(this.startTime).getTime(),
+            max:new Date(this.endTime).getTime() + 1,
+            axisLabel: {
+              color: "#999999",
+              margin: 20,
+              fontWeight: "bold",
+              formatter: (value) => {
+                return this.parseTime(value, "{m}/{d}");
+              },
+            },
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          yAxis: {
+            type: 'value',
+            min: 0,
+            max: 600,
+            interval: 100,
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          series: [
+            {
+              name: '尿酸',
+              type: 'bar',
+              data: data,
+              // barWidth: '40%'
+              barWidth: 10, // 设置柱子的宽度为 20 像素
+            }
+          ]
+        };
+        
+        this.uricAcidChart.setOption(option);
+        
+        // 响应式调整
+        window.addEventListener('resize', () => {
+          if (this.uricAcidChart) {
+            this.uricAcidChart.resize();
+          }
+        });
+      },
+      initHeartRateChart(){
+        const chartDom = this.$refs.heartRateChart;
+        this.heartRateChart = echarts.init(chartDom);
+        const option = {
+          tooltip: {
+            trigger: 'item',
+            formatter: function(params) {
+              if (!params.data || params.data.length < 3) {
+                return '无数据';
+              }
+              const value = params.data[1];
+              const createTime = params.data[2];
+              return `时间: ${createTime}<br/>心率: ${value} 次/分`;
+            }
+          },
+          grid: {
+            left: '3%',
+            right: '4%',
+            bottom: '3%',
+            top: '3%',
+            containLabel: true
+          },
+          xAxis: {
+            type: 'time',
+            boundaryGap: false,
+            splitNumber: 7,
+            min:new Date(this.startTime).getTime(),
+            max:new Date(this.endTime).getTime() + 1,
+            axisLabel: {
+              color: "#999999",
+              margin: 20,
+              // fontSize: 14,
+              fontWeight: "bold",
+              formatter: (value) => {
+                return this.parseTime(value, "{m}/{d}");
+              },
+            },
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          yAxis: {
+            type: 'value',
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          series: [
+            {
+              name: '心率',
+              data: this.heartRateData.values,
+              type: 'line',
+              smooth: true,
+              symbol: 'circle',
+              symbolSize: 7,
+              itemStyle: {
+                color: '#FF9F43'
+              },
+              lineStyle: {
+                color: '#FF9F43'
+              },
+              areaStyle: {
+                color: {
+                  type: 'linear',
+                  x: 0,
+                  y: 0,
+                  x2: 0,
+                  y2: 1,
+                  colorStops: [
+                    {
+                      offset: 0,
+                      color: 'rgba(255, 159, 67, 0.3)'
+                    },
+                    {
+                      offset: 1,
+                      color: 'rgba(255, 159, 67, 0.1)'
+                    }
+                  ]
+                }
+              }
+            }
+          ]
+        };
+        
+        this.heartRateChart.setOption(option);
+        
+        // 响应式调整
+        window.addEventListener('resize', () => {
+          this.heartRateChart.resize();
+        });
+      },
+      initBloodOxygenChart(){
+        const chartDom = this.$refs.bloodOxygenChart;
+        this.bloodOxygenChart = echarts.init(chartDom);
+
+        const data = this.bloodOxygenData.values.map((item, index) => {
+          return {
+            value: [item[0],item[1]],
+            itemStyle: {
+              color: item[3]
+            },
+            time:item[2]
+          };
+        });
+        
+        const option = {
+          tooltip: {
+            trigger: 'item',
+            formatter: function(params) {
+              if (!params.data || params.data.length < 4) {
+                return '无数据';
+              }
+              const value = params.data.value[1];
+              const createTime = params.data.time;
+              return `时间: ${createTime}<br/>血氧: ${value} %`;
+            }
+          },
+          grid: {
+            left: '3%',
+            right: '4%',
+            bottom: '3%',
+            top: '3%',
+            containLabel: true
+          },
+          xAxis: {
+            type: 'time',
+            splitNumber: 7,
+            min:new Date(this.startTime).getTime(),
+            max:new Date(this.endTime).getTime() + 1,
+            axisLabel: {
+              color: "#999999",
+              margin: 20,
+              fontWeight: "bold",
+              formatter: (value) => {
+                return this.parseTime(value, "{m}/{d}");
+              },
+            },
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          yAxis: {
+            type: 'value',
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          series: [
+            {
+              name: '血氧',
+              type: 'bar',
+              data: data,
+              // barWidth: '40%'
+              barWidth: 10, // 设置柱子的宽度为 20 像素
+            }
+          ]
+        };
+        
+        this.bloodOxygenChart.setOption(option);
+        
+        // 响应式调整
+        window.addEventListener('resize', () => {
+          if (this.bloodOxygenChart) {
+            this.bloodOxygenChart.resize();
+          }
+        });
+      },
+      initSleepChart(){
+        const chartDom = this.$refs.sleepChart;
+        this.sleepChart = echarts.init(chartDom);
+        const option = {
+          tooltip: {
+            show: true,
+            formatter: (params) => {
+              const start = params.value[3]
+              const end = params.value[4]
+              return `${start}到${end}<br/>${params.name}`;
+            },
+          },
+          grid: {
+            left: '3%',
+            right: '4%',
+            bottom: '3%',
+            top: '3%',
+            containLabel: true
+          },
+          xAxis: {
+            type: 'time',
+            boundaryGap: false,
+            splitNumber: 7,
+            min:new Date(this.startTime).getTime(),
+            max:new Date(this.endTime).getTime() + 1,
+            axisLabel: {
+              color: "#999999",
+              margin: 20,
+              // fontSize: 14,
+              fontWeight: "bold",
+              formatter: (value) => {
+                return this.parseTime(value, "{m}/{d}");
+              },
+            },
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          yAxis: {
+            max: 14,
+            show: false,
+            axisTick: {
+              show: false,
+            },
+            splitLine: {
+              show: false,
+            },
+          },
+          series: [
+          {
+            type: "custom",
+            renderItem: (params, api) => {
+              let yValue = api.value(2);
+              let start = api.coord([api.value(0), yValue]);
+              let size = api.size([api.value(1) - api.value(0), yValue]);
+              let style = api.style();
+              return {
+                type: "rect",
+                shape: {
+                  x: start[0],
+                  y: start[1],
+                  width: size[0],
+                  height: size[1],
+                },
+                style: style,
+              };
+            },
+            data: [],
+          },
+          ]
+        };
+        
+        this.sleepChart.setOption(option);
+        
+        // 响应式调整
+        window.addEventListener('resize', () => {
+          this.sleepChart.resize();
+        });
+      },
+      updateCharts() {
+        if (this.bloodSugarChart) {
+          // 更新血糖图表数据
+          this.bloodSugarChart.setOption({
+            series: [
+              {
+                data: this.bloodSugarData.values
+              }
+            ],
+            xAxis: {
+              min:new Date(this.startTime).getTime(),
+              max:new Date(this.endTime).getTime() + 1,
+            },
+          });
+        }
+
+        if (this.bloodPressureChart) {
+          this.bloodPressureChart.setOption({
+            series: [
+              {
+                name: 'sbp(mmgh)',
+                type: "line",
+                symbol: "none",
+                data: this.bloodPressureData.sbp
+              },
+              {
+                name: 'dbp(mmgh)',
+                type: "line",
+                symbol: "none",
+                data: this.bloodPressureData.dbp
+              },
+              {
+                name: 'hr(次/分)',
+                type: "line",
+                symbol: "none",
+                data: this.bloodPressureData.hr
+              },
+            ],
+            xAxis: {
+              min:new Date(this.startTime).getTime(),
+              max:new Date(this.endTime).getTime()  + 1,
+            },
+          });
+        }
+        
+        if (this.uricAcidChart) {
+          // 更新尿酸图表数据
+          const data = this.uricAcidData.values.map((item, index) => {
+            return {
+              value: [item[0],item[1]],
+              itemStyle: {
+                color: item[3]
+              },
+              time:item[2]
+            };
+          });
+          
+          this.uricAcidChart.setOption({
+            series: [
+              {
+                // type: 'bar',
+                data: data
+              }
+            ],
+            xAxis: {
+              min:new Date(this.startTime).getTime(),
+              max:new Date(this.endTime).getTime() + 1,
+            },
+          });
+        }
+        if (this.heartRateChart) {
+          this.heartRateChart.setOption({
+            series: [
+              {
+                data: this.heartRateData.values
+              }
+            ],
+            xAxis: {
+              min:new Date(this.startTime).getTime(),
+              max:new Date(this.endTime).getTime() + 1,
+            },
+          });
+        }
+        if (this.bloodOxygenChart) {
+          const data = this.bloodOxygenData.values.map((item, index) => {
+            return {
+              value: [item[0],item[1]],
+              itemStyle: {
+                color: item[3]
+              },
+              time:item[2]
+            };
+          });
+          
+          this.bloodOxygenChart.setOption({
+            series: [
+              {
+                // type: 'bar',
+                data: data
+              }
+            ],
+            xAxis: {
+              min:new Date(this.startTime).getTime(),
+              max:new Date(this.endTime).getTime() + 1,
+            },
+          });
+        }
+       
+        if (this.sleepChart) {
+          const chartData = this.sleepData.map((item, index) => {
+            return {
+              name: this.sleepDefaultInfo[item.type] && this.sleepDefaultInfo[item.type].name,
+              value: [
+                new Date(item.start).getTime(),
+                new Date(item.end).getTime(),
+                this.sleepDefaultInfo[item.type] && this.sleepDefaultInfo[item.type].yValue,
+                item.start,
+                item.end
+              ],
+              itemStyle: {
+                color: this.sleepDefaultInfo[item.type] && this.sleepDefaultInfo[item.type].color,
+              },
+            };
+          });
+          this.sleepChart.setOption({
+            series: [
+              {
+                data: chartData
+              }
+            ],
+            xAxis: {
+              min:new Date(this.startTime).getTime(),
+              max:new Date(this.endTime).getTime() + 1,
+            },
+          });
+        }
+      }
+    },
+    beforeDestroy() {
+      // 清除事件监听
+      window.removeEventListener('resize', this.resizeHandler);
+      
+      // 销毁图表实例
+      if (this.bloodSugarChart) {
+        this.bloodSugarChart.dispose();
+        this.bloodSugarChart = null;
+      }
+      if (this.bloodPressureChart) {
+        this.bloodPressureChart.dispose();
+        this.bloodPressureChart = null;
+      }
+      if (this.uricAcidChart) {
+        this.uricAcidChart.dispose();
+        this.uricAcidChart = null;
+      }
+      if (this.heartRateChart) {
+        this.heartRateChart.dispose();
+        this.heartRateChart = null;
+      }
+      if (this.bloodOxygenChart) {
+        this.bloodOxygenChart.dispose();
+        this.bloodOxygenChart = null;
+      }
+      if (this.sleepChart) {
+        this.sleepChart.dispose();
+        this.sleepChart = null;
+      }
+    }
+  };
+  </script>
+  
+  <style scoped lang="scss">
+  
+  /* 确保内容不会因为隐藏滚动条而被截断 */
+  .weekly-report {
+    padding: 20px;
+    background-color: #f5f7fa;
+    min-height: 100%;
+    overflow-y: auto;
+    overflow-x: hidden;
+  }
+  
+  .report-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+  }
+  
+  .report-title {
+    display: flex;
+    align-items: center;
+    font-size: 18px;
+    font-weight: bold;
+    color: #2284ff;
+    
+    .title-icon {
+      width: 13px;
+      height: 20px;
+      margin-right: 10px;
+    }
+  }
+  
+  .report-card {
+    margin-bottom: 20px;
+    border-radius: 8px;
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+    overflow: hidden; /* 确保内容不会溢出卡片 */
+  }
+  
+  .progress-section {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 20px 0;
+    
+    @media (min-width: 768px) {
+      flex-direction: row;
+      justify-content: space-around;
+      align-items: flex-start;
+    }
+  }
+  
+  .progress-container {
+    position: relative;
+    margin-bottom: 20px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    
+    @media (min-width: 768px) {
+      margin-bottom: 0;
+    }
+    
+    .progress-info {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      text-align: center;
+      
+      .progress-value {
+        font-size: 24px;
+        font-weight: bold;
+        color: #FF9F43;
+        
+        .progress-unit {
+          font-size: 14px;
+        }
+      }
+      
+      .progress-label {
+        font-size: 12px;
+        color: #606266;
+      }
+    }
+    
+    .health-status {
+      margin-top: 10px;
+      font-size: 16px;
+      font-weight: bold;
+      color: #FF9F43;
+    }
+    .health-description {
+    margin-top: 8px;
+    font-size: 12px;
+    color: #606266;
+    text-align: center;
+    max-width: 240px;
+    line-height: 1.5;
+    padding: 0 10px;
+  }
+}
+  
+  .health-metrics-container {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    
+    @media (min-width: 768px) {
+      width: 60%;
+    }
+  }
+  
+  .health-metrics-row {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 10px;
+  }
+  
+  .metric-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background-color: #fff;
+    border-radius: 8px;
+    padding: 10px 15px;
+    width: 48%;
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+    
+    .metric-data {
+      .metric-value {
+        font-size: 16px;
+        font-weight: bold;
+        
+        .metric-unit {
+          font-size: 12px;
+          font-weight: normal;
+        }
+      }
+      
+      .metric-label {
+        font-size: 12px;
+        color: #606266;
+      }
+    }
+    
+    .metric-icon {
+      width: 32px;
+      height: 32px;
+      border-radius: 50%;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      
+      i {
+        font-size: 16px;
+        color: white;
+      }
+      
+      &.green {
+        background-color: #4CD964;
+      }
+      
+      &.blue {
+        background-color: #2284FF;
+      }
+      
+      &.orange {
+        background-color: #FF9F43;
+      }
+      
+      &.red {
+        background-color: #FF6B6B;
+      }
+      
+      &.purple {
+        background-color: #8A70D6;
+      }
+    }
+  }
+  
+  .section-title {
+    font-size: 16px;
+    font-weight: bold;
+    margin: 20px 0 15px;
+    color: #303133;
+    padding-left: 10px;
+  }
+  
+  /* 健康数据部分 */
+  .health-data-section {
+    padding: 0 10px;
+  }
+  
+  .trend-chart {
+    background-color: #fff;
+    border-radius: 8px;
+    padding: 15px;
+    margin-bottom: 20px;
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  }
+  
+  .trend-title {
+    font-size: 14px;
+    font-weight: bold;
+    color: #303133;
+  }
+  
+  .trend-subtitle {
+    font-size: 12px;
+    color: #909399;
+    margin-bottom: 10px;
+  }
+  
+  .line-chart-container,
+  .bar-chart-container {
+    height: 250px;
+    width: 100%;
+  }
+  
+  .trend-advice {
+    font-size: 12px;
+    color: #606266;
+    background-color: #f5f7fa;
+    padding: 10px;
+    border-radius: 4px;
+    margin-top: 10px;
+  }
+  
+  .legend {
+    display: flex;
+    flex-wrap: wrap;
+    margin-top: 10px;
+  }
+  
+  .legend-item {
+    display: flex;
+    align-items: center;
+    margin-right: 15px;
+    font-size: 12px;
+    color: #606266;
+  }
+  
+  .legend-color {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    margin-right: 5px;
+    border-radius: 2px;
+  }
+  
+  .health-tabs {
+    margin-top: 20px;
+  }
+  
+  /* 健康档案部分 */
+  .health-archive-section {
+    padding: 0 10px;
+  }
+  
+  .archive-items {
+    display: flex;
+    flex-direction: column;
+    gap: 15px;
+  }
+  
+  .archive-item {
+    display: flex;
+    background-color: #fff;
+    border-radius: 8px;
+    padding: 15px;
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  }
+  
+  .archive-icon {
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin-right: 15px;
+    flex-shrink: 0;
+    
+    i {
+      font-size: 18px;
+      color: white;
+    }
+    
+    &.warning {
+      background-color: #FF6B6B;
+    }
+    
+    &.analysis {
+      background-color: #4CD964;
+    }
+    
+    &.suggestion {
+      background-color: #2284FF;
+    }
+    
+    &.attention {
+      background-color: #FF9F43;
+    }
+  }
+  
+  .archive-content {
+    flex: 1;
+  }
+  
+  .archive-title {
+    font-size: 14px;
+    font-weight: bold;
+    color: #303133;
+    margin-bottom: 5px;
+  }
+  
+  .archive-text {
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.5;
+  }
+  
+  /* AI舌诊部分 */
+  .ai-diagnosis-section {
+    padding: 0 10px 20px;
+  }
+  
+  .date-selector-tabs {
+    display: flex;
+    overflow-x: auto;
+    margin-bottom: 15px;
+  }
+  
+  .date-tab {
+    padding: 8px 15px;
+    background-color: #f5f7fa;
+    border-radius: 4px;
+    margin-right: 8px;
+    font-size: 14px;
+    cursor: pointer;
+    white-space: nowrap;
+    
+    &.active {
+      background-color: #FF9F43;
+      color: white;
+    }
+  }
+  
+  .diagnosis-content {
+    background-color: #fff;
+    border-radius: 8px;
+    padding: 15px;
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  }
+  
+  .diagnosis-time {
+    text-align: center;
+    font-size: 14px;
+    color: #909399;
+    margin-bottom: 15px;
+  }
+  
+  .diagnosis-result {
+    text-align: center;
+    margin-bottom: 20px;
+  }
+  
+  .diagnosis-label {
+    font-size: 14px;
+    color: #606266;
+  }
+  
+  .diagnosis-type {
+    font-size: 20px;
+    font-weight: bold;
+    color: #FF6B6B;
+    margin-top: 5px;
+  }
+  
+  .diagnosis-section {
+    margin-bottom: 20px;
+  }
+  
+  .diagnosis-section-title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #FF9F43;
+    margin-bottom: 10px;
+    position: relative;
+    padding-left: 10px;
+    
+    &:before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 4px;
+      height: 16px;
+      background-color: #FF9F43;
+      border-radius: 2px;
+    }
+  }
+  
+  .diagnosis-feature {
+    margin-bottom: 10px;
+    display: flex;
+    flex-wrap: wrap;
+  }
+  
+  .feature-dot {
+    width: 8px;
+    height: 8px;
+    background-color: #FF9F43;
+    border-radius: 50%;
+    margin-right: 8px;
+    margin-top: 6px;
+  }
+  
+  .feature-title {
+    font-size: 14px;
+    font-weight: bold;
+    color: #303133;
+    margin-right: 10px;
+  }
+  
+  .feature-description {
+    font-size: 12px;
+    color: #606266;
+    flex: 1;
+  }
+  
+  .diagnosis-analysis {
+    margin-bottom: 15px;
+  }
+  
+  .analysis-title {
+    font-size: 14px;
+    font-weight: bold;
+    color: #303133;
+    margin-bottom: 5px;
+  }
+  
+  .analysis-content {
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.5;
+  }
+  
+  .diagnosis-characteristics {
+    margin-bottom: 10px;
+  }
+  
+  .characteristics-title {
+    font-size: 13px;
+    font-weight: bold;
+    color: #303133;
+    margin-bottom: 3px;
+  }
+  
+  .characteristics-content {
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.5;
+  }
+  </style>

+ 5 - 0
src/components/DeviceInfo/Sleep.vue

@@ -117,6 +117,11 @@
             <span v-if="scope.row.type === 7">快速眼动</span>
           </template>
         </el-table-column>
+        <template #empty>
+            <div>
+                今日暂无数据,请选择其他日期
+            </div>
+        </template>
       </el-table>
       <pagination v-show="(total > 0) && detailsType" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getDataPage"/>
     </div> 

+ 5 - 0
src/components/DeviceInfo/Sports.vue

@@ -29,6 +29,11 @@
       <el-table-column prop="sportTime" label="运动时长(min)" />
       <el-table-column prop="startTime" label="开始时间" />
       <el-table-column prop="type" label="运动类型" />
+      <template #empty>
+          <div>
+              今日暂无数据,请选择其他日期
+          </div>
+      </template>
     </el-table>
     <pagination v-show="(total > 0) && detailsType" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getDataPage"/>
     </div>

+ 8 - 4
src/components/DeviceInfo/Temperature.vue

@@ -24,7 +24,11 @@
                 <el-table-column prop="createTime" label="时间" />
                 <el-table-column prop="estTemp" label="体温(℃)" />
                 <el-table-column prop="shellTemp" label="体表温度(℃)" />
-                
+                <template #empty>
+                    <div>
+                        今日暂无数据,请选择其他日期
+                    </div>
+                </template>
             </el-table>
             <pagination v-show="(total > 0) && detailsType" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getDataPage"/>
         </div>
@@ -70,9 +74,9 @@ export default {
                     boundaryGap: false,
                     type: "time",
                     splitNumber: 6,
-                    axisLine: {
-                        show: false,
-                    },
+                    // axisLine: {
+                    //     show: false,
+                    // },
                     axisTick: {
                         show: false,
                     },

+ 5 - 0
src/components/DeviceInfo/Uricacid.vue

@@ -17,6 +17,11 @@
             </el-table-column>
             <el-table-column prop="val" label="测量结果">
             </el-table-column>
+            <template #empty>
+                <div>
+                    今日暂无数据,请选择其他日期
+                </div>
+            </template>
         </el-table>
 
     </div>

+ 5 - 0
src/components/DeviceInfo/Urineketones.vue

@@ -16,6 +16,11 @@
             </el-table-column>
             <el-table-column prop="val" align="center" label="测量结果">
             </el-table-column>
+            <template #empty>
+                <div>
+                    今日暂无数据,请选择其他日期
+                </div>
+            </template>
         </el-table>
 
     </div>

+ 8 - 1
src/utils/request.js

@@ -33,7 +33,14 @@ service.interceptors.request.use(config => {
       const value = config.params[propName];
       var part = encodeURIComponent(propName) + "=";
       if (value !== null && typeof(value) !== "undefined") {
-        if (typeof value === 'object') {
+        // 数组:重复键名 companyIds=1&companyIds=2,便于 Spring @RequestParam List 绑定
+        if (Array.isArray(value)) {
+          value.forEach(item => {
+            if (item !== null && typeof item !== 'undefined') {
+              url += encodeURIComponent(propName) + '=' + encodeURIComponent(item) + '&';
+            }
+          });
+        } else if (typeof value === 'object') {
           for (const key of Object.keys(value)) {
             if (value[key] !== null && typeof (value[key]) !== 'undefined') {
               let params = propName + '[' + key + ']';

+ 298 - 0
src/views/company/companyMoneyLogsDetail/index.vue

@@ -0,0 +1,298 @@
+<template>
+  <div class="app-container withdraw-detail">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" class="search-form" label-width="88px">
+      <el-form-item label="分公司" prop="companyIds">
+        <el-select
+          v-model="queryParams.companyIds"
+          multiple
+          collapse-tags
+          filterable
+          remote
+          reserve-keyword
+          clearable
+          placeholder="分公司名称关键词模糊搜索"
+          :remote-method="remoteSearchCompany"
+          :loading="companyLoading"
+          style="width: 320px"
+        >
+          <el-option
+            v-for="item in companyOptions"
+            :key="item.companyId"
+            :label="item.companyName"
+            :value="item.companyId"
+          />
+        </el-select>
+        <el-button type="text" size="small" @click="selectAllCompanies">全选</el-button>
+        <el-button type="text" size="small" @click="invertCompanySelection">反选</el-button>
+      </el-form-item>
+      <el-form-item label="明细类型" prop="detailTypes">
+        <el-select
+          v-model="queryParams.detailTypes"
+          multiple
+          collapse-tags
+          clearable
+          placeholder="全部"
+          style="width: 280px"
+        >
+          <el-option
+            v-for="item in detailTypeOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="small" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="small" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <p class="hint-text">不选分公司、不选类型时,查询全部公司与全部明细类型;数据自 2026-03-27 起。</p>
+
+    <el-row class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          icon="el-icon-download"
+          size="small"
+          @click="handleExport"
+          v-hasPermi="['company:companyMoneyLogsDetail:export']"
+        >导出</el-button>
+      </el-col>
+    </el-row>
+
+    <el-table
+      v-loading="loading"
+      :data="tableList"
+      border
+      stripe
+      :header-cell-style="{ textAlign: 'center' }"
+      :cell-style="{ textAlign: 'center' }"
+    >
+      <el-table-column label="序号" width="70" align="center">
+        <template slot-scope="scope">
+          {{ (queryParams.pageNum - 1) * queryParams.pageSize + scope.$index + 1 }}
+        </template>
+      </el-table-column>
+      <el-table-column label="公司名称" prop="companyName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="所属销售" prop="salesName" min-width="100">
+        <template slot-scope="scope">
+          {{ scope.row.salesName || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="订单号" prop="orderCode" min-width="160" show-overflow-tooltip>
+        <template slot-scope="scope">
+          {{ scope.row.orderCode || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="交易单号" prop="tradeNo" min-width="180" show-overflow-tooltip>
+        <template slot-scope="scope">
+          {{ scope.row.tradeNo || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="订单状态" align="center" min-width="100">
+        <template slot-scope="scope">
+          <template v-if="scope.row.orderStatusText != null && scope.row.orderStatusText !== ''">
+            <el-tag
+              v-for="(item, index) in statusOptions"
+              :key="index"
+              v-if="scope.row.orderStatusText == item.dictValue"
+            >{{ item.dictLabel }}</el-tag>
+            <el-tag v-if="!statusOptions.some(i => scope.row.orderStatusText == i.dictValue)">{{ scope.row.orderStatusText || scope.row.orderStatus }}</el-tag>
+          </template>
+          <el-tag v-else>{{ scope.row.orderStatusText || '-' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="售后状态" prop="afterSalesStatusText" min-width="100">
+        <template slot-scope="scope">
+          {{ scope.row.afterSalesStatusText || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="订单记录时间" prop="recordTime" min-width="170" align="center">
+        <template slot-scope="scope">
+          {{ formatRecordTime(scope.row.recordTime) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="明细类型" prop="detailTypeText" min-width="130" />
+      <el-table-column label="金额" prop="amount" min-width="120" align="right" header-align="center">
+        <template slot-scope="scope">
+          <span :class="amountClass(scope.row.amount)">{{ formatAmount(scope.row.amount) }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </div>
+</template>
+
+<script>
+import { listWithdrawDetailCompanyOptions, listWithdrawDetail, exportWithdrawDetail } from '@/api/company/withdrawDetail'
+
+export default {
+  name: 'CompanyWithdrawDetailAdmin',
+  data() {
+    return {
+      loading: true,
+      companyLoading: false,
+      companyOptions: [],
+      tableList: [],
+      total: 0,
+      detailTypeOptions: [
+        { value: 1, label: '订单金额入账' },
+        { value: 2, label: '订单金额扣减' },
+        { value: 3, label: '总公司充值' },
+        { value: 4, label: '总公司扣款' },
+        { value: 5, label: '分公司提现' },
+        { value: 6, label: '总公司驳回' }
+      ],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        companyIds: [],
+        detailTypes: []
+      },
+      statusOptions: []
+    }
+  },
+  created() {
+    this.getDicts('store_order_status').then(res => {
+      this.statusOptions = res.data || []
+    })
+    this.remoteSearchCompany('')
+    this.getList()
+  },
+  methods: {
+    remoteSearchCompany(query) {
+      this.companyLoading = true
+      const keyword = query != null ? String(query).trim() : ''
+      listWithdrawDetailCompanyOptions({ keyword }).then(res => {
+        this.companyOptions = res.data || []
+        this.companyLoading = false
+      }).catch(() => {
+        this.companyLoading = false
+      })
+    },
+    selectAllCompanies() {
+      const ids = this.companyOptions.map(o => o.companyId).filter(id => id != null)
+      this.queryParams.companyIds = [...new Set(ids)]
+    },
+    invertCompanySelection() {
+      const ids = this.companyOptions.map(o => o.companyId).filter(id => id != null)
+      const next = new Set(this.queryParams.companyIds || [])
+      ids.forEach(id => {
+        if (next.has(id)) {
+          next.delete(id)
+        } else {
+          next.add(id)
+        }
+      })
+      this.queryParams.companyIds = [...next]
+    },
+    buildListParams() {
+      const p = {
+        pageNum: this.queryParams.pageNum,
+        pageSize: this.queryParams.pageSize
+      }
+      if (this.queryParams.companyIds && this.queryParams.companyIds.length > 0) {
+        p.companyIds = this.queryParams.companyIds
+      }
+      if (this.queryParams.detailTypes && this.queryParams.detailTypes.length > 0) {
+        p.detailTypes = this.queryParams.detailTypes
+      }
+      return p
+    },
+    getList() {
+      this.loading = true
+      listWithdrawDetail(this.buildListParams()).then(response => {
+        this.tableList = response.rows || []
+        this.total = response.total || 0
+        this.loading = false
+      }).catch(() => {
+        this.loading = false
+      })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.queryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        companyIds: [],
+        detailTypes: []
+      }
+      this.remoteSearchCompany('')
+      this.getList()
+    },
+    formatRecordTime(val) {
+      if (!val) return '-'
+      if (typeof val === 'string') {
+        const norm = val.replace('T', ' ').replace(/-/g, '.')
+        if (norm.length >= 19) {
+          return norm.slice(0, 10) + '-' + norm.slice(11, 19)
+        }
+      }
+      return this.parseTime(val, '{y}.{m}.{d}-{h}:{i}:{s}')
+    },
+    formatAmount(val) {
+      if (val == null || val === '') return '-'
+      const n = Number(val)
+      if (Number.isNaN(n)) return String(val)
+      const abs = Math.abs(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+      if (n > 0) return '+' + abs
+      if (n < 0) return '-' + abs
+      return '0.00'
+    },
+    amountClass(val) {
+      if (val == null || val === '') return ''
+      const n = Number(val)
+      if (n < 0) return 'amt-neg'
+      if (n > 0) return 'amt-pos'
+      return ''
+    },
+    handleExport() {
+      this.$confirm('是否按当前条件导出提现明细?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        const p = {}
+        if (this.queryParams.companyIds && this.queryParams.companyIds.length > 0) {
+          p.companyIds = this.queryParams.companyIds
+        }
+        if (this.queryParams.detailTypes && this.queryParams.detailTypes.length > 0) {
+          p.detailTypes = this.queryParams.detailTypes
+        }
+        return exportWithdrawDetail(p)
+      }).then(response => {
+        this.download(response.msg)
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-form {
+  margin-bottom: 8px;
+}
+.hint-text {
+  color: #909399;
+  font-size: 13px;
+  margin: 0 0 12px 0;
+}
+.withdraw-detail .amt-pos {
+  color: #67c23a;
+}
+.withdraw-detail .amt-neg {
+  color: #f56c6c;
+}
+</style>

+ 8 - 2
src/views/course/courseWatchComment/index.vue

@@ -237,6 +237,8 @@ export default {
   name: "CourseWatchComment",
   data() {
     return {
+      /** 与后端 cate_type 一致:0-评论(课程),1-公域看课评论 */
+      pageCateType: 0,
       // 弹幕状态选项
       barrageStatusOptions: [
         { dictValue: 0, dictLabel: "正常" },
@@ -274,6 +276,7 @@ export default {
         isAll: true, //判断是否属于全局查询
         status: '',
         userStatus: '',
+        cateType: 0,
         // userId: null,
         // userType: null,
         // courseId: null,
@@ -303,6 +306,7 @@ export default {
     /** 查询看课评论列表 */
     getList() {
       this.loading = true;
+      this.queryParams.cateType = this.pageCateType;
       listCourseWatchComment(this.queryParams).then(response => {
         this.courseWatchCommentList = response.rows.list;
         this.total = response.rows.total;
@@ -357,6 +361,7 @@ export default {
     /** 重置按钮操作 */
     resetQuery() {
       this.resetForm("queryForm");
+      this.queryParams.cateType = this.pageCateType;
       this.handleQuery();
     },
     // 多选框选中数据
@@ -376,6 +381,7 @@ export default {
       this.reset();
       this.form.status = row.status;
       this.form.commentId = row.commentId;
+      this.form.cateType = this.pageCateType;
       this.updateStatus = true;
       this.title = "修改弹幕状态";
     },
@@ -425,7 +431,7 @@ export default {
       this.$refs["form"].validate(valid => {
         if (valid) {
           console.log(this.form)
-          updateBarrageStatus(this.form).then(response => {
+          updateBarrageStatus({ ...this.form, cateType: this.pageCateType }).then(response => {
             this.msgSuccess("修改成功");
             this.updateStatus = false;
             this.getList();
@@ -484,7 +490,7 @@ export default {
     },
     /** 导出按钮操作 */
     handleExport() {
-      const queryParams = this.queryParams;
+      const queryParams = { ...this.queryParams, cateType: this.pageCateType };
       this.$confirm('是否确认导出当前看课评论数据项?', "警告", {
         confirmButtonText: "确定",
         cancelButtonText: "取消",

+ 280 - 0
src/views/course/publiccourse/commentSensitive/index.vue

@@ -0,0 +1,280 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="敏感词" prop="word">
+        <el-input
+          v-model="queryParams.word"
+          placeholder="请输入需要过滤的敏感词"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="敏感词添加时间" prop="createTime" label-width="120px">
+        <el-date-picker clearable size="small"
+                        v-model="queryParams.createTime"
+                        type="date"
+                        value-format="yyyy-MM-dd"
+                        placeholder="选择敏感词添加时间">
+        </el-date-picker>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['live:words:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          :disabled="single"
+          @click="handleUpdate"
+          v-hasPermi="['live:words:edit']"
+        >修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['live:words:remove']"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['live:words:export']"
+        >导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="wordsList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="敏感词唯一标识" align="center" prop="wordId" />
+      <el-table-column label="需要过滤的敏感词" align="center" prop="word" />
+      <el-table-column label="敏感词添加时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['live:words:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['live:words:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <!-- 添加或修改直播间敏感词过滤对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="120px">
+        <el-form-item label="敏感词" prop="word">
+          <el-input v-model="form.word" placeholder="请输入需要过滤的敏感词" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {listWords, getWords, delWords, addWords, updateWords, exportWords} from "@/api/live/words";
+
+export default {
+  name: "Words",
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 直播间敏感词过滤表格数据
+      wordsList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        word: null,
+        createTime: null
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        word: [
+          { required: true, message: "需要过滤的敏感词不能为空", trigger: "blur" }
+        ],
+      }
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    /** 查询直播间敏感词过滤列表 */
+    getList() {
+      this.loading = true;
+      listWords(this.queryParams).then(response => {
+        this.wordsList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        wordId: null,
+        word: null,
+        createTime: null
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.wordId)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加直播间敏感词过滤";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const wordId = row.wordId || this.ids
+      getWords(wordId).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改直播间敏感词过滤";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.wordId != null) {
+            updateWords(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addWords(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const wordIds = row.wordId || this.ids;
+      this.$confirm('是否确认删除直播间敏感词过滤编号为"' + wordIds + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delWords(wordIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有直播间敏感词过滤数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportWords(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    }
+  }
+};
+</script>

+ 753 - 0
src/views/course/publiccourse/courseQuestionBank/index.vue

@@ -0,0 +1,753 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="问题" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入问题标题"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <!--      <el-form-item label="排序" prop="sort">-->
+      <!--        <el-input-->
+      <!--          v-model="queryParams.sort"-->
+      <!--          placeholder="请输入排序"-->
+      <!--          clearable-->
+      <!--          size="small"-->
+      <!--          @keyup.enter.native="handleQuery"-->
+      <!--        />-->
+      <!--      </el-form-item>-->
+      <el-form-item label="类别" prop="type">
+        <el-select v-model="queryParams.type" placeholder="请选择类别" clearable size="small">
+          <el-option
+            v-for="dict in typeOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="题目类别" prop="questionType">
+        <el-select v-model="queryParams.questionType" ref="typeSelect" placeholder="请选择类别" @change="changeCateType" clearable size="small">
+          <el-option
+            v-for="item in questionRootTypeOptions"
+            :key="item.dictValue"
+            :label="item.dictLabel"
+            :value="item.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="题目子类别" prop="questionSubType" label-width="100px">
+        <el-select v-model="queryParams.questionSubType" ref="typeSelect" placeholder="请选择子类别" clearable size="small">
+          <el-option
+            v-for="item in questionSubTypeOptions"
+            :key="item.dictValue"
+            :label="item.dictLabel"
+            :value="item.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+
+
+      <!--      <el-form-item label="状态" prop="status">-->
+      <!--        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable size="small">-->
+      <!--          <el-option-->
+      <!--            v-for="dict in statusOptions"-->
+      <!--            :key="dict.dictValue"-->
+      <!--            :label="dict.dictLabel"-->
+      <!--            :value="dict.dictValue"-->
+      <!--          />-->
+      <!--        </el-select>-->
+      <!--      </el-form-item>-->
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['course:courseQuestionBank:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          :disabled="single"
+          @click="handleUpdate"
+          v-hasPermi="['course:courseQuestionBank:edit']"
+        >修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['course:courseQuestionBank:remove']"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['course:courseQuestionBank:export']"
+        >导出</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          plain
+          type="info"
+          icon="el-icon-upload2"
+          size="mini"
+          @click="handleImport"
+          v-hasPermi="['course:courseQuestionBank:importData']"
+        >导入</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="courseQuestionBankList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="标题" align="center" prop="title" show-overflow-tooltip width="300" />
+      <el-table-column label="类别" align="center" prop="type">
+        <template slot-scope="scope">
+          <dict-tag :options="typeOptions" :value="scope.row.type"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="题目类别" align="center" prop="questionType">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.questionType">{{ getCategoryName(scope.row.questionType) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="题目子类别" align="center" prop="questionType">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.questionSubType">{{ getCategoryName(scope.row.questionSubType) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="排序" align="center" prop="sort" />
+      <el-table-column v-if="!hideAnswerFields" label="答案" align="center" prop="answer" />
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['course:courseQuestionBank:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['course:courseQuestionBank:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <!-- 添加或修改题库对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="1000px" append-to-body>
+      <el-form ref="form" :model="form" :rules="dynamicRules" label-width="80px">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="请输入问题" />
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="form.sort"  :min="0" label="请输入排序"></el-input-number>
+        </el-form-item>
+        <el-form-item label="类别 " prop="type">
+          <el-select v-model="form.type" placeholder="请选择类别" @change="changeType">
+            <el-option
+              v-for="dict in typeOptions"
+              :key="dict.dictValue"
+              :label="dict.dictLabel"
+              :value="parseInt(dict.dictValue)"
+            ></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="题目类别 " prop="questionType">
+          <el-select v-model="form.questionType" clearable placeholder="请选择题目类别" @change="changeCateType">
+            <el-option
+              v-for="item in questionRootTypeOptions"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue"
+            />
+          </el-select>
+          <el-select v-model="form.questionSubType" clearable placeholder="请选择题目子类别" >
+            <el-option
+              v-for="item in questionSubTypeOptions"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-radio-group v-model="form.status">
+            <el-radio
+              v-for="dict in statusOptions"
+              :key="dict.dictValue"
+              :label="parseInt(dict.dictValue)"
+            >{{dict.dictLabel}}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <!-- 提示用户选择类别 -->
+        <el-alert v-if="!form.type" title="选择题目类别后添加选项" type="warning" show-icon></el-alert>
+
+        <el-form-item label="选项"  v-if="form.type">
+          <el-button @click="addRow" size="mini" type="primary">新增选项</el-button>
+          <el-table border :data="question"  :cell-style="{ textAlign: 'center' }"		 :header-cell-style="{textAlign: 'center'}"   >
+            <el-table-column label="序号" width="65px" >
+              <template slot-scope="scope">
+                {{ '选项'+getOptionLabel(scope.$index)+':' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="选项" prop="name" >
+              <template slot-scope="scope">
+                <el-input v-model="scope.row.name" :placeholder="getOptionLabel(scope.$index) + ':' + '请输入标题'" ></el-input>
+              </template>
+            </el-table-column>
+            <el-table-column v-if="!hideAnswerFields" label="是否为答案" prop="isWrite" >
+              <template slot-scope="scope">
+                <el-tooltip
+                  v-if="!scope.row.name"
+                  content="请先填写选项标题内容"
+                  placement="top"
+                  effect="dark"
+                >
+                  <el-switch
+                    v-model="scope.row.isAnswer"
+                    :active-value="1"
+                    :inactive-value="0"
+                    @change="handleAnswerChange(scope.row,scope.$index)"
+                    :disabled="Boolean(!scope.row.name || (form.type === 1 && scope.row.isAnswer && selectedAnswer !== scope.row))"
+                  ></el-switch>
+                </el-tooltip>
+                <el-switch
+                  v-else
+                  v-model="scope.row.isAnswer"
+                  :active-value="1"
+                  :inactive-value="0"
+                  @change="handleAnswerChange(scope.row,scope.$index)"
+                  :disabled="Boolean(form.type === 1 && scope.row.isAnswer && selectedAnswer !== scope.row)"
+                ></el-switch>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作">
+              <template slot-scope="scope">
+                <el-button @click="deleteRow(scope.$index)"   size="mini" type="text" v-if="question.length>1">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-form-item>
+        <el-form-item v-if="!hideAnswerFields" label="答案:" prop="answer">
+          <span style="background-color: #faedc9; font-size: 20px;">
+            {{ form.answer }}
+          </span>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 导入 -->
+    <el-dialog :title="upload.title" :visible.sync="upload.open" width="400px" append-to-body>
+      <el-upload ref="upload" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag>
+        <i class="el-icon-upload"></i>
+        <div class="el-upload__text">
+          将文件拖到此处,或
+          <em>点击上传</em>
+        </div>
+        <div class="el-upload__tip" slot="tip">
+          <el-link type="info" style="font-size:12px" @click="importTemplate">下载模板</el-link>
+        </div>
+        <div class="el-upload__tip" style="color:red" slot="tip">提示:仅允许导入“xls”或“xlsx”格式文件!</div>
+      </el-upload>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" :loading="upload.isUploading" :disabled="upload.isUploading" @click="submitFileForm">确 定</el-button>
+        <el-button @click="upload.open = false">取 消</el-button>
+      </div>
+    </el-dialog>
+    <el-dialog title="导入结果" :close-on-press-escape="false" :close-on-click-modal="false" :visible.sync="importMsgOpen" width="500px" append-to-body>
+      <div class="import-msg" v-html="importMsg">
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="importMsgOpen = false">关 闭</el-button>
+        <el-button
+          v-if="failList && failList.length > 0"
+          type="primary"
+          @click="handleExportFailList(failList)"
+          :loading="exportFailLoading"
+        >导出失败信息</el-button>
+      </div>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script>
+import {
+  listCourseQuestionBank,
+  getCourseQuestionBank,
+  delCourseQuestionBank,
+  addCourseQuestionBank,
+  updateCourseQuestionBank,
+  exportCourseQuestionBank,
+  importTemplate,
+  exportFail
+} from "@/api/course/courseQuestionBank";
+import { getToken } from "@/utils/auth";
+import {
+  listUserCourseCategory,
+  getCatePidList,
+  getCateListByPid
+} from '@/api/course/userCourseCategory'
+import { getConfigByKey } from '@/api/system/config'
+
+export default {
+  name: "CourseQuestionBank",
+  computed: {
+    alphabet() {
+      return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+    },
+    /** 点播配置:不校验答案时隐藏列表/表单中的答案相关项(与 validateAnswerWhenWatch 为 false / "0" 一致) */
+    hideAnswerFields() {
+      const cfg = this.courseConfig;
+      if (!cfg || !Object.prototype.hasOwnProperty.call(cfg, 'validateAnswerWhenWatch')) {
+        return false;
+      }
+      const v = cfg.validateAnswerWhenWatch;
+      return v === false || v === '0' || v === 0 || v === 'false';
+    },
+    dynamicRules() {
+      const r = { ...this.rules };
+      if (this.hideAnswerFields) {
+        delete r.answer;
+      }
+      return r;
+    },
+  },
+  watch: {
+  },
+  mounted() {
+  },
+  data() {
+    return {
+      /** course.config 解析后的对象 */
+      courseConfig: {},
+      exportFailLoading: false,
+      //单选
+      selectedAnswer:null,
+      //多选
+      selectedAnswers: [],
+
+      typeOptions: [],
+      statusOptions: [],
+      question:[
+        { name: "", isAnswer: 0, indexId:0},
+        { name: "", isAnswer: 0, indexId:1},
+        { name: "", isAnswer: 0, indexId:2},
+        { name: "", isAnswer: 0, indexId:3},
+      ],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 题库表格数据
+      courseQuestionBankList: [],
+      questionTypeOptions: [],
+      questionRootTypeOptions: [],
+      questionSubTypeOptions: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        title: null,
+        sort: null,
+        type: null,
+        status: null,
+        question: null,
+        questionType:null,
+        questionSubType: null,
+        answer: null,
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        title: [{ required: true, message: "不能为空", trigger: "blur" }],
+        sort: [{ required: true, message: "不能为空", trigger: "blur" }],
+        type: [{ required: true, message: "不能为空", trigger: "blur" }],
+        status: [{ required: true, message: "不能为空", trigger: "blur" }],
+        question: [{ required: true, message: "不能为空", trigger: "blur" }],
+        answer: [{ required: true, message: "不能为空", trigger: "blur" }],
+      },
+      // 导入
+      importMsgOpen:false,
+      failList:[],
+      importMsg: '',
+      upload: {
+        // 是否显示弹出层(文件导入)
+        open: false,
+        // 弹出层标题(文件导入)
+        title: "",
+        // 是否禁用上传
+        isUploading: false,
+        // 设置上传的请求头部
+        headers: { Authorization: "Bearer " + getToken() },
+        // 上传的地址
+        url: process.env.VUE_APP_BASE_API + "/course/courseQuestionBank/importData",
+      }
+    };
+  },
+  created() {
+    this.getList();
+    getConfigByKey('course.config').then(response => {
+      if (response.data && response.data.configValue) {
+        try {
+          this.courseConfig = JSON.parse(response.data.configValue) || {};
+        } catch (e) {
+          this.courseConfig = {};
+        }
+      }
+    }).catch(() => { this.courseConfig = {}; });
+    this.getDicts("sys_course_temp_type").then(response => {
+      this.typeOptions = response.data;
+    });
+    this.getDicts("sys_company_status").then(response => {
+      this.statusOptions = response.data;
+    });
+    this.getCategoryTree()
+  },
+  methods: {
+    getOptionLabel(index) {
+      return this.alphabet[index];
+    },
+// 导出失败信息
+    handleExportFailList(failList) {
+      if (!failList || failList.length === 0) {
+        this.msgWarning("没有失败信息可导出");
+        return;
+      }
+
+
+      this.exportFailLoading = true;
+      this.$confirm('是否确认导出失败信息?', "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "info"
+      }).then(() => {
+        // 调用导出失败信息的API
+        exportFail(failList).then(response => {
+          this.download(response.msg);
+          this.msgSuccess("失败信息导出成功");
+          this.exportFailLoading = false;
+        }).catch(() => {
+          this.msgError("失败信息导出失败");
+          this.exportFailLoading = false;
+        });
+      }).catch(() => {
+        this.exportFailLoading = false;
+      });
+    },
+
+    // 分类树
+    getCategoryTree() {
+      listUserCourseCategory().then(response => {
+        this.questionTypeOptions = response.data
+      });
+      getCatePidList().then(response => {
+        this.questionRootTypeOptions = response.data
+      });
+    },
+    changeCateType(val) {
+      if (!val) {
+        return
+      }
+      getCateListByPid(val).then(response => {
+        this.questionSubTypeOptions = response.data
+      })
+    },
+    getCategoryName(id) {
+      return this.questionTypeOptions.find(item => item.cateId === id)?.cateName || '';
+    },
+    //题目类型发生变化
+    changeType(){
+      this.selectedAnswers = [];
+      this.selectedAnswer = null;
+      this.question.forEach(item => {
+        item.isAnswer = 0; // 清除所有选项的选中状态
+      });
+      // 重置答案的显示值
+      this.form.answer = null;
+    },
+    //选择答案
+    handleAnswerChange(row, index) {
+      if (this.form.type === 1) {
+        // 单选模式:记录选中的答案并取消其他选项
+        this.selectedAnswer = row;
+        this.question.forEach((item) => {
+          if (item !== row) {
+            item.isAnswer = 0;
+          }
+        });
+        // 保存单选的答案
+        // this.form.answer = this.getOptionLabel(index);
+        this.form.answer = row.name;
+      } else if (this.form.type === 2) {
+
+        // 多选模式:将所有被选中的答案存储到 selectedAnswers 数组 先检查当前选项是否已经在选中答案列表中
+        if (row.isAnswer === 1) {
+          // 如果选中,将该答案加入到选中数组
+          this.selectedAnswers.push(row);
+        } else {
+          // 如果取消选中,从选中数组中移除
+          this.selectedAnswers = this.selectedAnswers.filter(item => item.indexId !== row.indexId);
+        }
+
+        // 对 selectedAnswers 进行排序 (假设按 row.indexId 排序)
+        this.selectedAnswers.sort((a, b) => a.indexId - b.indexId);
+
+        // 更新 form.answer,保存所有选中的答案
+        this.form.answer = this.selectedAnswers.map(item => item.name);
+      }
+
+    },
+    addRow() {
+      this.question.push({ name: '', isAnswer: 0,indexId: this.question.length});},
+    deleteRow(index) {
+      if (this.form.type === 1 && this.question[index] === this.selectedAnswer) {
+        this.selectedAnswer = null;
+      }
+      this.question.splice(index, 1);
+    },
+    /** 查询题库列表 */
+    getList() {
+      this.loading = true;
+      listCourseQuestionBank(this.queryParams).then(response => {
+        this.courseQuestionBankList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+      this.changeCateType(this.queryParams.questionType)
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        id: null,
+        title: null,
+        sort: null,
+        type: 1,
+        status: 1,
+        question: null,
+        createTime: null,
+        answer: null,
+        createBy: null
+      };
+      this.resetForm("form");
+      this.question = [
+        { name: "", isAnswer: 0, indexId:0},
+        { name: "", isAnswer: 0, indexId:1},
+        { name: "", isAnswer: 0, indexId:2},
+        { name: "", isAnswer: 0, indexId:3},
+      ]
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.questionSubTypeOptions = []
+      this.open = true;
+      this.title = "添加题库";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id || this.ids
+      getCourseQuestionBank(id).then(response => {
+        this.form = response.data;
+        this.changeCateType(this.form.questionType)
+
+        //初始化多选的选择结果,单选的选择的时候,就取消其他的了 selectedAnswers
+        if (this.form.type===2) {
+          this.form.answer=JSON.parse(this.form.answer)
+          this.selectedAnswers = JSON.parse(this.form.question).filter(item => item.isAnswer === 1);
+        }
+
+        this.question=JSON.parse(this.form.question)
+        this.open = true;
+        this.title = "修改题库";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+
+          if (this.question.some(q => q.name === "")) {
+            this.$message.error("不能存在空选项")
+            return
+          }
+
+          if (this.hideAnswerFields) {
+            this.question.forEach(q => { q.isAnswer = 0 });
+            this.selectedAnswer = null;
+            this.selectedAnswers = [];
+            if (this.form.type === 2) {
+              this.form.answer = [];
+            } else {
+              this.form.answer = '';
+            }
+          }
+
+          this.form.question=JSON.stringify(this.question)
+
+          if (this.form.type===2){
+            this.form.answer=JSON.stringify(this.form.answer)
+            // this.form.answer = this.form.answer.join(',');
+          }
+          if (this.form.id != null) {
+            updateCourseQuestionBank(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+
+            addCourseQuestionBank(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const ids = row.id || this.ids;
+      this.$confirm('是否确认删除题库编号为"' + ids + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delCourseQuestionBank(ids);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有题库数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportCourseQuestionBank(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    },
+    // 下载模板
+    importTemplate() {
+      importTemplate().then((response) => {
+        this.download(response.msg);
+      });
+    },
+    // 导入
+    handleImport() {
+      this.upload.title = "导入题目";
+      this.upload.open = true;
+    },
+    submitFileForm() {
+      this.$refs.upload.submit();
+    },
+    // 文件上传中处理
+    handleFileUploadProgress(event, file, fileList) {
+      this.upload.isUploading = true;
+    },
+    // 文件上传成功处理
+    handleFileSuccess(response, file, fileList) {
+      this.upload.open = false;
+      this.upload.isUploading = false;
+      this.$refs.upload.clearFiles();
+      this.importMsgOpen=true;
+      this.importMsg = response.data.message
+      this.failList = response.data.failList
+      this.getList();
+    },
+  }
+};
+</script>

+ 508 - 0
src/views/course/publiccourse/courseWatchComment/index.vue

@@ -0,0 +1,508 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="搜索" prop="keywords">
+        <el-input
+          v-model="queryParams.keywords"
+          placeholder="请输入用户昵称/课程名称/视频名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+          style="width: 280px"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="操作状态"
+          clearable
+          size="small"
+          style="width: 240px"
+        >
+          <el-option
+            v-for="dict in statusOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <!--      <el-col :span="1.5">-->
+      <!--        <el-button-->
+      <!--          type="primary"-->
+      <!--          plain-->
+      <!--          icon="el-icon-plus"-->
+      <!--          size="mini"-->
+      <!--          @click="handleAdd"-->
+      <!--          v-hasPermi="['course:courseWatchComment:add']"-->
+      <!--        >新增</el-button>-->
+      <!--      </el-col>-->
+      <!--      <el-col :span="1.5">-->
+      <!--        <el-button-->
+      <!--          type="success"-->
+      <!--          plain-->
+      <!--          icon="el-icon-edit"-->
+      <!--          size="mini"-->
+      <!--          :disabled="single"-->
+      <!--          @click="handleUpdate"-->
+      <!--          v-hasPermi="['course:courseWatchComment:edit']"-->
+      <!--        >修改</el-button>-->
+      <!--      </el-col>-->
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['course:courseWatchComment:remove']"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['course:courseWatchComment:export']"
+        >导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="courseWatchCommentList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <!--      <el-table-column label="评论id" align="center" prop="commentId" />-->
+      <!--      <el-table-column label="用户id" align="center" prop="userId" />-->
+      <el-table-column label="用户昵称" align="center" prop="nickName" width="130px" />
+      <!--      <el-table-column label="用户类型,1-管理员,2-用户" align="center" prop="userType" />-->
+      <!--      <el-table-column label="评论类型 1:评论,2:回复" align="center" prop="type" />-->
+      <!--      <el-table-column label="父评论id" align="center" prop="parentId" />-->
+      <el-table-column label="评论内容" align="center" prop="content" />
+      <el-table-column label="所属课程" align="center" prop="courseName" />
+      <el-table-column label="所属小节" align="center" prop="title" />
+      <!--      <el-table-column label="弹幕状态" align="center" prop="status">-->
+      <!--        <template slot-scope="scope">-->
+      <!--          <el-tag :type="getStatusTagType(scope.row.status)">-->
+      <!--            {{ formatBarrageStatus(scope.row.status) }}-->
+      <!--          </el-tag>-->
+      <!--        </template>-->
+      <!--      </el-table-column>-->
+      <el-table-column label="评论时间" align="center" prop="createTime" width="160px"/>
+      <!--      <el-table-column label="是否是撤回的消息,1-是,0-否" align="center" prop="isRevoke" />-->
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <!--          <el-button-->
+          <!--            size="mini"-->
+          <!--            type="text"-->
+          <!--            icon="el-icon-edit"-->
+          <!--            @click="handleUpdate(scope.row)"-->
+          <!--            v-hasPermi="['course:courseWatchComment:edit']"-->
+          <!--          >修改</el-button>-->
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdateStatus(scope.row)"
+          >修改弹幕状态</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleAddKeyWords(scope.row)"
+          >添加关键字</el-button>
+          <el-button
+            v-if="scope.row.userStatus === 1 "
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleAddBlack(scope.row)"
+          >拉黑</el-button>
+          <el-button
+            v-if="scope.row.userStatus === 0 "
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleClearBlack(scope.row)"
+          >解除拉黑</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['course:courseWatchComment:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="关键字" prop="keyword">
+          <el-input v-model="form.keyword" placeholder="请输入关键字" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog :title="title" :visible.sync="updateStatus" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="弹幕状态" prop="barrageStatus">
+          <el-radio-group v-model="form.status">
+            <el-radio
+              v-for="dict in barrageStatusOptions"
+              :key="dict.dictValue"
+              :label="dict.dictValue"
+              v-if="dict.dictValue !== 2"
+            >
+              <el-tag :type="getStatusTagType(dict.dictValue)" size="small">
+                {{dict.dictLabel}}
+              </el-tag>
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="updateForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+    <!--    &lt;!&ndash; 添加或修改看课评论对话框 &ndash;&gt;-->
+    <!--    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>-->
+    <!--      <el-form ref="form" :model="form" :rules="rules" label-width="80px">-->
+    <!--        <el-form-item label="用户id" prop="userId">-->
+    <!--          <el-input v-model="form.userId" placeholder="请输入用户id" />-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="用户类型,1-管理员,2-用户" prop="userType">-->
+    <!--          <el-select v-model="form.userType" placeholder="请选择用户类型,1-管理员,2-用户">-->
+    <!--            <el-option label="请选择字典生成" value="" />-->
+    <!--          </el-select>-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="课程id" prop="courseId">-->
+    <!--          <el-input v-model="form.courseId" placeholder="请输入课程id" />-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="视频id" prop="videoId">-->
+    <!--          <el-input v-model="form.videoId" placeholder="请输入视频id" />-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="评论类型 1:评论,2:回复" prop="type">-->
+    <!--          <el-select v-model="form.type" placeholder="请选择评论类型 1:评论,2:回复">-->
+    <!--            <el-option label="请选择字典生成" value="" />-->
+    <!--          </el-select>-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="父评论id" prop="parentId">-->
+    <!--          <el-input v-model="form.parentId" placeholder="请输入父评论id" />-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="评论内容" prop="content">-->
+    <!--          <el-input v-model="form.content" type="textarea" placeholder="请输入内容" />-->
+    <!--        </el-form-item>-->
+    <!--        <el-form-item label="是否是撤回的消息,1-是,0-否" prop="isRevoke">-->
+    <!--          <el-select v-model="form.isRevoke" placeholder="请选择是否是撤回的消息,1-是,0-否">-->
+    <!--            <el-option label="请选择字典生成" value="" />-->
+    <!--          </el-select>-->
+    <!--        </el-form-item>-->
+    <!--      </el-form>-->
+    <!--      <div slot="footer" class="dialog-footer">-->
+    <!--        <el-button type="primary" @click="submitForm">确 定</el-button>-->
+    <!--        <el-button @click="cancel">取 消</el-button>-->
+    <!--      </div>-->
+    <!--    </el-dialog>-->
+  </div>
+</template>
+
+<script>
+import { listCourseWatchComment, delCourseWatchComment, exportCourseWatchComment, addBlack,clearBlack,updateBarrageStatus } from "@/api/course/courseWatchComment";
+import { addKeyword } from "@/api/system/keyword";
+
+export default {
+  name: "CourseWatchComment",
+  data() {
+    return {
+      /** 与后端 cate_type 一致:0-评论(课程),1-公域看课评论 */
+      pageCateType: 1,
+      // 弹幕状态选项
+      barrageStatusOptions: [
+        { dictValue: 0, dictLabel: "正常" },
+        { dictValue: 1, dictLabel: "屏蔽" },
+        { dictValue: 2, dictLabel: "人工" }
+      ],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 看课评论表格数据
+      courseWatchCommentList: [],
+      //下拉状态
+      statusOptions:[],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      updateStatus: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        keywords: null,
+        isAll: true, //判断是否属于全局查询
+        status: '',
+        userStatus: '',
+        cateType: 1,
+        // userId: null,
+        // userType: null,
+        // courseId: null,
+        // videoId: null,
+        // type: null,
+        // parentId: null,
+        // content: null,
+        // isRevoke: null
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        keyword: [
+          { required: true, message: "关键字不能为空", trigger: "blur" }
+        ]
+      }
+    };
+  },
+  created() {
+    this.getDicts("sys_barrage_clean_type").then((response) => {
+      this.statusOptions = response.data;
+    });
+    this.getList();
+  },
+  methods: {
+    /** 查询看课评论列表 */
+    getList() {
+      this.loading = true;
+      this.queryParams.cateType = this.pageCateType;
+      listCourseWatchComment(this.queryParams).then(response => {
+        this.courseWatchCommentList = response.rows.list;
+        this.total = response.rows.total;
+        this.loading = false;
+      });
+    },
+    // 获取状态标签类型
+    getStatusTagType(status) {
+      switch(status) {
+        case 0: return 'success';
+        case 1: return 'danger';
+        case 2: return 'primary';
+        default: return 'info';
+      }
+    },
+    // 格式化状态显示
+    formatBarrageStatus(status) {
+      const dict = this.barrageStatusOptions.find(item => item.dictValue === status);
+      return dict ? dict.dictLabel : status;
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.updateStatus = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        commentId: null,
+        userId: null,
+        userType: null,
+        courseId: null,
+        videoId: null,
+        type: null,
+        parentId: null,
+        content: null,
+        createTime: null,
+        updateTime: null,
+        isRevoke: null,
+        keyword: null,
+        status: '',
+        userStatus: ''
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.queryParams.cateType = this.pageCateType;
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.commentId)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 添加关键字按钮操作 */
+    handleAddKeyWords(row) {
+      this.reset();
+      this.form.keyword = row.content; // 将评论内容设置为关键字
+      this.open = true;
+      this.title = "添加到关键字";
+    },
+    handleUpdateStatus(row) {
+      this.reset();
+      this.form.status = row.status;
+      this.form.commentId = row.commentId;
+      this.form.cateType = this.pageCateType;
+      this.updateStatus = true;
+      this.title = "修改弹幕状态";
+    },
+    /** 解除拉黑用户按钮操作 */
+    handleClearBlack(row) {
+      this.$confirm(`谨慎操作,确定要解除"${row.nickName}"用户拉黑吗`, "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        const data = {
+          fsUserId: row.userId,
+          commentStatus: 0
+        };
+        clearBlack(data).then(response => {
+          this.msgSuccess("操作成功");
+          this.getList(); // 重新加载列表
+        }).catch(() => {
+          this.msgError("操作失败");
+        });
+      }).catch(() => {
+        // 用户取消操作
+      });
+    },
+    /** 拉黑用户按钮操作 */
+    handleAddBlack(row) {
+      this.$confirm(`谨慎操作,确定要拉黑用户"${row.nickName}"吗`, "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        const data = {
+          fsUserId: row.userId,
+          commentStatus: 1
+        };
+        addBlack(data).then(response => {
+          this.msgSuccess("操作成功");
+          this.getList(); // 重新加载列表
+        }).catch(() => {
+          this.msgError("操作失败");
+        });
+      }).catch(() => {
+        // 用户取消操作
+      });
+    },
+    updateForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          console.log(this.form)
+          updateBarrageStatus({ ...this.form, cateType: this.pageCateType }).then(response => {
+            this.msgSuccess("修改成功");
+            this.updateStatus = false;
+            this.getList();
+          }).catch(() => {
+            this.msgError("修改失败");
+          });
+        }
+      })
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          const data = {
+            keyword: this.form.keyword
+          };
+          addKeyword(data).then(response => {
+            this.msgSuccess("操作成功");
+            this.open = false;
+            this.getList();
+          }).catch(() => {
+            this.msgError("操作失败");
+          });
+        }
+      });
+    },
+    // /** 新增按钮操作 */
+    // handleAdd() {
+    //   this.reset();
+    //   this.open = true;
+    //   this.title = "添加看课评论";
+    // },
+    // /** 修改按钮操作 */
+    // handleUpdate(row) {
+    //   this.reset();
+    //   const commentId = row.commentId || this.ids
+    //   getCourseWatchComment(commentId).then(response => {
+    //     this.form = response.data;
+    //     this.open = true;
+    //     this.title = "修改看课评论";
+    //   });
+    // },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const commentIds = row.commentId || this.ids;
+      this.$confirm('是否确认删除此看课评论?删除后不可恢复', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delCourseWatchComment(commentIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = { ...this.queryParams, cateType: this.pageCateType };
+      this.$confirm('是否确认导出当前看课评论数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportCourseWatchComment(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    }
+  }
+};
+</script>

+ 336 - 0
src/views/course/publiccourse/courseWatchLog/index.vue

@@ -0,0 +1,336 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
+      <el-form-item label="课程/章节" prop="keywords">
+        <el-input
+          v-model="queryParams.keywords"
+          placeholder="请输入课程名称/章节名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="课程分类" prop="cateId">
+        <el-select
+          v-model="queryParams.cateId"
+          placeholder="请选择课程分类"
+          clearable
+          filterable
+          size="small"
+          @change="onRootCateChange"
+        >
+          <el-option
+            v-for="item in rootCategoryOptions"
+            :key="item.cateId"
+            :label="item.cateName"
+            :value="item.cateId"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="子分类" prop="subCateId">
+        <el-select
+          v-model="queryParams.subCateId"
+          placeholder="请选择子分类"
+          clearable
+          filterable
+          size="small"
+          :disabled="!queryParams.cateId"
+        >
+          <el-option
+            v-for="item in subCategoryOptions"
+            :key="item.cateId"
+            :label="item.cateName"
+            :value="item.cateId"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="筛选时间" prop="dateRange">
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="yyyy-MM-dd"
+          size="small"
+          clearable
+          @change="onDateRangeChange"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-tabs v-model="activeTab" @tab-click="onTabClick">
+      <el-tab-pane label="课程数据" name="course">
+        <el-row :gutter="10" class="mb8">
+          <el-col :span="1.5">
+            <el-button
+              type="warning"
+              plain
+              icon="el-icon-download"
+              size="mini"
+              :loading="exportLoading"
+              @click="handleExport"
+              v-hasPermi="['course:publicCourseWatchStat:export']"
+            >导出</el-button>
+          </el-col>
+          <right-toolbar :showSearch.sync="showSearch" @queryTable="getCourseList" />
+        </el-row>
+        <el-table v-loading="courseLoading" border :data="courseList">
+          <el-table-column label="时间" align="center" prop="statDate" width="110">
+            <template slot-scope="scope">
+              <span>{{ formatDay(scope.row.statDate) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="课程ID" align="center" prop="courseId" width="90" />
+          <el-table-column label="课程名称" align="center" prop="courseName" min-width="120" show-overflow-tooltip />
+          <el-table-column label="课程分类" align="center" prop="rootCateName" min-width="100" show-overflow-tooltip />
+          <el-table-column label="子分类" align="center" prop="subCateName" min-width="90" show-overflow-tooltip />
+          <el-table-column label="曝光位置" align="center" prop="exposurePositionLabel" width="130" />
+          <el-table-column label="曝光次数" align="center" prop="exposurePv" width="90" />
+          <el-table-column label="曝光人数" align="center" prop="exposureUv" width="90" />
+          <el-table-column label="点击次数" align="center" prop="clickPv" width="90" />
+          <el-table-column label="点击人数" align="center" prop="clickUv" width="90" />
+          <el-table-column label="点击率" align="center" width="90">
+            <template slot-scope="scope">
+              <span>{{ scope.row.clickRate != null ? scope.row.clickRate + '%' : '0%' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="课程观看人数" align="center" prop="watchUv" width="110" />
+          <el-table-column label="课程完课人数" align="center" prop="finishUv" width="110" />
+        </el-table>
+        <pagination
+          v-show="courseTotal > 0"
+          :total="courseTotal"
+          :page.sync="queryParams.pageNum"
+          :limit.sync="queryParams.pageSize"
+          @pagination="getCourseList"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="目录数据" name="catalog">
+        <el-row :gutter="10" class="mb8">
+          <el-col :span="1.5">
+            <el-button
+              type="warning"
+              plain
+              icon="el-icon-download"
+              size="mini"
+              :loading="exportLoading"
+              @click="handleExport"
+              v-hasPermi="['course:publicCourseWatchStat:export']"
+            >导出</el-button>
+          </el-col>
+          <right-toolbar :showSearch.sync="showSearch" @queryTable="getCatalogList" />
+        </el-row>
+        <el-table v-loading="catalogLoading" border :data="catalogList">
+          <el-table-column label="目录ID" align="center" prop="videoId" width="90" />
+          <el-table-column label="目录名称" align="center" prop="catalogName" min-width="120" show-overflow-tooltip />
+          <el-table-column label="课程名称" align="center" prop="courseName" min-width="120" show-overflow-tooltip />
+          <el-table-column label="课程分类" align="center" prop="catePath" min-width="120" show-overflow-tooltip />
+          <el-table-column label="浏览量(PV)" align="center" prop="pv" width="100" />
+          <el-table-column label="观看人数(UV)" align="center" prop="uv" width="110" />
+          <el-table-column label="完课人数" align="center" prop="finishUv" width="90" />
+          <el-table-column label="评论数" align="center" prop="commentCount" width="80" />
+          <el-table-column label="完课率" align="center" width="90">
+            <template slot-scope="scope">
+              <span>{{ scope.row.finishRate != null ? scope.row.finishRate + '%' : '0%' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="平均学习时长" align="center" prop="avgWatchDuration" width="120" />
+          <el-table-column label="看课奖励类型" align="center" prop="rewardType" min-width="100" show-overflow-tooltip />
+          <el-table-column label="答题人数" align="center" prop="answerUv" width="90" />
+          <el-table-column label="领取积分人数" align="center" prop="integralReceiveUv" width="110" />
+          <el-table-column label="分享私聊数" align="center" prop="sharePrivateCount" width="100" />
+          <el-table-column label="分享朋友圈数" align="center" prop="shareTimelineCount" width="110" />
+          <el-table-column label="操作" align="center" width="100" fixed="right">
+            <template slot-scope="scope">
+              <el-button type="text" size="mini" @click="handleUserData(scope.row)">用户数据</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <pagination
+          v-show="catalogTotal > 0"
+          :total="catalogTotal"
+          :page.sync="queryParams.pageNum"
+          :limit.sync="queryParams.pageSize"
+          @pagination="getCatalogList"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import {
+  listPublicCourseWatchStatCourseDay,
+  listPublicCourseWatchStatCatalog,
+  exportPublicCourseWatchStatCourseDay,
+  exportPublicCourseWatchStatCatalog
+} from '@/api/course/publicCourseWatchStat'
+import { listUserCourseCategory } from '@/api/course/userCourseCategory'
+
+export default {
+  name: 'PublicCourseWatchLog',
+  data() {
+    return {
+      showSearch: true,
+      activeTab: 'course',
+      exportLoading: false,
+      dateRange: [],
+      categoryFlat: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        keywords: null,
+        cateId: null,
+        subCateId: null,
+        beginDate: null,
+        endDate: null
+      },
+      courseLoading: false,
+      courseList: [],
+      courseTotal: 0,
+      catalogLoading: false,
+      catalogList: [],
+      catalogTotal: 0
+    }
+  },
+  computed: {
+    rootCategoryOptions() {
+      return this.categoryFlat.filter(c => c.pid === 0)
+    },
+    subCategoryOptions() {
+      if (!this.queryParams.cateId) {
+        return []
+      }
+      return this.categoryFlat.filter(c => c.pid === this.queryParams.cateId)
+    }
+  },
+  created() {
+    this.initDateRange()
+    this.loadCategories()
+    this.getCourseList()
+  },
+  methods: {
+    initDateRange() {
+      const end = new Date()
+      const start = new Date()
+      start.setTime(start.getTime() - 3600 * 1000 * 24 * 30)
+      const fmt = d => this.parseTime(d, '{y}-{m}-{d}')
+      this.dateRange = [fmt(start), fmt(end)]
+      this.queryParams.beginDate = this.dateRange[0]
+      this.queryParams.endDate = this.dateRange[1]
+    },
+    onDateRangeChange(val) {
+      if (val && val.length === 2) {
+        this.queryParams.beginDate = val[0]
+        this.queryParams.endDate = val[1]
+      } else {
+        this.queryParams.beginDate = null
+        this.queryParams.endDate = null
+      }
+    },
+    loadCategories() {
+      listUserCourseCategory({ cateType: 1 }).then(res => {
+        this.categoryFlat = res.data || []
+      })
+    },
+    onRootCateChange() {
+      this.queryParams.subCateId = null
+    },
+    /** tab-click 触发时 v-model 可能尚未更新,必须用 tab.name */
+    onTabClick(tab) {
+      const name = tab.name
+      this.queryParams.pageNum = 1
+      if (name === 'course') {
+        this.getCourseList()
+      } else {
+        this.getCatalogList()
+      }
+    },
+    formatDay(val) {
+      if (!val) {
+        return ''
+      }
+      if (typeof val === 'string') {
+        return val.length >= 10 ? val.substring(0, 10) : val
+      }
+      return this.parseTime(val, '{y}-{m}-{d}')
+    },
+    buildQuery() {
+      return { ...this.queryParams }
+    },
+    buildExportQuery() {
+      const q = { ...this.queryParams }
+      delete q.pageNum
+      delete q.pageSize
+      return q
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      if (this.activeTab === 'course') {
+        this.getCourseList()
+      } else {
+        this.getCatalogList()
+      }
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.queryParams.cateId = null
+      this.queryParams.subCateId = null
+      this.queryParams.pageNum = 1
+      this.queryParams.pageSize = 10
+      this.initDateRange()
+      this.handleQuery()
+    },
+    getCourseList() {
+      this.courseLoading = true
+      listPublicCourseWatchStatCourseDay(this.buildQuery()).then(res => {
+        this.courseList = res.rows
+        this.courseTotal = res.total
+        this.courseLoading = false
+      }).catch(() => {
+        this.courseLoading = false
+      })
+    },
+    getCatalogList() {
+      this.catalogLoading = true
+      listPublicCourseWatchStatCatalog(this.buildQuery()).then(res => {
+        this.catalogList = res.rows
+        this.catalogTotal = res.total
+        this.catalogLoading = false
+      }).catch(() => {
+        this.catalogLoading = false
+      })
+    },
+    handleExport() {
+      const tabLabel = this.activeTab === 'course' ? '课程数据' : '目录数据'
+      this.$confirm(`是否确认导出当前筛选条件下的「${tabLabel}」?`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.exportLoading = true
+        const q = this.buildExportQuery()
+        const req = this.activeTab === 'course'
+          ? exportPublicCourseWatchStatCourseDay(q)
+          : exportPublicCourseWatchStatCatalog(q)
+        return req
+      }).then(res => {
+        if (res && res.msg) {
+          this.download(res.msg)
+        }
+        this.exportLoading = false
+      }).catch(() => {
+        this.exportLoading = false
+      })
+    },
+    handleUserData() {
+      this.$modal.msg('用户数据功能开发中')
+    }
+  }
+}
+</script>

+ 515 - 0
src/views/course/publiccourse/userCourseCategory/index.vue

@@ -0,0 +1,515 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="分类名称" prop="cateName">
+        <el-input
+          v-model="queryParams.cateName"
+          placeholder="请输入分类名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="是否推荐" prop="isShow">
+        <el-select v-model="queryParams.isShow" placeholder="是否推荐" clearable size="small">
+          <el-option
+            v-for="dict in orOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['course:userCourseCategory:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          :disabled="single"
+          @click="handleUpdate"
+          v-hasPermi="['course:userCourseCategory:edit']"
+        >修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['course:userCourseCategory:remove']"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['course:userCourseCategory:export']"
+        >导出</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          plain
+          type="info"
+          icon="el-icon-upload2"
+          size="mini"
+          @click="handleImport"
+          v-hasPermi="['course:userCourseCategory:importData']"
+        >导入</el-button>
+      </el-col>
+
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleFansExport"
+          v-hasPermi="['course:userCourseCategory:fansExport']"
+        >重粉导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table
+      height="500" border
+      v-loading="loading"
+      :data="userCourseCategoryList"
+      row-key="cateId"
+      :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
+      @selection-change="handleSelectionChange"
+    >
+      <el-table-column type="selection" width="55" align="center"/>
+      <el-table-column label="分类id" align="left" prop="cateId" />
+      <el-table-column label="分类名称" align="left" prop="cateName" />
+      <el-table-column  label="状态" align="center" prop="isShow">
+        <template slot-scope="scope">
+          <div>
+            <el-tag v-if="scope.row.isShow === 1" :type="''">显示</el-tag>
+            <el-tag v-else :type=" 'info' ">隐藏</el-tag>
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column label="排序" sortable align="center" prop="sort" />
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['course:userCourseCategory:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['course:userCourseCategory:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!--    <pagination-->
+    <!--      v-show="total>0"-->
+    <!--      :total="total"-->
+    <!--      :page.sync="queryParams.pageNum"-->
+    <!--      :limit.sync="queryParams.pageSize"-->
+    <!--      @pagination="getList"-->
+    <!--    />-->
+
+    <!-- 添加或修改课堂分类对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="上级分类" prop="pid"  >
+          <treeselect v-model="form.pid" :options="categoryOptions" :normalizer="normalizer" placeholder="请选择上级分类" />
+        </el-form-item>
+        <el-form-item label="分类名称" prop="cateName">
+          <el-input v-model="form.cateName" placeholder="请输入分类名称" />
+        </el-form-item>
+        <!-- <el-form-item label="图标"  prop="pic" v-if="form.pid!=0">
+           <Material v-model="picArr" type="image" :num="1" :width="150" :height="150" />
+        </el-form-item> -->
+        <el-form-item label="状态" prop="isShow">
+          <el-radio v-model="form.isShow" label="1">显示</el-radio>
+          <el-radio v-model="form.isShow" label="0">隐藏</el-radio>
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="form.sort" placeholder="请输入排序" />
+        </el-form-item>
+
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 导入 -->
+    <el-dialog :title="upload.title" :visible.sync="upload.open" width="400px" append-to-body>
+      <el-upload ref="upload" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag>
+        <i class="el-icon-upload"></i>
+        <div class="el-upload__text">
+          将文件拖到此处,或
+          <em>点击上传</em>
+        </div>
+        <div class="el-upload__tip" slot="tip">
+          <el-link type="info" style="font-size:12px" @click="importTemplate">下载模板</el-link>
+        </div>
+        <div class="el-upload__tip" style="color:red" slot="tip">提示:仅允许导入“xls”或“xlsx”格式文件!</div>
+      </el-upload>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" :loading="upload.isUploading" :disabled="upload.isUploading" @click="submitFileForm">确 定</el-button>
+        <el-button @click="upload.open = false">取 消</el-button>
+      </div>
+    </el-dialog>
+    <el-dialog title="导入结果" :close-on-press-escape="false" :close-on-click-modal="false" :visible.sync="importMsgOpen" width="500px" append-to-body>
+      <div class="import-msg" v-html="importMsg">
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="importMsgOpen = false">关 闭</el-button>
+        <el-button
+          v-if="failList && failList.length > 0"
+          type="primary"
+          v-hasPermi="['course:userCourseCategory:exportFail']"
+          @click="handleExportFailList(failList)"
+          :loading="exportFailLoading"
+        >导出失败信息</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listUserCourseCategory, getUserCourseCategory, delUserCourseCategory, addUserCourseCategory, updateUserCourseCategory, exportUserCourseCategory, exportFans,importTemplate, exportFail } from "@/api/course/userCourseCategory";
+import Treeselect from "@riophae/vue-treeselect";
+import "@riophae/vue-treeselect/dist/vue-treeselect.css";
+import { getToken } from '@/utils/auth'
+export default {
+  name: "UserCourseCategory",
+  components: {
+    Treeselect
+  },
+  data() {
+    return {
+      orOptions:[],
+      categoryOptions:[],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 课堂分类表格数据
+      userCourseCategoryList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        pid: null,
+        cateName: null,
+        sort: null,
+        isShow: null,
+        isDel: null,
+        cateType: 1
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        pid: [
+          { required: true, message: "父id不能为空", trigger: "blur" }
+        ],
+        cateName: [
+          { required: true, message: "分类名称不能为空", trigger: "blur" }
+        ],
+      },
+      // 导入
+      importMsgOpen:false,
+      exportFailLoading: false,
+      failList:[],
+      importMsg: '',
+      upload: {
+        // 是否显示弹出层(文件导入)
+        open: false,
+        // 弹出层标题(文件导入)
+        title: "",
+        // 是否禁用上传
+        isUploading: false,
+        // 设置上传的请求头部
+        headers: { Authorization: "Bearer " + getToken() },
+        // 上传的地址
+        url: process.env.VUE_APP_BASE_API + "/course/userCourseCategory/importData",
+      }
+    };
+  },
+  created() {
+    this.getList();
+    this.getDicts("sys_company_or").then(response => {
+      this.orOptions = response.data;
+    });
+  },
+  methods: {
+    /** 查询课堂分类列表 */
+    getList() {
+      this.loading = true;
+      listUserCourseCategory(this.queryParams).then(response => {
+        this.userCourseCategoryList = this.handleTree(response.data, "cateId", "pid");
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    /** 转换课程分类数据结构 */
+    normalizer(node) {
+      if (node.children && !node.children.length) {
+        delete node.children;
+      }
+      return {
+        id: node.cateId,
+        label: node.cateName,
+        children: node.children
+      };
+    },
+    getTreeselect() {
+      listUserCourseCategory({ cateType: 1 }).then(response => {
+        this.categoryOptions = [];
+        const data = { cateId: 0, cateName: '顶级目录', children: [] };
+        data.children = response.data.filter(item => item.pid == 0)
+        this.categoryOptions.push(data);
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        cateId: null,
+        pid: null,
+        cateName: null,
+        sort: null,
+        isShow: null,
+        createTime: null,
+        updateTime: null,
+        isDel: null,
+        cateType: 1
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.cateId)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+
+      console.log("selection",  selection);
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.getTreeselect();
+      this.open = true;
+      this.title = "添加课堂分类";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      this.getTreeselect();
+      const cateId = row.cateId || this.ids
+      getUserCourseCategory(cateId).then(response => {
+        this.form = response.data;
+        this.form.isShow = response.data.isShow.toString();
+        this.open = true;
+        this.title = "修改课堂分类";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.cateId != null) {
+            updateUserCourseCategory(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addUserCourseCategory(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const cateIds = row.cateId || this.ids;
+      this.$confirm('是否确认删除课堂分类编号为"' + cateIds + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delUserCourseCategory(cateIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有课堂分类数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportUserCourseCategory(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    },
+    // 下载模板
+    importTemplate() {
+      importTemplate().then((response) => {
+        this.download(response.msg);
+      });
+    },
+    // 导入
+    handleImport() {
+      this.upload.title = "导入题目";
+      this.upload.open = true;
+    },
+    submitFileForm() {
+      this.$refs.upload.submit();
+    },
+    // 文件上传中处理
+    handleFileUploadProgress(event, file, fileList) {
+      this.upload.isUploading = true;
+    },
+    // 文件上传成功处理
+    handleFileSuccess(response, file, fileList) {
+      this.upload.open = false;
+      this.upload.isUploading = false;
+      this.$refs.upload.clearFiles();
+      this.importMsgOpen=true;
+      this.importMsg = response.data.message
+      this.failList = response.data.failList
+      this.getList();
+    },
+    // 导出失败信息
+    handleExportFailList(failList) {
+      if (!failList || failList.length === 0) {
+        this.msgError("没有失败信息可导出");
+        return;
+      }
+      this.exportFailLoading = true;
+      this.$confirm('是否确认导出失败信息?', "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "info"
+      }).then(() => {
+        // 调用导出失败信息的API
+        exportFail(failList).then(response => {
+          this.download(response.msg);
+          this.msgSuccess("失败信息导出成功");
+          this.exportFailLoading = false;
+        }).catch(() => {
+          this.msgError("失败信息导出失败");
+          this.exportFailLoading = false;
+        });
+      }).catch(() => {
+        this.exportFailLoading = false;
+      });
+    },
+    handleFansExport() {
+      if (!Array.isArray(this.ids) || this.ids.length === 0) {
+        this.$message.warning('请先选择至少一条数据');
+        return;
+      }
+
+      const params = { ids: this.ids };
+
+      this.$confirm('是否确认导出重粉?', '警告', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        const loading = this.$loading({
+          lock: true,
+          text: '正在导出数据,请稍候...',
+          spinner: 'el-icon-loading',
+          background: 'rgba(0, 0, 0, 0.3)'
+        });
+
+        return exportFans(params)
+          .then(res => {
+            if (res.code !== 200){
+              this.$message.warning(res.msg);
+              return;
+            }
+
+            this.download(res.msg);
+          })
+
+          .finally(() => {
+            loading.close();
+          });
+      });
+    }
+  },
+
+};
+</script>

+ 1248 - 0
src/views/course/publiccourse/usercourse/index.vue

@@ -0,0 +1,1248 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
+      <el-form-item label="课堂分类" prop="cateId">
+        <el-select v-model="queryParams.cateId" placeholder="请选择" clearable size="small"
+                   @change="getQuerySubCateList(queryParams.cateId)">
+          <el-option
+            v-for="dict in categoryOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="课堂子分类" prop="subCateId">
+        <el-select v-model="queryParams.subCateId" placeholder="请选择" clearable size="small">
+          <el-option
+            v-for="dict in querySubCateOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="课堂id" prop="courseId">
+        <el-input
+          v-model="queryParams.courseId"
+          placeholder="请输入课堂id"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="课堂名称" prop="courseName">
+        <el-input
+          v-model="queryParams.courseName"
+          placeholder="请输入课堂名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="关联的公司" prop="companyIds">
+        <el-select v-model="queryParams.companyIdsList" multiple placeholder="请选择公司" filterable clearable style="width: 90%;">
+          <el-option
+            v-for="dict in companyOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="课堂类型" prop="isPrivate" style="display: none">
+        <el-select v-model="queryParams.isPrivate" placeholder="请选择" clearable size="small">
+          <el-option
+            v-for="dict in courseTypeOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['course:userCourse:add']"
+        >新增
+        </el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          :disabled="single"
+          @click="handleUpdate"
+          v-hasPermi="['course:userCourse:edit']"
+        >修改
+        </el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['course:userCourse:remove']"
+        >删除
+        </el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['course:userCourse:export']"
+        >导出
+        </el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!--    <el-table height="600" border v-loading="loading" :data="userCourseList" @selection-change="handleSelectionChange" style="width: 100%" :fit="true">-->
+    <el-table max-height="600" border v-loading="loading" :data="userCourseList" @selection-change="handleSelectionChange" style="width: 100%" :fit="true">
+      <el-table-column type="selection" width="55" align="center"/>
+      <el-table-column label="课程ID" align="center" prop="courseId" width="55"/>
+      <el-table-column label="所属项目" align="center" prop="projectName" width="120"/>
+      <el-table-column label="封面图片" align="center" prop="imgUrl" width="170">
+        <template slot-scope="scope">
+          <el-popover
+            placement="right"
+            title=""
+            trigger="hover"
+          >
+            <img slot="reference" :src="scope.row.imgUrl" width="100">
+            <img :src="scope.row.imgUrl" style="max-width: 300px;">
+          </el-popover>
+        </template>
+      </el-table-column>
+      <el-table-column label="课堂名称" align="center" show-overflow-tooltip prop="courseName" min-width="100"/>
+      <el-table-column label="排序" align="center" prop="sort" width="80"/>
+      <el-table-column label="分类名称" align="center" prop="cateName" width="120"/>
+      <el-table-column label="子分类名称" align="center" prop="subCateName" width="120"/>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            @click="handleCatalog(scope.row)"
+            v-hasPermi="['course:userCourse:cateMange']"
+          >目录管理
+          </el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['course:userCourse:edit']"
+          >修改
+          </el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdateRedPage(scope.row)"
+            v-hasPermi="['course:userCourse:editRedPage']"
+          >统一修改红包金额
+          </el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleCopy(scope.row)"
+            v-hasPermi="['course:userCourse:copy']"
+          >复制
+          </el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['course:userCourse:remove']"
+          >删除
+          </el-button>
+          <el-button
+            size="mini"
+            type="text"
+            v-if="scope.row.isPrivate === 1"
+            v-has-permi="['course:userCourse:editConfig']"
+            @click="configCourse(scope.row)"
+          >过程页配置
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <!-- 添加或修改课程对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="1200px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="110px">
+        <el-row>
+          <el-form-item label="所属项目" prop="project">
+            <el-select v-model="form.project" placeholder="请选择项目" filterable clearable size="small">
+              <el-option
+                v-for="dict in projectOptions"
+                :key="dict.dictValue"
+                :label="dict.dictLabel"
+                :value="dict.dictValue"
+              />
+            </el-select>
+          </el-form-item>
+        </el-row>
+        <el-row>
+          <el-col :span="8">
+            <el-form-item label="课堂名称" prop="courseName">
+              <el-input v-model="form.courseName" placeholder="请输入课堂名称"/>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="课堂分类" prop="cateId">
+              <el-select v-model="form.cateId" placeholder="请选择" clearable size="small"
+                         @change="getSubCateList(form.cateId)">
+                <el-option
+                  v-for="dict in categoryOptions"
+                  :key="dict.dictValue"
+                  :label="dict.dictLabel"
+                  :value="dict.dictValue"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="课堂子分类" prop="subCateId">
+              <el-select v-model="form.subCateId" placeholder="请选择" clearable size="small">
+                <el-option
+                  v-for="dict in subCategoryOptions"
+                  :key="dict.dictValue"
+                  :label="dict.dictLabel"
+                  :value="dict.dictValue"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="排序" prop="sort">
+              <el-input-number v-model="form.sort"  :min="0"  label="排序"></el-input-number>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="课堂简介" prop="description">
+              <el-input v-model="form.description" type="textarea" :rows="2" placeholder="请输入课堂简介"/>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="课程封面" prop="imgUrl">
+          <ImageUpload v-model="form.imgUrl" type="image" :num="10" :width="150" :height="150"/>
+        </el-form-item>
+        <el-form-item label="关联公司" prop="tags">
+          <el-select v-model="companyIds" multiple placeholder="请选择公司" filterable clearable style="width: 90%;">
+            <el-option
+              v-for="dict in companyOptions"
+              :key="dict.dictValue"
+              :label="dict.dictLabel"
+              :value="dict.dictValue"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-divider content-position="left">推荐管理</el-divider>
+        <el-row :gutter="16" class="rec-row" v-for="(rec, idx) in recommendSlots" :key="idx">
+          <el-col :span="24">
+            <el-form-item :label="rec.label">
+              <div class="rec-inline">
+                <el-checkbox v-model="form[rec.enabledKey]" :true-label="1" :false-label="0">启用</el-checkbox>
+                <el-select v-model="form[rec.modeKey]" placeholder="方式" clearable size="small" style="width: 120px; margin-left: 12px;">
+                  <el-option label="插入" :value="1"/>
+                  <el-option label="替换" :value="2"/>
+                </el-select>
+                <el-input-number v-model="form[rec.sortKey]" :min="0" :controls="true" placeholder="位置序号" size="small" style="width: 160px; margin-left: 12px;"/>
+              </div>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="是否上架" prop="isShow" required>
+          <el-radio-group v-model="form.isShow">
+            <el-radio label="1">上架</el-radio>
+            <el-radio label="0">下架</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+
+    <el-dialog title="修改课程红包金额" :visible.sync="openRedPage.open" width="1000px" append-to-body>
+      <el-form ref="openRedPage" :model="openRedPage" :rules="rulesRedPage" label-width="110px">
+        <el-form-item label="红包金额" prop="redPacketMoney">
+          <el-input-number v-model="openRedPage.redPacketMoney" :min="0.1" :max="200" :step="0.1"></el-input-number>
+        </el-form-item>
+        <div v-if="!!enableRandomRedPacket" style=" display: flex;
+                  flex-direction: column;">
+          <div v-for="(rule, index) in openRedPage.rules" :key="index" class="form-row">
+            <el-form-item
+              label="随机红包金额区间"
+              :prop="`rules.${index}.minAmount`"
+              :rules="[
+              { required: true, message: '请输入最小金额', trigger: 'blur' },
+              { validator: validateMinAmount, trigger: 'blur', index: index }
+            ]"
+              class="form-item-amount"
+            >
+              <el-input
+                v-model.number="rule.minAmount"
+                type="number"
+                :min="0.01"
+                :precision="2"
+                :step="0.01"
+                placeholder="最小金额"
+                size="small"
+                class="amount-input"
+                @input="handleAmountInput(rule, 'minAmount')"
+              ></el-input>
+              <span class="separator">-</span>
+              <el-input
+                v-model.number="rule.maxAmount"
+                type="number"
+                :min="rule.minAmount || 0.01"
+                :precision="2"
+                :step="0.01"
+                placeholder="最大金额"
+                size="small"
+                class="amount-input"
+                @input="handleAmountInput(rule, 'maxAmount')"
+              ></el-input>
+              <span class="suffix">元</span>
+            </el-form-item>
+            <el-form-item
+              label="随机权重"
+              :prop="`rules.${index}.weight`"
+              :rules="[
+                  { required: true, message: '请输入权重', trigger: 'blur' },
+                  { type: 'integer', message: '权重必须为整数', trigger: 'blur' },
+                ]"
+              class="form-item-weight"
+            >
+              <el-input
+                v-model.number="rule.weight"
+                type="number"
+                :min="1"
+                placeholder="权重"
+                size="small"
+              ></el-input>
+            </el-form-item>
+            <el-tooltip class="item" effect="dark" content="权重越高,被随机到的概率越大" placement="top">
+              <i class="el-icon-question"></i>
+            </el-tooltip>
+            <div class="action-buttons">
+              <el-button
+                icon="el-icon-plus"
+                size="mini"
+                type="text"
+                @click="addRule(index)"
+                class="add-btn"
+              >
+                新增
+              </el-button>
+              <el-button
+                icon="el-icon-delete"
+                size="mini"
+                type="text"
+                @click="deleteRule(index)"
+                :disabled="openRedPage.rules.length <= 1"
+                class="delete-btn"
+              >
+                删除
+              </el-button>
+            </div>
+          </div>
+        </div>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitFormRedPage">确 定</el-button>
+        <el-button @click="cancelRedPage">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 过程页配置 -->
+    <el-dialog
+      :visible.sync="configDialog.dialogVisible"
+      title="过程页配置"
+      append-to-body
+      width="1200px"
+    >
+      <el-form :model="configDialog.form" :rules="configDialog.rules" ref="configForm" label-width="110px">
+        <el-form-item label="过程页图片" prop="coverImg">
+          <ImageUpload v-model="configDialog.form.coverImg" :height="150" :limit="1" :width="150" type="image"/>
+          <i class="el-icon-warning"/>
+          <span style="color: rgb(153, 169, 191)"> 不配置将使用课程默认图片</span>
+        </el-form-item>
+        <el-form-item label="首播电视台" prop="tvEnable">
+          <el-switch v-model="configDialog.form.tvEnable" active-color="#13ce66"/>
+        </el-form-item>
+        <el-form-item prop="tv" v-if="configDialog.form.tvEnable">
+          <el-input v-model="configDialog.form.tv" clearable></el-input>
+          <i class="el-icon-warning"/>
+          <span style="color: rgb(153, 169, 191)"> 多个首播电视台,请用英文逗号隔开</span>
+        </el-form-item>
+        <el-form-item label="网络播放平台" prop="networkEnable">
+          <el-switch v-model="configDialog.form.networkEnable" active-color="#13ce66"/>
+        </el-form-item>
+        <el-form-item prop="network" v-if="configDialog.form.networkEnable">
+          <el-input v-model="configDialog.form.network" clearable></el-input>
+          <i class="el-icon-warning"/>
+          <span style="color: rgb(153, 169, 191)"> 多个网络播放平台,请用英文逗号隔开</span>
+        </el-form-item>
+        <el-form-item label="制作单位" prop="unitEnable">
+          <el-switch v-model="configDialog.form.unitEnable" active-color="#13ce66"/>
+        </el-form-item>
+        <el-form-item prop="unit" v-if="configDialog.form.unitEnable">
+          <el-input v-model="configDialog.form.unit" clearable></el-input>
+          <i class="el-icon-warning"/>
+          <span style="color: rgb(153, 169, 191)"> 多个制作单位,请用英文逗号隔开</span>
+        </el-form-item>
+        <el-form-item label="专家顾问团队" prop="teamEnable">
+          <el-switch v-model="configDialog.form.teamEnable" active-color="#13ce66"/>
+        </el-form-item>
+        <el-form-item prop="team" v-if="configDialog.form.teamEnable">
+          <el-input v-model="configDialog.form.team" clearable></el-input>
+          <i class="el-icon-warning"/>
+          <span style="color: rgb(153, 169, 191)"> 多个专家顾问,请用英文逗号隔开</span>
+        </el-form-item>
+        <el-form-item label="支持单位" prop="supportEnable">
+          <el-switch v-model="configDialog.form.supportEnable" active-color="#13ce66"/>
+        </el-form-item>
+        <el-form-item prop="support" v-if="configDialog.form.supportEnable">
+          <el-input v-model="configDialog.form.support" clearable></el-input>
+          <i class="el-icon-warning"/>
+          <span style="color: rgb(153, 169, 191)"> 多个支持单位,请用英文逗号隔开</span>
+        </el-form-item>
+        <el-form-item label="监督投诉电话" prop="complaintPhone">
+          <el-input v-model="configDialog.form.complaintPhone" placeholder="请输入监督投诉电话" clearable/>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary"
+                   :loading="configDialog.updating"
+                   :disabled="configDialog.updating"
+                   @click="submitConfigForm">确 定</el-button>
+        <el-button @click="cancelConfig">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <el-drawer
+      :with-header="false"
+      size="75%"
+      :title="show.title" :visible.sync="show.open" append-to-body>
+      <userCourseCatalogDetails ref="userCourseCatalogDetails"/>
+    </el-drawer>
+  </div>
+</template>
+
+<script>
+import {
+  listUserCourse,
+  getUserCourse,
+  delUserCourse,
+  addUserCourse,
+  updateUserCourse,
+  exportUserCourse,
+  updateIsShow,
+  copyUserCourse,
+  putOn,
+  pullOff, updateUserCourseRedPage,
+  editConfig
+} from '@/api/course/userCourse'
+
+import {getSelectableRange} from "@/api/qw/sopTemp";
+import Treeselect from "@riophae/vue-treeselect";
+import "@riophae/vue-treeselect/dist/vue-treeselect.css";
+import Editor from '@/components/Editor/wang';
+import ImageUpload from '@/components/ImageUpload/index';
+import {listBySearch} from "@/api/course/userTalent";
+import userCourseCatalogDetails from '@/views/components/course/userCourseCatalogDetails.vue';
+import {getAllCourseCategoryList, getCatePidList, getCateListByPid} from "@/api/course/userCourseCategory";
+import {allList} from "@/api/company/company";
+import VideoUpload from '@/components/VideoUpload/index.vue'
+import { getConfigByKey } from '@/api/system/config'
+export default {
+  name: "UserCourse",
+  components: {
+    VideoUpload,
+    Treeselect,
+    Editor, ImageUpload, userCourseCatalogDetails
+  },
+  watch:{
+    // 深度监听 rules 数组的变化,以更新总权重
+    "openRedPage.rules": {
+      handler(val) {
+        // this.calculateTotalWeight();
+        this.validateRules();
+      },
+      deep: true,
+    },
+  },
+  data() {
+    return {
+      /** 与后端 is_private 一致:1-课程管理(私域),0-公域课程管理 */
+      pageIsPrivate: 0,
+      talentParam: {
+        phone: null,
+        talentId: null
+      },
+      talentList: [],
+      startTimeRange: [],
+      show: {
+        title: "目录管理",
+        open: false
+      },
+      activeName: "1",
+      projectOptions: [],
+      tagsOptions: [],
+      tags: [],
+      companyIds: [],
+      courseTypeOptions: [],
+      orOptions: [],
+      specShowOptions: [],
+      specTypeOptions: [],
+      categoryOptions: [],
+      subCategoryOptions: [],
+      querySubCateOptions: [],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 课程表格数据
+      userCourseList: [],
+      companyOptions: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      openRedPage:{
+        open:false,
+        courseId:null,
+        courseName:null,
+        redPacketMoney:0.1,
+        //随机红包配置
+        rules:[
+          {
+            minAmount: 0.01,
+            maxAmount: 0.01,
+            weight: 100,
+          }
+        ]
+      },
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        cateId: null,
+        subCateId: null,
+        title: null,
+        imgUrl: null,
+        userId: null,
+        sort: null,
+        status: null,
+        isVip: null,
+        isHot: null,
+        isShow: "1",
+        views: null,
+        duration: null,
+        description: null,
+        hotRanking: null,
+        integral: null,
+        price: null,
+        courseId: null,
+        isPrivate: 0,
+        companyIdsList:[],
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        courseName: [
+          {required: true, message: "课堂名称不能为空", trigger: "blur"}
+        ],
+        imgUrl: [
+          {required: true, message: "封面图片不能为空", trigger: "blur"}
+        ],
+        isTui: [
+          {required: true, message: "是否推荐不能为空", trigger: "blur"}
+        ],
+        isBest: [
+          {required: true, message: "是否精选不能为空", trigger: "blur"}
+        ],
+        isFast: [
+          {required: true, message: "是否允许快进不能为空", trigger: "blur"}
+        ],
+        isAutoPlay: [
+          {required: true, message: "是否自动播放不能为空", trigger: "blur"}
+        ],
+        sort: [
+          {required: true, message: "排序不能为空", trigger: "blur"}
+        ],
+        views: [
+          {required: true, message: "播放量不能为空", trigger: "blur"}
+        ],
+        likes: [
+          {required: true, message: "点赞数不能为空", trigger: "blur"}
+        ],
+        favoriteNum: [
+          {required: true, message: "收藏数不能为空", trigger: "blur"}
+        ],
+        shares: [
+          {required: true, message: "分享数不能为空", trigger: "blur"}
+        ],
+        isIntegral: [
+          {required: true, message: "是否允许积分兑换不能为空", trigger: "blur"}
+        ],
+        isShow: [
+          {required: true, message: "上架状态不能为空", trigger: "blur"}
+        ],
+        isPrivate: [
+          {required: true, message: "公私域不能为空", trigger: "blur"}
+        ],
+        integral: [
+          {required: true, message: "小节兑换积分不能为空", trigger: "blur"}
+        ],
+      },
+
+      rulesRedPage:{
+        redPacketMoney: [
+          {required: true, message: "红包金额不能为空", trigger: "blur"}
+        ],
+      },
+      configDialog: {
+        dialogVisible: false,
+        updating: false,
+        form: {
+          id: null,
+          coverImg: null,
+          tvEnable: 0,
+          tv: null,
+          networkEnable: 0,
+          network: null,
+          unitEnable: 0,
+          unit: null,
+          teamEnable: 0,
+          team: null,
+          supportEnable: 0,
+          support: null,
+          //监督投诉电话
+          complaintPhone: null,
+        },
+        rules: {
+          tv: [
+            { required: true, message: '首播电视台不能为空', trigger: 'blur' }
+          ],
+          network: [
+            { required: true, message: '网络播放平台不能为空', trigger: 'blur' }
+          ],
+          unit: [
+            { required: true, message: '制作单位不能为空', trigger: 'blur' }
+          ],
+          team: [
+            { required: true, message: '专家顾问团队不能为空', trigger: 'blur' }
+          ],
+          support: [
+            { required: true, message: '支持单位不能为空', trigger: 'blur' }
+          ],
+        }
+      },
+      enableRandomRedPacket:false,
+      /** 推荐位配置(与 form 字段名对应) */
+      recommendSlots: [
+        { label: '首页课程顶部推荐位', enabledKey: 'recHomeCourseTopEnabled', modeKey: 'recHomeCourseTopMode', sortKey: 'recHomeCourseTopSort' },
+        { label: '商城首页推荐位', enabledKey: 'recMallHomeEnabled', modeKey: 'recMallHomeMode', sortKey: 'recMallHomeSort' },
+        { label: '首页长视频瀑布流', enabledKey: 'recHomeLongVideoEnabled', modeKey: 'recHomeLongVideoMode', sortKey: 'recHomeLongVideoSort' }
+      ]
+    };
+  },
+  created() {
+    this.getList();
+    getConfigByKey('randomRedpacket:config').then(res=>{
+      console.log("res::")
+      console.log(res);
+      let configData = res.data;
+      if(!!configData && !!configData.configValue){
+        let configValue = JSON.parse(configData.configValue);
+        console.log(configValue);
+        if(!!configValue.enableRandomRedpacket){
+          this.enableRandomRedPacket = configValue.enableRandomRedpacket;
+        }
+      }
+    }).catch(res=>{
+
+    })
+    getCatePidList().then(response => {
+      this.categoryOptions = response.data;
+    });
+
+
+    getSelectableRange().then(e => {
+      this.startTimeRange = e.data;
+    })
+    // this.getTreeselect();
+    this.getDicts("sys_spec_show").then(response => {
+      this.specShowOptions = response.data;
+    });
+    this.getDicts("sys_spec_type").then(response => {
+      this.specTypeOptions = response.data;
+    });
+    this.getDicts("sys_course_type").then(response => {
+      this.courseTypeOptions = response.data;
+    });
+    this.getDicts("sys_course_project").then(response => {
+      this.projectOptions = response.data;
+    });
+    this.getDicts("sys_course_tags").then(response => {
+      this.tagsOptions = response.data;
+    });
+    this.getDicts("sys_company_or").then(response => {
+      this.orOptions = response.data;
+    });
+    allList().then(response => {
+      this.companyOptions = response.rows;
+    });
+  },
+  methods: {
+    selectTalent() {
+
+    },
+    talentMethod(query) {
+      if (query !== '') {
+        this.talentParam.phone = query;
+        listBySearch(this.talentParam).then(response => {
+          this.talentList = response.data;
+        });
+      }
+    },
+    getSubCateList(pid) {
+      this.form.subCateId = null;
+      if (pid == '') {
+        this.subCategoryOptions = [];
+        return
+      }
+      getCateListByPid(pid).then(response => {
+        this.subCategoryOptions = response.data;
+      });
+    },
+    getQuerySubCateList(pid) {
+      this.queryParams.subCateId = null;
+      if (pid == '') {
+        this.querySubCateOptions = [];
+        return
+      }
+      this.queryParams.subCateId = null;
+      getCateListByPid(pid).then(response => {
+        this.querySubCateOptions = response.data;
+      });
+    },
+    handleShow(row) {
+      var isShowValue = row.isShow === 0 ? 1 : 0;
+      var course = {courseId: row.courseId, isShow: isShowValue};
+      updateIsShow(course).then(response => {
+        this.msgSuccess("修改成功");
+        this.getList();
+      });
+    },
+    handleCatalog(row) {
+      const courseId = row.courseId;
+      this.show.open = true;
+      setTimeout(() => {
+        this.$refs.userCourseCatalogDetails.getDetails(courseId, row.courseName, row.isPrivate);
+      }, 200);
+    },
+    /** 转换课堂分类数据结构 */
+    normalizer(node) {
+      if (node.children && !node.children.length) {
+        delete node.children;
+      }
+      return {
+        id: node.cateId,
+        label: node.cateName,
+        children: node.children
+      };
+    },
+    getTreeselect() {
+      getAllCourseCategoryList().then(response => {
+        this.categoryOptions = [];
+        const data = this.handleTree(response.data, "cateId", "pid");
+        this.categoryOptions = data;
+      });
+    },
+    /** 查询课程列表 */
+    getList() {
+      this.loading = true;
+      this.queryParams.isPrivate = this.pageIsPrivate;
+      listUserCourse(this.queryParams).then(response => {
+        this.userCourseList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        courseId: null,
+        cateId: null,
+        subCateId: null,
+        title: null,
+        imgUrl: null,
+        secondImg: null,
+        userId: null,
+        sort: null,
+        createTime: null,
+        updateTime: null,
+        status: 0,
+        isVip: null,
+        isAutoPlay: "1",
+        isIntegral: "0",
+        isShow: "1",
+        isFast: "1",
+        isTui: "1",
+        isBest: "1",
+        isNext: "1",
+        isPrivate: String(this.pageIsPrivate),
+        views: 100000,
+        duration: null,
+        description: null,
+        hotRanking: null,
+        integral: null,
+        price: null,
+        likes: 100000,
+        shares: 100000,
+        favoriteNum: 100000,
+        hotNum: 100000,
+        recHomeCourseTopEnabled: 0,
+        recHomeCourseTopMode: 1,
+        recHomeCourseTopSort: undefined,
+        recMallHomeEnabled: 0,
+        recMallHomeMode: 1,
+        recMallHomeSort: undefined,
+        recHomeLongVideoEnabled: 0,
+        recHomeLongVideoMode: 1,
+        recHomeLongVideoSort: undefined,
+      };
+      this.tags = [];
+      this.subCategoryOptions = []
+      this.companyIds = []
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.queryParams.companyIdsList = [];
+      this.queryParams.isShow = this.activeName
+      this.queryParams.isPrivate = this.pageIsPrivate;
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.courseId)
+      this.single = selection.length !== 1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.talentList = [];
+      this.open = true;
+      this.title = "添加课程";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      this.talentList = [];
+      const courseId = row.courseId || this.ids
+      getUserCourse(courseId).then(response => {
+        this.form = response.data;
+        // this.form.cateId = response.data.cateId.toString();
+        if (this.form.cateId) {
+          getCateListByPid(this.form.cateId).then(response => {
+            this.subCategoryOptions = response.data;
+          });
+        }
+        // this.form.courseType = response.data.courseType.toString();
+        if (response.data.project != null) {
+          this.form.project = response.data.project.toString();
+        }
+        if (response.data.tags != null) {
+          this.tags = response.data.tags.split(",")
+        }
+        this.form.isAutoPlay = response.data.isAutoPlay.toString();
+        this.form.isShow = response.data.isShow.toString();
+        this.form.isBest = response.data.isBest.toString();
+        this.form.isFast = response.data.isFast.toString();
+        this.form.isIntegral = response.data.isIntegral.toString();
+        this.form.isTui = response.data.isTui.toString();
+        this.form.isNext = response.data.isNext.toString();
+        this.form.isPrivate = (response.data.isPrivate != null && response.data.isPrivate !== '')
+          ? response.data.isPrivate.toString()
+          : String(this.pageIsPrivate);
+        const n = v => (v === undefined || v === null ? undefined : Number(v));
+        this.form.recHomeCourseTopEnabled = n(response.data.recHomeCourseTopEnabled) ?? 0;
+        this.form.recHomeCourseTopMode = n(response.data.recHomeCourseTopMode) ?? 1;
+        this.form.recHomeCourseTopSort = n(response.data.recHomeCourseTopSort);
+        this.form.recMallHomeEnabled = n(response.data.recMallHomeEnabled) ?? 0;
+        this.form.recMallHomeMode = n(response.data.recMallHomeMode) ?? 1;
+        this.form.recMallHomeSort = n(response.data.recMallHomeSort);
+        this.form.recHomeLongVideoEnabled = n(response.data.recHomeLongVideoEnabled) ?? 0;
+        this.form.recHomeLongVideoMode = n(response.data.recHomeLongVideoMode) ?? 1;
+        this.form.recHomeLongVideoSort = n(response.data.recHomeLongVideoSort);
+        this.talentParam.talentId = response.data.talentId;
+        if (this.form.companyIds != null) {
+          this.companyIds = ((this.form.companyIds).split(",").map(Number))
+        } else {
+          this.companyIds = []
+        }
+
+        listBySearch(this.talentParam).then(response => {
+          this.talentList = response.data;
+        });
+        this.open = true;
+        this.title = "修改课程";
+      });
+    },
+
+    handleUpdateRedPage(row){
+      this.openRedPage.open=true;
+      this.openRedPage.courseId=row.courseId;
+      this.openRedPage.courseName=row.courseName;
+
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+
+          this.form.companyIds = this.companyIds.toString()
+          this.form.isPrivate = this.pageIsPrivate
+          if (this.form.courseId != null) {
+            updateUserCourse(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addUserCourse(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+
+    submitFormRedPage(){
+
+      const courseId = this.openRedPage.courseId;
+      const redPacketMoney = this.openRedPage.redPacketMoney;
+      let randomRedPacketRules = JSON.stringify( this.openRedPage.rules);
+      console.log(randomRedPacketRules)
+      this.$confirm('是否确认将课程id 为"' + courseId + '"的红包批量修改为:【'+redPacketMoney+'】?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function () {
+        return updateUserCourseRedPage({courseId:courseId,redPacketMoney:redPacketMoney,randomRedPacketRules:randomRedPacketRules});
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("修改成功");
+        this.openRedPage.open=false;
+      }).finally(() => {
+        this.getList();
+        this.openRedPage.open=false;
+      });
+
+    },
+    cancelRedPage(){
+      this.openRedPage.open=false;
+    },
+
+    /** 复制按钮操作 */
+    handleCopy(row) {
+      const courseId = row.courseId;
+      this.$confirm('是否确认复制课程编号为"' + courseId + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function () {
+        return copyUserCourse(courseId);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("复制成功");
+      }).catch(() => {
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const courseIds = row.courseId || this.ids;
+      this.$confirm('是否确认删除课程编号为"' + courseIds + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function () {
+        return delUserCourse(courseIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {
+      });
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = { ...this.queryParams, isPrivate: this.pageIsPrivate };
+      this.$confirm('是否确认导出所有课程数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportUserCourse(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {
+      });
+    },
+    putOn() {
+      const courseIds = this.ids;
+      if (courseIds == null || courseIds == "") {
+        return this.$message("未选择课程");
+      }
+      this.$confirm('是否确认批量上架课程?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function () {
+        return putOn(courseIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("上架成功");
+      }).catch(function () {
+      });
+    },
+    pullOff() {
+      const courseIds = this.ids;
+      if (courseIds == null || courseIds == "") {
+        return this.$message("未选择课程");
+      }
+      this.$confirm('是否确认批量下架课程?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function () {
+        return pullOff(courseIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("下架成功");
+      }).catch(function () {
+      });
+    },
+    configCourse(row) {
+      if (row.configJson) {
+        this.configDialog.form = {
+          tvEnable: 0,
+          networkEnable: 0,
+          unitEnable: 0,
+          teamEnable: 0,
+          supportEnable: 0,
+          ...JSON.parse(row.configJson)
+        }
+      }
+      this.configDialog.form.id = row.courseId
+      this.configDialog.dialogVisible = true;
+      this.configDialog.updating = false
+    },
+    submitConfigForm() {
+      this.$refs['configForm'].validate(valid => {
+        if (!valid) {
+          this.msgError('请完善配置内容')
+          return
+        }
+
+        if (this.configDialog.updating) {
+          return
+        }
+        this.configDialog.updating = true
+
+        const content = {
+          coverImg: this.configDialog.form.coverImg,
+          tvEnable: this.configDialog.form.tvEnable,
+          tv: this.configDialog.form.tv?.replace(",",","),
+          networkEnable: this.configDialog.form.networkEnable,
+          network: this.configDialog.form.network?.replace(",",","),
+          unitEnable: this.configDialog.form.unitEnable,
+          unit: this.configDialog.form.unit?.replace(",",","),
+          teamEnable: this.configDialog.form.teamEnable,
+          team: this.configDialog.form.team?.replace(",",","),
+          supportEnable: this.configDialog.form.supportEnable,
+          support: this.configDialog.form.support?.replace(",",","),
+        }
+
+        const params = {
+          id: this.configDialog.form.id,
+          configJson: JSON.stringify(content)
+        }
+
+        editConfig(params).then(() => {
+          this.msgSuccess('修改成功')
+          this.configDialog.dialogVisible = false;
+          this.getList()
+        }).finally(() => {
+          setTimeout(() => {
+            this.configDialog.updating = false
+          }, 500)
+        })
+      })
+    },
+    cancelConfig() {
+      this.configDialog.form = {
+        id: null,
+        coverImg: null,
+        tvEnable: 0,
+        tv: null,
+        networkEnable: 0,
+        network: null,
+        unitEnable: 0,
+        unit: null,
+        teamEnable: 0,
+        team: null,
+        supportEnable: 0,
+        support: null
+      }
+      this.resetForm('configForm')
+      this.configDialog.dialogVisible = false;
+    },
+    // 实时过滤金额输入,只允许两位小数
+    handleAmountInput(rule, field) {
+      let value = rule[field];
+      if (value === null || value === undefined) return;
+
+      // 转换为字符串处理
+      let str = value.toString();
+
+      // 移除除数字和小数点外的所有字符
+      str = str.replace(/[^0-9.]/g, '');
+
+      // 只保留一个小数点
+      const dotIndex = str.indexOf('.');
+      if (dotIndex !== -1) {
+        str = str.substring(0, dotIndex + 1) + str.substring(dotIndex + 1).replace(/\./g, '');
+      }
+
+      // 限制小数点后最多两位
+      if (dotIndex !== -1 && str.length > dotIndex + 3) {
+        str = str.substring(0, dotIndex + 3);
+      }
+
+      // 转换回数字并更新
+      rule[field] = parseFloat(str) || 0;
+    },
+    deleteRule(index) {
+      this.$confirm("确定要删除这个区间吗?", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning",
+      }).then(() => {
+        this.openRedPage.rules.splice(index, 1);
+        this.$message({
+          type: "success",
+          message: "删除成功!",
+        });
+      });
+    },
+    addRule(index) {
+      // 在当前行的后面插入一个新行
+      this.openRedPage.rules.splice(index + 1, 0, {
+        minAmount: 0.01,
+        maxAmount: 0.01,
+        weight: 100,
+      });
+    },
+    // 自定义校验规则:确保最大金额大于最小金额
+    validateMinAmount(rule, value, callback) {
+      // debugger;
+      // const maxAmount = this.form29.rules[].maxAmount
+
+      const index = rule.index;
+      const maxAmount = this.openRedPage.rules[index].maxAmount;
+
+      if (value > maxAmount) {
+        callback(new Error("最小金额不能大于最大金额"));
+      } else {
+        callback();
+      }
+    },
+    validateRules() {
+      this.openRedPage.rules.forEach((rule) => {
+        if (rule.minAmount === undefined || rule.minAmount < 0.01) {
+          rule.minAmount = 0.01;
+        }
+        if (rule.maxAmount === undefined || rule.maxAmount < rule.minAmount) {
+          rule.maxAmount = rule.minAmount;
+        }
+        if (rule.weight === undefined || rule.weight < 1) {
+          rule.weight = 1;
+        }
+      });
+    },
+
+  }
+};
+</script>

+ 3160 - 0
src/views/course/publiccourse/videoResource/index.vue

@@ -0,0 +1,3160 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="素材名称" prop="resourceName">
+        <el-input
+          v-model="queryParams.resourceName"
+          placeholder="请输入素材名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="文件名称" prop="fileName">
+        <el-input
+          v-model="queryParams.fileName"
+          placeholder="请输入素材名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="分类" prop="typeId">
+        <el-select
+          v-model="queryParams.typeId"
+          clearable
+          placeholder="请选择或输入分类"
+          @change="val => changeCateType(val, 1)"
+          filterable
+          :filter-method="filterTypeOptions"
+          @visible-change="handleSelectVisibleChange">
+          <el-option
+            v-for="item in filteredRootTypeList"
+            :key="item.dictValue"
+            :label="item.dictLabel"
+            :value="item.dictValue">
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="子分类" prop="typeSubId">
+        <el-select v-model="queryParams.typeSubId" clearable placeholder="请选择子分类">
+          <el-option
+            v-for="item in subTypeList"
+            :key="item.dictValue"
+            :label="item.dictLabel"
+            :value="item.dictValue">
+          </el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="cyan" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          :disabled="hasMinimizableDialog"
+          v-hasPermi="['course:videoResource:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleBatchAdd"
+          :disabled="hasMinimizableDialog"
+          v-hasPermi="['course:videoResource:batchAdd']"
+        >批量新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          icon="el-icon-plus"
+          size="mini"
+          :disabled="multiple"
+          @click="handleBatchUpdate"
+          v-hasPermi="['course:videoResource:batchUpdateClass']"
+        >批量修改分类</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['course:videoResource:remove']"
+        >删除</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table v-loading="loading" :data="resourceList" @selection-change="handleSelectionChange" border>
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="序号" width="55" align="center">
+        <template slot-scope="scope">
+          {{ scope.$index + 1 }}
+        </template>
+      </el-table-column>
+      <el-table-column label="素材名称" align="center" :show-overflow-tooltip="true" prop="resourceName"/>
+      <el-table-column label="文件名称" align="center" :show-overflow-tooltip="true" prop="fileName"/>
+      <el-table-column label="排序" align="center" prop="sort" />
+      <el-table-column label="分类" align="center">
+        <template slot-scope="scope">
+          <span v-if="scope.row.typeId">{{ getTypeName(scope.row.typeId) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="子分类" align="center">
+        <template slot-scope="scope">
+          <span v-if="scope.row.typeSubId">{{ getTypeName(scope.row.typeSubId) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="视频文件" align="center">
+        <template slot-scope="scope">
+          <a
+            @click="handleVideoPreview(scope.row.videoUrl)"
+            style="background-color: #409EFF; color: white; border: none; border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 12px; display: inline-block; text-decoration: none;">
+            查看文件
+          </a>
+        </template>
+      </el-table-column>
+      <el-table-column label="CDN" align="center">
+        <template slot-scope="scope">
+          <a
+            @click="copy(scope.row.videoUrl)"
+            style="color: #409EFF; border: none; border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 12px; display: inline-block; text-decoration: none;">
+            复制链接
+          </a>
+        </template>
+      </el-table-column>
+      <el-table-column label="关联题目" align="center">
+        <template slot-scope="scope">
+          <a
+            @click="handleViewProject(scope.row, 3)"
+            :style="scope.row.projectIds ? {
+              backgroundColor: '#409EFF',
+              color: 'white',
+              border: 'none',
+              borderRadius: '4px',
+              padding: '4px 12px',
+              cursor: 'pointer',
+              fontSize: '12px',
+              display: 'inline-block',
+              textDecoration: 'none'
+            } : {
+              backgroundColor: 'rgb(154 156 159)',
+              color: 'white',
+              border: 'none',
+              cursor: 'pointer',
+              borderRadius: '4px',
+              padding: '4px 12px',
+              fontSize: '12px',
+              display: 'inline-block',
+              textDecoration: 'none'
+            }">
+            {{ scope.row.projectIds ? '查看详情' : '未关联题目' }}
+          </a>
+        </template>
+      </el-table-column>
+      <el-table-column label="视频时长" align="center">
+        <template slot-scope="scope">
+          <div style="padding: 4px 12px;background: linear-gradient(to right, rgb(196 219 255), #409EFF)">{{ formatDuration(scope.row.duration) }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="视频展示类型" align="center" prop="displayType" width="110">
+        <template slot-scope="scope">
+          {{ scope.row.displayType === 'portrait' ? '竖屏' : '横屏' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+            :disabled="hasMinimizableDialog"
+            v-hasPermi="['course:videoResource:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['course:videoResource:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <!-- 添加或修改视频素材库对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="700px" append-to-body :before-close="cancel"
+               @minimize="hasMinimizableDialog = true" @restore="hasMinimizableDialog = false">
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="素材名称" prop="resourceName" style="margin-top: 20px">
+          <el-input v-model="form.resourceName" placeholder="请输入" />
+        </el-form-item>
+
+        <el-form-item label="文件名称" prop="fileName" style="margin-top: 20px;display: none">
+          <el-input v-model="form.fileName" placeholder="请输入" />
+        </el-form-item>
+
+        <el-form-item label="分类" prop="typeId">
+          <el-select v-model="form.typeId" placeholder="请选择分类" style="width: 100%" @change="val => changeCateType(val, 2)">
+            <el-option
+              v-for="item in rootTypeList"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="子分类" prop="typeSubId">
+          <el-select v-model="form.typeSubId" clearable placeholder="请选择子分类" style="width: 100%">
+            <el-option
+              v-for="item in subTypeList"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="form.sort"  :min="0" label="请输入排序"></el-input-number>
+        </el-form-item>
+
+        <el-form-item label="视频展示类型" prop="displayType">
+          <el-radio-group v-model="form.displayType">
+            <el-radio label="landscape">横屏</el-radio>
+            <el-radio label="portrait">竖屏</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="关联题目" prop="projectIds">
+          <el-select
+            ref="customSelect"
+            class="custom-select-class"
+            v-model="form.projectIds"
+            multiple
+            placeholder="请选择关联题目"
+            @click.native.stop="openProjectDialog(form.projectIds, 0)"
+            style="width: 100%;">
+            <el-option
+              v-for="item in projectShowList"
+              :key="item.id"
+              :label="item.title"
+              :value="item.id">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="上传视频" prop="videoUrl" required>
+          <el-upload
+            ref="videoUpload"
+            action="#"
+            list-type="picture-card"
+            :http-request="videoUpload"
+            :file-list="fileList"
+            :on-change="handleFileChange"
+            accept=".mp4">
+            <i slot="default" class="el-icon-plus"></i>
+            <div slot="file" slot-scope="{file}">
+              <div
+                class="el-upload-list__item-thumbnail"
+                style="display: flex; justify-content: center; align-items: center; background-color: #f5f7fa; height: 146px;"
+              >
+                <i class="el-icon-video-camera" style="font-size: 48px;" />
+              </div>
+
+              <span v-if="file.status === 'success'" class="el-upload-list__item-status-label">
+                <i class="el-icon-upload-success el-icon--check"></i>
+              </span>
+              <span v-if="file.status === 'fail'" class="el-upload-list__item-status-label">
+                <i class="el-icon-circle-close"></i>
+              </span>
+
+              <span class="el-upload-list__item-actions">
+                <span
+                  class="el-upload-list__item-preview"
+                  @click="handleVideoPreview(form.videoUrl)"
+                >
+                  <i class="el-icon-zoom-in"></i>
+                </span>
+                <span
+                  class="el-upload-list__item-delete"
+                  @click="handleRemove(file)"
+                >
+                  <i class="el-icon-delete"></i>
+                </span>
+              </span>
+
+              <div v-if="file.status === 'uploading'" class="el-upload-list__item-progress">
+                <div class="dual-upload-progress">
+                  <div class="total-progress">
+                    <el-progress
+                      :percentage="currentUploadProgress.total"
+                      :show-text="true"
+                      :width="52"
+                      :format="() => `${Math.round(currentUploadProgress.total)}%`"
+                    ></el-progress>
+                  </div>
+                  <div class="line-progress-container">
+                    <div class="line-progress-item">
+                      <span class="line-label">线路1:</span>
+                      <el-progress
+                        :percentage="currentUploadProgress.line1"
+                        :show-text="false"
+                        :width="30"
+                        :status="currentUploadProgress.line1Status === 'success' ? 'success' :
+                                currentUploadProgress.line1Status === 'failed' ? 'exception' : ''"
+                      ></el-progress>
+                      <span class="line-status-text">{{ Math.round(currentUploadProgress.line1) }}%</span>
+                    </div>
+                    <div class="line-progress-item">
+                      <span class="line-label">线路2:</span>
+                      <el-progress
+                        :percentage="currentUploadProgress.line2"
+                        :show-text="false"
+                        :width="30"
+                        :status="currentUploadProgress.line2Status === 'success' ? 'success' :
+                                currentUploadProgress.line2Status === 'failed' ? 'exception' : ''"
+                      ></el-progress>
+                      <span class="line-status-text">{{ Math.round(currentUploadProgress.line2) }}%</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </el-upload>
+        </el-form-item>
+
+        <el-form-item label="时长">
+          <span>{{ formatDuration(form.duration) }}</span>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="cancel">取消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="add" :disabled="isUploading || add">
+          {{ isUploading ? '上传中...' : '保存' }}
+        </el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog
+      title="视频预览"
+      :visible.sync="videoPreviewVisible"
+      append-to-body
+      :close-on-click-modal="true"
+      width="700px"
+      class="video-preview-dialog"
+      :modal-append-to-body="false"
+      :before-close="handleCloseVideoPreview">
+      <video ref="up-video" id="video" width="100%" height="400px" controls :src="videoPreviewUrl" />
+    </el-dialog>
+
+    <!--批量修改弹框-->
+    <el-dialog :title="'批量修改'" :visible.sync="batchUpdateVisible" width="700px" append-to-body :before-close="cancel"
+               @minimize="hasMinimizableDialog = true" @restore="hasMinimizableDialog = false">
+      <el-form ref="form" :model="batchUpdateForm" :rules="rules" label-width="80px">
+        <el-form-item label="分类" prop="typeId">
+          <el-select v-model="batchUpdateForm.typeId" placeholder="请选择分类" style="width: 100%" @change="val => changeCateType(val, 2)">
+            <el-option
+              v-for="item in rootTypeList"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="子分类" prop="typeSubId">
+          <el-select v-model="batchUpdateForm.typeSubId" clearable placeholder="请选择子分类" style="width: 100%">
+            <el-option
+              v-for="item in subTypeList"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue">
+            </el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div class="dialog-footer">
+        <el-button @click="cancelBatch">取 消</el-button>
+        <el-button type="primary" @click="submitBatchUpdate">保 存</el-button>
+      </div>
+    </el-dialog>
+    <!-- 批量选择视频弹窗 -->
+    <minimizable-dialog :title="'选择视频'" :visible.sync="batchAddVisible" width="1200px" append-to-body class="batch-dialog"
+                        :close-on-click-modal="false" :before-close="cancelBeforeBatch" @minimize="hasMinimizableDialog = true"
+                        @restore="hasMinimizableDialog = false">
+      <div class="filter-container">
+        <el-button type="primary" icon="el-icon-plus" size="small" @click="showUploadPanel">上传视频</el-button>
+      </div>
+
+      <el-table
+        v-loading="batchLoading"
+        :data="videoList"
+        height="350"
+        border>
+        <el-table-column label="序号" width="60" align="center">
+          <template slot-scope="scope">
+            {{ scope.$index + 1 }}
+          </template>
+        </el-table-column>
+        <el-table-column label="素材名称" align="center" prop="resourceName" min-width="120" />
+        <el-table-column label="文件名称" align="center" prop="fileName" min-width="120" />
+        <el-table-column label="分类" align="center" min-width="100">
+          <template slot-scope="scope">
+            {{ getTypeName(scope.row.typeId) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="子分类" align="center" min-width="100">
+          <template slot-scope="scope">
+            {{ getTypeName(scope.row.typeSubId) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="关联项目" align="center" min-width="100">
+          <template slot-scope="scope">
+            <a
+              @click="handleViewProject(scope.row, 4)"
+              :style="scope.row.projectIds.length > 0 ? {
+              backgroundColor: '#409EFF',
+              color: 'white',
+              border: 'none',
+              borderRadius: '4px',
+              padding: '4px 12px',
+              cursor: 'pointer',
+              fontSize: '12px',
+              display: 'inline-block',
+              textDecoration: 'none'
+            } : {
+              backgroundColor: 'rgb(154 156 159)',
+              color: 'white',
+              border: 'none',
+              cursor: 'pointer',
+              borderRadius: '4px',
+              padding: '4px 12px',
+              fontSize: '12px',
+              display: 'inline-block',
+              textDecoration: 'none'
+            }">
+              {{ scope.row.projectIds.length > 0 ? '查看详情' : '未关联题目' }}
+            </a>
+          </template>
+        </el-table-column>
+        <el-table-column label="视频文件" align="center" prop="fileName" min-width="120">
+          <template slot-scope="scope">
+            <a
+              @click="handleVideoPreview(scope.row.videoUrl)"
+              style="background-color: #409EFF; color: white; border: none; border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 12px; display: inline-block; text-decoration: none;">
+              查看文件
+            </a>
+          </template>
+        </el-table-column>
+        <el-table-column label="视频时长" align="center" width="80">
+          <template slot-scope="scope">
+            {{ formatDuration(scope.row.duration) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="上传进度" align="center" width="200">
+          <template slot-scope="scope">
+            <div class="batch-upload-progress">
+              <div v-if="scope.row.uploadStatus === 'queued'" class="queue-status">
+                <el-tag :color="getQueueStatusColor(scope.row.uploadStatus)" size="small">
+                  {{ getUploadStatusText(scope.row.uploadStatus, scope.row.queuePosition) }}
+                </el-tag>
+              </div>
+              <div v-else class="total-progress-row">
+                <span class="progress-label">总进度:</span>
+                <el-progress
+                  :percentage="scope.row.progress || 0"
+                  :status="getProgressStatus(scope.row)"
+                  :show-text="true"
+                  :format="() => `${Math.round(scope.row.progress || 0)}%`"
+                ></el-progress>
+              </div>
+              <div v-if="scope.row.uploadDetails && scope.row.uploadStatus !== 'queued'" class="line-progress-rows">
+                <div class="line-progress-row">
+                  <span class="line-label">线路1:</span>
+                  <el-progress
+                    :percentage="scope.row.uploadDetails.line1 || 0"
+                    :status="scope.row.uploadDetails.line1Status === 'success' ? 'success' :
+                            scope.row.uploadDetails.line1Status === 'failed' ? 'exception' : 'warning'"
+                    :show-text="false"
+                    style="width: 60px;"
+                  ></el-progress>
+                  <span class="line-percentage">{{ Math.round(scope.row.uploadDetails.line1 || 0) }}%</span>
+                </div>
+                <div class="line-progress-row">
+                  <span class="line-label">线路2:</span>
+                  <el-progress
+                    :percentage="scope.row.uploadDetails.line2 || 0"
+                    :status="scope.row.uploadDetails.line2Status === 'success' ? 'success' :
+                            scope.row.uploadDetails.line2Status === 'failed' ? 'exception' : 'warning'"
+                    :show-text="false"
+                    style="width: 60px;"
+                  ></el-progress>
+                  <span class="line-percentage">{{ Math.round(scope.row.uploadDetails.line2 || 0) }}%</span>
+                </div>
+              </div>
+            </div>
+          </template>
+
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="150">
+          <template slot-scope="scope">
+            <el-button
+              size="mini"
+              type="text"
+              icon="el-icon-edit"
+              :disabled="scope.row.progress !== 100"
+              @click="handleEditVideo(scope.row)">
+              编辑
+              <el-tooltip
+                v-if="scope.row.progress !== 100"
+                content="上传完成后可编辑"
+                placement="top"
+              >
+                <i class="el-icon-question"></i>
+              </el-tooltip>
+            </el-button>
+            <el-button
+              size="mini"
+              type="text"
+              icon="el-icon-delete"
+              @click="handleDeleteVideo(scope.row)"
+              :style="scope.row.uploadStatus === 'queued' ? 'color: #E6A23C;' : ''"
+            >
+              {{ scope.row.uploadStatus === 'queued' ? '取消' : '删除' }}
+            </el-button>
+            <el-button
+              v-if="scope.row.progress < 100 && scope.row.uploadStatus === 'failed'"
+              size="mini"
+              type="text"
+              icon="el-icon-refresh"
+              @click="retryBatchUpload(scope.row)"
+              style="color: #E6A23C;"
+            >
+              重试
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="dialog-footer">
+        <el-button @click="cancelBatch">取 消</el-button>
+        <el-button type="primary" :loading="add" :disabled="add || isUploading" @click="submitBatchAdd">保 存</el-button>
+      </div>
+      <!-- 批量上传视频弹窗 -->
+      <el-dialog
+        title="批量上传视频"
+        :visible.sync="showUpload"
+        width="500px"
+        append-to-body
+        :close-on-click-modal="false"
+        class="upload-dialog">
+        <el-form :model="batchUploadForm" ref="batchUploadForm" label-width="80px">
+          <el-form-item style="margin-top: 20px" label="分类" prop="typeId" :rules="[{ required: true, message: '请选择分类', trigger: 'blur' }]">
+            <el-select
+              v-model="batchUploadForm.typeId"
+              placeholder="请选择分类"
+              style="width: 100%"
+              @change="val => changeCateType(val, 3)"
+              filterable
+            >
+              <el-option
+                v-for="item in rootTypeList"
+                :key="item.dictValue"
+                :label="item.dictLabel"
+                :value="item.dictValue">
+              </el-option>
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="子分类" prop="typeSubId" :rules="[{ required: true, message: '请选择子分类', trigger: 'blur' }]">
+            <el-select
+              v-model="batchUploadForm.typeSubId"
+              clearable
+              placeholder="请选择子分类"
+              style="width: 100%"
+              @change="changeSubType"
+              filterable
+            >
+              <el-option
+                v-for="item in subTypeList"
+                :key="item.dictValue"
+                :label="item.dictLabel"
+                :value="item.dictValue">
+              </el-option>
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="视频展示类型" prop="displayType">
+            <el-radio-group v-model="batchUploadForm.displayType">
+              <el-radio label="landscape">横屏</el-radio>
+              <el-radio label="portrait">竖屏</el-radio>
+            </el-radio-group>
+          </el-form-item>
+
+          <el-form-item label="关联题目" prop="projectIds" v-show="currentProject === 'myhk'">
+            <el-select
+              ref="customSelect"
+              class="custom-select-class"
+              v-model="batchUploadForm.projectIds"
+              multiple
+              placeholder="请选择关联题目"
+              @click.native.stop="openProjectDialog(batchUploadForm.projectIds, 1)"
+              style="width: 100%;">
+              <el-option
+                v-for="item in projectShowList"
+                :key="item.id"
+                :label="item.title"
+                :value="item.id">
+              </el-option>
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="上传视频" prop="files">
+            <el-upload
+              ref="batchVideoUpload"
+              action="#"
+              :http-request="batchVideoUpload"
+              :file-list="batchFileList"
+              :on-change="handleBatchFileChange"
+              multiple
+              accept=".mp4">
+              <el-button type="primary" :disabled="batchUploadForm.typeSubId === null">选择文件</el-button>
+              <div slot="tip" class="el-upload__tip">选择分类后才可以上传视频</div>
+            </el-upload>
+          </el-form-item>
+        </el-form>
+      </el-dialog>
+
+      <!-- 添加或修改视频素材库对话框 -->
+      <el-dialog :title="batchEditDialog.title" :visible.sync="batchEditDialog.open" width="600px" append-to-body :before-close="batchEditCancel">
+        <el-form ref="batchEditDialogForm" :model="batchEditDialog.form" :rules="batchEditDialog.rules" label-width="80px">
+          <el-form-item label="素材名称" prop="resourceName" style="margin-top: 20px">
+            <el-input v-model="batchEditDialog.form.resourceName" placeholder="请输入" />
+          </el-form-item>
+
+          <el-form-item label="视频展示类型" prop="displayType">
+            <el-radio-group v-model="batchEditDialog.form.displayType">
+              <el-radio label="landscape">横屏</el-radio>
+              <el-radio label="portrait">竖屏</el-radio>
+            </el-radio-group>
+          </el-form-item>
+
+          <el-form-item label="文件名称" prop="fileName" style="margin-top: 20px;display: none">
+            <el-input v-model="batchEditDialog.form.fileName" placeholder="请输入" />
+          </el-form-item>
+
+          <el-form-item label="关联题目" prop="projectIds">
+            <el-select
+              ref="customSelect"
+              class="custom-select-class"
+              v-model="batchEditDialog.form.projectIds"
+              multiple
+              placeholder="请选择关联题目"
+              @click.native.stop="openProjectDialog(batchEditDialog.form.projectIds, 2)"
+              style="width: 100%;">
+              <el-option
+                v-for="item in projectShowList"
+                :key="item.id"
+                :label="item.title"
+                :value="item.id">
+              </el-option>
+            </el-select>
+          </el-form-item>
+        </el-form>
+        <div slot="footer" class="dialog-footer">
+          <el-button @click="batchEditCancel">取消</el-button>
+          <el-button type="primary" @click="batchEditSubmitForm">保存</el-button>
+        </div>
+      </el-dialog>
+
+    </minimizable-dialog>
+
+    <!-- 项目选择弹窗 -->
+    <el-dialog
+      title="选择题目"
+      :visible.sync="projectDialogVisible"
+      width="1000px"
+      append-to-body
+      @close="cancelSelectProject"
+      :close-on-click-modal="false">
+      <div class="project-container">
+        <!-- 左侧分类树 -->
+        <div class="category-tree">
+          <div class="tree-fixed-header">
+            <el-input
+              placeholder="请输入分类名称"
+              v-model="categoryFilterText"
+              size="small"
+              clearable
+              prefix-icon="el-icon-search">
+            </el-input>
+          </div>
+          <div class="tree-content">
+            <el-tree
+              ref="categoryTree"
+              :data="categoryTreeData"
+              node-key="cateId"
+              highlight-current
+              :filter-node-method="filterNode"
+              :props="{ label: 'cateName', children: 'children' }"
+              @node-click="handleCategoryClick"
+              :expand-on-click-node="false"
+              default-expand-all>
+              <span class="custom-tree-node" slot-scope="{ node, data }">
+                <span>{{ node.label }}</span>
+              </span>
+            </el-tree>
+          </div>
+        </div>
+
+        <!-- 右侧题目列表 -->
+        <div class="project-list">
+          <div class="filter-container">
+            <el-form :inline="true" :model="projectQueryParams" ref="projectForm">
+              <el-form-item>
+                <el-input prefix-icon="el-icon-search" @input="searchProjects" v-model="projectQueryParams.title" placeholder="请输入题目标题" clearable size="small" />
+              </el-form-item>
+            </el-form>
+          </div>
+
+          <el-table
+            ref="projectTable"
+            height="350"
+            :data="projectList"
+            row-key="id"
+            v-loading="projectLoading"
+            border
+            @select="handleProjectSelect"
+            @select-all="handleProjectSelect">
+            <el-table-column type="selection" reserve-selection width="55" align="center" />
+            <el-table-column label="序号" width="60" align="center">
+              <template slot-scope="scope">
+                {{ scope.row.sort }}
+              </template>
+            </el-table-column>
+            <el-table-column label="题目标题" align="center" prop="title" min-width="200" show-overflow-tooltip />
+          </el-table>
+
+          <div class="table-footer">
+            <el-pagination style="text-align: right" v-show="projectListTotal>0" :pager-count="5" background
+                           @size-change="handleProjectPageSizeChange" @current-change="handleProjectPageChange" :current-page="projectQueryParams.pageNum"
+                           :page-sizes="[10, 20, 30, 50]" :page-size="projectQueryParams.pageSize" layout="total, sizes, prev, pager, next, jumper"
+                           :total="projectListTotal">
+            </el-pagination>
+          </div>
+        </div>
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <span style="float: left; color: #606266; font-size: 13px;">
+          共选择 <span style="color: #409EFF; font-weight: bold;">{{ selectedProjectIds.length }}</span> 个题目
+        </span>
+        <el-button @click="projectDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmSelectProject">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 项目列表弹窗 -->
+    <el-dialog
+      title="题目列表"
+      :visible.sync="projectListDialogVisible"
+      width="600px"
+      append-to-body
+      :close-on-click-modal="false">
+      <div class="project-list-container" style="max-height: 500px; overflow-y: auto; padding: 10px;">
+        <!-- 题目列表 -->
+        <div v-for="(item, index) in projectShowList" :key="index" class="question-card">
+          <!-- 题目标题 -->
+          <div class="question-header">
+            <span class="question-index">{{ index + 1 }}</span>
+            <span class="question-title">{{ item.title }}</span>
+          </div>
+
+          <!-- 题目类型 -->
+          <div class="question-type">
+            <el-tag size="small" type="primary">{{ item.type === 1 ? '单选' : '多选' }}</el-tag>
+          </div>
+
+          <!-- 题目内容 -->
+          <div class="question-content" v-if="item.question && item.question.length > 0">
+            <div class="content-title">题目内容:</div>
+            <div v-for="(q, qIndex) in JSON.parse(item.question)" :key="qIndex" class="question-item"
+                 :style=" q.isAnswer === 1 ? 'background-color: rgb(234 245 251)' : ''">
+              <div class="question-item-header">
+                <span class="item-index">{{ convertToLetter(qIndex) }}</span>
+                <span class="item-name">{{ q.name }}</span>
+              </div>
+            </div>
+          </div>
+
+          <!-- 答案 -->
+          <div class="question-answer" v-if="item.answer">
+            <span class="answer-label">答案:</span>
+            <span class="answer-content">{{ item.answer }}</span>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  addVideoResource,
+  deleteVideoResource,
+  getVideoResource,
+  listVideoResource,
+  updateVideoResource,
+  batchAddVideoResource,
+  batchUpdateVideoResource
+} from '@/api/course/videoResource'
+import {listUserCourseCategory,getCatePidList,getCateListByPid} from '@/api/course/userCourseCategory'
+import {getByIds, listCourseQuestionBank} from '@/api/course/courseQuestionBank'
+import {getThumbnail} from "@/api/course/userVideo";
+import {uploadObject} from "@/utils/cos.js";
+import {uploadToOBS} from "@/utils/obs.js";
+import {uploadToHSY} from "@/utils/hsy.js";
+import MinimizableDialog from "@/components/MinimizableDialog"
+import log from "@/views/monitor/job/log.vue";
+
+export default {
+  name: 'VideoResource',
+  components: {
+    MinimizableDialog
+  },
+  data() {
+    return {
+      /** 本页视频资源类型:0-视频资源,1-公域课视频资源(与后端 video_type 一致) */
+      pageVideoType: 1,
+      filteredRootTypeList: [], // 过滤后的分类列表
+      originalRootTypeList: [], // 原始分类列表
+      // 遮罩层
+      loading: true,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 视频素材库表格数据
+      resourceList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        resourceName: null,
+        fileName: null,
+        typeId: null,
+        typeSubId: null,
+        videoType: 1
+      },
+      // 表单参数
+      form: {
+        id: null,
+        resourceName: null,
+        fileName: null,
+        thumbnail: null,
+        line1: null,
+        line2: null,
+        line3: null,
+        duration: null,
+        fileSize: null,
+        fileKey: null,
+        videoUrl: null,
+        typeId: null,
+        typeSubId: null,
+        projectIds: [],
+        sort: null,
+        displayType: 'landscape',
+        videoType: 1,
+        hsyVid:null,//火山云上传视频返回vid
+        hsyVodUrl:null,//火山云url
+        // 新增上传状态字段
+        uploadStatus: 'pending', // pending, uploading, success, failed
+        uploadProgress: {
+          total: 0,
+          line1: 0,
+          line2: 0,
+          line1Status: 'pending', // pending, uploading, success, failed
+          line2Status: 'pending'
+        }
+      },
+      // 表单校验
+      rules: {
+        resourceName: [
+          { required: true, message: "素材名称不能为空", trigger: "blur" }
+        ],
+        fileName: [
+          { required: true, message: "文件名称不能为空", trigger: "blur" }
+        ],
+        typeId: [
+          { required: true, message: "请选择分类", trigger: "change" }
+        ],
+        typeSubId: [
+          { required: true, message: "请选择子分类", trigger: "change" }
+        ],
+        sort: [
+          { required: true, message: '排序不能为空', trigger: 'blur' }
+        ],
+        videoUrl: [
+          { required: true, message: "请上传视频", trigger: "change" }
+        ]
+      },
+      // 课程列表数据
+      projectList: [],
+      projectShowList: [],
+      // 分类
+      typeList: [],
+      rootTypeList: [],
+      subTypeList: [],
+      // 视频上传
+      videoDisabled: false,
+      videoPreviewVisible: false,
+      videoPreviewUrl: '',
+      fileList: [],
+      // 批量添加相关
+      batchAddVisible: false,
+      batchLoading: false,
+      videoList: [],
+
+      // 批量修改相关
+      batchUpdateVisible: false,
+      batchUpdateLoading: false,
+      batchUpdateForm: {
+        typeId: null,
+        typeSubId: null,
+        ids: [],
+      },
+
+      // 批量上传相关
+      showUpload: false,
+      batchUploadForm: {
+        typeId: null,
+        typeSubId: null,
+        projectIds: [],
+        files: [],
+        displayType: 'landscape'
+      },
+      batchFileList: [],
+
+      // 弹窗选择项目相关
+      projectDialogVisible: false,
+      projectQueryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        questionType: null,
+        questionSubType: null,
+        title: null
+      },
+      projectLoading: false,
+      projectListTotal: 0,
+      selectedProjectIds: [],
+      selectedType: 0,
+      currentRow: null,
+      // 分类树相关数据
+      categoryFilterText: '',
+      categoryTreeData: [],
+      add: false,
+      // 题目列表
+      projectListDialogVisible: false,
+      // 修改视频记录
+      batchEditDialog: {
+        title: '修改视频',
+        open: false,
+        form: {},
+        rules: {
+          resourceName: [
+            { required: true, message: "素材名称不能为空", trigger: "blur" }
+          ],
+          fileName: [
+            { required: true, message: "文件名称不能为空", trigger: "blur" }
+          ]
+        },
+      },
+      // 是否存在最小化窗口
+      hasMinimizableDialog: false,
+      // 新增上传相关状态
+      isUploading: false,
+      currentUploadProgress: {
+        total: 0,
+        line1: 0,
+        line2: 0,
+        line1Status: 'pending',
+        line2Status: 'pending'
+      },
+      // 上传任务队列
+      uploadQueue: [], // 上传队列
+      currentBatchSize: 2, // 每批上传数量
+      isProcessingBatch: false, // 是否正在处理批次
+      currentBatchIndex: 0, // 当前批次索引
+      uploadCancellationTokens: new Map(), // Store cancellation functions by video ID
+      currentProject: process.env.VUE_APP_PROJECT
+    }
+  },
+  watch: {
+    categoryFilterText(val) {
+      this.$refs.categoryTree.filter(val);
+    }
+  },
+  created() {
+    this.getTypeList();
+    this.getRootTypeList()
+    this.getList();
+  },
+  methods: {
+    /** 将数字索引转换为字母序号 (0->A, 1->B, 等) */
+    convertToLetter(index) {
+      // 确保索引是数字
+      let numIndex = parseInt(index, 10);
+      // 如果无法转换成数字,返回原始值
+      if (isNaN(numIndex)) {
+        return index;
+      }
+      // 处理负数情况
+      if (numIndex < 0) {
+        return index;
+      }
+      // 直接使用索引计算ASCII码(0对应A,1对应B,以此类推)
+      return String.fromCharCode(65 + numIndex); // 65 是大写字母 A 的ASCII码
+    },
+    /** 查询视频素材库列表 */
+    getList() {
+      this.loading = true;
+      this.queryParams.videoType = this.pageVideoType;
+      listVideoResource(this.queryParams).then(response => {
+        this.resourceList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+      this.resetForm("form");
+      this.batchUpdateVisible = false;
+      this.changeCateType(this.queryParams.typeId)
+      this.$refs.videoUpload.clearFiles()
+    },
+    // 表单重置
+    reset() {
+      // 初始化表单对象
+      this.form = {
+        id: null,
+        resourceName: null,
+        fileName: null,
+        thumbnail: null,
+        line1: null,
+        line2: null,
+        line3: null,
+        duration: null,
+        fileSize: null,
+        fileKey: null,
+        videoUrl: null,
+        typeId: null,
+        typeSubId: null,
+        projectIds: [],
+        displayType: 'landscape',
+        videoType: this.pageVideoType
+      };
+      // 重置表单验证状态
+      this.resetForm("form");
+      this.add = false
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.queryParams.videoType = this.pageVideoType;
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      // 先清空文件列表
+      this.fileList = [];
+      this.projectShowList = [];
+
+      // 先重置表单
+      this.reset();
+      this.subTypeList = []
+
+      // 重置上传组件
+      if (this.$refs.videoUpload) {
+        this.$refs.videoUpload.clearFiles();
+      }
+
+      // 所有重置完成后再打开弹窗
+      this.open = true;
+      this.title = "添加视频素材库";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      // 先清空文件列表
+      this.fileList = [];
+      this.projectShowList = [];
+
+      // 先重置表单
+      this.reset();
+      this.subTypeList = []
+
+      const id = row.id
+
+      // 获取数据并设置表单
+      getVideoResource(id).then(async response => {
+        this.form = response.data;
+        // 视频展示类型:旧数据可能为空,默认横屏
+        if (!this.form.displayType) {
+          this.form.displayType = 'landscape';
+        }
+        if (this.form.videoType === null || this.form.videoType === undefined) {
+          this.form.videoType = this.pageVideoType;
+        }
+        await this.changeCateType(this.form.typeId)
+
+        // 处理projectIds,确保是数组格式
+        if (this.form.projectIds && typeof this.form.projectIds === 'string') {
+          this.form.projectIds = this.form.projectIds.split(',').map(id => parseInt(id));
+        } else if (!this.form.projectIds) {
+          this.form.projectIds = [];
+        }
+
+        // 如果存在关联项目,获取项目详情用于回显
+        if (this.form.projectIds && this.form.projectIds.length > 0) {
+          // 加载项目列表信息用于回显
+          await getByIds({ids: this.form.projectIds.join(',')}).then(reponse => {
+            this.projectShowList = reponse.data
+          });
+        }
+
+        // 如果存在视频URL,设置fileList
+        if (this.form.videoUrl) {
+          this.fileList = [{
+            name: this.form.fileName || '视频文件',
+            url: this.form.videoUrl,
+            thumbnail: this.form.thumbnail,
+            status: 'success' // 设置为成功状态
+          }];
+        }
+
+        // 所有设置完成后再打开弹窗
+        this.open = true;
+        this.title = "修改视频素材库";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.add){
+            this.$message.warning("请勿重复提交")
+            return
+          }
+          this.add = true
+
+          const params = Object.assign({}, this.form);
+          console.log("提交素材表单参数",this.form)
+          params.videoType = this.pageVideoType;
+          params.projectIds = this.form.projectIds.join(',');
+          if (this.form.id != null) {
+            updateVideoResource(params).then(response => {
+              if (response.code === 200) {
+                this.msgSuccess("修改成功");
+                this.open = false;
+                this.getList();
+              }
+            })
+          } else {
+            addVideoResource(params).then(response => {
+              if (response.code === 200) {
+                this.msgSuccess("新增成功");
+                this.open = false;
+                this.getList();
+              }
+            })
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const ids = row.id || this.ids;
+      this.$confirm('是否确认删除视频素材库编号为"' + ids + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return deleteVideoResource(ids);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(function() {});
+    },
+    /** 查询视频分类列表 */
+    getTypeList() {
+      listUserCourseCategory().then(response => {
+        this.typeList = response.data;
+      });
+    },
+    getRootTypeList() {
+      getCatePidList().then(response => {
+        this.rootTypeList = response.data;
+        this.originalRootTypeList = [...response.data]; // 保存原始数据
+        this.filteredRootTypeList = [...response.data]; // 初始化过滤列表
+      });
+    },
+    /** 过滤分类选项 */
+    filterTypeOptions(query) {
+      if (!query) {
+        this.filteredRootTypeList = [...this.originalRootTypeList];
+        return;
+      }
+
+      this.filteredRootTypeList = this.originalRootTypeList.filter(item => {
+        return item.dictLabel.toLowerCase().includes(query.toLowerCase()) ||
+          item.dictValue.toString().includes(query);
+      });
+    },
+    /** 选择后重置过滤 */
+    onTypeSelect(value) {
+      // 确保选中后恢复完整列表
+      this.filteredRootTypeList = [...this.originalRootTypeList];
+      // 触发 change 事件
+      this.changeCateType(value, 1);
+    },
+    /** 重置过滤 */
+    resetFilter() {
+      this.filteredRootTypeList = [...this.originalRootTypeList];
+    },
+
+    /** 清除过滤 */
+    clearFilter() {
+      this.$nextTick(() => {
+        if (!this.queryParams.typeId) {
+          this.filteredRootTypeList = [...this.originalRootTypeList];
+        }
+      });
+    },
+    /** 处理下拉框显示状态变化 */
+    handleSelectVisibleChange(visible) {
+      if (!visible) {
+        // 下拉框关闭时恢复完整列表
+        this.filteredRootTypeList = [...this.originalRootTypeList];
+      } else {
+        // 下拉框打开时也确保列表完整
+        this.filteredRootTypeList = [...this.originalRootTypeList];
+      }
+    },
+    async changeCateType(val, type) {
+      if (type === 1) {
+        this.queryParams.typeSubId = null
+      }
+      if (type === 2) {
+        this.form.typeSubId = null
+      }
+      if (type === 3) {
+        this.batchUploadForm.typeSubId = null
+        if (this.currentProject === 'myhk') {
+          this.batchUploadForm.projectIds = []
+        }
+      }
+      this.subTypeList = []
+      if (!val) {
+        return
+      }
+      await getCateListByPid(val).then(response => {
+        this.subTypeList = response.data
+      })
+    },
+    changeSubType(val) {
+      if (this.currentProject !== 'myhk') {
+        return
+      }
+      this.projectShowList = []
+      this.batchUploadForm.projectIds = []
+      listCourseQuestionBank({questionSubType: val}).then(async response => {
+        const projectIds = response.rows.map(item => item.id)
+
+        // 如果存在关联项目,获取项目详情用于回显
+        if (projectIds && projectIds.length > 0) {
+          // 加载项目列表信息用于回显
+          await getByIds({ids: projectIds.join(',')}).then(reponse => {
+            this.projectShowList = reponse.data
+            this.batchUploadForm.projectIds = projectIds
+          });
+        }
+      })
+    },
+    /** 预览视频 */
+    handleVideoPreview(url) {
+      this.videoPreviewVisible = true;
+      this.videoPreviewUrl = url || this.form.videoUrl;
+    },
+
+    /** 格式化视频时长 */
+    formatDuration(seconds) {
+      if (!seconds) return '00:00';
+
+      const minutes = Math.floor(seconds / 60);
+      const remainingSeconds = seconds % 60;
+
+      return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
+    },
+    /** 移除视频 */
+    handleRemove(file) {
+      this.fileList.splice(this.fileList.indexOf(file), 1);
+      this.form.videoUrl = '';
+      this.form.thumbnail = '';
+      this.form.duration = 0;
+      this.form.fileSize = 0;
+      this.form.fileName = '';
+      this.form.line1 = '';
+      this.form.line_2 = '';
+      this.form.line_3 = '';
+      this.uploadType = null
+      this.fileSize = null
+      this.fileKey = null
+    },
+    //获取第一帧封面
+    async getFirstThumbnail(file, form){
+      try {
+        //截取小文件
+        // 打印原始文件名和大小
+        console.log("原始文件名:", file.name);
+        console.log("原始文件大小:", file.size, "bytes");
+        console.log("原始文件类型:", file.type);
+
+        // 截取小文件
+        const clippedBlob = await this.clipVideoFirstTwoSeconds(file);
+        console.log("clippedBlob:::::::::", clippedBlob);
+        console.log("截取后的Blob大小:", clippedBlob.size, "bytes");
+        console.log("截取后的Blob类型:", clippedBlob.type);
+
+        const clippedFile = new File([clippedBlob], 'clipped_video.mp4', {
+          type: 'video/mp4',
+          lastModified: Date.now()
+        });
+        // 3. 调用接口获取封面
+        const response = await getThumbnail(clippedFile);
+        console.log("获取封面请求---------------》",response)
+        form.thumbnail = response.url;
+      } catch (error) {
+        console.error('获取封面失败:', error);
+      }
+    },
+
+    //截取大文件视频
+    async clipVideoFirstTwoSeconds(file) {
+      return new Promise((resolve, reject) => {
+        const video = document.createElement('video');
+        video.src = URL.createObjectURL(file);
+        video.muted = true;
+        video.playsInline = true;
+
+        video.onloadedmetadata = () => {
+          video.currentTime = 0; // 定位到第一帧
+          video.onseeked = () => {
+            const canvas = document.createElement('canvas');
+            canvas.width = video.videoWidth;
+            canvas.height = video.videoHeight;
+            const ctx = canvas.getContext('2d');
+            ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+
+            canvas.toBlob(
+              (blob) => {
+                URL.revokeObjectURL(video.src);
+                resolve(blob); // 返回 JPEG Blob
+              },
+              'image/jpeg',
+              0.8 // 质量
+            );
+          };
+        };
+
+        video.onerror = () => {
+          URL.revokeObjectURL(video.src);
+          reject(new Error('视频加载失败'));
+        };
+      });
+    },
+    //上传腾讯云Pcdn
+    async uploadVideoToTxPcdn(file, form, onProgress) {
+      try {
+        // 更新线路1状态为上传中
+        this.updateUploadProgress('line1Status', 'uploading');
+
+        const data = await uploadObject(file, (progress) => {
+          const progressPercent = Math.floor(progress.percent * 100);
+          this.updateUploadProgress('line1', progressPercent);
+          const progressEvent = { percent: progressPercent, loaded: progress.loaded, total: progress.total, lengthComputable: true };
+          onProgress(progressEvent);
+        }, 1, (uploadInfo) => {
+          if (form.tempId) {
+            const tokens = this.uploadCancellationTokens.get(form.tempId) || {};
+            tokens.cos = uploadInfo.cancel;
+            this.uploadCancellationTokens.set(form.tempId, tokens);
+          }
+        });
+
+        let line_1 = `${process.env.VUE_APP_VIDEO_LINE_1}${data.urlPath}`;
+        form.fileKey = data.urlPath.substring(1);
+        form.videoUrl = line_1;
+        form.line1 = line_1;
+
+        // 更新线路1状态为成功
+        this.updateUploadProgress('line1Status', 'success');
+        this.updateUploadProgress('line1', 100);
+
+        this.$message.success("线路一上传成功");
+        return { success: true, url: line_1 };
+      } catch (error) {
+        // 更新线路1状态为失败
+        this.updateUploadProgress('line1Status', 'failed');
+        this.$message.error("线路一上传失败");
+        return { success: false, error: error.message };
+      }
+    },
+    //上传华为云Obs
+    // async uploadVideoToHwObs(file, form, onProgress) {
+    //   try {
+    //     // 更新线路2状态为上传中
+    //     this.updateUploadProgress('line2Status', 'uploading');
+    //
+    //     const data = await uploadToOBS(file, (progress) => {
+    //       const progressPercent = Math.floor(progress);
+    //       this.updateUploadProgress('line2', progressPercent);
+    //       const progressEvent = { percent: progressPercent, loaded: progress, total: progress, lengthComputable: true };
+    //       onProgress(progressEvent);
+    //     }, 1, (uploadInfo) => {
+    //       if (form.tempId) {
+    //         const tokens = this.uploadCancellationTokens.get(form.tempId) || {};
+    //         tokens.obs = uploadInfo.cancel;
+    //         this.uploadCancellationTokens.set(form.tempId, tokens);
+    //       }
+    //     });
+    //
+    //     form.line2 = `${process.env.VUE_APP_VIDEO_LINE_2}/${data.urlPath}`;
+    //
+    //     // 更新线路2状态为成功
+    //     this.updateUploadProgress('line2Status', 'success');
+    //     this.updateUploadProgress('line2', 100);
+    //
+    //     this.$message.success("线路二上传成功");
+    //     return { success: true, url: form.line2 };
+    //   } catch (error) {
+    //     // 更新线路2状态为失败
+    //     this.updateUploadProgress('line2Status', 'failed');
+    //     this.$message.error("线路二上传失败");
+    //     return { success: false, error: error.message };
+    //   }
+    // },
+    //上传火山云
+    async uploadVideoToHsy(file, form, onProgress) {
+      try {
+        this.updateUploadProgress('line2Status', 'uploading');
+
+        const data = await uploadToHSY(
+          file,
+          (progress) => {
+            // 火山云的进度是小数0-1
+            if (typeof progress.percent === 'number') {
+              const percent = Math.floor(progress.percent * 100);
+
+              // 更新线路2进度
+              this.updateUploadProgress('line2', percent);
+
+              // 对外统一 progress 事件(模拟 xhr)
+              onProgress?.({
+                percent,
+                loaded: percent,
+                total: 100,
+                lengthComputable: true
+              });
+            }
+
+            // 状态同步(成功 / 失败)
+            if (progress.status === 'success') {
+              this.updateUploadProgress('line2Status', 'success');
+              this.updateUploadProgress('line2', 100);
+            }
+
+            if (progress.status === 'failed') {
+              this.updateUploadProgress('line2Status', 'failed');
+            }
+          },
+          1,
+          (uploadInfo) => {
+            if (form.tempId) {
+              const tokens = this.uploadCancellationTokens.get(form.tempId) || {};
+              tokens.hsy = uploadInfo.cancel;
+              this.uploadCancellationTokens.set(form.tempId, tokens);
+            }
+          }
+        );
+        console.log("上传火山云返回参数",data)
+
+        form.line2 = `${process.env.VUE_APP_VIDEO_URL}/${data.SourceInfo.FileName}`;
+        this.form.hsyVid = data.Vid
+        this.form.hsyVodUrl = process.env.VUE_APP_VIDEO_URL+"/"+data.SourceInfo.FileName
+        console.log("this.form",this.form)
+        this.$message.success('线路二上传成功');
+        return { success: true, url: form.line2 };
+
+      } catch (error) {
+        this.updateUploadProgress('line2Status', 'failed');
+        this.$message.error('线路二上传失败');
+        return { success: false, error: error?.message || 'upload failed' };
+      }
+    },
+    // 更新上传进度的辅助方法
+    updateUploadProgress(key, value) {
+      this.currentUploadProgress[key] = value;
+
+      // 计算总进度:只有两个线路都成功才算100%
+      if (this.currentUploadProgress.line1Status === 'success' && this.currentUploadProgress.line2Status === 'success') {
+        this.currentUploadProgress.total = 100;
+      } else if (this.currentUploadProgress.line1Status === 'failed' || this.currentUploadProgress.line2Status === 'failed') {
+        // 如果任一线路失败,总进度保持当前状态
+        this.currentUploadProgress.total = Math.min(
+          (this.currentUploadProgress.line1 + this.currentUploadProgress.line2) / 2,
+          99 // 失败时最多99%
+        );
+      } else {
+        // 正常上传中,计算平均进度
+        this.currentUploadProgress.total = Math.min(
+          (this.currentUploadProgress.line1 + this.currentUploadProgress.line2) / 2,
+          99 // 上传中最多99%,只有都成功才100%
+        );
+      }
+    },
+    // 上传视频
+    async videoUpload(options) {
+      this.isUploading = true;
+      this.form.uploadStatus = 'uploading';
+      this.form.tempId = Math.random().toString(36).substring(2, 15);
+      const file = options.file;
+      this.getMediaDuration(file);
+      this.currentUploadProgress = { total: 0, line1: 0, line2: 0, line1Status: 'pending', line2Status: 'pending' };
+
+      try {
+        await this.getFirstThumbnail(file, this.form);
+        const [line1Result, line2Result] = await Promise.allSettled([
+          this.uploadVideoToTxPcdn(file, this.form, options.onProgress),
+          //this.uploadVideoToHwObs(file, this.form, options.onProgress)
+          this.uploadVideoToHsy(file, this.form, options.onProgress)
+        ]);
+
+        const line1Success = line1Result.status === 'fulfilled' && line1Result.value.success;
+        const line2Success = line2Result.status === 'fulfilled' && line2Result.value.success;
+
+        if (line1Success && line2Success) {
+          this.form.uploadStatus = 'success';
+          this.form.uploadProgress = { total: 100, line1: 100, line2: 100, line1Status: 'success', line2Status: 'success' };
+          this.currentUploadProgress.total = 100;
+          this.$message.success("视频上传完成!两个线路都上传成功");
+        } else {
+          this.form.uploadStatus = 'failed';
+          this.form.uploadProgress = {
+            total: this.currentUploadProgress.total,
+            line1: this.currentUploadProgress.line1,
+            line2: this.currentUploadProgress.line2,
+            line1Status: this.currentUploadProgress.line1Status,
+            line2Status: this.currentUploadProgress.line2Status
+          };
+          const failedLines = [];
+          if (!line1Success) failedLines.push('线路1');
+          if (!line2Success) failedLines.push('线路2');
+          this.$message.error(`视频上传失败!${failedLines.join('、')} 上传失败,请重试`);
+        }
+        this.form.fileName = file.name;
+        this.form.fileSize = file.size;
+      } catch (error) {
+        this.form.uploadStatus = 'failed';
+        this.$message.error("视频上传过程中发生错误,请重试");
+      } finally {
+        this.isUploading = false;
+        if (this.form.tempId) {
+          this.uploadCancellationTokens.delete(this.form.tempId);
+        }
+      }
+    },
+    // 获取媒体文件时长
+    getMediaDuration(file) {
+      // 处理视频
+      const video = document.createElement('video');
+      video.preload = 'metadata';
+      video.onloadedmetadata = () => {
+        this.form.duration = Math.round(video.duration);
+      };
+      video.src = URL.createObjectURL(file);
+    },
+    handleFileChange(file, files) {
+      // 保留最后一个文件
+      this.fileList = files.slice(-1);
+    },
+    /** 批量新增 */
+    handleBatchAdd() {
+      this.batchAddVisible = true;
+      this.add =false
+      this.videoList = []; // 清空之前的视频列表
+    },
+    /** 批量修改 */
+    handleBatchUpdate(){
+      if (this.ids.length === 0) {
+        this.$message.warning("请至少选择一条数据");
+        return;
+      }
+      this.resetForm("form");
+      this.batchUpdateForm.ids = this.ids; // 将选中的ID传递给批量修改表单
+      this.batchUpdateVisible = true;
+    },
+    cancelBeforeBatch(done, cancel) {
+      if (!this.videoList || this.videoList.length === 0) {
+        done()
+        return
+      }
+      this.$confirm('关闭窗口视频需要重新上传,确定关闭吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        done()
+      }).catch(() => {
+        cancel()
+      });
+    },
+    /** 取消批量 */
+    cancelBatch() {
+      if (!this.videoList || this.videoList.length === 0) {
+        this.batchAddVisible = false
+        this.batchUpdateVisible = false
+
+        return
+      }
+      this.$confirm('关闭窗口视频需要重新上传,确定关闭吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.batchAddVisible = false
+        this.batchUpdateVisible = false
+        this.changeCateType(this.queryParams.typeId)
+      }).catch(() => { });
+    },
+    /** 批量修改 */
+    submitBatchUpdate() {
+      console.log("批量上传表单提交参数",this.form)
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.batchUpdateForm.ids.length === 0) {
+            this.$message.warning("未选择任何数据");
+            return;
+          }
+          this.$confirm('是否确认修改视频素材库编号为"' + this.batchUpdateForm.ids.join(',') + '"的数据项?', "警告", {
+            confirmButtonText: "确定",
+            cancelButtonText: "取消",
+            type: "warning"
+          }).then(() => {
+            // 构造正确的参数格式
+            const params = new URLSearchParams();
+            params.append('typeId', this.batchUpdateForm.typeId || '');
+            params.append('typeSubId', this.batchUpdateForm.typeSubId || '');
+            params.append('ids', this.batchUpdateForm.ids.join(','));
+            params.append('videoType', String(this.pageVideoType));
+            return batchUpdateVideoResource(params);
+          }).then(() => {
+            this.getList();
+            this.batchUpdateVisible = false;
+            this.msgSuccess("修改成功");
+          }).catch(() => {});
+        }
+      });
+    },
+    /** 提交批量添加 */
+    submitBatchAdd() {
+      if (this.videoList.length === 0) {
+        this.$message.warning("请选择视频");
+        return;
+      }
+
+      // 检查是否所有选中的视频都已上传完成
+      console.log("videoList",this.videoList)
+      const incompleteVideos = this.videoList.filter(item => (item.progress || 0) < 100);
+      if (incompleteVideos.length > 0) {
+        this.$message.warning('有未完成上传的视频,请先完成上传');
+        return;
+      }
+
+      if (this.add){
+        this.$message.warning("请勿重复提交")
+        return
+      }
+      this.add = true
+
+      // 调用批量添加API
+      const videoList = JSON.parse(JSON.stringify(this.videoList));
+      videoList.forEach(item => {
+        item.projectIds = item.projectIds.join(",");
+        item.videoType = this.pageVideoType;
+      });
+      batchAddVideoResource(videoList).then(response => {
+        if (response.code === 200) {
+          this.$message.success('批量添加成功');
+          this.batchAddVisible = false;
+          this.getList();
+        }
+      })
+    },
+    /** 获取分类名称 */
+    getTypeName(typeId) {
+      const type = this.typeList.find(item => item.cateId === typeId);
+      return type ? type.cateName : '';
+    },
+    /** 编辑视频信息 */
+    handleEditVideo(row) {
+      if (row.progress !== 100) {
+        this.$message.warning('请等待上传完成后再编辑');
+        return;
+      }
+      this.batchEditDialog.form = Object.assign({}, row)
+      this.changeCateType(row.typeId)
+      this.batchEditDialog.open = true
+    },
+    batchEditCancel() {
+      this.resetForm('batchEditDialogForm')
+      this.batchEditDialog.open = false
+    },
+    batchEditSubmitForm() {
+      let temp = this.videoList.find(item => item.tempId === this.batchEditDialog.form.tempId)
+      Object.assign(temp, this.batchEditDialog.form)
+      this.batchEditDialog.open = false
+    },
+    handleDeleteVideo(row) {
+      if (row.uploadStatus === 'uploading') {
+        this.$confirm('该视频正在上传中,确认要取消上传并删除吗?', '取消上传', {
+          confirmButtonText: '确定取消',
+          cancelButtonText: '继续上传',
+          type: 'warning'
+        }).then(() => {
+          // Cancel the upload and remove from list
+          this.cancelVideoUpload(row);
+        }).catch(() => {
+          // User chose to continue uploading
+          this.$message.info('继续上传该视频');
+        });
+      } else {
+        // Original confirmation for non-uploading videos
+        this.$confirm('确认要从列表中删除该视频吗?', '提示', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => {
+          this.removeVideoFromList(row);
+        }).catch(() => {
+          // 取消删除
+        });
+      }
+    },
+
+    cancelVideoUpload(row) {
+      const cancellationTokens = this.uploadCancellationTokens.get(row.tempId);
+      if (cancellationTokens) {
+        // Cancel COS upload if exists
+        if (cancellationTokens.cos) {
+          try {
+            cancellationTokens.cos();
+            console.log('COS upload cancelled for video:', row.tempId);
+          } catch (error) {
+            console.error('Error cancelling COS upload:', error);
+          }
+        }
+
+        // Cancel OBS upload if exists
+        if (cancellationTokens.obs) {
+          try {
+            cancellationTokens.obs();
+            console.log('OBS upload cancelled for video:', row.tempId);
+          } catch (error) {
+            console.error('Error cancelling OBS upload:', error);
+          }
+        }
+
+        // Remove cancellation tokens
+        this.uploadCancellationTokens.delete(row.tempId);
+      }
+
+      const videoIndex = this.videoList.findIndex(item => item.tempId === row.tempId);
+      if (videoIndex !== -1) {
+        this.videoList[videoIndex].uploadStatus = 'cancelled';
+        this.videoList.splice(videoIndex, 1);
+      }
+
+      const queueIndex = this.uploadQueue.findIndex(item => item.tempId === row.tempId);
+      if (queueIndex !== -1) {
+        this.uploadQueue.splice(queueIndex, 1);
+        this.updateQueuePositions();
+      }
+
+      this.$message.success('已取消视频上传并从列表中移除');
+    },
+
+    removeVideoFromList(row) {
+      const videoIndex = this.videoList.findIndex(item => item.tempId === row.tempId);
+      if (videoIndex !== -1) {
+        this.videoList.splice(videoIndex, 1);
+      }
+
+      // Remove from upload queue if it's still queued
+      const queueIndex = this.uploadQueue.findIndex(item => item.tempId === row.tempId);
+      if (queueIndex !== -1) {
+        this.uploadQueue.splice(queueIndex, 1);
+        this.updateQueuePositions();
+        this.$message.success('已从上传队列中移除该视频');
+      } else {
+        this.$message.success('已从列表中移除该视频');
+      }
+    },
+    /** 删除视频 */
+    // handleDeleteVideo(row) {
+    //   this.$confirm('确认要从列表中删除该视频吗?', '提示', {
+    //     confirmButtonText: '确定',
+    //     cancelButtonText: '取消',
+    //     type: 'warning'
+    //   }).then(() => {
+    //     const videoIndex = this.videoList.findIndex(item => item.tempId === row.tempId);
+    //     if (videoIndex !== -1) {
+    //       this.videoList.splice(videoIndex, 1);
+    //     }
+
+    //     // Remove from upload queue if it's still queued
+    //     const queueIndex = this.uploadQueue.findIndex(item => item.tempId === row.tempId);
+    //     if (queueIndex !== -1) {
+    //       this.uploadQueue.splice(queueIndex, 1);
+    //       this.updateQueuePositions();
+    //       this.$message.success('已从上传队列中移除该视频');
+    //     } else {
+    //       this.$message.success('已从列表中移除该视频');
+    //     }
+    //   }).catch(() => {
+    //     // 取消删除
+    //   });
+    // },
+    /** 显示上传面板 */
+    showUploadPanel() {
+      this.showUpload = true;
+
+      if (this.currentProject === 'myhk') {
+        this.batchUploadForm = {
+          ...this.batchUploadForm,
+          files: [],
+          videoType: this.pageVideoType
+        };
+      } else {
+        this.batchUploadForm = {
+          typeId: null,
+          typeSubId: null,
+          projectIds: [],
+          files: [],
+          displayType: 'landscape',
+          videoType: this.pageVideoType
+        };
+        this.subTypeList = []
+      }
+
+      this.batchFileList = [];
+      if (this.$refs.batchVideoUpload) {
+        this.$refs.batchVideoUpload.clearFiles();
+      }
+    },
+    /** 批量文件变更 */
+    handleBatchFileChange(file, fileList) {
+      this.batchFileList = fileList;
+    },
+    /** 批量上传视频 */
+    async batchVideoUpload(options) {
+      const file = options.file;
+
+      const tempVideo = {
+        tempId: Math.random().toString(36).substring(2, 15),
+        resourceName: file.name.substring(0, file.name.lastIndexOf('.')),
+        fileName: file.name,
+        thumbnail: null,
+        line1: null,
+        line2: null,
+        line3: null,
+        duration: 0,
+        fileSize: file.size,
+        fileKey: null,
+        videoUrl: null,
+        typeId: this.batchUploadForm.typeId,
+        typeSubId: this.batchUploadForm.typeSubId,
+        projectIds: this.batchUploadForm.projectIds,
+        displayType: this.batchUploadForm.displayType || 'landscape',
+        videoType: this.pageVideoType,
+        progress: 0,
+        uploadStatus: 'queued', // Set initial status to queued
+        uploadDetails: {
+          line1: 0,
+          line2: 0,
+          line1Status: 'pending',
+          line2Status: 'pending'
+        },
+        file: file,
+        queuePosition: this.uploadQueue.length + 1 // Track queue position
+      };
+
+      this.uploadQueue.push(tempVideo);
+      this.videoList.unshift(tempVideo);
+
+      // 获取视频时长
+      const video = document.createElement('video');
+      video.preload = 'metadata';
+      video.onloadedmetadata = () => {
+        const index = this.videoList.findIndex(item => item.tempId === tempVideo.tempId);
+        if (index !== -1) {
+          tempVideo.duration = Math.round(video.duration);
+        }
+      };
+      video.src = URL.createObjectURL(file);
+
+      // 关闭上传弹窗
+      this.showUpload = false;
+
+      if (!this.isProcessingBatch) {
+        this.processUploadQueue();
+      }
+
+      if (this.$refs.batchVideoUpload) {
+        this.$refs.batchVideoUpload.clearFiles();
+      }
+    },
+
+    async processUploadQueue() {
+      if (this.isProcessingBatch || this.uploadQueue.length === 0) {
+        return;
+      }
+
+      this.isUploading = true;
+      this.isProcessingBatch = true;
+
+      while (this.uploadQueue.length > 0) {
+        // Get next batch (up to 5 videos)
+        const currentBatch = this.uploadQueue.splice(0, this.currentBatchSize);
+
+        // Update status for current batch
+        currentBatch.forEach(video => {
+          const index = this.videoList.findIndex(item => item.tempId === video.tempId);
+          if (index !== -1) {
+            this.videoList[index].uploadStatus = 'uploading';
+          }
+        });
+
+        // Process current batch in parallel
+        const batchPromises = currentBatch.map(video => this.uploadSingleVideo(video));
+
+        try {
+          await Promise.allSettled(batchPromises);
+          this.$message.success(`批次上传完成,已处理 ${currentBatch.length} 个视频`);
+        } catch (error) {
+          this.$message.error(`批次上传过程中发生错误: ${error.message}`);
+        }
+
+        // Update queue positions for remaining videos
+        this.updateQueuePositions();
+
+        // Small delay between batches
+        if (this.uploadQueue.length > 0) {
+          await new Promise(resolve => setTimeout(resolve, 1000));
+        }
+      }
+
+      this.isProcessingBatch = false;
+      this.isUploading = false;
+      this.$message.success('所有视频上传队列处理完成!');
+      console.log("批量上传form",this.form)
+    },
+
+    async uploadSingleVideo(tempVideo) {
+      try {
+        // 获取封面
+        await this.getFirstThumbnail(tempVideo.file, tempVideo);
+
+        // 并行上传到两个服务器
+        const [line1Result, line2Result] = await Promise.allSettled([
+          this.uploadVideoToTxPcdnBatch(tempVideo.file, tempVideo),
+          // this.uploadVideoToHwObsBatch(tempVideo.file, tempVideo)
+          this.uploadVideoToHSYBatch(tempVideo.file, tempVideo),
+
+        ]);
+
+        // 检查上传结果
+        const line1Success = line1Result.status === 'fulfilled' && line1Result.value.success;
+        const line2Success = line2Result.status === 'fulfilled' && line2Result.value.success;
+
+        const index = this.videoList.findIndex(item => item.tempId === tempVideo.tempId);
+        if (index !== -1) {
+          if (line1Success && line2Success) {
+            this.videoList[index].progress = 100;
+            this.videoList[index].uploadStatus = 'success';
+            this.videoList[index].uploadDetails.line1Status = 'success';
+            this.videoList[index].uploadDetails.line2Status = 'success';
+            this.videoList[index].uploadDetails.line1 = 100;
+            this.videoList[index].uploadDetails.line2 = 100;
+          } else {
+            this.videoList[index].uploadStatus = 'failed';
+            this.videoList[index].uploadDetails.line1Status = line1Success ? 'success' : 'failed';
+            this.videoList[index].uploadDetails.line2Status = line2Success ? 'success' : 'failed';
+            this.updateBatchProgress(index);
+          }
+        }
+
+        return { success: line1Success && line2Success, tempVideo };
+      } catch (error) {
+        const index = this.videoList.findIndex(item => item.tempId === tempVideo.tempId);
+        if (index !== -1) {
+          this.videoList[index].uploadStatus = 'failed';
+        }
+        throw error;
+      }
+    },
+
+
+    /** 复制视频链接到剪贴板 */
+    copy(url) {
+      // 创建一个临时的input元素
+      const input = document.createElement('input');
+      // 设置input的值为要复制的视频链接
+      input.value = url;
+      // 将input添加到DOM中
+      document.body.appendChild(input);
+      // 选中input的值
+      input.select();
+      // 执行复制命令
+      document.execCommand('copy');
+      // 从DOM中移除input元素
+      document.body.removeChild(input);
+      // 提示用户复制成功
+      this.$message({
+        message: '已复制到剪贴板',
+        type: 'success',
+        duration: 1500
+      });
+    },
+    getProjectList() {
+      this.projectLoading = true
+      listCourseQuestionBank(this.projectQueryParams).then(response => {
+        this.projectList = response.rows;
+        this.projectListTotal = response.total;
+
+        // 如果存在已选择的项目,预选中表格中对应的行
+        this.$nextTick(() => {
+          // 获取表格组件实例
+          const projectTable = this.$refs.projectTable;
+          if (projectTable) {
+            if (this.selectedProjectIds.length > 0) {
+              // 遍历项目列表,找到匹配的ID并选中对应行
+              const selectedIds = this.selectedProjectIds;
+              this.projectList.forEach(row => {
+                if (selectedIds.includes(row.id)) {
+                  projectTable.toggleRowSelection(row, true);
+                }
+              });
+            }
+          }
+        });
+        this.projectLoading = false
+      });
+    },
+    /** 打开项目选择弹窗 */
+    openProjectDialog(projectIds, type) {
+      this.$nextTick(() => {
+        if (this.$refs.customSelect) {
+          this.$refs.customSelect.blur();
+        }
+      });
+      // 重置查询参数
+      this.projectQueryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        questionType: null,
+        title: null
+      };
+
+      this.selectedType = type
+
+      // 设置选中的项目IDs
+      if (projectIds) {
+        if (typeof projectIds === 'string') {
+          this.selectedProjectIds = projectIds.split(',').map(id => parseInt(id));
+        } else if (Array.isArray(projectIds)) {
+          this.selectedProjectIds = [...projectIds];
+        } else if (typeof projectIds === 'number') {
+          this.selectedProjectIds = [projectIds];
+        }
+      } else {
+        this.selectedProjectIds = [];
+      }
+
+      // 显示弹窗
+      this.projectDialogVisible = true;
+
+      // 加载分类树数据
+      this.initCategoryTree();
+
+      // 加载项目列表
+      this.getProjectList();
+    },
+
+    /** 确认选择项目 */
+    confirmSelectProject() {
+      // 更新表单中的项目ID
+      if (this.selectedType === 0) {
+        this.form.projectIds = this.selectedProjectIds;
+      }
+
+      else if (this.selectedType === 1) {
+        this.batchUploadForm.projectIds = this.selectedProjectIds;
+      }
+
+      else if (this.selectedType === 2) {
+        this.batchEditDialog.form.projectIds = this.selectedProjectIds;
+      }
+
+      else if (this.selectedType === 3) {
+        const params = {
+          id: this.currentRow.id,
+          projectIds: this.selectedProjectIds.join(","),
+          videoType: this.pageVideoType
+        }
+        updateVideoResource(params).then(response => {
+          if (response.code === 200) {
+            this.msgSuccess("修改成功");
+            this.open = false;
+            this.getList();
+          }
+        });
+      }
+
+      else if (this.selectedType === 4) {
+        this.currentRow.projectIds = this.selectedProjectIds
+      }
+
+      this.projectDialogVisible = false;
+    },
+
+    /** 取消选择项目 */
+    cancelSelectProject() {
+      const projectTable = this.$refs.projectTable;
+      if (projectTable) {
+        // 清空表格数据
+        projectTable.clearSelection();
+      }
+      this.projectDialogVisible = false;
+    },
+    handleProjectSelect(selection) {
+      this.selectedProjectIds = selection.map(item => item.id);
+      selection.forEach(item => {
+        // 检查是否已存在该项目,不存在则添加
+        if (!this.projectShowList.some(p => p.id === item.id)) {
+          this.projectShowList.push(item);
+        }
+      });
+    },
+    /** 项目列表页码变更 */
+    handleProjectPageChange(page) {
+      this.projectQueryParams.pageNum = page;
+      this.getProjectList();
+    },
+
+    /** 项目列表每页条数变更 */
+    handleProjectPageSizeChange(size) {
+      this.projectQueryParams.pageNum = 1;
+      this.projectQueryParams.pageSize = size;
+      this.getProjectList();
+    },
+
+    /** 处理查看项目 */
+    handleViewProject(row, type) {
+      // 保存当前选择的行,以便后续操作
+      this.currentRow = row;
+
+      // 设置form对象的projectIds为当前行的项目IDs
+      this.projectShowList = [];
+
+      if (!row.projectIds || row.projectIds.length === 0) {
+        this.openProjectDialog(null, type)
+        return;
+      }
+
+      let projectIds = row.projectIds
+      if (Array.isArray(row.projectIds)) {
+        projectIds = projectIds.join(',');
+      }
+
+      getByIds({ids: projectIds}).then(response => {
+        this.projectShowList = response.data;
+      })
+
+      // 打开弹窗展示列表
+      this.projectListDialogVisible = true;
+    },
+    /** 初始化分类树 */
+    initCategoryTree() {
+      // 获取分类列表
+      listUserCourseCategory().then(response => {
+        const treeDate = this.handleTree(response.data, "cateId", "pid");
+        this.categoryTreeData = [{
+          cateId: 0,
+          cateName: '全部',
+          children: treeDate
+        }];
+      });
+    },
+    filterNode(value, data) {
+      if (!value) return true;
+      return data.cateName.indexOf(value) !== -1;
+    },
+    /** 处理分类点击 */
+    handleCategoryClick(data, node) {
+      // 更新查询参数
+      this.projectQueryParams.pageNum = 1;
+
+      // 如果是全部分类,则清空分类过滤
+      if (node.level === 1) {
+        this.projectQueryParams.questionType = null;
+        this.projectQueryParams.questionSubType = null;
+      }
+      else if (node.level === 2) {
+        this.projectQueryParams.questionType = data.cateId;
+        this.projectQueryParams.questionSubType = null;
+      }
+      else if (node.level === 3) {
+        this.projectQueryParams.questionType = null;
+        this.projectQueryParams.questionSubType = data.cateId;
+      }
+
+      // 重新加载项目列表
+      this.getProjectList();
+    },
+
+    /** 搜索项目 */
+    searchProjects() {
+      this.projectQueryParams.pageNum = 1;
+      this.getProjectList();
+    },
+
+    /** 视频预览弹窗关闭前的处理函数 */
+    handleCloseVideoPreview(done) {
+      // 停止视频播放
+      const video = document.getElementById('video');
+      if (video) {
+        video.pause();
+        video.currentTime = 0;
+      }
+      // 关闭弹窗
+      this.videoPreviewVisible = false;
+      done();
+    },
+
+    updateQueuePositions() {
+      this.uploadQueue.forEach((video, index) => {
+        video.queuePosition = index + 1;
+        const videoIndex = this.videoList.findIndex(item => item.tempId === video.tempId);
+        if (videoIndex !== -1) {
+          this.videoList[videoIndex].queuePosition = index + 1;
+        }
+      });
+    },
+
+    getQueueStatusColor(status) {
+      switch (status) {
+        case 'success':
+          return '#67C23A';
+        case 'failed':
+          return '#F56C6C';
+        case 'uploading':
+          return '#409EFF';
+        case 'queued':
+          return '#E6A23C';
+        default:
+          return '#909399';
+      }
+    },
+
+    // 批量上传 - 腾讯云
+    async uploadVideoToTxPcdnBatch(file, tempVideo) {
+      console.log("--------------",JSON.stringify(tempVideo))
+      try {
+        const data = await uploadObject(file, (progress) => {
+          const progressPercent = Math.floor(progress.percent * 100);
+          const index = this.videoList.findIndex(item => item.tempId === tempVideo.tempId);
+          if (index !== -1) {
+            this.videoList[index].uploadDetails.line1 = progressPercent;
+            this.videoList[index].uploadDetails.line1Status = 'uploading';
+            this.updateBatchProgress(index);
+          }
+        }, 1, (uploadInfo) => {
+          const tokens = this.uploadCancellationTokens.get(tempVideo.tempId) || {};
+          tokens.cos = uploadInfo.cancel;
+          this.uploadCancellationTokens.set(tempVideo.tempId, tokens);
+        });
+
+        let line_1 = `${process.env.VUE_APP_VIDEO_LINE_1}${data.urlPath}`;
+        tempVideo.fileKey = data.urlPath.substring(1);
+        tempVideo.videoUrl = line_1;
+        tempVideo.line1 = line_1;
+        return { success: true, url: line_1 };
+      } catch (error) {
+        return { success: false, error: error.message };
+      }
+    },
+    // 批量上传 - 华为云
+    // async uploadVideoToHwObsBatch(file, tempVideo) {
+    //   try {
+    //     const data = await uploadToOBS(file, (progress) => {
+    //       const progressPercent = Math.floor(progress);
+    //       const index = this.videoList.findIndex(item => item.tempId === tempVideo.tempId);
+    //       if (index !== -1) {
+    //         this.videoList[index].uploadDetails.line2 = progressPercent;
+    //         this.videoList[index].uploadDetails.line2Status = 'uploading';
+    //         this.updateBatchProgress(index);
+    //       }
+    //     }, 1, (uploadInfo) => {
+    //       const tokens = this.uploadCancellationTokens.get(tempVideo.tempId) || {};
+    //       tokens.obs = uploadInfo.cancel;
+    //       this.uploadCancellationTokens.set(tempVideo.tempId, tokens);
+    //     });
+    //
+    //     tempVideo.line2 = `${process.env.VUE_APP_VIDEO_LINE_2}/${data.urlPath}`;
+    //     return { success: true, url: tempVideo.line2 };
+    //   } catch (error) {
+    //     return { success: false, error: error.message };
+    //   }
+    // },
+    async uploadVideoToHSYBatch(file, tempVideo) {
+      try {
+        const data = await uploadToHSY(file, (progress) => {
+          const progressPercent = Math.floor(progress);
+          const index = this.videoList.findIndex(item => item.tempId === tempVideo.tempId);
+          if (index !== -1) {
+            this.videoList[index].uploadDetails.line2 = progressPercent;
+            this.videoList[index].uploadDetails.line2Status = 'uploading';
+            this.updateBatchProgress(index);
+          }
+        }, 1, (uploadInfo) => {
+          const tokens = this.uploadCancellationTokens.get(tempVideo.tempId) || {};
+          tokens.obs = uploadInfo.cancel;
+          this.uploadCancellationTokens.set(tempVideo.tempId, tokens);
+        });
+        console.log("批量上传返回参数",data)
+        tempVideo.line2 = `${process.env.VUE_APP_VIDEO_URL}/${data.SourceInfo.FileName}`;
+        tempVideo.hsyVid = data.Vid;
+
+        return { success: true, url: tempVideo.line2};
+      } catch (error) {
+        return { success: false, error: error.message };
+      }
+    },
+    // 更新批量上传的总进度
+    updateBatchProgress(index) {
+      if (index >= 0 && index < this.videoList.length) {
+        const item = this.videoList[index];
+        const line1Progress = item.uploadDetails.line1 || 0;
+        const line2Progress = item.uploadDetails.line2 || 0;
+        // 只有两个线路都成功才算100%
+        if (item.uploadDetails.line1Status === 'success' && item.uploadDetails.line2Status === 'success') {
+          item.progress = 100;
+        } else if (item.uploadDetails.line1Status === 'failed' || item.uploadDetails.line2Status === 'failed') {
+          // 如果任一线路失败,总进度保持当前状态,不超过99%
+          item.progress = Math.min((line1Progress + line2Progress) / 2, 99);
+        } else {
+          // 正常上传中,计算平均进度,不超过99%
+          item.progress = Math.min((line1Progress + line2Progress) / 2, 99);
+        }
+      }
+    },
+    // 重试批量上传
+    async retryBatchUpload(row) {
+      // const index = this.videoList.findIndex(item => item.tempId === row.tempId);
+      // if (index === -1) return;
+      // const {line1, line2,line1Status,line2Status} = this.videoList[index].uploadDetails
+      // // 重置状态
+      // this.videoList[index].uploadStatus = 'uploading';
+      // // this.videoList[index].progress = 0;
+      // this.videoList[index].uploadDetails = {
+      // line1: line1 == 100?line1:0,
+      // line2: line2 == 100?line2:0,
+      // line1Status: line1Status =='success'?line1Status:'pending',
+      // line2Status: line2Status =='success'?line2Status:'pending'
+      // };
+      // const tempVideo = this.videoList[index];
+      // try {
+      // // 重新上传
+      // // const [line1Result, line2Result] = await Promise.allSettled([
+      // // this.uploadVideoToTxPcdnBatch(tempVideo.file, tempVideo),
+      // // // this.uploadVideoToHwObsBatch(tempVideo.file, tempVideo)
+      // // ]);
+      // if (line1 !== 100) {
+      // const line1Result = await this.uploadVideoToTxPcdnBatch(tempVideo.file, tempVideo)
+      // }
+      // if (line2 !== 100) {
+      // const line2Result = await this.uploadVideoToHwObsBatch(tempVideo.file, tempVideo)
+      // }
+      // const line1Success = line1Result.status === 'fulfilled' && line1Result.value.success;
+      // const line2Success = line2Result.status === 'fulfilled' && line2Result.value.success;
+      // if ( line1 == 100 || line1Success) {
+      // this.videoList[index].uploadDetails.line1Status = 'success';
+      // this.videoList[index].uploadDetails.line1 = 100;
+      // this.$message.success(`文件 ${tempVideo.fileName} 重试上传成功`);
+      // } else {
+      // this.videoList[index].uploadDetails.line1Status = line1Success ? 'success' : 'failed';
+      // this.$message.error(`文件 ${tempVideo.fileName} 重试上传失败`);
+      // }
+      // if (line2 == 100 || line2Success) {
+      // this.videoList[index].uploadDetails.line2Status = 'success';
+      // this.videoList[index].uploadDetails.line2 = 100;
+      // this.$message.success(`文件 ${tempVideo.fileName} 重试上传成功`);
+      // } else {
+      // this.videoList[index].uploadDetails.line2Status = line2Success ? 'success' : 'failed';
+      // this.$message.error(`文件 ${tempVideo.fileName} 重试上传失败`);
+      // }
+      // // if (line1Success && line2Success) {
+      // // this.videoList[index].progress = 100;
+      // // this.videoList[index].uploadStatus = 'success';
+      // // this.videoList[index].uploadDetails.line1Status = 'success';
+      // // this.videoList[index].uploadDetails.line2Status = 'success';
+      // // this.videoList[index].uploadDetails.line1 = 100;
+      // // this.videoList[index].uploadDetails.line2 = 100;
+      // // this.$message.success(`文件 ${tempVideo.fileName} 重试上传成功`);
+      // // } else {
+      // // this.videoList[index].uploadStatus = 'failed';
+      // // this.videoList[index].uploadDetails.line1Status = line1Success ? 'success' : 'failed';
+      // // this.videoList[index].uploadDetails.line2Status = line2Success ? 'success' : 'failed';
+      // // this.$message.error(`文件 ${tempVideo.fileName} 重试上传失败`);
+      // // }
+      // } catch (error) {
+      // this.videoList[index].uploadStatus = 'failed';
+      // this.$message.error(`文件 ${tempVideo.fileName} 重试上传失败`);
+      // }
+      const index = this.videoList.findIndex(item => item.tempId === row.tempId);
+      if (index === -1) return;
+      const tempVideo = this.videoList[index];
+      const uploadDetails = tempVideo.uploadDetails || {};
+
+      // 检查哪些线路需要重试
+      const needRetryLine1 = uploadDetails.line1Status === 'failed' || uploadDetails.line1Status === 'pending';
+      const needRetryLine2 = uploadDetails.line2Status === 'failed' || uploadDetails.line2Status === 'pending';
+
+      if (!needRetryLine1 && !needRetryLine2) {
+        this.$message.info('所有线路都已上传成功,无需重试');
+        return;
+      }
+
+      // 更新整体状态为上传中
+      this.videoList[index].uploadStatus = 'uploading';
+
+      // 只重置需要重试的线路状态
+      if (needRetryLine1) {
+        this.videoList[index].uploadDetails.line1 = 0;
+        this.videoList[index].uploadDetails.line1Status = 'pending';
+      }
+      if (needRetryLine2) {
+        this.videoList[index].uploadDetails.line2 = 0;
+        this.videoList[index].uploadDetails.line2Status = 'pending';
+      }
+
+      try {
+        const uploadPromises = [];
+
+        // 根据需要重试的线路创建上传任务
+        if (needRetryLine1) {
+          uploadPromises.push(
+            this.uploadVideoToTxPcdnBatch(tempVideo.file, tempVideo)
+              .then(result => ({ line: 'line1', result }))
+              .catch(error => ({ line: 'line1', result: { success: false, error: error.message } }))
+          );
+        } else {
+          // 如果线路1不需要重试,创建一个已成功的Promise
+          uploadPromises.push(Promise.resolve({ line: 'line1', result: { success: true } }));
+        }
+
+        if (needRetryLine2) {
+          uploadPromises.push(
+            this.uploadVideoToHwObsBatch(tempVideo.file, tempVideo)
+              .then(result => ({ line: 'line2', result }))
+              .catch(error => ({ line: 'line2', result: { success: false, error: error.message } }))
+          );
+        } else {
+          // 如果线路2不需要重试,创建一个已成功的Promise
+          uploadPromises.push(Promise.resolve({ line: 'line2', result: { success: true } }));
+        }
+
+        // 等待所有上传任务完成
+        const results = await Promise.all(uploadPromises);
+
+        // 处理结果
+        let line1Success = true;
+        let line2Success = true;
+        let retryMessages = [];
+
+        results.forEach(({ line, result }) => {
+          if (line === 'line1') {
+            line1Success = result.success;
+            if (needRetryLine1) {
+              if (result.success) {
+                this.videoList[index].uploadDetails.line1Status = 'success';
+                this.videoList[index].uploadDetails.line1 = 100;
+                retryMessages.push('线路1重试成功');
+              } else {
+                this.videoList[index].uploadDetails.line1Status = 'failed';
+                retryMessages.push('线路1重试失败');
+              }
+            }
+          } else if (line === 'line2') {
+            line2Success = result.success;
+            if (needRetryLine2) {
+              if (result.success) {
+                this.videoList[index].uploadDetails.line2Status = 'success';
+                this.videoList[index].uploadDetails.line2 = 100;
+                retryMessages.push('线路2重试成功');
+              } else {
+                this.videoList[index].uploadDetails.line2Status = 'failed';
+                retryMessages.push('线路2重试失败');
+              }
+            }
+          }
+        });
+
+        // 更新总体状态和进度
+        if (line1Success && line2Success) {
+          this.videoList[index].progress = 100;
+          this.videoList[index].uploadStatus = 'success';
+          this.$message.success(`文件 ${tempVideo.fileName} 重试完成:${retryMessages.join(',')}`);
+        } else {
+          this.videoList[index].uploadStatus = 'failed';
+          // 重新计算进度
+          this.updateBatchProgress(index);
+          this.$message.error(`文件 ${tempVideo.fileName} 重试完成:${retryMessages.join(',')}`);
+        }
+      } catch (error) {
+        this.videoList[index].uploadStatus = 'failed';
+        this.$message.error(`文件 ${tempVideo.fileName} 重试过程中发生错误:${error.message || '未知错误'}`);
+      }
+    },
+    // 重试单个上传
+    async retryUpload(row) {
+      this.$confirm('确认要重新上传该视频吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        // 这里需要重新触发上传,但由于原始文件可能已经不存在
+        // 建议用户重新选择文件上传
+        this.$message.info('请重新选择文件进行上传');
+        this.handleUpdate(row);
+      }).catch(() => {});
+    },
+    // 获取上传状态图标
+    getUploadStatusIcon(status) {
+      switch (status) {
+        case 'success': return 'el-icon-success';
+        case 'failed': return 'el-icon-error';
+        case 'uploading': return 'el-icon-loading';
+        default: return 'el-icon-time';
+      }
+    },
+    // 获取上传状态颜色
+    getUploadStatusColor(status) {
+      switch (status) {
+        case 'success': return '#67C23A';
+        case 'failed': return '#F56C6C';
+        case 'uploading': return '#409EFF';
+        default: return '#909399';
+      }
+    },
+    // 获取上传状态文本
+    getUploadStatusText(status, queuePosition) {
+      switch (status) {
+        case 'success':
+          return '上传成功';
+        case 'failed':
+          return '上传失败';
+        case 'uploading':
+          return '上传中';
+        case 'queued':
+          return `队列中 (第${queuePosition}位)`;
+        default:
+          return '待上传';
+      }
+    },
+    // 获取线路状态图标
+    getLineStatusIcon(status) {
+      switch (status) {
+        case 'success': return 'el-icon-check';
+        case 'failed': return 'el-icon-close';
+        case 'uploading': return 'el-icon-loading';
+        default: return 'el-icon-minus';
+      }
+    },
+    // 获取线路状态颜色
+    getLineStatusColor(status) {
+      switch (status) {
+        case 'success': return '#67C23A';
+        case 'failed': return '#F56C6C';
+        case 'uploading': return '#409EFF';
+        default: return '#C0C4CC';
+      }
+    },
+    // 获取进度条状态
+    getProgressStatus(row) {
+      if (row.progress === 100 && row.uploadStatus === 'success') {
+        return 'success';
+      } else if (row.uploadStatus === 'failed') {
+        return 'exception';
+      }
+      return '';
+    },
+  }
+}
+</script>
+
+<style scoped>
+/* 自定义表格样式 */
+.el-table .video-cell {
+  padding: 5px;
+}
+
+/* 设置删除和修改按钮的间距 */
+.el-button + .el-button {
+  margin-left: 5px;
+}
+
+/* 上传状态样式 */
+.upload-status-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+
+.status-indicator {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.status-text {
+  font-size: 12px;
+}
+
+.upload-details {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  font-size: 11px;
+}
+
+.line-status {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.line-label {
+  min-width: 35px;
+  color: #606266;
+}
+
+.line-progress {
+  color: #909399;
+}
+
+/* 双线上传进度样式 */
+.dual-upload-progress {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+
+.total-progress {
+  margin-bottom: 4px;
+}
+
+.line-progress-container {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  width: 100%;
+}
+
+.line-progress-item {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 10px;
+}
+
+.line-label {
+  min-width: 30px;
+  color: #606266;
+}
+
+.line-status-text {
+  min-width: 25px;
+  color: #909399;
+  font-size: 10px;
+}
+
+/* 批量上传进度样式 */
+.batch-upload-progress {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.total-progress-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  white-space: nowrap;
+  /* 防止换行 */
+  min-width: 0;
+  /* 允许flex项目收缩 */
+}
+
+.progress-label {
+  font-size: 12px;
+  color: #606266;
+  min-width: 40px;
+}
+
+.line-progress-rows {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.line-progress-row {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 11px;
+}
+
+.line-percentage {
+  min-width: 30px;
+  color: #909399;
+  font-size: 11px;
+}
+
+::v-deep .upload-icon {
+  font-size: 28px;
+  color: #8c939d;
+}
+
+::v-deep .upload-text {
+  font-size: 12px;
+  color: #606266;
+  margin-top: 10px;
+}
+
+/* 表单样式 */
+::v-deep .el-form-item {
+  margin-bottom: 22px;
+}
+
+/* 批量选择弹窗样式 */
+::v-deep .batch-dialog .el-dialog__body {
+  padding: 0 20px 20px;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+::v-deep .el-dialog .el-dialog__body {
+  padding: 0 20px 20px;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+.filter-container {
+  padding: 15px 0;
+  background-color: #fff;
+  border-bottom: 1px solid #ebeef5;
+  position: relative;
+  text-align: right;
+}
+
+.dialog-footer {
+  text-align: right;
+  margin-top: 20px;
+  padding-right: 20px;
+}
+
+::v-deep .batch-dialog .el-table {
+  margin-top: 10px;
+}
+
+::v-deep .el-table__header-wrapper th {
+  background-color: #f5f7fa;
+  color: #606266;
+  font-weight: 500;
+  padding: 8px 0;
+}
+
+::v-deep .el-table__header-wrapper th {
+  background-color: #f5f7fa;
+  color: #606266;
+  font-weight: 500;
+  padding: 8px 0;
+}
+
+/* 确保弹窗中表格内容垂直居中 */
+::v-deep .batch-dialog .el-table .cell {
+  line-height: 23px;
+}
+
+::v-deep .el-table .cell {
+  line-height: 23px;
+}
+
+/* 自定义滚动条样式 */
+::v-deep .batch-dialog .el-dialog__body::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::v-deep .el-dialog .el-dialog__body::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::v-deep .batch-dialog .el-dialog__body::-webkit-scrollbar-thumb {
+  background: #c0c4cc;
+  border-radius: 3px;
+}
+
+::v-deep .el-dialog .el-dialog__body::-webkit-scrollbar-thumb {
+  background: #c0c4cc;
+  border-radius: 3px;
+}
+
+::v-deep .batch-dialog .el-dialog__body::-webkit-scrollbar-track {
+  background: #f5f7fa;
+}
+
+::v-deep .el-dialog .el-dialog__body::-webkit-scrollbar-track {
+  background: #f5f7fa;
+}
+
+/* 批量上传视频弹窗样式 */
+.upload-dialog .el-dialog__body {
+  padding: 20px;
+}
+
+::v-deep .upload-dialog .el-dialog__header {
+  padding: 15px 20px;
+  background-color: #f9f9f9;
+  border-bottom: 1px solid #ebeef5;
+}
+
+::v-deep .upload-dialog .el-dialog__title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+}
+
+/* 项目选择弹窗样式 */
+::v-deep .el-dialog__wrapper {
+  z-index: 2001 !important;
+}
+
+::v-deep .el-dialog__header {
+  padding: 15px 20px;
+  background-color: #f9f9f9;
+  border-bottom: 1px solid #ebeef5;
+}
+
+::v-deep .el-dialog__title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+}
+
+::v-deep .el-pagination {
+  text-align: center;
+  margin-top: 10px;
+}
+
+/* 项目选择弹窗的样式 */
+.project-container {
+  display: flex;
+  height: 500px;
+}
+
+.category-tree {
+  width: 250px;
+  height: 100%;
+  border-right: 1px solid #ebeef5;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+.tree-fixed-header {
+  padding: 10px;
+  background-color: #fff;
+  border-bottom: 1px solid #ebeef5;
+  z-index: 10;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+}
+
+.tree-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 10px 10px 10px 10px;
+}
+
+.project-list {
+  flex: 1;
+  padding: 10px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  width: calc(100% - 250px);
+  /* 固定宽度为剩余空间 */
+}
+
+.project-list .filter-container {
+  padding: 0 0 15px 0;
+  text-align: left;
+}
+
+.project-list .table-footer {
+  margin-top: 15px;
+}
+
+::v-deep .el-tree-node__content {
+  height: 36px;
+  border-radius: 4px;
+  margin-bottom: 2px;
+}
+
+::v-deep .el-tree-node:focus > .el-tree-node__content {
+  background-color: #e6f7ff;
+}
+
+::v-deep .el-tree-node__content:hover {
+  background-color: #f0f7ff;
+}
+
+::v-deep .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
+  background-color: #e6f7ff;
+  color: #1890ff;
+}
+
+::v-deep .custom-tree-node {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 14px;
+  padding: 0 8px;
+}
+
+/* 视频预览弹窗样式 */
+::v-deep .video-preview-dialog {
+  z-index: 3000 !important;
+}
+
+::v-deep .video-preview-dialog .el-dialog {
+  margin-top: 10vh !important;
+}
+
+::v-deep .video-preview-dialog .el-dialog__header {
+  padding: 15px 20px;
+  background-color: #f9f9f9;
+  border-bottom: 1px solid #ebeef5;
+}
+
+::v-deep .video-preview-dialog .el-dialog__body {
+  padding: 15px;
+  background-color: #000;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+::v-deep .video-preview-dialog video {
+  max-width: 100%;
+  max-height: 70vh;
+}
+
+/* 题目列表样式 */
+.project-list-container {
+  background-color: #f5f7fa;
+  border-radius: 4px;
+}
+
+.question-card {
+  background-color: #ffffff;
+  border-radius: 4px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  padding: 15px;
+  margin-bottom: 15px;
+  position: relative;
+}
+
+.question-header {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 10px;
+  border-bottom: 1px solid #ebeef5;
+  padding-bottom: 10px;
+}
+
+.question-index {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 24px;
+  height: 24px;
+  background-color: #409EFF;
+  color: white;
+  border-radius: 50%;
+  font-size: 14px;
+  margin-right: 10px;
+  flex-shrink: 0;
+}
+
+.question-title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+  line-height: 1.5;
+  word-break: break-all;
+}
+
+.question-type {
+  margin-bottom: 15px;
+}
+
+.content-title {
+  font-weight: 500;
+  color: #606266;
+  margin-bottom: 10px;
+}
+
+.question-content {
+  margin-bottom: 15px;
+}
+
+.question-item {
+  background-color: #f8f8f8;
+  border-left: 3px solid #409EFF;
+  padding: 10px;
+  margin-bottom: 10px;
+  border-radius: 0 4px 4px 0;
+}
+
+.question-item-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 5px;
+}
+
+.item-index {
+  display: inline-block;
+  margin-right: 10px;
+  font-weight: 500;
+  color: #409EFF;
+}
+
+.item-name {
+  color: #303133;
+}
+
+.question-answer {
+  background-color: #f0f9eb;
+  padding: 10px;
+  border-radius: 4px;
+  border-left: 3px solid #67c23a;
+}
+
+.answer-label {
+  font-weight: 500;
+  color: #67c23a;
+  margin-right: 10px;
+}
+
+.answer-content {
+  color: #606266;
+}
+
+::v-deep .custom-select-class .el-select__tags {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  flex-wrap: nowrap;
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+::v-deep .custom-select-class .el-tag {
+  margin-bottom: 4px;
+  margin-right: 0;
+}
+
+/* Added queue status styles */
+.queue-status {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 8px;
+}
+
+.queue-status .el-tag {
+  font-size: 12px;
+  padding: 4px 8px;
+}
+</style>

+ 10 - 5
src/views/course/userCourse/index.vue

@@ -493,6 +493,8 @@ export default {
   },
   data() {
     return {
+      /** 与后端 is_private 一致:1-课程管理(私域),0-公域课程管理 */
+      pageIsPrivate: 1,
       talentParam: {
         phone: null,
         talentId: null
@@ -786,6 +788,7 @@ export default {
     /** 查询课程列表 */
     getList() {
       this.loading = true;
+      this.queryParams.isPrivate = this.pageIsPrivate;
       listUserCourse(this.queryParams).then(response => {
         this.userCourseList = response.rows;
         this.total = response.total;
@@ -819,7 +822,7 @@ export default {
         isTui: "1",
         isBest: "1",
         isNext: "1",
-        isPrivate: "1",
+        isPrivate: String(this.pageIsPrivate),
         views: 100000,
         duration: null,
         description: null,
@@ -846,6 +849,7 @@ export default {
       this.resetForm("queryForm");
       this.queryParams.companyIdsList = [];
       this.queryParams.isShow = this.activeName
+      this.queryParams.isPrivate = this.pageIsPrivate;
       this.handleQuery();
     },
     // 多选框选中数据
@@ -888,7 +892,9 @@ export default {
         this.form.isIntegral = response.data.isIntegral.toString();
         this.form.isTui = response.data.isTui.toString();
         this.form.isNext = response.data.isNext.toString();
-        this.form.isPrivate = response.data.isPrivate.toString();
+        this.form.isPrivate = (response.data.isPrivate != null && response.data.isPrivate !== '')
+          ? response.data.isPrivate.toString()
+          : String(this.pageIsPrivate);
         this.talentParam.talentId = response.data.talentId;
         if (this.form.companyIds != null) {
           this.companyIds = ((this.form.companyIds).split(",").map(Number))
@@ -916,8 +922,7 @@ export default {
         if (valid) {
 
           this.form.companyIds = this.companyIds.toString()
-          // 私域课程
-          this.form.isPrivate = 1
+          this.form.isPrivate = this.pageIsPrivate
           if (this.form.courseId != null) {
             updateUserCourse(this.form).then(response => {
               this.msgSuccess("修改成功");
@@ -993,7 +998,7 @@ export default {
     },
     /** 导出按钮操作 */
     handleExport() {
-      const queryParams = this.queryParams;
+      const queryParams = { ...this.queryParams, isPrivate: this.pageIsPrivate };
       this.$confirm('是否确认导出所有课程数据项?', "警告", {
         confirmButtonText: "确定",
         cancelButtonText: "取消",

+ 5 - 3
src/views/course/userCourseCategory/index.vue

@@ -247,7 +247,8 @@ export default {
         cateName: null,
         sort: null,
         isShow: null,
-        isDel: null
+        isDel: null,
+        cateType: 0
       },
       // 表单参数
       form: {},
@@ -307,7 +308,7 @@ export default {
       };
     },
     getTreeselect() {
-      listUserCourseCategory().then(response => {
+      listUserCourseCategory({ cateType: 0 }).then(response => {
         this.categoryOptions = [];
         const data = { cateId: 0, cateName: '顶级目录', children: [] };
         data.children = response.data.filter(item => item.pid == 0)
@@ -329,7 +330,8 @@ export default {
         isShow: null,
         createTime: null,
         updateTime: null,
-        isDel: null
+        isDel: null,
+        cateType: 0
       };
       this.resetForm("form");
     },

+ 22 - 5
src/views/course/videoResource/index.vue

@@ -845,6 +845,8 @@ export default {
   },
   data() {
     return {
+      /** 本页视频资源类型:0-视频资源,1-公域课视频资源(与后端 video_type 一致) */
+      pageVideoType: 0,
       filteredRootTypeList: [], // 过滤后的分类列表
       originalRootTypeList: [], // 原始分类列表
       // 遮罩层
@@ -872,7 +874,8 @@ export default {
         resourceName: null,
         fileName: null,
         typeId: null,
-        typeSubId: null
+        typeSubId: null,
+        videoType: 0
       },
       // 表单参数
       form: {
@@ -892,6 +895,7 @@ export default {
         projectIds: [],
         sort: null,
         displayType: 'landscape',
+        videoType: 0,
         hsyVid:null,//火山云上传视频返回vid
         hsyVodUrl:null,//火山云url
         // 新增上传状态字段
@@ -1045,6 +1049,7 @@ export default {
     /** 查询视频素材库列表 */
     getList() {
       this.loading = true;
+      this.queryParams.videoType = this.pageVideoType;
       listVideoResource(this.queryParams).then(response => {
         this.resourceList = response.rows;
         this.total = response.total;
@@ -1078,7 +1083,8 @@ export default {
         typeId: null,
         typeSubId: null,
         projectIds: [],
-        displayType: 'landscape'
+        displayType: 'landscape',
+        videoType: this.pageVideoType
       };
       // 重置表单验证状态
       this.resetForm("form");
@@ -1092,6 +1098,7 @@ export default {
     /** 重置按钮操作 */
     resetQuery() {
       this.resetForm("queryForm");
+      this.queryParams.videoType = this.pageVideoType;
       this.handleQuery();
     },
     // 多选框选中数据
@@ -1138,6 +1145,9 @@ export default {
         if (!this.form.displayType) {
           this.form.displayType = 'landscape';
         }
+        if (this.form.videoType === null || this.form.videoType === undefined) {
+          this.form.videoType = this.pageVideoType;
+        }
         await this.changeCateType(this.form.typeId)
 
         // 处理projectIds,确保是数组格式
@@ -1182,6 +1192,7 @@ export default {
 
           const params = Object.assign({}, this.form);
           console.log("提交素材表单参数",this.form)
+          params.videoType = this.pageVideoType;
           params.projectIds = this.form.projectIds.join(',');
           if (this.form.id != null) {
             updateVideoResource(params).then(response => {
@@ -1687,6 +1698,7 @@ export default {
             params.append('typeId', this.batchUpdateForm.typeId || '');
             params.append('typeSubId', this.batchUpdateForm.typeSubId || '');
             params.append('ids', this.batchUpdateForm.ids.join(','));
+            params.append('videoType', String(this.pageVideoType));
             return batchUpdateVideoResource(params);
           }).then(() => {
             this.getList();
@@ -1721,6 +1733,7 @@ export default {
       const videoList = JSON.parse(JSON.stringify(this.videoList));
       videoList.forEach(item => {
         item.projectIds = item.projectIds.join(",");
+        item.videoType = this.pageVideoType;
       });
       batchAddVideoResource(videoList).then(response => {
         if (response.code === 200) {
@@ -1871,7 +1884,8 @@ export default {
       if (this.currentProject === 'myhk') {
         this.batchUploadForm = {
           ...this.batchUploadForm,
-          files: []
+          files: [],
+          videoType: this.pageVideoType
         };
       } else {
         this.batchUploadForm = {
@@ -1879,7 +1893,8 @@ export default {
           typeSubId: null,
           projectIds: [],
           files: [],
-          displayType: 'landscape'
+          displayType: 'landscape',
+          videoType: this.pageVideoType
         };
         this.subTypeList = []
       }
@@ -1913,6 +1928,7 @@ export default {
         typeSubId: this.batchUploadForm.typeSubId,
         projectIds: this.batchUploadForm.projectIds,
         displayType: this.batchUploadForm.displayType || 'landscape',
+        videoType: this.pageVideoType,
         progress: 0,
         uploadStatus: 'queued', // Set initial status to queued
         uploadDetails: {
@@ -2145,7 +2161,8 @@ export default {
       else if (this.selectedType === 3) {
         const params = {
           id: this.currentRow.id,
-          projectIds: this.selectedProjectIds.join(",")
+          projectIds: this.selectedProjectIds.join(","),
+          videoType: this.pageVideoType
         }
         updateVideoResource(params).then(response => {
           if (response.code === 200) {

+ 18 - 18
src/views/hisStore/components/productAfterSalesOrder.vue

@@ -329,24 +329,24 @@ export default {
           { required: true, message: "退款金额不能为空", trigger: "blur" }
         ],
         // 新增一级原因必填
-        reasonId1: [
-          { required: true, message: "请选择一级原因", trigger: "change" }
-        ],
-        reasonId2: [
-          {
-            validator: (rule, value, callback) => {
-              if (this.form.reasonId1 && !value) {
-                callback(new Error("请选择二级原因"));
-              } else {
-                callback();
-              }
-            },
-            trigger: "change"
-          }
-        ],
-        auditRemark: [
-          { required: true, message: "请输入审核备注", trigger: "blur" }
-        ]
+        // reasonId1: [
+        //   { required: true, message: "请选择一级原因", trigger: "change" }
+        // ],
+        // reasonId2: [
+        //   {
+        //     validator: (rule, value, callback) => {
+        //       if (this.form.reasonId1 && !value) {
+        //         callback(new Error("请选择二级原因"));
+        //       } else {
+        //         callback();
+        //       }
+        //     },
+        //     trigger: "change"
+        //   }
+        // ],
+        // auditRemark: [
+        //   { required: true, message: "请输入审核备注", trigger: "blur" }
+        // ]
       },
       reasonList: [],
       reason2List: []

+ 230 - 119
src/views/live/liveConfig/answer.vue

@@ -1,63 +1,65 @@
 <template>
   <div class="container-md">
     <div class="tip-box">
-      选择用于本节直播课程的题库试题,试题可用于直播间内发送
-      <el-link
-        type="primary"
-        style="margin-left: 5px;"
-        @click="handleToQuestionBank"
-      >配置题库试题 >></el-link>
+      为本直播间选择<strong>课程题库</strong>中的试题(也可先在题库中手动新增题目,再回到此处勾选添加)。
+<!--      <el-link-->
+<!--        type="primary"-->
+<!--        style="margin-left: 5px;"-->
+<!--        @click="handleToCourseQuestionBank"-->
+<!--      >打开课程题库 &gt;&gt;</el-link>-->
     </div>
 
     <el-button type="primary" icon="el-icon-plus" style="margin: 20px 0;" @click="handleAddQuestion">添加试题</el-button>
-    <!-- 试题列表表格 -->
     <el-table
       :data="questionLiveList"
       style="width: 100%"
       v-loading="loading"
     >
-      <!-- 题干列:显示试题的主要内容 -->
       <el-table-column
         prop="title"
         label="题干"
         show-overflow-tooltip
-      ></el-table-column>
-
-      <!-- 题型列:显示是单选还是多选 -->
+      />
       <el-table-column
         prop="type"
         label="题型"
+        width="100"
       >
         <template slot-scope="scope">
-          {{ scope.row.type === 1 ? '单选题' : '多选题' }}
+          {{ scope.row.type === 1 ? '单选题' : scope.row.type === 2 ? '多选题' : '—' }}
         </template>
       </el-table-column>
-
-      <!-- 创建时间列:显示试题创建的时间 -->
       <el-table-column
         prop="createTime"
         label="创建时间"
-      ></el-table-column>
-
-      <!-- 操作列:包含编辑和删除按钮 -->
+        width="170"
+      />
       <el-table-column
         label="操作"
-        width="180"
+        width="280"
         fixed="right"
       >
         <template slot-scope="scope">
-          <!-- 删除按钮:用于移除试题 -->
+          <el-button
+            type="text"
+            size="small"
+            @click="handleStartQuiz(scope.row)"
+          >开始答题</el-button>
+          <el-button
+            type="text"
+            size="small"
+            @click="handleCloseQuiz(scope.row)"
+          >结束答题</el-button>
           <el-button
             type="text"
             size="small"
             style="color: #F56C6C;"
             @click="handleDelete(scope.row)"
-          >删除</el-button>
+          >除</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <!-- 分页组件:用于分页展示试题列表 -->
     <pagination
       v-show="questionTotal > 0"
       :total="questionTotal"
@@ -67,82 +69,85 @@
       style="margin-top: 20px;"
     />
 
-    <!-- 添加试题弹窗 -->
     <el-dialog
       title="添加试题"
       :visible.sync="questionDialogVisible"
-      width="800px"
+      width="860px"
+      append-to-body
       :close-on-click-modal="false"
       :close-on-press-escape="false"
+      @open="onAddDialogOpen"
+      @closed="onAddDialogClosed"
     >
-      <div class="dialog-content">
-        <div style="text-align: right; margin-bottom: 20px;">
-          <el-input
-            v-model="searchTitle"
-            placeholder="请输入搜索内容"
-            style="width: 300px;"
-            @input="handleQuestionSearch"
-          ></el-input>
-        </div>
+      <el-tabs v-model="addQuestionTab">
+        <el-tab-pane label="从课程题库选择" name="bank">
+          <div class="dialog-toolbar">
+            <el-input
+              v-model="searchTitle"
+              placeholder="题干关键词"
+              clearable
+              size="small"
+              style="width: 220px; margin-right: 12px;"
+              @keyup.enter.native="handleQuestionSearch"
+            />
+            <el-select
+              v-model="optionTypeFilter"
+              placeholder="题型"
+              clearable
+              size="small"
+              style="width: 120px; margin-right: 12px;"
+            >
+              <el-option label="单选题" :value="1" />
+              <el-option label="多选题" :value="2" />
+            </el-select>
+            <el-button type="primary" icon="el-icon-search" size="small" @click="handleQuestionSearch">搜索</el-button>
+          </div>
 
-        <el-table
-          ref="questionTable"
-          :data="questionList"
-          style="width: 100%"
-          v-loading="questionLoading"
-          @selection-change="handleSelectionChange"
-          @row-click="handleRowClick"
-          row-key="id"
-        >
-          <!-- 复选框列:用于多选试题 -->
-          <el-table-column
-            type="selection"
-            width="55"
-          >
-          </el-table-column>
-          <!-- 题干列:显示试题的主要内容 -->
-          <el-table-column
-            prop="title"
-            label="题干"
-            class-name="clickable-column"
-          ></el-table-column>
-          <!-- 题型列:显示单选或多选 -->
-          <el-table-column
-            prop="type"
-            label="题型"
-            class-name="clickable-column"
+          <el-table
+            ref="questionTable"
+            :data="questionList"
+            style="width: 100%"
+            v-loading="questionLoading"
+            @selection-change="handleSelectionChange"
+            @row-click="handleRowClick"
+            row-key="id"
+            max-height="420"
           >
-            <template slot-scope="scope">
-              {{ scope.row.type === 1 ? '单选题' : '多选题' }}
-            </template>
-          </el-table-column>
-          <!-- 创建人列 -->
-          <el-table-column
-            prop="createBy"
-            label="创建人"
-            class-name="clickable-column"
-          ></el-table-column>
-          <!-- 创建时间列 -->
-          <el-table-column
-            prop="createTime"
-            label="创建时间"
-            width="150"
-            class-name="clickable-column"
-          ></el-table-column>
-        </el-table>
+            <el-table-column type="selection" width="50" align="center" reserve-selection />
+            <el-table-column prop="title" label="题干" min-width="200" show-overflow-tooltip class-name="clickable-column" />
+            <el-table-column prop="type" label="题型" width="90" class-name="clickable-column">
+              <template slot-scope="scope">
+                {{ scope.row.type === 1 ? '单选' : scope.row.type === 2 ? '多选' : '—' }}
+              </template>
+            </el-table-column>
+            <el-table-column prop="createBy" label="创建人" width="100" show-overflow-tooltip class-name="clickable-column" />
+            <el-table-column prop="createTime" label="创建时间" width="160" class-name="clickable-column" />
+          </el-table>
 
-        <pagination
-          v-show="total > 0"
-          :total="total"
-          :page.sync="queryParams.pageNum"
-          :limit.sync="queryParams.pageSize"
-          @pagination="getQuestionList"
-          style="margin-top: 20px;"
-        />
-      </div>
-      <div slot="footer" class="dialog-footer">
+          <pagination
+            v-show="total > 0"
+            :total="total"
+            :page.sync="queryParams.pageNum"
+            :limit.sync="queryParams.pageSize"
+            @pagination="getQuestionList"
+            style="margin-top: 16px;"
+          />
+        </el-tab-pane>
+        <el-tab-pane label="手动维护题库" name="manual">
+          <el-alert
+            title="自定义题目请先在「课程题库」中新增或编辑;保存后回到本页「从课程题库选择」勾选添加。"
+            type="info"
+            :closable="false"
+            show-icon
+            style="margin-bottom: 16px;"
+          />
+          <el-button type="primary" plain icon="el-icon-document" @click="handleToCourseQuestionBank">前往课程题库</el-button>
+        </el-tab-pane>
+      </el-tabs>
+
+      <div slot="footer" class="dialog-footer" v-show="addQuestionTab === 'bank'">
         <div style="display: flex; justify-content: space-between; align-items: center;">
-          <span class="selected-count">当前已选择 <span style="color: #00BFFF; font-style: italic;">{{ selectedQuestions.length }}</span> 题</span>
+          <span class="selected-count">当前已选择 <span class="num">{{ selectedQuestions.length }}</span> 题(题目来自课程题库)</span>
           <div>
             <el-button @click="questionDialogVisible = false">取 消</el-button>
             <el-button type="primary" @click="confirmAddQuestion">确 定</el-button>
@@ -154,12 +159,18 @@
 </template>
 
 <script>
-
-import {addLiveQuestionLive, listLiveQuestionLive, listLiveQuestionOptionList} from "@/api/live/liveQuestionLive";
+import {
+  addLiveQuestionLive,
+  listLiveQuestionLive,
+  listLiveQuestionOptionList,
+  deleteLiveQuestionLive
+} from '@/api/live/liveQuestionLive'
 
 export default {
+  name: 'LiveConfigAnswer',
   data() {
     return {
+      addQuestionTab: 'bank',
       questionDialogVisible: false,
       questionLoading: false,
       questionTotal: 0,
@@ -168,19 +179,21 @@ export default {
       questionList: [],
       loading: true,
       searchTitle: '',
+      optionTypeFilter: null,
       total: 0,
       queryParams: {
         pageNum: 1,
         pageSize: 10,
         title: null,
+        type: null,
         liveId: null
       },
       questionParams: {
         pageNum: 1,
         pageSize: 10,
         liveId: null
-      },
-    };
+      }
+    }
   },
   created() {
     this.liveId = this.$route.params.liveId
@@ -188,73 +201,171 @@ export default {
     this.questionParams.liveId = this.liveId
     this.getLiveQuestionLiveList()
   },
+  computed: {
+    userId() {
+      return this.$store.state.user.user.userId
+    }
+  },
   methods: {
+    sendLiveQuizCmd(cmd, data) {
+      const conn = this.$store.state.liveWs[this.liveId]
+      if (!conn || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) {
+        this.$message.warning('直播 WebSocket 未连接,请从直播配置页进入并保持本页不刷新')
+        return false
+      }
+      const payload = {
+        cmd,
+        liveId: Number(this.liveId),
+        userId: this.userId,
+        data: JSON.stringify(data || {})
+      }
+      conn.send(JSON.stringify(payload))
+      return true
+    },
+    handleStartQuiz(row) {
+      if (!row || row.id == null) {
+        return
+      }
+      if (this.sendLiveQuizCmd('liveQuizStart', { relId: row.id })) {
+        this.$message.success('已向直播间广播本题')
+      }
+    },
+    handleCloseQuiz(row) {
+      const data = row && row.id != null ? { relId: row.id } : {}
+      if (this.sendLiveQuizCmd('liveQuizClose', data)) {
+        this.$message.success('已广播结束答题')
+      }
+    },
+    onAddDialogOpen() {
+      this.addQuestionTab = 'bank'
+      this.searchTitle = ''
+      this.optionTypeFilter = null
+      this.queryParams.pageNum = 1
+      this.queryParams.title = null
+      this.queryParams.type = null
+      this.getQuestionList()
+    },
+    onAddDialogClosed() {
+      this.selectedQuestions = []
+      this.$nextTick(() => {
+        const t = this.$refs.questionTable
+        if (t && typeof t.clearSelection === 'function') {
+          t.clearSelection()
+        }
+      })
+    },
     handleQuestionSearch() {
       this.queryParams.pageNum = 1
-      this.queryParams.title = this.searchTitle
+      this.queryParams.title = this.searchTitle || null
+      this.queryParams.type = this.optionTypeFilter
       this.getQuestionList()
     },
     getLiveQuestionLiveList() {
       this.loading = true
       listLiveQuestionLive(this.questionParams).then(response => {
-        this.questionLiveList = response.rows
-        this.questionTotal = response.total
+        this.questionLiveList = response.rows || []
+        this.questionTotal = response.total || 0
+        this.loading = false
+      }).catch(() => {
         this.loading = false
       })
     },
-    handleToQuestionBank() {
-      this.$router.push('/live/liveQuestionBank')
+    handleToCourseQuestionBank() {
+      this.$router.push('/course/courseQuestionBank')
     },
     handleAddQuestion() {
       this.questionDialogVisible = true
-      this.getQuestionList()
     },
     getQuestionList() {
       this.questionLoading = true
-      listLiveQuestionOptionList(this.queryParams).then(response => {
-        this.questionList = response.rows
-        this.total = response.total
+      const params = {
+        ...this.queryParams,
+        liveId: this.liveId
+      }
+      listLiveQuestionOptionList(params).then(response => {
+        this.questionList = response.rows || []
+        this.total = response.total || 0
+        this.questionLoading = false
+        this.$nextTick(() => this.syncTableSelection())
+      }).catch(() => {
         this.questionLoading = false
       })
     },
+    syncTableSelection() {
+      const table = this.$refs.questionTable
+      if (!table || !this.questionList.length) return
+      const selectedIds = new Set(this.selectedQuestions.map(l => l.id))
+      this.questionList.forEach(row => {
+        table.toggleRowSelection(row, selectedIds.has(row.id))
+      })
+    },
     handleSelectionChange(selection) {
-      this.selectedQuestions = selection
+      const sel = selection || []
+      const currentIds = new Set(this.questionList.map(r => r.id))
+      const fromOtherPages = this.selectedQuestions.filter(q => !currentIds.has(q.id))
+      const merged = [...fromOtherPages, ...sel]
+      if (merged.length > 15) {
+        this.$message.warning('最多一次添加 15 题')
+        this.$nextTick(() => this.syncTableSelection())
+        return
+      }
+      this.selectedQuestions = merged
     },
     confirmAddQuestion() {
       if (this.selectedQuestions.length === 0) {
-        this.$message({
-          message: '请选择要添加的试题',
-          type: 'warning'
-        })
+        this.$message.warning('请选择要添加的试题')
         return
       }
-      // 调用添加直播间试题接口
       addLiveQuestionLive({
         liveId: this.liveId,
         questionIds: this.selectedQuestions.map(item => item.id).join(',')
-      }).then(response => {
+      }).then(() => {
+        this.$message.success('添加成功')
         this.questionDialogVisible = false
         this.getLiveQuestionLiveList()
       })
     },
-    handleRowClick(row, column) {
-      // 如果点击的是复选框列,不进行处理
-      if (column.type === 'selection') {
+    handleDelete(row) {
+      if (!row || row.id == null) return
+      this.$confirm('确认从本直播间移除该试题?(不会删除课程题库中的题目)', '提示', {
+        type: 'warning'
+      }).then(() => {
+        return deleteLiveQuestionLive({ liveId: this.liveId, ids: String(row.id) })
+      }).then(() => {
+        this.$message.success('已移除')
+        this.getLiveQuestionLiveList()
+      }).catch(() => {})
+    },
+    handleRowClick(row, column, event) {
+      if (column && column.type === 'selection') {
         return
       }
-
-      // 获取表格实例
-      const table = this.$refs.questionTable[0]
+      const table = this.$refs.questionTable
       if (!table) {
         return
       }
-
-      // 判断当前行是否已经被选中
       const isSelected = this.selectedQuestions.some(item => item.id === row.id)
-
-      // 切换选中状态
       table.toggleRowSelection(row, !isSelected)
-    },
+    }
   }
-};
+}
 </script>
+
+<style scoped>
+.tip-box {
+  line-height: 1.6;
+  color: #606266;
+}
+.dialog-toolbar {
+  margin-bottom: 12px;
+}
+.selected-count {
+  font-size: 13px;
+  color: #606266;
+}
+.selected-count .num {
+  color: #409eff;
+  font-style: italic;
+  font-weight: 600;
+}
+</style>

+ 1 - 1
src/views/live/liveConfig/index.vue

@@ -142,7 +142,7 @@ export default {
         { name: '红包配置', label: '红包配置', index: 'liveRedConf'},
         { name: '抽奖配置', label: '抽奖配置', index: 'liveLotteryConf'},
         { name: '优惠券配置', label: '优惠券配置', index: 'liveCoupon'},
-        // { name: '答题', label: '答题', index: 'answer'},
+        { name: '直播间试题', label: '直播间试题', index: 'answer'},
         { name: '直播商品', label: '直播商品', index: 'goods'},
         { name: '回放设置', label: '回放设置', index: 'liveReplay'},
         // { name: '观看积分', label: '观看积分', index: 'watchScore'},

+ 1222 - 0
src/views/live/liveStatistics/LiveStatisticsPanel.vue

@@ -0,0 +1,1222 @@
+<template>
+  <div class="app-container live-statistics-panel-root">
+    <el-alert
+      v-if="trainingPeriodId"
+      title="仅可选择当前营期下的直播间进行统计查询。"
+      type="info"
+      :closable="false"
+      show-icon
+      class="mb8"
+    />
+    <!-- 左上角:选择直播间(多选,最多展示8个,多余折叠)+ 查询按钮 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <div
+          class="live-select-input"
+          @click="openLiveSelectDialog">
+          <div class="live-select-content">
+            <template v-if="selectedLiveList.length === 0">
+              <span class="placeholder">请选择直播间</span>
+            </template>
+            <template v-else>
+              <template v-for="(item, idx) in displayedLives">
+                <span :key="item.liveId" class="live-tag">{{ item.liveName }}({{ item.liveId }})</span>
+                <span v-if="idx < displayedLives.length - 1 || selectedLiveList.length > 7" class="tag-sep">、</span>
+              </template>
+              <span v-if="selectedLiveList.length > 7" class="fold-text">等{{ selectedLiveList.length }}个</span>
+            </template>
+          </div>
+          <div class="live-select-suffix">
+            <i v-if="selectedLiveList.length > 0" class="el-icon-circle-close" @click.stop="handleClearLive"></i>
+            <i class="el-icon-arrow-down"></i>
+          </div>
+        </div>
+      </el-col>
+      <el-col :span="1">
+        <el-button
+          type="primary"
+          icon="el-icon-search"
+          size="small"
+          :loading="overviewLoading"
+          :disabled="selectedLiveList.length === 0"
+          @click="fetchOverview">
+          查询
+        </el-button>
+      </el-col>
+    </el-row>
+
+    <el-card ref="statsCard" class="box-card" shadow="never">
+      <div class="stats-main">
+        <!-- 固定导航栏:滚动时固定在顶部 -->
+        <div class="stats-nav-wrapper">
+          <div v-if="navFixed" ref="statsNavSpacer" class="stats-nav-spacer" :style="{ height: navHeight + 'px' }"/>
+          <div
+            ref="statsNavBar"
+            :class="['stats-nav-bar', { 'is-fixed': navFixed }]"
+            :style="navFixed ? navFixedStyle : {}">
+            <span
+              v-for="item in navItems"
+              :key="item.key"
+              :class="['nav-item', { active: activeNav === item.key }]"
+              @click="scrollToSection(item.key)">
+              {{ item.label }}
+            </span>
+          </div>
+        </div>
+        <!-- 四个板块竖排展示,每个板块独立卡片样式 -->
+        <el-card ref="sectionOverview" class="stats-section-card" shadow="never" data-section="overview">
+          <div slot="header" class="section-title">数据概览</div>
+          <div v-loading="overviewLoading" class="overview-content">
+            <template >
+              <!-- 直播数据 -->
+              <div class="overview-block">
+                <div class="overview-block-title">直播数据</div>
+                <el-row :gutter="16" class="overview-grid">
+                  <el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="item in overviewItems" :key="item.key">
+                    <div class="overview-item">
+                      <div class="overview-label">{{ item.label }}</div>
+                      <div class="overview-value">{{ formatOverviewValue(overviewData[item.key]) }}</div>
+                    </div>
+                  </el-col>
+                </el-row>
+              </div>
+              <!-- 互动带货数据 -->
+              <div class="overview-block">
+                <div class="overview-block-title">互动带货数据</div>
+                <el-row :gutter="16" class="overview-grid">
+                  <el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="item in interactionItems" :key="item.key">
+                    <div class="overview-item">
+                      <div class="overview-label">{{ item.label }}</div>
+                      <div class="overview-value">{{ formatOverviewValue(overviewData[item.key]) }}</div>
+                    </div>
+                  </el-col>
+                </el-row>
+              </div>
+            </template>
+          </div>
+        </el-card>
+        <el-card ref="sectionTrend" class="stats-section-card" shadow="never" data-section="trend">
+          <div slot="header" class="section-title">直播趋势</div>
+          <div v-loading="trendLoading" class="trend-chart-wrap">
+            <div  ref="trendChartRef" class="trend-chart"></div>
+          </div>
+        </el-card>
+        <el-card ref="sectionStudent" class="stats-section-card" shadow="never" data-section="student">
+          <div slot="header" class="section-title">直播间学员</div>
+          <div class="student-section">
+            <el-form :model="studentQueryParams" :inline="true" class="filter-form student-filter">
+              <el-form-item label="直播名称">
+                <el-select
+                  v-model="studentQueryParams.liveIds"
+                  multiple
+                  collapse-tags
+                  placeholder="不选则展示全部已选直播间"
+                  size="small"
+                  clearable
+                  style="width: 280px;">
+                  <el-option
+                    v-for="item in selectedLiveList"
+                    :key="item.liveId"
+                    :label="item.liveName + '(' + item.liveId + ')'"
+                    :value="item.liveId"/>
+                </el-select>
+              </el-form-item>
+              <el-form-item label="首次访问时间">
+                <el-date-picker
+                  v-model="studentFirstEntryRange"
+                  type="datetimerange"
+                  range-separator="至"
+                  start-placeholder="开始时间"
+                  end-placeholder="结束时间"
+                  value-format="yyyy-MM-dd HH:mm:ss"
+                  size="small"
+                  style="width: 340px;">
+                </el-date-picker>
+              </el-form-item>
+              <el-form-item>
+                <el-button type="primary" icon="el-icon-search" size="small" :loading="studentLoading" @click="fetchStudentList">查询</el-button>
+                <el-button icon="el-icon-refresh" size="small" @click="resetStudentQuery">重置</el-button>
+              </el-form-item>
+            </el-form>
+            <el-table
+              v-loading="studentLoading"
+              :data="studentList"
+              border
+              style="width: 100%">
+              <el-table-column label="用户名" align="center" min-width="160">
+                <template slot-scope="scope">
+                  <div class="user-name-cell">
+                    <el-avatar :size="28" :src="scope.row.avatar" icon="el-icon-user-solid"/>
+                    <el-tooltip :content="scope.row.userName" placement="top">
+                      <span class="user-name-text">{{ scope.row.userName && scope.row.userName.length > 8 ? scope.row.userName.substring(0, 8) + '...' : scope.row.userName }}</span>
+                    </el-tooltip>
+                  </div>
+                </template>
+              </el-table-column>
+              <el-table-column label="直播间名称" align="center" prop="liveName" min-width="150" show-overflow-tooltip/>
+              <el-table-column label="销售名称" align="center" prop="salesName" min-width="100" show-overflow-tooltip/>
+              <el-table-column label="用户创建时间" align="center" prop="userCreateTime" width="170"/>
+              <el-table-column label="联系方式" align="center" prop="contact" width="140"/>
+            </el-table>
+            <pagination
+              v-show="studentTotal > 0"
+              :total="studentTotal"
+              :page.sync="studentQueryParams.pageNum"
+              :limit.sync="studentQueryParams.pageSize"
+              :auto-scroll="false"
+              @pagination="fetchStudentList"
+            />
+          </div>
+        </el-card>
+        <el-card ref="sectionCompare" class="stats-section-card" shadow="never" data-section="compare">
+          <div slot="header" class="section-title">数据对比</div>
+          <div class="compare-tabs-wrap">
+            <el-radio-group v-model="compareTab" size="small" class="compare-tabs">
+              <el-radio-button label="invite">邀课对比</el-radio-button>
+              <el-radio-button label="product">商品对比</el-radio-button>
+            </el-radio-group>
+            <!-- 邀课对比 -->
+            <div v-show="compareTab === 'invite'" class="compare-panel">
+              <el-form :model="inviteCompareQueryParams" :inline="true" class="filter-form">
+                <el-form-item label="分享人">
+                  <el-select
+                    v-model="inviteCompareQueryParams.companyUserIds"
+                    multiple
+                    collapse-tags
+                    placeholder="请选择分享人(需先选择直播间并查询)"
+                    size="small"
+                    clearable
+                    style="width: 400px;">
+                    <el-option
+                      v-for="item in inviteSalesOptions"
+                      :key="(item.companyId || '') + '_' + (item.companyUserId || '')"
+                      :label="item.salesName + (item.companyName ? '(' + item.companyName + ')' : '')"
+                      :value="(item.companyId || '') + '_' + (item.companyUserId || '')"/>
+                  </el-select>
+                </el-form-item>
+                <el-form-item>
+                  <el-button type="primary" icon="el-icon-search" size="small" :loading="inviteCompareLoading" @click="fetchInviteCompareList">查询</el-button>
+                  <el-button icon="el-icon-refresh" size="small" @click="resetInviteCompareQuery">重置</el-button>
+                </el-form-item>
+              </el-form>
+              <el-table
+                v-loading="inviteCompareLoading"
+                :data="inviteCompareList"
+                border
+                style="width: 100%">
+                <el-table-column label="归属公司" align="center" prop="companyName" min-width="140" show-overflow-tooltip/>
+                <el-table-column label="销售名称" align="center" prop="salesName" min-width="120" show-overflow-tooltip/>
+                <el-table-column label="邀请人数" align="center" prop="inviteCount" width="120"/>
+                <el-table-column label="已支付订单数" align="center" prop="paidOrderCount" width="140"/>
+                <el-table-column label="订单总金额" align="center" prop="totalGmv" width="140">
+                  <template slot-scope="scope">
+                    {{ formatProductMoney(scope.row.totalGmv) }}
+                  </template>
+                </el-table-column>
+              </el-table>
+              <pagination
+                v-show="inviteCompareTotal > 0"
+                :total="inviteCompareTotal"
+                :page.sync="inviteCompareQueryParams.pageNum"
+                :limit.sync="inviteCompareQueryParams.pageSize"
+                :auto-scroll="false"
+                @pagination="fetchInviteCompareList"
+              />
+            </div>
+            <!-- 商品对比 -->
+            <div v-show="compareTab === 'product'" class="compare-panel">
+              <el-form :model="productCompareQueryParams" :inline="true" class="filter-form">
+                <el-form-item label="直播间带货的产品">
+                  <el-select
+                    v-model="productCompareQueryParams.productIds"
+                    multiple
+                    collapse-tags
+                    placeholder="请选择商品(需先选择直播间并查询)"
+                    size="small"
+                    clearable
+                    style="width: 400px;">
+                    <el-option
+                      v-for="item in liveProductOptions"
+                      :key="item.productId"
+                      :label="item.productName"
+                      :value="item.productId"/>
+                  </el-select>
+                </el-form-item>
+                <el-form-item>
+                  <el-button type="primary" icon="el-icon-search" size="small" :loading="productCompareLoading" @click="fetchProductCompareList">查询</el-button>
+                  <el-button icon="el-icon-refresh" size="small" @click="resetProductCompareQuery">重置</el-button>
+                </el-form-item>
+              </el-form>
+              <el-table
+                v-loading="productCompareLoading"
+                :data="productCompareList"
+                border
+                style="width: 100%">
+                <el-table-column label="商品名称" align="center" prop="productName" min-width="180" show-overflow-tooltip/>
+                <el-table-column label="下单未支付人数" align="center" prop="unpaidUserCount" width="140"/>
+                <el-table-column label="成交人数" align="center" prop="paidUserCount" width="120"/>
+                <el-table-column label="成交金额" align="center" prop="totalGmv" width="140">
+                  <template slot-scope="scope">
+                    {{ formatProductMoney(scope.row.totalGmv) }}
+                  </template>
+                </el-table-column>
+              </el-table>
+              <pagination
+                v-show="productCompareTotal > 0"
+                :total="productCompareTotal"
+                :page.sync="productCompareQueryParams.pageNum"
+                :limit.sync="productCompareQueryParams.pageSize"
+                :auto-scroll="false"
+                @pagination="fetchProductCompareList"
+              />
+            </div>
+          </div>
+        </el-card>
+      </div>
+    </el-card>
+
+    <!-- 选择直播间弹窗 -->
+    <el-dialog
+      title="选择直播间"
+      :visible.sync="liveSelectDialogVisible"
+      width="900px"
+      append-to-body
+      :close-on-click-modal="false"
+      @open="handleDialogOpen"
+      @close="handleDialogClose">
+      <!-- 上面:筛选条件 -->
+      <el-form :model="liveQueryParams" ref="liveQueryForm" :inline="true" label-width="100px" class="filter-form">
+        <el-form-item label="直播名称" prop="liveName">
+          <el-input
+            v-model="liveQueryParams.liveName"
+            placeholder="请输入直播名称"
+            clearable
+            size="small"
+            style="width: 160px;"
+            @keyup.enter.native="handleLiveSearch"/>
+        </el-form-item>
+        <el-form-item label="直播时间" prop="createTimeRange">
+          <el-date-picker
+            v-model="createTimeRange"
+            type="datetimerange"
+            range-separator="至"
+            start-placeholder="开始时间"
+            end-placeholder="结束时间"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            size="small"
+            style="width: 340px;">
+          </el-date-picker>
+        </el-form-item>
+        <el-form-item v-if="!trainingPeriodId" label="公司名称" prop="companyName">
+          <el-input
+            v-model="liveQueryParams.companyName"
+            placeholder="请输入公司名称"
+            clearable
+            size="small"
+            style="width: 160px;"
+            @keyup.enter.native="handleLiveSearch"/>
+        </el-form-item>
+        <el-form-item label="直播状态" prop="status">
+          <el-select v-model="liveQueryParams.status" placeholder="请选择" clearable size="small" style="width: 120px;">
+            <el-option label="待直播" :value="1"></el-option>
+            <el-option label="直播中" :value="2"></el-option>
+            <el-option label="已结束" :value="3"></el-option>
+            <el-option label="直播回放中" :value="4"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" size="small" @click="handleLiveSearch">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="small" @click="resetLiveQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 下面:展示列表(多选,支持翻页继续选择) -->
+      <el-table
+        ref="liveTable"
+        v-loading="liveListLoading"
+        :data="liveList"
+        row-key="liveId"
+        @selection-change="handleSelectionChange"
+        border
+        max-height="400">
+        <el-table-column type="selection" width="55" align="center" reserve-selection />
+        <el-table-column label="直播ID" align="center" prop="liveId" width="100"/>
+        <el-table-column label="直播名称" align="center" prop="liveName" min-width="150" show-overflow-tooltip/>
+        <el-table-column label="直播状态" align="center" prop="status" width="120">
+          <template slot-scope="scope">
+            <el-tag v-if="scope.row.status === 1" size="small">待直播</el-tag>
+            <el-tag v-else-if="scope.row.status === 2" type="success" size="small">直播中</el-tag>
+            <el-tag v-else-if="scope.row.status === 3" type="info" size="small">已结束</el-tag>
+            <el-tag v-else-if="scope.row.status === 4" type="warning" size="small">直播回放中</el-tag>
+            <span v-else>---</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="直播公司" align="center" prop="companyName" min-width="120" show-overflow-tooltip>
+          <template slot-scope="scope">
+            {{ scope.row.companyName || '总台' }}
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="liveTotal > 0"
+        :total="liveTotal"
+        :page.sync="liveQueryParams.pageNum"
+        :limit.sync="liveQueryParams.pageSize"
+        @pagination="getLiveList"
+      />
+
+      <div slot="footer" class="dialog-footer">
+        <span class="selected-count">已选 {{ selectedLiveList.length }}/15 个</span>
+        <el-button @click="liveSelectDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="confirmSelectLive">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { listLive } from '@/api/live/live'
+import { listTrainingLive } from '@/api/live/trainingCampAdmin'
+import { getLiveStatisticsOverview, getLiveEntryTrend, listLiveRoomStudents, listProductCompareStats, listInviteSalesOptions, listInviteCompareStats } from '@/api/live/liveData'
+import { listLiveGoods } from '@/api/live/liveGoods'
+
+export default {
+  name: 'LiveStatisticsPanel',
+  props: {
+    /** 若传入,则直播间选择范围仅限该营期(训练营工作台) */
+    trainingPeriodId: {
+      type: [Number, String],
+      default: null
+    },
+    /** 总后台营期统计必填:企业 ID(与 trainingCampAdmin 接口一致) */
+    trainingCampCompanyId: {
+      type: [Number, String],
+      default: null
+    }
+  },
+  data() {
+    return {
+      // 已选择的直播间(多选)
+      selectedLiveList: [],
+      // 选择弹窗
+      liveSelectDialogVisible: false,
+      liveListLoading: false,
+      liveList: [],
+      liveTotal: 0,
+      // 直播间查询参数
+      liveQueryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        liveName: null,
+        companyName: null,
+        status: null,
+        beginTime: null,
+        endTime: null
+      },
+      // 创建时间范围(用于日期选择器双向绑定)
+      createTimeRange: null,
+      // 当前激活的导航
+      activeNav: 'overview',
+      // 导航配置
+      navItems: [
+        { key: 'overview', label: '数据概览' },
+        { key: 'trend', label: '直播趋势' },
+        { key: 'student', label: '直播间学员' },
+        { key: 'compare', label: '数据对比' }
+      ],
+      // 导航栏固定
+      navFixed: false,
+      navHeight: 48,
+      navFixedStyle: {},
+      // 数据概览
+      overviewData: {},
+      overviewLoading: false,
+      // 直播趋势折线图
+      trendLoading: false,
+      trendChartData: { xAxis: [], series: [] },
+      trendChart: null,
+      // 直播间学员
+      studentList: [],
+      studentTotal: 0,
+      studentLoading: false,
+      studentQueryParams: {
+        liveIds: [],
+        pageNum: 1,
+        pageSize: 10
+      },
+      studentFirstEntryRange: null,
+      // 数据对比切换 tab:invite=邀课对比,product=商品对比
+      compareTab: 'invite',
+      // 商品对比(嵌入数据对比)
+      liveProductOptions: [],
+      productCompareList: [],
+      productCompareTotal: 0,
+      productCompareLoading: false,
+      productCompareQueryParams: {
+        productIds: [],
+        liveIds: [],
+        pageNum: 1,
+        pageSize: 10
+      },
+      // 邀课对比(嵌入数据对比)
+      inviteSalesOptions: [],
+      inviteCompareList: [],
+      inviteCompareTotal: 0,
+      inviteCompareLoading: false,
+      inviteCompareQueryParams: {
+        companyUserIds: [],
+        pageNum: 1,
+        pageSize: 10
+      }
+    }
+  },
+  computed: {
+    overviewItems() {
+      return [
+        { key: 'beforeLiveUv', label: '开播前访问人数(uv)' },
+        { key: 'totalWatchUv', label: '累计观看人数(uv)' },
+        { key: 'over10MinCount', label: '停留时长超过10分钟人数' },
+        { key: 'totalWatchMinutes', label: '总观看时长(分钟)' },
+        { key: 'avgWatchMinutes', label: '平均观看时长(分钟)' },
+        { key: 'replayWatchUv', label: '累计回放观看人数(uv)' },
+        { key: 'replayVisitPv', label: '累计回放访问人数(pv)' },
+        { key: 'replayOnlyCount', label: '仅看过回放的人数' },
+        { key: 'replayTotalMinutes', label: '回放总观看时长(分钟)' },
+        { key: 'replayAvgMinutes', label: '回放平均观看时长(分钟)' },
+        { key: 'completeCount', label: '完课人数' },
+        { key: 'subscribeCount', label: '预约人数' }
+      ]
+    },
+    interactionItems() {
+      return [
+        { key: 'lotteryCount', label: '抽奖次数' },
+        { key: 'lotteryJoinCount', label: '参与抽奖人数' },
+        { key: 'lotteryWinCount', label: '中奖人数' },
+        { key: 'paidUserCount', label: '支付点击人数' },
+        { key: 'unpaidUserCount', label: '未支付人数' },
+        { key: 'totalGmv', label: '总成交金额' },
+        { key: 'totalProductQty', label: '商品总销量' }
+      ]
+    },
+    headerOffset() {
+      const fixed = this.$store.state.settings.fixedHeader
+      const tagsView = this.$store.state.settings.tagsView
+      if (fixed && tagsView) return 84
+      if (fixed) return 50
+      return 0
+    },
+    displayedLives() {
+      return this.selectedLiveList.slice(0, 7)
+    },
+    hasTrendData() {
+      return this.trendChartData.xAxis && this.trendChartData.xAxis.length > 0
+    }
+  },
+  watch: {
+    trainingPeriodId() {
+      this.handleClearLive()
+      if (this.liveSelectDialogVisible) {
+        this.liveQueryParams.pageNum = 1
+        this.getLiveList()
+      }
+    },
+    trainingCampCompanyId() {
+      this.handleClearLive()
+      if (this.liveSelectDialogVisible) {
+        this.liveQueryParams.pageNum = 1
+        this.getLiveList()
+      }
+    }
+  },
+  mounted() {
+    this.$nextTick(() => this.measureNavHeight())
+    window.addEventListener('scroll', this.handleNavScroll, { passive: true })
+    window.addEventListener('resize', this.handleNavScroll)
+    window.addEventListener('resize', this.handleTrendChartResize)
+    this.trendChartData = this.getDefaultTrendData()
+    this.$nextTick(() => setTimeout(() => this.renderTrendChart(), 100))
+  },
+  beforeDestroy() {
+    window.removeEventListener('scroll', this.handleNavScroll)
+    window.removeEventListener('resize', this.handleNavScroll)
+    window.removeEventListener('resize', this.handleTrendChartResize)
+    if (this.trendChart) {
+      this.trendChart.dispose()
+      this.trendChart = null
+    }
+  },
+  methods: {
+    openLiveSelectDialog() {
+      this.liveSelectDialogVisible = true
+    },
+    handleClearLive() {
+      this.selectedLiveList = []
+      this.studentList = []
+      this.studentTotal = 0
+      this.studentQueryParams.liveIds = []
+      this.studentFirstEntryRange = null
+      this.liveProductOptions = []
+      this.productCompareList = []
+      this.productCompareTotal = 0
+      this.productCompareQueryParams.productIds = []
+      if (this.trendChart) {
+        this.trendChart.dispose()
+        this.trendChart = null
+      }
+      this.trendChartData = { xAxis: [], series: [] }
+      this.overviewData = {}
+      this.$nextTick(() => {
+        const table = this.$refs.liveTable
+        if (table && typeof table.clearSelection === 'function') {
+          table.clearSelection()
+        }
+        this.handleTrendChartResize()
+      })
+    },
+    handleDialogOpen() {
+      this.getLiveList()
+    },
+    handleDialogClose() {
+      this.resetLiveQuery()
+    },
+    handleLiveSearch() {
+      this.liveQueryParams.pageNum = 1
+      this.getLiveList()
+    },
+    resetLiveQuery() {
+      this.liveQueryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        liveName: null,
+        companyName: null,
+        status: null,
+        beginTime: null,
+        endTime: null
+      }
+      this.createTimeRange = null
+    },
+    getLiveList() {
+      const params = { ...this.liveQueryParams }
+      if (this.createTimeRange && this.createTimeRange.length === 2) {
+        params.beginTime = this.createTimeRange[0]
+        params.endTime = this.createTimeRange[1]
+      }
+      this.liveListLoading = true
+      let req
+      if (this.trainingPeriodId != null && this.trainingPeriodId !== '') {
+        const q = { ...params, trainingPeriodId: Number(this.trainingPeriodId) }
+        if (this.trainingCampCompanyId != null && this.trainingCampCompanyId !== '') {
+          q.companyId = Number(this.trainingCampCompanyId)
+        }
+        req = listTrainingLive(q)
+      } else {
+        req = listLive(params)
+      }
+      req.then(res => {
+        this.liveList = res.rows || []
+        this.liveTotal = res.total || 0
+        this.liveListLoading = false
+        this.$nextTick(() => this.setTableSelection())
+      }).catch(() => {
+        this.liveListLoading = false
+      })
+    },
+    handleSelectionChange(selection) {
+      if (selection && selection.length > 15) {
+        this.$message.warning('最多只能选择15个直播间')
+        this.$nextTick(() => this.setTableSelection())
+      } else {
+        this.selectedLiveList = selection || []
+      }
+    },
+    setTableSelection() {
+      this.$nextTick(() => {
+        const table = this.$refs.liveTable
+        if (!table || !this.liveList.length) return
+        const selectedIds = new Set(this.selectedLiveList.map(l => l.liveId))
+        this.liveList.forEach(row => {
+          table.toggleRowSelection(row, selectedIds.has(row.liveId))
+        })
+      })
+    },
+    confirmSelectLive() {
+      this.liveSelectDialogVisible = false
+      // 默认不选直播名称=展示全部已选直播间的人数
+    },
+    formatProductMoney(val) {
+      if (val === undefined || val === null) return '¥0.00'
+      const num = typeof val === 'number' ? val : parseFloat(val)
+      return '¥' + (isNaN(num) ? '0.00' : num.toFixed(2))
+    },
+    fetchLiveProductsForSelectedLives() {
+      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.liveProductOptions = []
+        return
+      }
+      const promises = liveIds.map(liveId => listLiveGoods({ liveId }).then(res => (res.rows || res.data?.rows || [])))
+      Promise.all(promises).then(results => {
+        const productMap = new Map()
+        results.flat().forEach(item => {
+          const pid = item.productId
+          if (pid && !productMap.has(pid)) {
+            productMap.set(pid, { productId: pid, productName: item.productName || item.name || '未知商品' })
+          }
+        })
+        this.liveProductOptions = Array.from(productMap.values())
+      }).catch(() => {
+        this.liveProductOptions = []
+      })
+    },
+    fetchProductCompareList() {
+      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.$message.warning('请先选择直播间')
+        return
+      }
+      const params = {
+        liveIds,
+        productIds: this.productCompareQueryParams.productIds,
+        pageNum: this.productCompareQueryParams.pageNum,
+        pageSize: this.productCompareQueryParams.pageSize
+      }
+      this.productCompareLoading = true
+      listProductCompareStats(params).then(res => {
+        this.productCompareList = res.rows || res.data?.rows || []
+        this.productCompareTotal = res.total || res.data?.total || 0
+        this.productCompareLoading = false
+      }).catch(() => {
+        this.productCompareLoading = false
+      })
+    },
+    resetProductCompareQuery() {
+      this.productCompareQueryParams.productIds = []
+      this.productCompareQueryParams.pageNum = 1
+      this.productCompareQueryParams.pageSize = 10
+      this.fetchProductCompareList()
+    },
+    fetchInviteSalesOptions() {
+      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.inviteSalesOptions = []
+        return
+      }
+      listInviteSalesOptions(liveIds).then(res => {
+        const data = res.data || res || []
+        this.inviteSalesOptions = Array.isArray(data) ? data : []
+      }).catch(() => {
+        this.inviteSalesOptions = []
+      })
+    },
+    fetchInviteCompareList() {
+      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.$message.warning('请先选择直播间')
+        return
+      }
+      const rawIds = this.inviteCompareQueryParams.companyUserIds || []
+      const companyUserIds = rawIds.map(v => {
+        if (typeof v === 'string' && v.includes('_')) {
+          return parseInt(v.split('_')[1], 10)
+        }
+        return typeof v === 'number' ? v : parseInt(v, 10)
+      }).filter(id => !isNaN(id))
+      const params = {
+        liveIds,
+        companyUserIds: companyUserIds.length ? companyUserIds : undefined,
+        pageNum: this.inviteCompareQueryParams.pageNum,
+        pageSize: this.inviteCompareQueryParams.pageSize
+      }
+      this.inviteCompareLoading = true
+      listInviteCompareStats(params).then(res => {
+        this.inviteCompareList = res.rows || res.data?.rows || []
+        this.inviteCompareTotal = res.total || res.data?.total || 0
+        this.inviteCompareLoading = false
+      }).catch(() => {
+        this.inviteCompareLoading = false
+      })
+    },
+    resetInviteCompareQuery() {
+      this.inviteCompareQueryParams.companyUserIds = []
+      this.inviteCompareQueryParams.pageNum = 1
+      this.inviteCompareQueryParams.pageSize = 10
+      this.fetchInviteCompareList()
+    },
+    fetchOverview() {
+      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.overviewData = {}
+        this.trendChartData = { xAxis: [], series: [] }
+        if (this.trendChart) {
+          this.trendChart.dispose()
+          this.trendChart = null
+        }
+        return
+      }
+      this.overviewLoading = true
+      getLiveStatisticsOverview(liveIds).then(res => {
+        this.overviewData = res.data || res || {}
+        this.overviewLoading = false
+        this.fetchTrend()
+        // 直播间学员:默认不选直播名称,自动查询全部已选直播间的用户
+        this.studentQueryParams.liveIds = []
+        this.studentFirstEntryRange = null
+        this.studentQueryParams.pageNum = 1
+        this.fetchStudentList()
+        // 商品对比:加载已选直播间的带货产品
+        this.fetchLiveProductsForSelectedLives()
+        // 邀课对比:加载已选直播间的分享人选项
+        this.fetchInviteSalesOptions()
+      }).catch(() => {
+        this.overviewLoading = false
+      })
+    },
+    fetchTrend() {
+      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.trendChartData = this.getDefaultTrendData()
+        this.$nextTick(() => {
+          setTimeout(() => this.renderTrendChart(), 150)
+        })
+        return
+      }
+      this.trendLoading = true
+      getLiveEntryTrend(liveIds).then(res => {
+        // 兼容多种返回格式: { code, msg, data: { xAxis, series } } 或 { data: [series] } 或 { xAxis, series }
+        const body = res && typeof res === 'object' && res.data !== undefined ? res.data : res
+        let xAxis = []
+        let series = []
+        const dataObj = body && typeof body === 'object' ? body : null
+        if (Array.isArray(body) && body.length > 0) {
+          series = body
+        } else if (dataObj && Array.isArray(dataObj.series) && dataObj.series.length > 0) {
+          xAxis = Array.isArray(dataObj.xAxis) ? dataObj.xAxis : []
+          series = dataObj.series
+        } else if (dataObj && Array.isArray(dataObj.data) && dataObj.data.length > 0) {
+          series = dataObj.data
+        } else if (dataObj && Array.isArray(dataObj.rows) && dataObj.rows.length > 0) {
+          series = dataObj.rows
+        }
+        series = Array.isArray(series) ? series : []
+        if (series.length && (!Array.isArray(xAxis) || !xAxis.length) && series[0] && Array.isArray(series[0].data)) {
+          const len = series[0].data.length
+          xAxis = ['开播前']
+          for (let m = 0; m < len - 1; m++) xAxis.push((m * 5) + 'min')
+        }
+        if (!series.length) {
+          this.trendChartData = this.getDefaultTrendData()
+        } else {
+          if (!xAxis.length) xAxis = this.getDefaultTrendData().xAxis
+          this.trendChartData = { xAxis, series }
+        }
+        this.trendLoading = false
+        this.$nextTick(() => {
+          setTimeout(() => {
+            this.renderTrendChart()
+            this.$nextTick(() => { if (this.trendChart) this.trendChart.resize() })
+          }, 80)
+        })
+      }).catch(() => {
+        this.trendChartData = this.getDefaultTrendData()
+        this.trendLoading = false
+        this.$nextTick(() => {
+          setTimeout(() => {
+            this.renderTrendChart()
+            this.$nextTick(() => {
+              if (this.trendChart) this.trendChart.resize()
+            })
+          }, 150)
+        })
+      })
+    },
+    getDefaultTrendData() {
+      const xAxis = ['开播前']
+      for (let m = 0; m <= 240; m += 5) {
+        xAxis.push(m + 'min')
+      }
+      const zeros = xAxis.map(() => 0)
+      return {
+        xAxis,
+        series: [{ name: '进入人数', data: zeros }]
+      }
+    },
+    renderTrendChart() {
+      const el = this.$refs.trendChartRef
+      if (!el) return
+      let xAxis = this.trendChartData.xAxis && Array.isArray(this.trendChartData.xAxis) ? this.trendChartData.xAxis : []
+      const series = this.trendChartData.series && Array.isArray(this.trendChartData.series) ? this.trendChartData.series : []
+      if (!series.length) return
+      if (!xAxis.length && series[0] && series[0].data) {
+        const len = series[0].data.length
+        xAxis = ['开播前']
+        for (let m = 0; m < len - 1; m++) xAxis.push((m * 5) + 'min')
+      }
+      if (!xAxis.length) xAxis = this.getDefaultTrendData().xAxis
+      if (this.trendChart) this.trendChart.dispose()
+      this.trendChart = echarts.init(el)
+      const option = {
+        title: { text: '相对直播开始时间的累计进入人数', left: 'center' },
+        tooltip: { trigger: 'axis' },
+        legend: { bottom: 0, type: 'scroll' },
+        grid: { left: '3%', right: '4%', bottom: '15%', top: 40, containLabel: true },
+        xAxis: { type: 'category', boundaryGap: false, data: xAxis },
+        yAxis: { type: 'value', name: '累计进入人数' },
+        series: series.map((s, i) => ({
+          name: s.name,
+          type: 'line',
+          smooth: true,
+          data: s.data || []
+        }))
+      }
+      this.trendChart.setOption(option)
+      this.$nextTick(() => {
+        if (this.trendChart) this.trendChart.resize()
+      })
+    },
+    handleTrendChartResize() {
+      if (this.trendChart) this.trendChart.resize()
+    },
+    fetchStudentList() {
+      const liveIds = this.studentQueryParams.liveIds && this.studentQueryParams.liveIds.length > 0
+        ? this.studentQueryParams.liveIds
+        : (this.selectedLiveList || []).map(l => l.liveId)
+      if (!liveIds.length) {
+        this.studentList = []
+        this.studentTotal = 0
+        return
+      }
+      const params = {
+        liveIds,
+        pageNum: this.studentQueryParams.pageNum || 1,
+        pageSize: this.studentQueryParams.pageSize || 10
+      }
+      if (this.studentFirstEntryRange && this.studentFirstEntryRange.length === 2) {
+        params.firstEntryTimeBegin = this.studentFirstEntryRange[0]
+        params.firstEntryTimeEnd = this.studentFirstEntryRange[1]
+      }
+      this.studentLoading = true
+      listLiveRoomStudents(params).then(res => {
+        this.studentList = res.rows || []
+        this.studentTotal = res.total || 0
+        this.studentLoading = false
+      }).catch(() => {
+        this.studentLoading = false
+      })
+    },
+    resetStudentQuery() {
+      this.studentQueryParams.liveIds = []
+      this.studentQueryParams.pageNum = 1
+      this.studentQueryParams.pageSize = 10
+      this.studentFirstEntryRange = null
+      this.fetchStudentList()
+    },
+    formatOverviewValue(val) {
+      if (val === undefined || val === null) return '0'
+      if (typeof val === 'number' && !Number.isInteger(val)) return Number(val).toFixed(2)
+      return String(val)
+    },
+    measureNavHeight() {
+      const nav = this.$refs.statsNavBar
+      if (nav) {
+        this.navHeight = nav.offsetHeight || 48
+      }
+    },
+    handleNavScroll() {
+      const nav = this.$refs.statsNavBar
+      const card = this.$refs.statsCard
+      if (!nav || !card) return
+
+      if (this.navFixed) {
+        const spacer = this.$refs.statsNavSpacer
+        if (spacer) {
+          const rect = spacer.getBoundingClientRect()
+          if (rect.top >= this.headerOffset) {
+            this.navFixed = false
+            this.navFixedStyle = {}
+          }
+        }
+      } else {
+        const rect = nav.getBoundingClientRect()
+        if (rect.top <= this.headerOffset) {
+          const cardRect = card.$el ? card.$el.getBoundingClientRect() : card.getBoundingClientRect()
+          this.navFixedStyle = {
+            left: cardRect.left + 'px',
+            top: this.headerOffset + 'px',
+            width: cardRect.width + 'px'
+          }
+          this.navFixed = true
+        }
+      }
+    },
+    scrollToSection(key) {
+      this.activeNav = key
+      const refMap = {
+        overview: 'sectionOverview',
+        trend: 'sectionTrend',
+        student: 'sectionStudent',
+        compare: 'sectionCompare'
+      }
+      const el = this.$refs[refMap[key]]
+      if (el && el.$el) {
+        el.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
+      } else if (el) {
+        el.scrollIntoView({ behavior: 'smooth', block: 'start' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.live-select-input {
+  display: flex;
+  align-items: flex-start;
+  width: 100%;
+  min-width: 200px;
+  min-height: 32px;
+  padding: 6px 12px;
+  border: 1px solid #DCDFE6;
+  border-radius: 4px;
+  cursor: pointer;
+  background: #fff;
+  overflow: hidden;
+}
+.live-select-input:hover {
+  border-color: #C0C4CC;
+}
+.live-select-content {
+  flex: 1;
+  display: flex;
+  flex-wrap: wrap;
+  align-content: flex-start;
+  align-items: center;
+  gap: 6px 8px;
+  min-height: 22px;
+  min-width: 0;
+  overflow: hidden;
+}
+.live-select-content .placeholder {
+  color: #C0C4CC;
+  font-size: 14px;
+}
+.live-tag {
+  display: inline-block;
+  padding: 0 8px;
+  height: 22px;
+  line-height: 22px;
+  font-size: 12px;
+  color: #606266;
+  background: #f4f4f5;
+  border-radius: 4px;
+}
+.tag-sep {
+  color: #909399;
+  margin: 0 2px;
+}
+.fold-text {
+  font-size: 12px;
+  color: #909399;
+}
+.live-select-suffix {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding-left: 10px;
+}
+.live-select-suffix i {
+  color: #C0C4CC;
+  font-size: 14px;
+  cursor: pointer;
+}
+.live-select-suffix i:hover {
+  color: #909399;
+}
+.stats-main {
+  padding-top: 0;
+}
+.stats-nav-wrapper {
+  position: relative;
+}
+.stats-nav-spacer {
+  flex-shrink: 0;
+}
+.stats-nav-bar {
+  position: relative;
+  z-index: 100;
+  display: flex;
+  width: 100%;
+  flex: 1 1 auto;
+  background: #fff;
+  border-bottom: 1px solid #ebeef5;
+  margin: 0 -20px 20px -20px;
+  padding: 0;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.03);
+}
+.stats-nav-bar.is-fixed {
+  position: fixed;
+  margin: 0;
+  left: 0;
+  right: 0;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+}
+.stats-nav-bar .nav-item {
+  flex: 1;
+  text-align: center;
+  padding: 14px 12px;
+  font-size: 14px;
+  color: #606266;
+  cursor: pointer;
+  border-bottom: 2px solid transparent;
+  margin-bottom: -1px;
+  transition: all 0.2s;
+}
+.stats-nav-bar .nav-item:hover {
+  color: #409EFF;
+}
+.stats-nav-bar .nav-item.active {
+  color: #409EFF;
+  font-weight: 500;
+  border-bottom-color: #409EFF;
+}
+.overview-content {
+  min-height: 120px;
+}
+.overview-grid {
+  margin-top: 8px;
+}
+.overview-item {
+  padding: 12px 16px;
+  margin-bottom: 12px;
+  background: #fafafa;
+  border-radius: 4px;
+  border-left: 3px solid #409EFF;
+  border: 1px solid #ebeef5;
+}
+.overview-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 6px;
+  line-height: 1.4;
+}
+.overview-value {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+.overview-block {
+  margin-bottom: 24px;
+}
+.overview-block:last-child {
+  margin-bottom: 0;
+}
+.overview-block-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #ebeef5;
+}
+.stats-section-card {
+  margin-bottom: 30px;
+  /* 固定导航时:顶部 84px + 导航栏 ~48px ≈ 132,留足空间避免遮挡 */
+  scroll-margin-top: 140px;
+}
+.stats-section-card:last-child {
+  margin-bottom: 0;
+}
+.stats-section {
+  min-height: 300px;
+  padding: 20px 0;
+}
+.stats-section .section-title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  padding-left: 10px;
+  border-bottom: 1px solid #ebeef5;
+  border-left: 3px solid #67C23A;
+}
+.box-card {
+  margin-bottom: 20px;
+}
+.filter-form {
+  margin-bottom: 16px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #ebeef5;
+}
+.dialog-footer {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 12px;
+}
+.dialog-footer .selected-count {
+  margin-right: auto;
+  font-size: 13px;
+  color: #606266;
+}
+.trend-chart-wrap {
+  min-height: 320px;
+}
+.trend-chart {
+  width: 100%;
+  height: 360px;
+}
+.student-section {
+  min-height: 200px;
+}
+.student-filter {
+  margin-bottom: 16px;
+}
+.user-name-cell {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+}
+.user-name-cell .user-name-text {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.overview-content {
+  min-height: 120px;
+}
+.overview-grid {
+  margin-top: 0;
+}
+.overview-item {
+  padding: 12px 16px;
+  margin-bottom: 12px;
+  background: #fafafa;
+  border-radius: 4px;
+  border: 1px solid #ebeef5;
+}
+.overview-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+  line-height: 1.4;
+}
+.overview-value {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+.compare-tabs-wrap {
+  padding-top: 4px;
+}
+.compare-tabs {
+  margin-bottom: 16px;
+}
+.compare-panel {
+  min-height: 200px;
+}
+</style>

+ 3 - 1166
src/views/live/liveStatistics/index.vue

@@ -1,1175 +1,12 @@
 <template>
-  <div class="app-container">
-    <!-- 左上角:选择直播间(多选,最多展示8个,多余折叠)+ 查询按钮 -->
-    <el-row :gutter="10" class="mb8">
-      <el-col :span="1.5">
-        <div
-          class="live-select-input"
-          @click="openLiveSelectDialog">
-          <div class="live-select-content">
-            <template v-if="selectedLiveList.length === 0">
-              <span class="placeholder">请选择直播间</span>
-            </template>
-            <template v-else>
-              <template v-for="(item, idx) in displayedLives">
-                <span :key="item.liveId" class="live-tag">{{ item.liveName }}({{ item.liveId }})</span>
-                <span v-if="idx < displayedLives.length - 1 || selectedLiveList.length > 7" class="tag-sep">、</span>
-              </template>
-              <span v-if="selectedLiveList.length > 7" class="fold-text">等{{ selectedLiveList.length }}个</span>
-            </template>
-          </div>
-          <div class="live-select-suffix">
-            <i v-if="selectedLiveList.length > 0" class="el-icon-circle-close" @click.stop="handleClearLive"></i>
-            <i class="el-icon-arrow-down"></i>
-          </div>
-        </div>
-      </el-col>
-      <el-col :span="1">
-        <el-button
-          type="primary"
-          icon="el-icon-search"
-          size="small"
-          :loading="overviewLoading"
-          :disabled="selectedLiveList.length === 0"
-          @click="fetchOverview">
-          查询
-        </el-button>
-      </el-col>
-    </el-row>
-
-    <el-card ref="statsCard" class="box-card" shadow="never">
-      <div class="stats-main">
-        <!-- 固定导航栏:滚动时固定在顶部 -->
-        <div class="stats-nav-wrapper">
-          <div v-if="navFixed" ref="statsNavSpacer" class="stats-nav-spacer" :style="{ height: navHeight + 'px' }"/>
-          <div
-            ref="statsNavBar"
-            :class="['stats-nav-bar', { 'is-fixed': navFixed }]"
-            :style="navFixed ? navFixedStyle : {}">
-            <span
-              v-for="item in navItems"
-              :key="item.key"
-              :class="['nav-item', { active: activeNav === item.key }]"
-              @click="scrollToSection(item.key)">
-              {{ item.label }}
-            </span>
-          </div>
-        </div>
-        <!-- 四个板块竖排展示,每个板块独立卡片样式 -->
-        <el-card ref="sectionOverview" class="stats-section-card" shadow="never" data-section="overview">
-          <div slot="header" class="section-title">数据概览</div>
-          <div v-loading="overviewLoading" class="overview-content">
-            <template >
-              <!-- 直播数据 -->
-              <div class="overview-block">
-                <div class="overview-block-title">直播数据</div>
-                <el-row :gutter="16" class="overview-grid">
-                  <el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="item in overviewItems" :key="item.key">
-                    <div class="overview-item">
-                      <div class="overview-label">{{ item.label }}</div>
-                      <div class="overview-value">{{ formatOverviewValue(overviewData[item.key]) }}</div>
-                    </div>
-                  </el-col>
-                </el-row>
-              </div>
-              <!-- 互动带货数据 -->
-              <div class="overview-block">
-                <div class="overview-block-title">互动带货数据</div>
-                <el-row :gutter="16" class="overview-grid">
-                  <el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="item in interactionItems" :key="item.key">
-                    <div class="overview-item">
-                      <div class="overview-label">{{ item.label }}</div>
-                      <div class="overview-value">{{ formatOverviewValue(overviewData[item.key]) }}</div>
-                    </div>
-                  </el-col>
-                </el-row>
-              </div>
-            </template>
-          </div>
-        </el-card>
-        <el-card ref="sectionTrend" class="stats-section-card" shadow="never" data-section="trend">
-          <div slot="header" class="section-title">直播趋势</div>
-          <div v-loading="trendLoading" class="trend-chart-wrap">
-            <div  ref="trendChartRef" class="trend-chart"></div>
-          </div>
-        </el-card>
-        <el-card ref="sectionStudent" class="stats-section-card" shadow="never" data-section="student">
-          <div slot="header" class="section-title">直播间学员</div>
-          <div class="student-section">
-            <el-form :model="studentQueryParams" :inline="true" class="filter-form student-filter">
-              <el-form-item label="直播名称">
-                <el-select
-                  v-model="studentQueryParams.liveIds"
-                  multiple
-                  collapse-tags
-                  placeholder="不选则展示全部已选直播间"
-                  size="small"
-                  clearable
-                  style="width: 280px;">
-                  <el-option
-                    v-for="item in selectedLiveList"
-                    :key="item.liveId"
-                    :label="item.liveName + '(' + item.liveId + ')'"
-                    :value="item.liveId"/>
-                </el-select>
-              </el-form-item>
-              <el-form-item label="首次访问时间">
-                <el-date-picker
-                  v-model="studentFirstEntryRange"
-                  type="datetimerange"
-                  range-separator="至"
-                  start-placeholder="开始时间"
-                  end-placeholder="结束时间"
-                  value-format="yyyy-MM-dd HH:mm:ss"
-                  size="small"
-                  style="width: 340px;">
-                </el-date-picker>
-              </el-form-item>
-              <el-form-item>
-                <el-button type="primary" icon="el-icon-search" size="small" :loading="studentLoading" @click="fetchStudentList">查询</el-button>
-                <el-button icon="el-icon-refresh" size="small" @click="resetStudentQuery">重置</el-button>
-              </el-form-item>
-            </el-form>
-            <el-table
-              v-loading="studentLoading"
-              :data="studentList"
-              border
-              style="width: 100%">
-              <el-table-column label="用户名" align="center" min-width="160">
-                <template slot-scope="scope">
-                  <div class="user-name-cell">
-                    <el-avatar :size="28" :src="scope.row.avatar" icon="el-icon-user-solid"/>
-                    <el-tooltip :content="scope.row.userName" placement="top">
-                      <span class="user-name-text">{{ scope.row.userName && scope.row.userName.length > 8 ? scope.row.userName.substring(0, 8) + '...' : scope.row.userName }}</span>
-                    </el-tooltip>
-                  </div>
-                </template>
-              </el-table-column>
-              <el-table-column label="直播间名称" align="center" prop="liveName" min-width="150" show-overflow-tooltip/>
-              <el-table-column label="销售名称" align="center" prop="salesName" min-width="100" show-overflow-tooltip/>
-              <el-table-column label="用户创建时间" align="center" prop="userCreateTime" width="170"/>
-              <el-table-column label="联系方式" align="center" prop="contact" width="140"/>
-            </el-table>
-            <pagination
-              v-show="studentTotal > 0"
-              :total="studentTotal"
-              :page.sync="studentQueryParams.pageNum"
-              :limit.sync="studentQueryParams.pageSize"
-              :auto-scroll="false"
-              @pagination="fetchStudentList"
-            />
-          </div>
-        </el-card>
-        <el-card ref="sectionCompare" class="stats-section-card" shadow="never" data-section="compare">
-          <div slot="header" class="section-title">数据对比</div>
-          <div class="compare-tabs-wrap">
-            <el-radio-group v-model="compareTab" size="small" class="compare-tabs">
-              <el-radio-button label="invite">邀课对比</el-radio-button>
-              <el-radio-button label="product">商品对比</el-radio-button>
-            </el-radio-group>
-            <!-- 邀课对比 -->
-            <div v-show="compareTab === 'invite'" class="compare-panel">
-              <el-form :model="inviteCompareQueryParams" :inline="true" class="filter-form">
-                <el-form-item label="分享人">
-                  <el-select
-                    v-model="inviteCompareQueryParams.companyUserIds"
-                    multiple
-                    collapse-tags
-                    placeholder="请选择分享人(需先选择直播间并查询)"
-                    size="small"
-                    clearable
-                    style="width: 400px;">
-                    <el-option
-                      v-for="item in inviteSalesOptions"
-                      :key="(item.companyId || '') + '_' + (item.companyUserId || '')"
-                      :label="item.salesName + (item.companyName ? '(' + item.companyName + ')' : '')"
-                      :value="(item.companyId || '') + '_' + (item.companyUserId || '')"/>
-                  </el-select>
-                </el-form-item>
-                <el-form-item>
-                  <el-button type="primary" icon="el-icon-search" size="small" :loading="inviteCompareLoading" @click="fetchInviteCompareList">查询</el-button>
-                  <el-button icon="el-icon-refresh" size="small" @click="resetInviteCompareQuery">重置</el-button>
-                </el-form-item>
-              </el-form>
-              <el-table
-                v-loading="inviteCompareLoading"
-                :data="inviteCompareList"
-                border
-                style="width: 100%">
-                <el-table-column label="归属公司" align="center" prop="companyName" min-width="140" show-overflow-tooltip/>
-                <el-table-column label="销售名称" align="center" prop="salesName" min-width="120" show-overflow-tooltip/>
-                <el-table-column label="邀请人数" align="center" prop="inviteCount" width="120"/>
-                <el-table-column label="已支付订单数" align="center" prop="paidOrderCount" width="140"/>
-                <el-table-column label="订单总金额" align="center" prop="totalGmv" width="140">
-                  <template slot-scope="scope">
-                    {{ formatProductMoney(scope.row.totalGmv) }}
-                  </template>
-                </el-table-column>
-              </el-table>
-              <pagination
-                v-show="inviteCompareTotal > 0"
-                :total="inviteCompareTotal"
-                :page.sync="inviteCompareQueryParams.pageNum"
-                :limit.sync="inviteCompareQueryParams.pageSize"
-                :auto-scroll="false"
-                @pagination="fetchInviteCompareList"
-              />
-            </div>
-            <!-- 商品对比 -->
-            <div v-show="compareTab === 'product'" class="compare-panel">
-              <el-form :model="productCompareQueryParams" :inline="true" class="filter-form">
-                <el-form-item label="直播间带货的产品">
-                  <el-select
-                    v-model="productCompareQueryParams.productIds"
-                    multiple
-                    collapse-tags
-                    placeholder="请选择商品(需先选择直播间并查询)"
-                    size="small"
-                    clearable
-                    style="width: 400px;">
-                    <el-option
-                      v-for="item in liveProductOptions"
-                      :key="item.productId"
-                      :label="item.productName"
-                      :value="item.productId"/>
-                  </el-select>
-                </el-form-item>
-                <el-form-item>
-                  <el-button type="primary" icon="el-icon-search" size="small" :loading="productCompareLoading" @click="fetchProductCompareList">查询</el-button>
-                  <el-button icon="el-icon-refresh" size="small" @click="resetProductCompareQuery">重置</el-button>
-                </el-form-item>
-              </el-form>
-              <el-table
-                v-loading="productCompareLoading"
-                :data="productCompareList"
-                border
-                style="width: 100%">
-                <el-table-column label="商品名称" align="center" prop="productName" min-width="180" show-overflow-tooltip/>
-                <el-table-column label="下单未支付人数" align="center" prop="unpaidUserCount" width="140"/>
-                <el-table-column label="成交人数" align="center" prop="paidUserCount" width="120"/>
-                <el-table-column label="成交金额" align="center" prop="totalGmv" width="140">
-                  <template slot-scope="scope">
-                    {{ formatProductMoney(scope.row.totalGmv) }}
-                  </template>
-                </el-table-column>
-              </el-table>
-              <pagination
-                v-show="productCompareTotal > 0"
-                :total="productCompareTotal"
-                :page.sync="productCompareQueryParams.pageNum"
-                :limit.sync="productCompareQueryParams.pageSize"
-                :auto-scroll="false"
-                @pagination="fetchProductCompareList"
-              />
-            </div>
-          </div>
-        </el-card>
-      </div>
-    </el-card>
-
-    <!-- 选择直播间弹窗 -->
-    <el-dialog
-      title="选择直播间"
-      :visible.sync="liveSelectDialogVisible"
-      width="900px"
-      append-to-body
-      :close-on-click-modal="false"
-      @open="handleDialogOpen"
-      @close="handleDialogClose">
-      <!-- 上面:筛选条件 -->
-      <el-form :model="liveQueryParams" ref="liveQueryForm" :inline="true" label-width="100px" class="filter-form">
-        <el-form-item label="直播名称" prop="liveName">
-          <el-input
-            v-model="liveQueryParams.liveName"
-            placeholder="请输入直播名称"
-            clearable
-            size="small"
-            style="width: 160px;"
-            @keyup.enter.native="handleLiveSearch"/>
-        </el-form-item>
-        <el-form-item label="直播时间" prop="createTimeRange">
-          <el-date-picker
-            v-model="createTimeRange"
-            type="datetimerange"
-            range-separator="至"
-            start-placeholder="开始时间"
-            end-placeholder="结束时间"
-            value-format="yyyy-MM-dd HH:mm:ss"
-            size="small"
-            style="width: 340px;">
-          </el-date-picker>
-        </el-form-item>
-        <el-form-item label="公司名称" prop="companyName">
-          <el-input
-            v-model="liveQueryParams.companyName"
-            placeholder="请输入公司名称"
-            clearable
-            size="small"
-            style="width: 160px;"
-            @keyup.enter.native="handleLiveSearch"/>
-        </el-form-item>
-        <el-form-item label="直播状态" prop="status">
-          <el-select v-model="liveQueryParams.status" placeholder="请选择" clearable size="small" style="width: 120px;">
-            <el-option label="待直播" :value="1"></el-option>
-            <el-option label="直播中" :value="2"></el-option>
-            <el-option label="已结束" :value="3"></el-option>
-            <el-option label="直播回放中" :value="4"></el-option>
-          </el-select>
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" icon="el-icon-search" size="small" @click="handleLiveSearch">搜索</el-button>
-          <el-button icon="el-icon-refresh" size="small" @click="resetLiveQuery">重置</el-button>
-        </el-form-item>
-      </el-form>
-
-      <!-- 下面:展示列表(多选,支持翻页继续选择) -->
-      <el-table
-        ref="liveTable"
-        v-loading="liveListLoading"
-        :data="liveList"
-        row-key="liveId"
-        @selection-change="handleSelectionChange"
-        border
-        max-height="400">
-        <el-table-column type="selection" width="55" align="center" reserve-selection />
-        <el-table-column label="直播ID" align="center" prop="liveId" width="100"/>
-        <el-table-column label="直播名称" align="center" prop="liveName" min-width="150" show-overflow-tooltip/>
-        <el-table-column label="直播状态" align="center" prop="status" width="120">
-          <template slot-scope="scope">
-            <el-tag v-if="scope.row.status === 1" size="small">待直播</el-tag>
-            <el-tag v-else-if="scope.row.status === 2" type="success" size="small">直播中</el-tag>
-            <el-tag v-else-if="scope.row.status === 3" type="info" size="small">已结束</el-tag>
-            <el-tag v-else-if="scope.row.status === 4" type="warning" size="small">直播回放中</el-tag>
-            <span v-else>---</span>
-          </template>
-        </el-table-column>
-        <el-table-column label="直播公司" align="center" prop="companyName" min-width="120" show-overflow-tooltip>
-          <template slot-scope="scope">
-            {{ scope.row.companyName || '总台' }}
-          </template>
-        </el-table-column>
-      </el-table>
-
-      <pagination
-        v-show="liveTotal > 0"
-        :total="liveTotal"
-        :page.sync="liveQueryParams.pageNum"
-        :limit.sync="liveQueryParams.pageSize"
-        @pagination="getLiveList"
-      />
-
-      <div slot="footer" class="dialog-footer">
-        <span class="selected-count">已选 {{ selectedLiveList.length }}/15 个</span>
-        <el-button @click="liveSelectDialogVisible = false">取 消</el-button>
-        <el-button type="primary" @click="confirmSelectLive">确 定</el-button>
-      </div>
-    </el-dialog>
-  </div>
+  <live-statistics-panel />
 </template>
 
 <script>
-import * as echarts from 'echarts'
-import { listLive } from '@/api/live/live'
-import { getLiveStatisticsOverview, getLiveEntryTrend, listLiveRoomStudents, listProductCompareStats, listInviteSalesOptions, listInviteCompareStats } from '@/api/live/liveData'
-import { listLiveGoods } from '@/api/live/liveGoods'
+import LiveStatisticsPanel from './LiveStatisticsPanel.vue'
 
 export default {
   name: 'LiveStatistics',
-  data() {
-    return {
-      // 已选择的直播间(多选)
-      selectedLiveList: [],
-      // 选择弹窗
-      liveSelectDialogVisible: false,
-      liveListLoading: false,
-      liveList: [],
-      liveTotal: 0,
-      // 直播间查询参数
-      liveQueryParams: {
-        pageNum: 1,
-        pageSize: 10,
-        liveName: null,
-        companyName: null,
-        status: null,
-        beginTime: null,
-        endTime: null
-      },
-      // 创建时间范围(用于日期选择器双向绑定)
-      createTimeRange: null,
-      // 当前激活的导航
-      activeNav: 'overview',
-      // 导航配置
-      navItems: [
-        { key: 'overview', label: '数据概览' },
-        { key: 'trend', label: '直播趋势' },
-        { key: 'student', label: '直播间学员' },
-        { key: 'compare', label: '数据对比' }
-      ],
-      // 导航栏固定
-      navFixed: false,
-      navHeight: 48,
-      navFixedStyle: {},
-      // 数据概览
-      overviewData: {},
-      overviewLoading: false,
-      // 直播趋势折线图
-      trendLoading: false,
-      trendChartData: { xAxis: [], series: [] },
-      trendChart: null,
-      // 直播间学员
-      studentList: [],
-      studentTotal: 0,
-      studentLoading: false,
-      studentQueryParams: {
-        liveIds: [],
-        pageNum: 1,
-        pageSize: 10
-      },
-      studentFirstEntryRange: null,
-      // 数据对比切换 tab:invite=邀课对比,product=商品对比
-      compareTab: 'invite',
-      // 商品对比(嵌入数据对比)
-      liveProductOptions: [],
-      productCompareList: [],
-      productCompareTotal: 0,
-      productCompareLoading: false,
-      productCompareQueryParams: {
-        productIds: [],
-        liveIds: [],
-        pageNum: 1,
-        pageSize: 10
-      },
-      // 邀课对比(嵌入数据对比)
-      inviteSalesOptions: [],
-      inviteCompareList: [],
-      inviteCompareTotal: 0,
-      inviteCompareLoading: false,
-      inviteCompareQueryParams: {
-        companyUserIds: [],
-        pageNum: 1,
-        pageSize: 10
-      }
-    }
-  },
-  computed: {
-    overviewItems() {
-      return [
-        { key: 'beforeLiveUv', label: '开播前访问人数(uv)' },
-        { key: 'totalWatchUv', label: '累计观看人数(uv)' },
-        { key: 'over10MinCount', label: '停留时长超过10分钟人数' },
-        { key: 'totalWatchMinutes', label: '总观看时长(分钟)' },
-        { key: 'avgWatchMinutes', label: '平均观看时长(分钟)' },
-        { key: 'replayWatchUv', label: '累计回放观看人数(uv)' },
-        { key: 'replayVisitPv', label: '累计回放访问人数(pv)' },
-        { key: 'replayOnlyCount', label: '仅看过回放的人数' },
-        { key: 'replayTotalMinutes', label: '回放总观看时长(分钟)' },
-        { key: 'replayAvgMinutes', label: '回放平均观看时长(分钟)' },
-        { key: 'completeCount', label: '完课人数' },
-        { key: 'subscribeCount', label: '预约人数' }
-      ]
-    },
-    interactionItems() {
-      return [
-        { key: 'lotteryCount', label: '抽奖次数' },
-        { key: 'lotteryJoinCount', label: '参与抽奖人数' },
-        { key: 'lotteryWinCount', label: '中奖人数' },
-        { key: 'paidUserCount', label: '支付点击人数' },
-        { key: 'unpaidUserCount', label: '未支付人数' },
-        { key: 'totalGmv', label: '总成交金额' },
-        { key: 'totalProductQty', label: '商品总销量' }
-      ]
-    },
-    headerOffset() {
-      const fixed = this.$store.state.settings.fixedHeader
-      const tagsView = this.$store.state.settings.tagsView
-      if (fixed && tagsView) return 84
-      if (fixed) return 50
-      return 0
-    },
-    displayedLives() {
-      return this.selectedLiveList.slice(0, 7)
-    },
-    hasTrendData() {
-      return this.trendChartData.xAxis && this.trendChartData.xAxis.length > 0
-    }
-  },
-  mounted() {
-    this.$nextTick(() => this.measureNavHeight())
-    window.addEventListener('scroll', this.handleNavScroll, { passive: true })
-    window.addEventListener('resize', this.handleNavScroll)
-    window.addEventListener('resize', this.handleTrendChartResize)
-    this.trendChartData = this.getDefaultTrendData()
-    this.$nextTick(() => setTimeout(() => this.renderTrendChart(), 100))
-  },
-  beforeDestroy() {
-    window.removeEventListener('scroll', this.handleNavScroll)
-    window.removeEventListener('resize', this.handleNavScroll)
-    window.removeEventListener('resize', this.handleTrendChartResize)
-    if (this.trendChart) {
-      this.trendChart.dispose()
-      this.trendChart = null
-    }
-  },
-  methods: {
-    openLiveSelectDialog() {
-      this.liveSelectDialogVisible = true
-    },
-    handleClearLive() {
-      this.selectedLiveList = []
-      this.studentList = []
-      this.studentTotal = 0
-      this.studentQueryParams.liveIds = []
-      this.studentFirstEntryRange = null
-      this.liveProductOptions = []
-      this.productCompareList = []
-      this.productCompareTotal = 0
-      this.productCompareQueryParams.productIds = []
-      if (this.trendChart) {
-        this.trendChart.dispose()
-        this.trendChart = null
-      }
-      this.trendChartData = { xAxis: [], series: [] }
-      this.overviewData = {}
-      this.$nextTick(() => {
-        const table = this.$refs.liveTable
-        if (table && typeof table.clearSelection === 'function') {
-          table.clearSelection()
-        }
-        this.handleTrendChartResize()
-      })
-    },
-    handleDialogOpen() {
-      this.getLiveList()
-    },
-    handleDialogClose() {
-      this.resetLiveQuery()
-    },
-    handleLiveSearch() {
-      this.liveQueryParams.pageNum = 1
-      this.getLiveList()
-    },
-    resetLiveQuery() {
-      this.liveQueryParams = {
-        pageNum: 1,
-        pageSize: 10,
-        liveName: null,
-        companyName: null,
-        status: null,
-        beginTime: null,
-        endTime: null
-      }
-      this.createTimeRange = null
-    },
-    getLiveList() {
-      const params = { ...this.liveQueryParams }
-      if (this.createTimeRange && this.createTimeRange.length === 2) {
-        params.beginTime = this.createTimeRange[0]
-        params.endTime = this.createTimeRange[1]
-      }
-      this.liveListLoading = true
-      listLive(params).then(res => {
-        this.liveList = res.rows || []
-        this.liveTotal = res.total || 0
-        this.liveListLoading = false
-        this.$nextTick(() => this.setTableSelection())
-      }).catch(() => {
-        this.liveListLoading = false
-      })
-    },
-    handleSelectionChange(selection) {
-      if (selection && selection.length > 15) {
-        this.$message.warning('最多只能选择15个直播间')
-        this.$nextTick(() => this.setTableSelection())
-      } else {
-        this.selectedLiveList = selection || []
-      }
-    },
-    setTableSelection() {
-      this.$nextTick(() => {
-        const table = this.$refs.liveTable
-        if (!table || !this.liveList.length) return
-        const selectedIds = new Set(this.selectedLiveList.map(l => l.liveId))
-        this.liveList.forEach(row => {
-          table.toggleRowSelection(row, selectedIds.has(row.liveId))
-        })
-      })
-    },
-    confirmSelectLive() {
-      this.liveSelectDialogVisible = false
-      // 默认不选直播名称=展示全部已选直播间的人数
-    },
-    formatProductMoney(val) {
-      if (val === undefined || val === null) return '¥0.00'
-      const num = typeof val === 'number' ? val : parseFloat(val)
-      return '¥' + (isNaN(num) ? '0.00' : num.toFixed(2))
-    },
-    fetchLiveProductsForSelectedLives() {
-      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.liveProductOptions = []
-        return
-      }
-      const promises = liveIds.map(liveId => listLiveGoods({ liveId }).then(res => (res.rows || res.data?.rows || [])))
-      Promise.all(promises).then(results => {
-        const productMap = new Map()
-        results.flat().forEach(item => {
-          const pid = item.productId
-          if (pid && !productMap.has(pid)) {
-            productMap.set(pid, { productId: pid, productName: item.productName || item.name || '未知商品' })
-          }
-        })
-        this.liveProductOptions = Array.from(productMap.values())
-      }).catch(() => {
-        this.liveProductOptions = []
-      })
-    },
-    fetchProductCompareList() {
-      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.$message.warning('请先选择直播间')
-        return
-      }
-      const params = {
-        liveIds,
-        productIds: this.productCompareQueryParams.productIds,
-        pageNum: this.productCompareQueryParams.pageNum,
-        pageSize: this.productCompareQueryParams.pageSize
-      }
-      this.productCompareLoading = true
-      listProductCompareStats(params).then(res => {
-        this.productCompareList = res.rows || res.data?.rows || []
-        this.productCompareTotal = res.total || res.data?.total || 0
-        this.productCompareLoading = false
-      }).catch(() => {
-        this.productCompareLoading = false
-      })
-    },
-    resetProductCompareQuery() {
-      this.productCompareQueryParams.productIds = []
-      this.productCompareQueryParams.pageNum = 1
-      this.productCompareQueryParams.pageSize = 10
-      this.fetchProductCompareList()
-    },
-    fetchInviteSalesOptions() {
-      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.inviteSalesOptions = []
-        return
-      }
-      listInviteSalesOptions(liveIds).then(res => {
-        const data = res.data || res || []
-        this.inviteSalesOptions = Array.isArray(data) ? data : []
-      }).catch(() => {
-        this.inviteSalesOptions = []
-      })
-    },
-    fetchInviteCompareList() {
-      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.$message.warning('请先选择直播间')
-        return
-      }
-      const rawIds = this.inviteCompareQueryParams.companyUserIds || []
-      const companyUserIds = rawIds.map(v => {
-        if (typeof v === 'string' && v.includes('_')) {
-          return parseInt(v.split('_')[1], 10)
-        }
-        return typeof v === 'number' ? v : parseInt(v, 10)
-      }).filter(id => !isNaN(id))
-      const params = {
-        liveIds,
-        companyUserIds: companyUserIds.length ? companyUserIds : undefined,
-        pageNum: this.inviteCompareQueryParams.pageNum,
-        pageSize: this.inviteCompareQueryParams.pageSize
-      }
-      this.inviteCompareLoading = true
-      listInviteCompareStats(params).then(res => {
-        this.inviteCompareList = res.rows || res.data?.rows || []
-        this.inviteCompareTotal = res.total || res.data?.total || 0
-        this.inviteCompareLoading = false
-      }).catch(() => {
-        this.inviteCompareLoading = false
-      })
-    },
-    resetInviteCompareQuery() {
-      this.inviteCompareQueryParams.companyUserIds = []
-      this.inviteCompareQueryParams.pageNum = 1
-      this.inviteCompareQueryParams.pageSize = 10
-      this.fetchInviteCompareList()
-    },
-    fetchOverview() {
-      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.overviewData = {}
-        this.trendChartData = { xAxis: [], series: [] }
-        if (this.trendChart) {
-          this.trendChart.dispose()
-          this.trendChart = null
-        }
-        return
-      }
-      this.overviewLoading = true
-      getLiveStatisticsOverview(liveIds).then(res => {
-        this.overviewData = res.data || res || {}
-        this.overviewLoading = false
-        this.fetchTrend()
-        // 直播间学员:默认不选直播名称,自动查询全部已选直播间的用户
-        this.studentQueryParams.liveIds = []
-        this.studentFirstEntryRange = null
-        this.studentQueryParams.pageNum = 1
-        this.fetchStudentList()
-        // 商品对比:加载已选直播间的带货产品
-        this.fetchLiveProductsForSelectedLives()
-        // 邀课对比:加载已选直播间的分享人选项
-        this.fetchInviteSalesOptions()
-      }).catch(() => {
-        this.overviewLoading = false
-      })
-    },
-    fetchTrend() {
-      const liveIds = (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.trendChartData = this.getDefaultTrendData()
-        this.$nextTick(() => {
-          setTimeout(() => this.renderTrendChart(), 150)
-        })
-        return
-      }
-      this.trendLoading = true
-      getLiveEntryTrend(liveIds).then(res => {
-        // 兼容多种返回格式: { code, msg, data: { xAxis, series } } 或 { data: [series] } 或 { xAxis, series }
-        const body = res && typeof res === 'object' && res.data !== undefined ? res.data : res
-        let xAxis = []
-        let series = []
-        const dataObj = body && typeof body === 'object' ? body : null
-        if (Array.isArray(body) && body.length > 0) {
-          series = body
-        } else if (dataObj && Array.isArray(dataObj.series) && dataObj.series.length > 0) {
-          xAxis = Array.isArray(dataObj.xAxis) ? dataObj.xAxis : []
-          series = dataObj.series
-        } else if (dataObj && Array.isArray(dataObj.data) && dataObj.data.length > 0) {
-          series = dataObj.data
-        } else if (dataObj && Array.isArray(dataObj.rows) && dataObj.rows.length > 0) {
-          series = dataObj.rows
-        }
-        series = Array.isArray(series) ? series : []
-        if (series.length && (!Array.isArray(xAxis) || !xAxis.length) && series[0] && Array.isArray(series[0].data)) {
-          const len = series[0].data.length
-          xAxis = ['开播前']
-          for (let m = 0; m < len - 1; m++) xAxis.push((m * 5) + 'min')
-        }
-        if (!series.length) {
-          this.trendChartData = this.getDefaultTrendData()
-        } else {
-          if (!xAxis.length) xAxis = this.getDefaultTrendData().xAxis
-          this.trendChartData = { xAxis, series }
-        }
-        this.trendLoading = false
-        this.$nextTick(() => {
-          setTimeout(() => {
-            this.renderTrendChart()
-            this.$nextTick(() => { if (this.trendChart) this.trendChart.resize() })
-          }, 80)
-        })
-      }).catch(() => {
-        this.trendChartData = this.getDefaultTrendData()
-        this.trendLoading = false
-        this.$nextTick(() => {
-          setTimeout(() => {
-            this.renderTrendChart()
-            this.$nextTick(() => {
-              if (this.trendChart) this.trendChart.resize()
-            })
-          }, 150)
-        })
-      })
-    },
-    getDefaultTrendData() {
-      const xAxis = ['开播前']
-      for (let m = 0; m <= 240; m += 5) {
-        xAxis.push(m + 'min')
-      }
-      const zeros = xAxis.map(() => 0)
-      return {
-        xAxis,
-        series: [{ name: '进入人数', data: zeros }]
-      }
-    },
-    renderTrendChart() {
-      const el = this.$refs.trendChartRef
-      if (!el) return
-      let xAxis = this.trendChartData.xAxis && Array.isArray(this.trendChartData.xAxis) ? this.trendChartData.xAxis : []
-      const series = this.trendChartData.series && Array.isArray(this.trendChartData.series) ? this.trendChartData.series : []
-      if (!series.length) return
-      if (!xAxis.length && series[0] && series[0].data) {
-        const len = series[0].data.length
-        xAxis = ['开播前']
-        for (let m = 0; m < len - 1; m++) xAxis.push((m * 5) + 'min')
-      }
-      if (!xAxis.length) xAxis = this.getDefaultTrendData().xAxis
-      if (this.trendChart) this.trendChart.dispose()
-      this.trendChart = echarts.init(el)
-      const option = {
-        title: { text: '相对直播开始时间的累计进入人数', left: 'center' },
-        tooltip: { trigger: 'axis' },
-        legend: { bottom: 0, type: 'scroll' },
-        grid: { left: '3%', right: '4%', bottom: '15%', top: 40, containLabel: true },
-        xAxis: { type: 'category', boundaryGap: false, data: xAxis },
-        yAxis: { type: 'value', name: '累计进入人数' },
-        series: series.map((s, i) => ({
-          name: s.name,
-          type: 'line',
-          smooth: true,
-          data: s.data || []
-        }))
-      }
-      this.trendChart.setOption(option)
-      this.$nextTick(() => {
-        if (this.trendChart) this.trendChart.resize()
-      })
-    },
-    handleTrendChartResize() {
-      if (this.trendChart) this.trendChart.resize()
-    },
-    fetchStudentList() {
-      const liveIds = this.studentQueryParams.liveIds && this.studentQueryParams.liveIds.length > 0
-        ? this.studentQueryParams.liveIds
-        : (this.selectedLiveList || []).map(l => l.liveId)
-      if (!liveIds.length) {
-        this.studentList = []
-        this.studentTotal = 0
-        return
-      }
-      const params = {
-        liveIds,
-        pageNum: this.studentQueryParams.pageNum || 1,
-        pageSize: this.studentQueryParams.pageSize || 10
-      }
-      if (this.studentFirstEntryRange && this.studentFirstEntryRange.length === 2) {
-        params.firstEntryTimeBegin = this.studentFirstEntryRange[0]
-        params.firstEntryTimeEnd = this.studentFirstEntryRange[1]
-      }
-      this.studentLoading = true
-      listLiveRoomStudents(params).then(res => {
-        this.studentList = res.rows || []
-        this.studentTotal = res.total || 0
-        this.studentLoading = false
-      }).catch(() => {
-        this.studentLoading = false
-      })
-    },
-    resetStudentQuery() {
-      this.studentQueryParams.liveIds = []
-      this.studentQueryParams.pageNum = 1
-      this.studentQueryParams.pageSize = 10
-      this.studentFirstEntryRange = null
-      this.fetchStudentList()
-    },
-    formatOverviewValue(val) {
-      if (val === undefined || val === null) return '0'
-      if (typeof val === 'number' && !Number.isInteger(val)) return Number(val).toFixed(2)
-      return String(val)
-    },
-    measureNavHeight() {
-      const nav = this.$refs.statsNavBar
-      if (nav) {
-        this.navHeight = nav.offsetHeight || 48
-      }
-    },
-    handleNavScroll() {
-      const nav = this.$refs.statsNavBar
-      const card = this.$refs.statsCard
-      if (!nav || !card) return
-
-      if (this.navFixed) {
-        const spacer = this.$refs.statsNavSpacer
-        if (spacer) {
-          const rect = spacer.getBoundingClientRect()
-          if (rect.top >= this.headerOffset) {
-            this.navFixed = false
-            this.navFixedStyle = {}
-          }
-        }
-      } else {
-        const rect = nav.getBoundingClientRect()
-        if (rect.top <= this.headerOffset) {
-          const cardRect = card.$el ? card.$el.getBoundingClientRect() : card.getBoundingClientRect()
-          this.navFixedStyle = {
-            left: cardRect.left + 'px',
-            top: this.headerOffset + 'px',
-            width: cardRect.width + 'px'
-          }
-          this.navFixed = true
-        }
-      }
-    },
-    scrollToSection(key) {
-      this.activeNav = key
-      const refMap = {
-        overview: 'sectionOverview',
-        trend: 'sectionTrend',
-        student: 'sectionStudent',
-        compare: 'sectionCompare'
-      }
-      const el = this.$refs[refMap[key]]
-      if (el && el.$el) {
-        el.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
-      } else if (el) {
-        el.scrollIntoView({ behavior: 'smooth', block: 'start' })
-      }
-    }
-  }
+  components: { LiveStatisticsPanel }
 }
 </script>
-
-<style scoped>
-.live-select-input {
-  display: flex;
-  align-items: flex-start;
-  width: 100%;
-  min-width: 200px;
-  min-height: 32px;
-  padding: 6px 12px;
-  border: 1px solid #DCDFE6;
-  border-radius: 4px;
-  cursor: pointer;
-  background: #fff;
-  overflow: hidden;
-}
-.live-select-input:hover {
-  border-color: #C0C4CC;
-}
-.live-select-content {
-  flex: 1;
-  display: flex;
-  flex-wrap: wrap;
-  align-content: flex-start;
-  align-items: center;
-  gap: 6px 8px;
-  min-height: 22px;
-  min-width: 0;
-  overflow: hidden;
-}
-.live-select-content .placeholder {
-  color: #C0C4CC;
-  font-size: 14px;
-}
-.live-tag {
-  display: inline-block;
-  padding: 0 8px;
-  height: 22px;
-  line-height: 22px;
-  font-size: 12px;
-  color: #606266;
-  background: #f4f4f5;
-  border-radius: 4px;
-}
-.tag-sep {
-  color: #909399;
-  margin: 0 2px;
-}
-.fold-text {
-  font-size: 12px;
-  color: #909399;
-}
-.live-select-suffix {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding-left: 10px;
-}
-.live-select-suffix i {
-  color: #C0C4CC;
-  font-size: 14px;
-  cursor: pointer;
-}
-.live-select-suffix i:hover {
-  color: #909399;
-}
-.stats-main {
-  padding-top: 0;
-}
-.stats-nav-wrapper {
-  position: relative;
-}
-.stats-nav-spacer {
-  flex-shrink: 0;
-}
-.stats-nav-bar {
-  position: relative;
-  z-index: 100;
-  display: flex;
-  width: 100%;
-  flex: 1 1 auto;
-  background: #fff;
-  border-bottom: 1px solid #ebeef5;
-  margin: 0 -20px 20px -20px;
-  padding: 0;
-  box-shadow: 0 1px 2px rgba(0,0,0,0.03);
-}
-.stats-nav-bar.is-fixed {
-  position: fixed;
-  margin: 0;
-  left: 0;
-  right: 0;
-  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
-}
-.stats-nav-bar .nav-item {
-  flex: 1;
-  text-align: center;
-  padding: 14px 12px;
-  font-size: 14px;
-  color: #606266;
-  cursor: pointer;
-  border-bottom: 2px solid transparent;
-  margin-bottom: -1px;
-  transition: all 0.2s;
-}
-.stats-nav-bar .nav-item:hover {
-  color: #409EFF;
-}
-.stats-nav-bar .nav-item.active {
-  color: #409EFF;
-  font-weight: 500;
-  border-bottom-color: #409EFF;
-}
-.overview-content {
-  min-height: 120px;
-}
-.overview-grid {
-  margin-top: 8px;
-}
-.overview-item {
-  padding: 12px 16px;
-  margin-bottom: 12px;
-  background: #fafafa;
-  border-radius: 4px;
-  border-left: 3px solid #409EFF;
-  border: 1px solid #ebeef5;
-}
-.overview-label {
-  font-size: 13px;
-  color: #909399;
-  margin-bottom: 6px;
-  line-height: 1.4;
-}
-.overview-value {
-  font-size: 18px;
-  font-weight: 600;
-  color: #303133;
-}
-.overview-block {
-  margin-bottom: 24px;
-}
-.overview-block:last-child {
-  margin-bottom: 0;
-}
-.overview-block-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #303133;
-  margin-bottom: 12px;
-  padding-bottom: 8px;
-  border-bottom: 1px solid #ebeef5;
-}
-.stats-section-card {
-  margin-bottom: 30px;
-  /* 固定导航时:顶部 84px + 导航栏 ~48px ≈ 132,留足空间避免遮挡 */
-  scroll-margin-top: 140px;
-}
-.stats-section-card:last-child {
-  margin-bottom: 0;
-}
-.stats-section {
-  min-height: 300px;
-  padding: 20px 0;
-}
-.stats-section .section-title {
-  font-size: 16px;
-  font-weight: 500;
-  color: #303133;
-  margin-bottom: 16px;
-  padding-bottom: 12px;
-  padding-left: 10px;
-  border-bottom: 1px solid #ebeef5;
-  border-left: 3px solid #67C23A;
-}
-.box-card {
-  margin-bottom: 20px;
-}
-.filter-form {
-  margin-bottom: 16px;
-  padding-bottom: 16px;
-  border-bottom: 1px solid #ebeef5;
-}
-.dialog-footer {
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  gap: 12px;
-}
-.dialog-footer .selected-count {
-  margin-right: auto;
-  font-size: 13px;
-  color: #606266;
-}
-.trend-chart-wrap {
-  min-height: 320px;
-}
-.trend-chart {
-  width: 100%;
-  height: 360px;
-}
-.student-section {
-  min-height: 200px;
-}
-.student-filter {
-  margin-bottom: 16px;
-}
-.user-name-cell {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-}
-.user-name-cell .user-name-text {
-  flex: 1;
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.overview-content {
-  min-height: 120px;
-}
-.overview-grid {
-  margin-top: 0;
-}
-.overview-item {
-  padding: 12px 16px;
-  margin-bottom: 12px;
-  background: #fafafa;
-  border-radius: 4px;
-  border: 1px solid #ebeef5;
-}
-.overview-label {
-  font-size: 13px;
-  color: #909399;
-  margin-bottom: 8px;
-  line-height: 1.4;
-}
-.overview-value {
-  font-size: 18px;
-  font-weight: 600;
-  color: #303133;
-}
-.compare-tabs-wrap {
-  padding-top: 4px;
-}
-.compare-tabs {
-  margin-bottom: 16px;
-}
-.compare-panel {
-  min-height: 200px;
-}
-</style>

+ 322 - 0
src/views/live/trainingCamp/PeriodWorkspace.vue

@@ -0,0 +1,322 @@
+<template>
+  <div class="app-container period-workspace">
+    <el-page-header
+      class="workspace-page-header"
+      @back="$emit('back')"
+      content="返回营期列表"
+    />
+    <el-card class="period-info-card" shadow="never">
+      <div class="period-info-body">
+        <div class="period-info-main">
+          <h2 class="period-info-name">{{ workspace.periodName || '—' }}</h2>
+          <p class="period-info-line">
+            <span class="label">开始时间</span>
+            <span class="value">{{ formatPeriodTime(workspace.startTime) }}</span>
+          </p>
+          <p class="period-info-line">
+            <span class="label">结束时间</span>
+            <span class="value">{{ formatPeriodTime(workspace.endTime) }}</span>
+          </p>
+          <p v-if="workspace.campName" class="period-info-line camp">
+            <span class="label">所属训练营</span>
+            <span class="value">{{ workspace.campName }}</span>
+          </p>
+        </div>
+        <div class="period-info-cover-col">
+          <el-image
+            v-if="workspace.periodImgUrl"
+            :src="workspace.periodImgUrl"
+            fit="cover"
+            class="period-info-cover-img"
+            :preview-src-list="[workspace.periodImgUrl]"
+          >
+            <div slot="error" class="period-info-cover-placeholder">
+              <span>加载失败</span>
+            </div>
+          </el-image>
+          <div v-else class="period-info-cover-placeholder">
+            <i class="el-icon-picture-outline" />
+            <span>暂无营期图片</span>
+          </div>
+        </div>
+      </div>
+    </el-card>
+    <el-tabs v-model="activeTab" class="workspace-tabs">
+      <el-tab-pane label="直播间" name="live">
+        <el-row class="mb8">
+          <el-col :span="24" align="right">
+            <el-button
+              v-hasPermi="['live:live:add', 'live:trainingCampAdmin:live:add']"
+              type="primary"
+              plain
+              icon="el-icon-plus"
+              size="mini"
+              @click="$emit('add-live')"
+            >新增</el-button>
+          </el-col>
+        </el-row>
+        <el-table
+          v-loading="liveLoading"
+          :data="liveList"
+          border
+          size="small"
+        >
+          <el-table-column label="直播间ID" prop="liveId" width="100" align="center" show-overflow-tooltip />
+          <el-table-column label="直播名称" prop="liveName" min-width="140" show-overflow-tooltip />
+          
+          <el-table-column label="开始时间" min-width="158" align="center" show-overflow-tooltip>
+            <template slot-scope="scope">{{ formatPeriodTime(scope.row.startTime) }}</template>
+          </el-table-column>
+          <el-table-column label="结束时间" min-width="158" align="center" show-overflow-tooltip>
+            <template slot-scope="scope">{{ formatPeriodTime(scope.row.finishTime) }}</template>
+          </el-table-column>
+          <el-table-column label="审核" width="88" align="center">
+            <template slot-scope="scope">
+              <el-tag v-if="scope.row.isAudit === 0" size="mini" type="warning">待审</el-tag>
+              <el-tag v-else-if="scope.row.isAudit === 1" size="mini" type="success">通过</el-tag>
+              <el-tag v-else-if="scope.row.isAudit === 2" size="mini" type="danger">驳回</el-tag>
+              <span v-else>—</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="96" align="center">
+            <template slot-scope="scope">
+              <el-tag v-if="scope.row.status == 1" size="mini">待直播</el-tag>
+              <el-tag v-else-if="scope.row.status == 2" size="mini" type="danger">直播中</el-tag>
+              <el-tag v-else-if="scope.row.status == 3" size="mini" type="info">已结束</el-tag>
+              <el-tag v-else-if="scope.row.status == 4" size="mini" type="success">回放中</el-tag>
+              <span v-else>—</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="200" align="center" fixed="right">
+            <template slot-scope="scope">
+              <el-button
+                v-hasPermi="['live:live:edit']"
+                type="text"
+                size="mini"
+                icon="el-icon-edit"
+                @click="$emit('edit-live', scope.row)"
+              >修改</el-button>
+              <el-button
+                v-hasPermi="['live:live:query']"
+                type="text"
+                size="mini"
+                @click="goLiveConfig(scope.row)"
+              >配置</el-button>
+              <el-button
+                type="text"
+                size="mini"
+                @click="openConsole(scope.row)"
+              >控制台</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <pagination
+          v-show="liveTotal > 0"
+          small
+          :total="liveTotal"
+          :page.sync="liveQuery.pageNum"
+          :limit.sync="liveQuery.pageSize"
+          @pagination="loadLiveList"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="数据概览" name="overview">
+        <live-statistics-panel
+          v-if="activeTab === 'overview' && workspace.periodId"
+          :training-period-id="workspace.periodId"
+          :training-camp-company-id="companyId"
+          :key="'camp-stats-' + workspace.periodId + '-' + (companyId || '')"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import { listTrainingLive } from '@/api/live/trainingCampAdmin'
+import LiveStatisticsPanel from '@/views/live/liveStatistics/LiveStatisticsPanel.vue'
+
+export default {
+  name: 'PeriodWorkspace',
+  components: { LiveStatisticsPanel },
+  props: {
+    workspace: {
+      type: Object,
+      required: true,
+      default: () => ({
+        periodId: null,
+        periodName: '',
+        campName: '',
+        periodImgUrl: '',
+        startTime: null,
+        endTime: null
+      })
+    },
+    companyId: {
+      type: [Number, String],
+      default: null
+    }
+  },
+  data() {
+    return {
+      activeTab: 'live',
+      liveLoading: false,
+      liveList: [],
+      liveTotal: 0,
+      liveQuery: { pageNum: 1, pageSize: 10 }
+    }
+  },
+  watch: {
+    companyId() {
+      if (this.workspace && this.workspace.periodId) {
+        this.liveQuery.pageNum = 1
+        this.loadLiveList()
+      }
+    },
+    workspace: {
+      deep: true,
+      immediate: true,
+      handler(val) {
+        if (val && val.periodId) {
+          this.liveQuery.pageNum = 1
+          this.activeTab = 'live'
+          this.loadLiveList()
+        } else {
+          this.liveList = []
+          this.liveTotal = 0
+        }
+      }
+    }
+  },
+  methods: {
+    formatPeriodTime(val) {
+      if (!val) return '—'
+      const s = String(val).replace('T', ' ')
+      if (s.length >= 19) {
+        return s.slice(0, 10) + ' ' + s.slice(11, 19)
+      }
+      return s.length >= 16 ? s.slice(0, 16) : s
+    },
+    loadLiveList() {
+      if (!this.workspace.periodId || !this.companyId) return
+      this.liveLoading = true
+      listTrainingLive({
+        ...this.liveQuery,
+        trainingPeriodId: this.workspace.periodId,
+        companyId: this.companyId
+      }).then(res => {
+        this.liveList = res.rows || []
+        this.liveTotal = res.total || 0
+        this.liveLoading = false
+      }).catch(() => {
+        this.liveLoading = false
+      })
+    },
+    /** 父组件在新建直播间成功后调用 */
+    refreshLiveList() {
+      this.loadLiveList()
+    },
+    goLiveConfig(row) {
+      this.$router.push('/live/liveConfig/' + row.liveId)
+    },
+    openConsole(row) {
+      const r = this.$router.resolve({ path: '/live/liveConsole/' + row.liveId })
+      window.open(r.href, '_blank')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.workspace-page-header {
+  margin-bottom: 16px;
+}
+.period-info-card {
+  margin-bottom: 16px;
+  border-radius: 8px;
+}
+.period-info-card >>> .el-card__body {
+  padding: 20px 24px;
+}
+.period-info-body {
+  display: flex;
+  align-items: stretch;
+  justify-content: space-between;
+  gap: 24px;
+  min-height: 132px;
+}
+.period-info-main {
+  flex: 1;
+  min-width: 0;
+}
+.period-info-name {
+  margin: 0 0 16px 0;
+  font-size: 20px;
+  font-weight: 600;
+  color: #303133;
+  line-height: 1.3;
+}
+.period-info-line {
+  margin: 0 0 10px 0;
+  font-size: 14px;
+  color: #606266;
+  line-height: 1.5;
+}
+.period-info-line:last-child {
+  margin-bottom: 0;
+}
+.period-info-line .label {
+  display: inline-block;
+  width: 88px;
+  color: #909399;
+}
+.period-info-line .value {
+  color: #303133;
+}
+.period-info-line.camp .value {
+  font-weight: 500;
+}
+.period-info-cover-col {
+  flex-shrink: 0;
+  width: 220px;
+  height: 132px;
+}
+.period-info-cover-img {
+  width: 220px;
+  height: 132px;
+  border-radius: 8px;
+  border: 1px solid #ebeef5;
+  overflow: hidden;
+}
+.period-info-cover-img >>> .el-image__inner {
+  width: 220px;
+  height: 132px;
+  object-fit: cover;
+}
+.period-info-cover-placeholder {
+  width: 220px;
+  height: 132px;
+  border-radius: 8px;
+  border: 1px dashed #dcdfe6;
+  background: #fafafa;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  color: #c0c4cc;
+  font-size: 13px;
+}
+.period-info-cover-placeholder i {
+  font-size: 36px;
+}
+.workspace-tabs {
+  margin-top: 8px;
+}
+/* 数据概览内嵌统计页:避免双层 app-container 顶距过大 */
+.workspace-tabs >>> .live-statistics-panel-root.app-container {
+  padding-top: 0;
+}
+.mb8 {
+  margin-bottom: 8px;
+}
+</style>

+ 884 - 0
src/views/live/trainingCamp/index.vue

@@ -0,0 +1,884 @@
+<template>
+  <div class="training-camp-root">
+    <!-- 训练营列表:左侧 30% + 营期 70% -->
+    <div v-show="!periodWorkspace.periodId" class="app-container training-camp-page">
+      <el-alert
+        title="总后台:可维护各企业的训练营与营期;企业端创建的营期直播间需经「营期直播间审核」通过(is_audit=1)后才会对用户端展示。普通直播仍在「直播管理」中维护。"
+        type="info"
+        :closable="false"
+        show-icon
+        class="mb12"
+      />
+
+      <el-row :gutter="16">
+        <el-col :span="7">
+          <el-card shadow="never" class="box-card">
+            <div slot="header" class="clearfix">
+              <span>训练营</span>
+              <el-input
+                v-model="campSearchName"
+                placeholder="按名称筛选"
+                size="mini"
+                clearable
+                style="width: 120px; margin-left: 8px"
+                @keyup.enter.native="loadCamps"
+              />
+              <el-button
+                v-hasPermi="['live:trainingCampAdmin:add']"
+                style="float: right; padding: 3px 0"
+                type="text"
+                icon="el-icon-plus"
+                @click="openCampForm()"
+              >新建</el-button>
+            </div>
+            <el-table
+              ref="campTable"
+              v-loading="campLoading"
+              :data="campList"
+              highlight-current-row
+              border
+              size="small"
+              @current-change="onCampChange"
+            >
+              <el-table-column label="企业" prop="companyName" min-width="120" show-overflow-tooltip />
+              <el-table-column label="名称" prop="campName" min-width="100" show-overflow-tooltip />
+              <el-table-column label="排序" prop="sortOrder" width="56" align="center" />
+              <el-table-column label="状态" width="68" align="center">
+                <template slot-scope="scope">
+                  <el-tag v-if="scope.row.status === 0" size="mini" type="success">正常</el-tag>
+                  <el-tag v-else size="mini" type="info">停用</el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column label="操作" width="96" align="center" fixed="right">
+                <template slot-scope="scope">
+                  <el-button
+                    v-hasPermi="['live:trainingCampAdmin:edit']"
+                    type="text"
+                    size="mini"
+                    @click.stop="openCampForm(scope.row)"
+                  >编辑</el-button>
+                  <el-button
+                    v-hasPermi="['live:trainingCampAdmin:remove']"
+                    type="text"
+                    size="mini"
+                    @click.stop="removeCamp(scope.row)"
+                  >删除</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-card>
+        </el-col>
+
+        <el-col :span="17">
+          <el-card shadow="never" class="box-card box-card-period">
+            <div slot="header" class="period-header">
+              <div class="period-header-left">
+                <span>营期</span>
+                <span v-if="currentCamp" class="sub-hint">({{ currentCamp.campName }})</span>
+              </div>
+              <div class="period-header-actions">
+                <el-button
+                  type="primary"
+                  plain
+                  size="small"
+                  icon="el-icon-right"
+                  :disabled="!currentPeriod"
+                  @click="enterPeriodWorkspace(currentPeriod)"
+                >进入营期</el-button>
+                <el-button
+                  v-hasPermi="['live:trainingCampAdmin:add']"
+                  type="primary"
+                  size="small"
+                  icon="el-icon-plus"
+                  :disabled="!currentCamp"
+                  @click="openPeriodForm()"
+                >新建营期</el-button>
+              </div>
+            </div>
+            <el-empty v-if="!currentCamp" description="请先选择训练营" :image-size="64" />
+            <el-table
+              v-else
+              ref="periodTable"
+              v-loading="periodLoading"
+              :data="periodList"
+              highlight-current-row
+              border
+              size="small"
+              @current-change="onPeriodChange"
+            >
+              <el-table-column label="营期名称" prop="periodName" min-width="120" show-overflow-tooltip />
+              <el-table-column label="营期图" width="76" align="center">
+                <template slot-scope="scope">
+                  <el-image
+                    v-if="scope.row.periodImgUrl"
+                    class="period-thumb"
+                    :src="scope.row.periodImgUrl"
+                    fit="cover"
+                    :preview-src-list="[scope.row.periodImgUrl]"
+                  />
+                  <span v-else class="period-thumb-empty">—</span>
+                </template>
+              </el-table-column>
+              <el-table-column label="开始" prop="startTime" width="100" show-overflow-tooltip>
+                <template slot-scope="scope">{{ formatDt(scope.row.startTime) }}</template>
+              </el-table-column>
+              <el-table-column label="结束" prop="endTime" width="100" show-overflow-tooltip>
+                <template slot-scope="scope">{{ formatDt(scope.row.endTime) }}</template>
+              </el-table-column>
+              <el-table-column label="操作" width="168" align="center" fixed="right">
+                <template slot-scope="scope">
+                  <el-button
+                    type="text"
+                    size="mini"
+                    icon="el-icon-right"
+                    @click.stop="enterPeriodWorkspace(scope.row)"
+                  >进入营期</el-button>
+                  <el-button
+                    v-hasPermi="['live:trainingCampAdmin:edit']"
+                    type="text"
+                    size="mini"
+                    @click.stop="openPeriodForm(scope.row)"
+                  >编辑</el-button>
+                  <el-button
+                    v-hasPermi="['live:trainingCampAdmin:remove']"
+                    type="text"
+                    size="mini"
+                    @click.stop="removePeriod(scope.row)"
+                  >删除</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-card>
+        </el-col>
+      </el-row>
+    </div>
+
+    <period-workspace
+      v-show="periodWorkspace.periodId"
+      ref="periodWorkspaceRef"
+      :workspace="periodWorkspace"
+      :company-id="periodWorkspace.companyId"
+      @back="closePeriodWorkspace"
+      @add-live="openLiveForm"
+      @edit-live="openLiveFormEdit"
+    />
+
+    <!-- 训练营表单 -->
+    <el-dialog :title="campForm.campId ? '编辑训练营' : '新建训练营'" :visible.sync="campOpen" width="480px" append-to-body>
+      <el-form ref="campFormRef" :model="campForm" :rules="campRules" label-width="88px">
+        <el-form-item v-if="!campForm.campId" label="所属企业" prop="companyId">
+          <el-select
+            v-model="campForm.companyId"
+            filterable
+            placeholder="请选择企业"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="co in companyOptions"
+              :key="co.companyId"
+              :label="co.companyName"
+              :value="co.companyId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-else label="所属企业">
+          <el-input :value="campForm.companyName || campForm.companyId" disabled />
+        </el-form-item>
+        <el-form-item label="名称" prop="campName">
+          <el-input v-model="campForm.campName" maxlength="128" show-word-limit placeholder="训练营名称" />
+        </el-form-item>
+        <el-form-item label="排序" prop="sortOrder">
+          <el-input-number v-model="campForm.sortOrder" :min="0" :max="9999" controls-position="right" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="campForm.status">
+            <el-radio :label="0">正常</el-radio>
+            <el-radio :label="1">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="说明" prop="description">
+          <el-input v-model="campForm.description" type="textarea" rows="3" maxlength="512" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="campOpen = false">取 消</el-button>
+        <el-button type="primary" @click="submitCamp">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 营期表单 -->
+    <el-dialog :title="periodForm.periodId ? '编辑营期' : '新建营期'" :visible.sync="periodOpen" width="520px" append-to-body>
+      <el-form ref="periodFormRef" :model="periodForm" :rules="periodRules" label-width="88px">
+        <el-form-item label="营期名称" prop="periodName">
+          <el-input v-model="periodForm.periodName" maxlength="128" show-word-limit />
+        </el-form-item>
+        <el-form-item label="营期图片" prop="periodImgUrl">
+          <image-upload v-model="periodForm.periodImgUrl" :limit="1" />
+        </el-form-item>
+        <el-form-item label="开始时间" prop="startTime">
+          <el-date-picker
+            v-model="periodForm.startTime"
+            type="datetime"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            placeholder="选择开始时间"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="结束时间" prop="endTime">
+          <el-date-picker
+            v-model="periodForm.endTime"
+            type="datetime"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            placeholder="选择结束时间"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="排序" prop="sortOrder">
+          <el-input-number v-model="periodForm.sortOrder" :min="0" :max="9999" controls-position="right" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="periodForm.status">
+            <el-radio :label="0">正常</el-radio>
+            <el-radio :label="1">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="说明" prop="description">
+          <el-input v-model="periodForm.description" type="textarea" rows="3" maxlength="512" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="periodOpen = false">取 消</el-button>
+        <el-button type="primary" @click="submitPeriod">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 新增/修改直播间(与「直播管理」表单一致;新增走营期接口,修改走直播更新接口) -->
+    <el-dialog :title="liveUpsertMode === 'edit' ? '修改直播间' : '添加直播间'" :visible.sync="liveOpen" width="900px" append-to-body>
+      <el-form ref="liveUpsertFormRef" :model="liveUpsertForm" :rules="liveUpsertRules" label-width="80px">
+        <el-form-item label="直播名称" prop="liveName">
+          <el-input v-model="liveUpsertForm.liveName" placeholder="请输入直播名称" />
+        </el-form-item>
+        <el-form-item label="显示类型" prop="showType">
+          <el-radio-group v-model="liveUpsertForm.showType">
+            <el-radio :label="1">横屏</el-radio>
+            <el-radio :label="2">竖屏</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="直播类型" prop="liveType">
+          <el-radio-group v-model="liveUpsertForm.liveType">
+            <el-radio
+              v-for="item in liveTypeDictList"
+              :key="item.dictValue"
+              :label="parseInt(item.dictValue)"
+            >
+              {{ item.dictLabel }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="直播描述" prop="liveDesc">
+          <Editor ref="liveUpsertEditor" :height="300" @on-text-change="onLiveDescChange" />
+        </el-form-item>
+        <el-form-item label="录播视屏" prop="videoUrl" v-if="liveUpsertForm.liveType == 2">
+          <video-upload
+            :fileKey.sync="liveUpsertForm.fileKey"
+            :fileSize.sync="liveUpsertForm.fileSize"
+            :videoUrl.sync="liveUpsertForm.videoUrl"
+            :fileName.sync="liveUpsertForm.fileName"
+            :line_1.sync="liveUpsertForm.lineOne"
+            :uploadType.sync="liveUpsertForm.uploadType"
+            :isTranscode.sync="liveUpsertForm.isTranscode"
+            ref="liveUpsertVideoUpload"
+            :transcodeFileKey.sync="liveUpsertForm.transcodeFileKey"
+            @video-duration="onLiveVideoDuration"
+            @change="onLiveVideoChange"
+          />
+        </el-form-item>
+        <el-form-item label="开始时间" prop="startTime">
+          <el-date-picker
+            size="small"
+            v-model="liveUpsertForm.startTime"
+            @change="onLiveStartTimeChange"
+            type="datetime"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            :picker-options="{
+              timePickerOptions: {
+                selectableRange: '00:00:00 - 23:59:59',
+                format: 'HH:mm:ss'
+              }
+            }"
+            placeholder="选择开始时间"
+          />
+        </el-form-item>
+        <el-form-item label="结束时间" prop="finishTime" v-loading="liveTimeLoading">
+          <el-date-picker
+            size="small"
+            v-model="liveUpsertForm.finishTime"
+            type="datetime"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            :picker-options="{
+              timePickerOptions: {
+                selectableRange: '00:00:00 - 23:59:59',
+                format: 'HH:mm:ss'
+              }
+            }"
+            placeholder="视屏播放结束"
+          />
+        </el-form-item>
+        <el-form-item label="直播封面" prop="liveImgUrl">
+          <image-upload v-model="liveUpsertForm.liveImgUrl" :limit="1" />
+        </el-form-item>
+        <el-form-item label="上下架" prop="isShow">
+          <el-radio-group v-model="liveUpsertForm.isShow">
+            <el-radio :label="1">上架</el-radio>
+            <el-radio :label="2">下架</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitLive">确 定</el-button>
+        <el-button @click="cancelLiveDialog">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listTrainingCamp,
+  addTrainingCamp,
+  updateTrainingCamp,
+  delTrainingCamp,
+  listTrainingPeriod,
+  addTrainingPeriod,
+  updateTrainingPeriod,
+  delTrainingPeriod,
+  addTrainingLive,
+  getTrainingPeriod
+} from '@/api/live/trainingCampAdmin'
+import { listCompany } from '@/api/company/company'
+import { getLive, updateLive } from '@/api/live/live'
+import PeriodWorkspace from './PeriodWorkspace.vue'
+import Editor from '@/components/Editor/wang'
+import VideoUpload from '@/components/LiveVideoUpload/single.vue'
+
+export default {
+  name: 'LiveTrainingCampAdmin',
+  components: { PeriodWorkspace, Editor, VideoUpload },
+  data() {
+    return {
+      campLoading: false,
+      periodLoading: false,
+      campList: [],
+      periodList: [],
+      currentCamp: null,
+      currentPeriod: null,
+      campOpen: false,
+      periodOpen: false,
+      liveOpen: false,
+      /** add | edit */
+      liveUpsertMode: 'add',
+      campForm: {},
+      periodForm: {},
+      liveTypeDictList: [],
+      liveUpsertForm: {
+        uploadType: 1,
+        isTranscode: 0,
+        transcodeFileKey: null,
+        videoUrl: null,
+        fileKey: null,
+        fileName: null,
+        fileSize: null,
+        lineOne: null
+      },
+      liveVideoUrl: '',
+      liveTimeLoading: false,
+      campSearchName: '',
+      companyOptions: [],
+      periodWorkspace: {
+        periodId: null,
+        periodName: '',
+        campName: '',
+        companyId: null,
+        periodImgUrl: '',
+        startTime: null,
+        endTime: null
+      },
+      campRules: {
+        campName: [{ required: true, message: '请输入训练营名称', trigger: 'blur' }]
+      },
+      periodRules: {
+        periodName: [{ required: true, message: '请输入营期名称', trigger: 'blur' }]
+      },
+      liveUpsertRules: {
+        liveName: [{ required: true, message: '不能为空', trigger: 'blur' }],
+        showType: [{ required: true, message: '不能为空', trigger: 'blur' }],
+        liveType: [{ required: true, message: '不能为空', trigger: 'blur' }],
+        startTime: [{ required: true, message: '不能为空', trigger: 'blur' }],
+        liveImgUrl: [{ required: true, message: '不能为空', trigger: 'blur' }],
+        isShow: [{ required: true, message: '不能为空', trigger: 'change' }]
+      }
+    }
+  },
+  watch: {
+    'liveUpsertForm.startTime': {
+      handler(newVal) {
+        if (!newVal) return
+        const timeObj = new Date(newVal)
+        if (isNaN(timeObj.getTime())) return
+        timeObj.setSeconds(1)
+        const formattedSeconds = this.padTime(timeObj.getSeconds())
+        const year = timeObj.getFullYear()
+        const month = this.padTime(timeObj.getMonth() + 1)
+        const day = this.padTime(timeObj.getDate())
+        const hours = this.padTime(timeObj.getHours())
+        const minutes = this.padTime(timeObj.getMinutes())
+        this.liveUpsertForm.startTime = `${year}-${month}-${day} ${hours}:${minutes}:${formattedSeconds}`
+      },
+      immediate: true,
+      deep: false
+    },
+    '$route.query.periodId'(val) {
+      if (!val) {
+        this.resetWorkspaceState(false)
+        return
+      }
+      const id = Number(val)
+      if (Number.isNaN(id)) return
+      if (this.periodWorkspace.periodId === id) return
+      this.restoreWorkspaceFromRoute(id)
+    }
+  },
+  created() {
+    this.loadCompanyOptions()
+    this.loadCamps()
+    this.getDicts('live_type').then(response => {
+      this.liveTypeDictList = response.data
+    })
+    const pid = this.$route.query.periodId
+    if (pid) {
+      const id = Number(pid)
+      if (!Number.isNaN(id)) {
+        this.$nextTick(() => this.restoreWorkspaceFromRoute(id))
+      }
+    }
+  },
+  methods: {
+    formatDt(val) {
+      if (!val) return '—'
+      const s = String(val).replace('T', ' ')
+      return s.length >= 16 ? s.slice(0, 16) : s
+    },
+    resetWorkspaceState(updateRoute) {
+      this.periodWorkspace = {
+        periodId: null,
+        periodName: '',
+        campName: '',
+        companyId: null,
+        periodImgUrl: '',
+        startTime: null,
+        endTime: null
+      }
+      if (updateRoute !== false) {
+        const q = { ...this.$route.query }
+        delete q.periodId
+        delete q.companyId
+        this.$router.replace({ path: this.$route.path, query: q }).catch(() => {})
+      }
+    },
+    enterPeriodWorkspace(row) {
+      if (!row || !row.periodId) {
+        this.$message.warning('请先选中一行营期,或点击操作栏「进入营期」')
+        return
+      }
+      const campName = (this.currentCamp && this.currentCamp.campName) || row.campName || ''
+      const companyId = (this.currentCamp && this.currentCamp.companyId) || null
+      this.periodWorkspace = {
+        periodId: row.periodId,
+        periodName: row.periodName || '',
+        campName,
+        companyId,
+        periodImgUrl: row.periodImgUrl || '',
+        startTime: row.startTime || null,
+        endTime: row.endTime || null
+      }
+      this.$router.replace({
+        path: this.$route.path,
+        query: {
+          ...this.$route.query,
+          periodId: String(row.periodId),
+          ...(companyId != null ? { companyId: String(companyId) } : {})
+        }
+      }).catch(() => {})
+    },
+    restoreWorkspaceFromRoute(periodId) {
+      const qcid = this.$route.query.companyId
+      const companyId = qcid != null && qcid !== '' ? Number(qcid) : undefined
+      getTrainingPeriod(periodId, companyId).then(res => {
+        const data = res.data
+        if (!data) {
+          this.$message.error('营期不存在或无权限')
+          this.resetWorkspaceState()
+          return
+        }
+        this.periodWorkspace = {
+          periodId: data.periodId,
+          periodName: data.periodName || '',
+          campName: data.campName || '',
+          companyId: data.companyId != null ? data.companyId : companyId,
+          periodImgUrl: data.periodImgUrl || '',
+          startTime: data.startTime || null,
+          endTime: data.endTime || null
+        }
+      }).catch(() => {
+        this.resetWorkspaceState()
+      })
+    },
+    closePeriodWorkspace() {
+      this.resetWorkspaceState(true)
+    },
+    loadCompanyOptions() {
+      listCompany({ pageNum: 1, pageSize: 2000 }).then(res => {
+        this.companyOptions = res.rows || []
+      }).catch(() => {})
+    },
+    loadCamps() {
+      this.campLoading = true
+      const q = { pageNum: 1, pageSize: 500 }
+      if (this.campSearchName) {
+        q.campName = this.campSearchName
+      }
+      listTrainingCamp(q).then(res => {
+        this.campList = res.rows || []
+        this.campLoading = false
+        this.$nextTick(() => {
+          if (this.currentCamp) {
+            const hit = this.campList.find(c => c.campId === this.currentCamp.campId)
+            if (hit) {
+              this.$refs.campTable && this.$refs.campTable.setCurrentRow(hit)
+            } else {
+              this.currentCamp = null
+              this.currentPeriod = null
+              this.periodList = []
+            }
+          }
+        })
+      }).catch(() => { this.campLoading = false })
+    },
+    onCampChange(row) {
+      this.currentCamp = row || null
+      this.currentPeriod = null
+      this.periodList = []
+      if (!row) return
+      this.loadPeriods()
+    },
+    loadPeriods() {
+      if (!this.currentCamp) return
+      this.periodLoading = true
+      listTrainingPeriod({
+        campId: this.currentCamp.campId,
+        companyId: this.currentCamp.companyId,
+        pageNum: 1,
+        pageSize: 500
+      }).then(res => {
+        this.periodList = res.rows || []
+        this.periodLoading = false
+      }).catch(() => { this.periodLoading = false })
+    },
+    onPeriodChange(row) {
+      this.currentPeriod = row || null
+    },
+    openCampForm(row) {
+      this.campForm = row
+        ? { ...row }
+        : { companyId: null, campName: '', sortOrder: 0, status: 0, description: '' }
+      this.campOpen = true
+      this.$nextTick(() => this.$refs.campFormRef && this.$refs.campFormRef.clearValidate())
+    },
+    submitCamp() {
+      this.$refs.campFormRef.validate(valid => {
+        if (!valid) return
+        if (!this.campForm.campId && !this.campForm.companyId) {
+          this.$message.warning('请选择所属企业')
+          return
+        }
+        const req = this.campForm.campId ? updateTrainingCamp(this.campForm) : addTrainingCamp(this.campForm)
+        req.then(res => {
+          this.$message.success((res && res.msg) || '保存成功')
+          this.campOpen = false
+          this.loadCamps()
+        })
+      })
+    },
+    removeCamp(row) {
+      this.$confirm('确认删除训练营「' + row.campName + '」?须先清空营期。').then(() => {
+        return delTrainingCamp(row.campId, row.companyId)
+      }).then(res => {
+        this.$message.success((res && res.msg) || '已删除')
+        if (this.currentCamp && this.currentCamp.campId === row.campId) {
+          this.currentCamp = null
+          this.currentPeriod = null
+          this.periodList = []
+        }
+        this.loadCamps()
+      }).catch(() => {})
+    },
+    openPeriodForm(row) {
+      if (!this.currentCamp) return
+      this.periodForm = row
+        ? { ...row, campId: row.campId, companyId: this.currentCamp.companyId }
+        : {
+          campId: this.currentCamp.campId,
+          companyId: this.currentCamp.companyId,
+          periodName: '',
+          periodImgUrl: '',
+          startTime: null,
+          endTime: null,
+          sortOrder: 0,
+          status: 0,
+          description: ''
+        }
+      this.periodOpen = true
+      this.$nextTick(() => this.$refs.periodFormRef && this.$refs.periodFormRef.clearValidate())
+    },
+    submitPeriod() {
+      this.$refs.periodFormRef.validate(valid => {
+        if (!valid) return
+        if (this.currentCamp && this.currentCamp.companyId != null) {
+          this.periodForm.companyId = this.currentCamp.companyId
+        }
+        const req = this.periodForm.periodId ? updateTrainingPeriod(this.periodForm) : addTrainingPeriod(this.periodForm)
+        req.then(res => {
+          this.$message.success((res && res.msg) || '保存成功')
+          this.periodOpen = false
+          if (this.periodWorkspace.periodId && this.periodForm.periodId === this.periodWorkspace.periodId) {
+            this.periodWorkspace = {
+              ...this.periodWorkspace,
+              periodName: this.periodForm.periodName || '',
+              periodImgUrl: this.periodForm.periodImgUrl || '',
+              startTime: this.periodForm.startTime || null,
+              endTime: this.periodForm.endTime || null
+            }
+          }
+          this.loadPeriods()
+        })
+      })
+    },
+    removePeriod(row) {
+      if (!this.currentCamp || !this.currentCamp.companyId) {
+        this.$message.warning('缺少企业信息')
+        return
+      }
+      this.$confirm('确认删除营期「' + row.periodName + '」?须先删除营期下直播间。').then(() => {
+        return delTrainingPeriod(row.periodId, this.currentCamp.companyId)
+      }).then(res => {
+        this.$message.success((res && res.msg) || '已删除')
+        if (this.currentPeriod && this.currentPeriod.periodId === row.periodId) {
+          this.currentPeriod = null
+        }
+        if (this.periodWorkspace.periodId === row.periodId) {
+          this.closePeriodWorkspace()
+        }
+        this.loadPeriods()
+      }).catch(() => {})
+    },
+    initLiveUpsertDefaults() {
+      this.liveUpsertForm = {
+        showType: 1,
+        liveType: 2,
+        isShow: 1,
+        isTranscode: 0,
+        uploadType: 1,
+        transcodeFileKey: null
+      }
+      this.liveVideoUrl = ''
+    },
+    cancelLiveDialog() {
+      this.liveOpen = false
+      this.liveUpsertMode = 'add'
+      this.initLiveUpsertDefaults()
+    },
+    padTime(n) {
+      return n < 10 ? `0${n}` : String(n)
+    },
+    formatLiveEndDate(date) {
+      const y = date.getFullYear()
+      const m = (date.getMonth() + 1).toString().padStart(2, '0')
+      const d = date.getDate().toString().padStart(2, '0')
+      const h = date.getHours().toString().padStart(2, '0')
+      const min = date.getMinutes().toString().padStart(2, '0')
+      const s = date.getSeconds().toString().padStart(2, '0')
+      return `${y}-${m}-${d} ${h}:${min}:${s}`
+    },
+    onLiveStartTimeChange() {
+      if (!this.liveUpsertForm.startTime) return
+      if (!this.liveUpsertForm.duration) return
+      const startDateTime = new Date(this.liveUpsertForm.startTime)
+      const endDateTime = new Date(startDateTime.getTime() + this.liveUpsertForm.duration * 1000)
+      this.liveUpsertForm.finishTime = this.formatLiveEndDate(endDateTime)
+    },
+    onLiveVideoDuration(duration) {
+      this.liveUpsertForm.duration = duration
+    },
+    onLiveVideoChange(videoUrl) {
+      this.liveVideoUrl = videoUrl
+      this.liveUpsertForm.videoUrl = videoUrl
+    },
+    onLiveDescChange(text) {
+      setTimeout(() => {
+        this.liveUpsertForm.liveDesc = text
+      }, 1)
+    },
+    openLiveForm() {
+      if (!this.periodWorkspace.periodId) {
+        this.$message.warning('请先进入营期')
+        return
+      }
+      this.liveUpsertMode = 'add'
+      this.initLiveUpsertDefaults()
+      this.liveOpen = true
+      this.$nextTick(() => {
+        if (this.$refs.liveUpsertFormRef) {
+          this.$refs.liveUpsertFormRef.clearValidate()
+        }
+        setTimeout(() => {
+          if (this.$refs.liveUpsertEditor) {
+            this.$refs.liveUpsertEditor.setText('')
+          }
+          if (this.$refs.liveUpsertVideoUpload) {
+            this.$refs.liveUpsertVideoUpload.reset()
+          }
+        }, 100)
+      })
+    },
+    openLiveFormEdit(row) {
+      if (!this.periodWorkspace.periodId) {
+        this.$message.warning('请先进入营期')
+        return
+      }
+      if (!row || !row.liveId) {
+        this.$message.warning('直播间信息无效')
+        return
+      }
+      this.liveUpsertMode = 'edit'
+      getLive(row.liveId).then(res => {
+        const d = res.data
+        if (!d) {
+          this.$message.error('获取直播间失败')
+          this.liveUpsertMode = 'add'
+          return
+        }
+        this.liveUpsertForm = { ...d }
+        this.liveVideoUrl = d.videoUrl || ''
+        this.liveOpen = true
+        this.$nextTick(() => {
+          if (this.$refs.liveUpsertFormRef) {
+            this.$refs.liveUpsertFormRef.clearValidate()
+          }
+          setTimeout(() => {
+            if (this.$refs.liveUpsertEditor) {
+              this.$refs.liveUpsertEditor.setText(d.liveDesc || '')
+            }
+            if (d.videoUrl) {
+              this.liveUpsertForm.videoUrl = d.videoUrl
+            }
+          }, 100)
+        })
+      }).catch(() => {
+        this.liveUpsertMode = 'add'
+      })
+    },
+    submitLive() {
+      if (this.liveUpsertForm.liveType === 2 && (!this.liveVideoUrl || this.liveVideoUrl.length === 0)) {
+        this.$message.error('请上传视频')
+        return
+      }
+      this.$refs.liveUpsertFormRef.validate(valid => {
+        if (!valid) return
+        if (!this.periodWorkspace.periodId) {
+          this.$message.warning('营期已失效,请返回列表重新进入')
+          return
+        }
+        const body = { ...this.liveUpsertForm }
+        body.videoUrl = this.liveVideoUrl
+        body.trainingPeriodId = this.periodWorkspace.periodId
+        if (this.liveUpsertMode === 'add') {
+          delete body.liveId
+          const cid = this.periodWorkspace.companyId || (this.currentCamp && this.currentCamp.companyId)
+          if (!cid) {
+            this.$message.error('缺少企业ID,无法创建直播间')
+            return
+          }
+          addTrainingLive(body, cid).then(res => {
+            this.$message.success((res && res.msg) || '新增成功')
+            this.liveOpen = false
+            this.liveUpsertMode = 'add'
+            this.initLiveUpsertDefaults()
+            this.$refs.periodWorkspaceRef && this.$refs.periodWorkspaceRef.refreshLiveList()
+          })
+        } else {
+          if (!body.liveId) {
+            this.$message.error('缺少直播间ID')
+            return
+          }
+          updateLive(body).then(res => {
+            this.$message.success((res && res.msg) || '修改成功')
+            this.liveOpen = false
+            this.liveUpsertMode = 'add'
+            this.initLiveUpsertDefaults()
+            this.$refs.periodWorkspaceRef && this.$refs.periodWorkspaceRef.refreshLiveList()
+          })
+        }
+      })
+    },
+  }
+}
+</script>
+
+<style scoped>
+.training-camp-root {
+  min-height: 100%;
+}
+.training-camp-page .mb12 {
+  margin-bottom: 12px;
+}
+.box-card {
+  min-height: 420px;
+}
+.box-card-period {
+  min-height: 420px;
+}
+.period-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.period-header-left {
+  flex: 1;
+  min-width: 0;
+}
+.period-header-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+.sub-hint {
+  color: #909399;
+  font-size: 12px;
+  margin-left: 6px;
+}
+.period-thumb {
+  width: 48px;
+  height: 48px;
+  border-radius: 4px;
+  vertical-align: middle;
+}
+.period-thumb-empty {
+  color: #c0c4cc;
+  font-size: 12px;
+}
+</style>

+ 163 - 0
src/views/live/trainingCamp/trainingLiveAudit.vue

@@ -0,0 +1,163 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="88px">
+      <el-form-item label="企业ID" prop="companyId">
+        <el-input v-model="queryParams.companyId" clearable placeholder="可选" style="width: 140px" />
+      </el-form-item>
+      <el-form-item label="审核状态" prop="isAudit">
+        <el-select v-model="queryParams.isAudit" clearable placeholder="全部" style="width: 120px">
+          <el-option label="待审核" :value="0" />
+          <el-option label="已通过" :value="1" />
+          <el-option label="已驳回" :value="2" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="直播名称" prop="liveName">
+        <el-input v-model="queryParams.liveName" clearable placeholder="模糊" style="width: 160px" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table v-loading="loading" :data="list" border size="small">
+      <el-table-column label="直播间ID" prop="liveId" width="96" align="center" />
+      <el-table-column label="直播名称" prop="liveName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="企业" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="训练营" prop="campName" min-width="100" show-overflow-tooltip />
+      <el-table-column label="营期" prop="periodName" min-width="100" show-overflow-tooltip />
+      <el-table-column label="审核" width="88" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.isAudit === 0" size="mini" type="warning">待审</el-tag>
+          <el-tag v-else-if="scope.row.isAudit === 1" size="mini" type="success">通过</el-tag>
+          <el-tag v-else-if="scope.row.isAudit === 2" size="mini" type="danger">驳回</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="开播时间" prop="startTime" width="158" align="center" />
+      <el-table-column label="操作" width="200" align="center" fixed="right">
+        <template slot-scope="scope">
+          <el-button
+            v-if="scope.row.isAudit === 0 || scope.row.isAudit === 2"
+            v-hasPermi="['live:trainingCampAdmin:audit:edit']"
+            type="text"
+            size="mini"
+            @click="handleAudit(scope.row, true)"
+          >通过</el-button>
+          <el-button
+            v-if="scope.row.isAudit === 0"
+            v-hasPermi="['live:trainingCampAdmin:audit:edit']"
+            type="text"
+            size="mini"
+            @click="openReject(scope.row)"
+          >驳回</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <el-dialog title="驳回原因" :visible.sync="rejectOpen" width="420px" append-to-body @close="rejectRemark = ''">
+      <el-input v-model="rejectRemark" type="textarea" rows="4" maxlength="500" show-word-limit placeholder="选填,写入直播间备注" />
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="rejectOpen = false">取 消</el-button>
+        <el-button type="primary" @click="confirmReject">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listTrainingLiveAudit, auditTrainingLive } from '@/api/live/trainingCampAdmin'
+
+export default {
+  name: 'TrainingLiveAudit',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      total: 0,
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        companyId: undefined,
+        isAudit: 0,
+        liveName: undefined
+      },
+      rejectOpen: false,
+      rejectRemark: '',
+      rejectRow: null
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      const q = { ...this.queryParams }
+      if (q.companyId === '' || q.companyId === undefined) {
+        delete q.companyId
+      } else {
+        q.companyId = Number(q.companyId)
+      }
+      if (q.isAudit === '' || q.isAudit === undefined) {
+        delete q.isAudit
+      }
+      listTrainingLiveAudit(q).then(res => {
+        this.list = res.rows || []
+        this.total = res.total || 0
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.queryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        companyId: undefined,
+        isAudit: 0,
+        liveName: undefined
+      }
+      this.getList()
+    },
+    handleAudit(row, passed) {
+      this.$confirm(passed ? '确认审核通过该直播间?' : '确认驳回?', '提示').then(() => {
+        return auditTrainingLive({
+          liveId: row.liveId,
+          passed,
+          remark: passed ? undefined : this.rejectRemark
+        })
+      }).then(() => {
+        this.$message.success('操作成功')
+        this.getList()
+      }).catch(() => {})
+    },
+    openReject(row) {
+      this.rejectRow = row
+      this.rejectRemark = ''
+      this.rejectOpen = true
+    },
+    confirmReject() {
+      if (!this.rejectRow) return
+      auditTrainingLive({
+        liveId: this.rejectRow.liveId,
+        passed: false,
+        remark: this.rejectRemark || undefined
+      }).then(() => {
+        this.$message.success('已驳回')
+        this.rejectOpen = false
+        this.getList()
+      })
+    }
+  }
+}
+</script>

+ 34 - 14
src/views/watch/deviceInfo/details.vue

@@ -64,7 +64,7 @@
           </div>
         </div>
         <div class="equipment-day">
-          已绑定 <span>400</span> 天, 佩戴 <span>4365</span> 天
+          <!-- 已绑定 <span>400</span> 天, 佩戴 <span>4365</span> 天 -->
         </div>
         <div class="equipment-btn">
           <el-button type="primary" size="small">一键上传</el-button>
@@ -83,7 +83,7 @@
       </el-tabs>
       
       <keep-alive>
-        <component :ref="currentTabComponent" :is="currentTabComponent" :deviceId="deviceId" :basicInfo="basicInfo" :detailsType="detailsType">
+        <component :ref="currentTabComponent" :is="currentTabComponent" :deviceId="deviceId" :basicInfo="basicInfo" :detailsType="detailsType" :userInfo="userInfo">
         </component>
       </keep-alive>
     </el-main>
@@ -115,6 +115,8 @@ import Overview from '@/components/DeviceInfo/Overview.vue';
 import Bloodoxygen from '@/components/DeviceInfo/Bloodoxygen.vue';
 import Uricacid from '@/components/DeviceInfo/Uricacid.vue';
 import Urineketones from '@/components/DeviceInfo/Urineketones.vue';
+import PressureValue from '@/components/DeviceInfo/PressureValue.vue';
+import Report from '@/components/DeviceInfo/Report.vue';
 
 export default {
   name: "DeviceDetails",
@@ -134,7 +136,9 @@ export default {
     Overview,
     Bloodoxygen,
     Uricacid,
-    Urineketones
+    Urineketones,
+    PressureValue,
+    Report
   },
   data() {
     return {
@@ -179,10 +183,10 @@ export default {
           label: '位置',
           name: 'location'
         },
-        {
-          label: '脉搏',
-          name: 'pulse'
-        },
+        // {
+        //   label: '脉搏',
+        //   name: 'pulse'
+        // },
         {
           label: '血糖',
           name: 'bloodsugar'
@@ -191,10 +195,10 @@ export default {
           label: '血压',
           name: 'bloodpressure'
         },
-        {
-          label: '血酮',
-          name: 'urineketones'
-        },
+        // {
+        //   label: '血酮',
+        //   name: 'urineketones'
+        // },
         {
           label: '尿酸',
           name: 'uricacid'
@@ -211,6 +215,14 @@ export default {
         //   label: '血脂',
         //   name: 'bloodfat'
         // },
+        {
+          label: '压力',
+          name: 'pressureValue'
+        },
+        {
+          label: '周报',
+          name: 'report'
+        },
       ]
     };
   },
@@ -238,8 +250,9 @@ export default {
         overview: Overview,
         bloodoxygen: Bloodoxygen,
         uricacid: Uricacid,
-        urineketones: Urineketones
-
+        urineketones: Urineketones,
+        pressureValue:PressureValue,
+        report:Report
 
 
       }
@@ -342,9 +355,12 @@ export default {
      */
     async getUserinfo(deviceId) {
       try {
-        const response = await getUser(deviceId);
+        const userId = this.$route.params.userId;
+        const selectType = this.$route.params.selectType;
+        const response = await getUser({deviceId:deviceId,userId:userId,selectType});
         if (response && response.data) {
           this.userInfo = response.data; // 确保数据被正确赋值
+          this.userInfo.userId = userId;
           // console.log("用户信息:", this.userInfo);
         } else {
           console.warn("用户信息返回为空或格式不正确:", response);
@@ -517,6 +533,10 @@ export default {
   // line-height: 160px;
   padding: 0;
 }
+::v-deep .el-main::-webkit-scrollbar {
+    width: 0;
+    display: none;  /* Chrome, Safari and Opera */
+  }
 
 body>.el-container {
   margin-bottom: 40px;

+ 94 - 47
src/views/watch/deviceInfo/index.vue

@@ -1,31 +1,31 @@
 <template>
   <div class="app-container">
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="所属公司" prop="companyId" v-if="shouldShowCompanySelect">
-        <el-select v-model="queryParams.companyId" placeholder="请选择所属公司" clearable size="small" @change="getCompanyUserList">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
+      <el-form-item label="所属技术主体" prop="companyId" v-if="shouldShowCompanySelect">
+        <el-select v-model="queryParams.companyId" placeholder="请选择所属技术主体" clearable filterable size="small" @change="getCompanyUserList">
           <el-option v-for="item in companyList" :key="item.companyId" :label="item.companyName"
             :value="item.companyId">
           </el-option>
         </el-select>
       </el-form-item>
-      <el-form-item label="所属员工" prop="companyUserId">
-        <el-select v-model="queryParams.companyUserId" placeholder="请选择所属员工" clearable size="small">
+      <el-form-item label="所属导医" prop="companyUserId">
+        <el-select v-model="queryParams.companyUserId" placeholder="请选择所属导医" clearable filterable size="small">
           <el-option v-for="item in companyUserList" :key="item.userId" :label="item.nickName"
             :value="item.userId">
           </el-option>
         </el-select>
       </el-form-item>
       <el-form-item label="所属医生" prop="doctorId">
-        <el-select v-model="queryParams.doctorId" placeholder="请选择所属医生" clearable size="small">
+        <el-select v-model="queryParams.doctorId" placeholder="请选择所属医生" clearable filterable size="small">
           <el-option v-for="item in doctorList" :key="item.doctorId" :label="item.doctorName"
             :value="item.doctorId">
           </el-option>
         </el-select>
       </el-form-item>
-      <el-form-item label="绑定用户" prop="watchUserName">
+      <!-- <el-form-item label="绑定用户" prop="watchUserName">
         <el-input v-model="queryParams.watchUserName" placeholder="请输入绑定用户" clearable size="small"
           @keyup.enter.native="handleQuery" />
-      </el-form-item>
+      </el-form-item> -->
       <el-form-item label="设备编号" prop="deviceNumber">
         <el-input v-model="queryParams.deviceNumber" placeholder="请输入设备编号" clearable size="small"
           @keyup.enter.native="handleQuery" />
@@ -52,6 +52,12 @@
           </el-option>
         </el-select>
       </el-form-item>
+      <el-form-item label="健康状态" prop="isNormal">
+        <el-select v-model="queryParams.isNormal" placeholder="请选择健康状态" clearable size="small">
+          <el-option v-for="item in healthOptions" :key="item.value" :label="item.label" :value="item.value">
+          </el-option>
+        </el-select>
+      </el-form-item>
       <el-form-item>
         <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
         <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@@ -140,10 +146,31 @@
     <el-table v-loading="loading" :data="deviceInfoList" @selection-change="handleSelectionChange">
       <el-table-column type="selection" width="55" align="center" />
       <el-table-column label="设备id" align="center" prop="deviceId" />
-      <el-table-column label="所属公司" align="center" prop="companyName" />
-      <el-table-column label="所属员工" align="center" prop="companyUserName" />
+      <el-table-column label="所属技术主体" align="center" prop="companyName" />
+      <el-table-column label="所属导医" align="center" prop="companyUserName" />
       <el-table-column label="所属医生" align="center" prop="doctorName" />
-      <el-table-column label="绑定用户" align="center" prop="watchUserName" />
+    
+      <el-table-column  label="绑定用户"  align="center" prop="watchUserName">
+        <!-- <template slot-scope="scope"> 
+          <div v-if="scope.row.users && scope.row.users!=null">
+              <el-link :underline="false" @click="handleDetails(scope.row.deviceId, scope.row.users.userId)">
+                {{ scope.row.users.nickName }}
+              </el-link>
+          </div>
+        </template> -->
+        
+      </el-table-column>
+      <el-table-column  label="绑定家人用户"  align="center" prop="familyUserName">
+          <!-- <template slot-scope="scope">
+            <div v-if="scope.row.familyUsers">
+              <div v-for="(item, index) in scope.row.familyUsers" :key="index">
+                <el-link :underline="false" @click="handleDetails(scope.row.deviceId, item.userId)">
+                  {{ item.nickName }}
+                </el-link>
+              </div>
+            </div>
+          </template> -->
+      </el-table-column>
       <el-table-column label="设备编号" align="center" prop="deviceNumber" width="150"/>
       <el-table-column label="电量" align="center" prop="battery" />
       <el-table-column label="信号" align="center" prop="rssi" />
@@ -158,13 +185,13 @@
           >
             最新数据
           </span>
-          <el-tag size="mini" :type="scope.row.isNormal == '异常'?'danger':'primary'">{{scope.row.isNormal}}</el-tag>
+          <el-tag size="mini" :type="scope.row.isNormal == 0?'danger':'primary'">{{ scope.row.isNormal === 1 ? '正常' : '异常' }}</el-tag>
 
         </template>
       </el-table-column>
       <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
         <template slot-scope="scope">
-          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetails(scope.row.deviceId)"
+          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetailsAndType(scope.row)"
             v-hasPermi="['watchApi:deviceInfo:query']">查询详情</el-button>
           <el-button size="mini" type="text" icon="el-icon-phone-outline" @click="handleSoSPeople(scope.row.deviceNumber)"
             v-hasPermi="['watchApi:deviceInfo:query']">查看紧急联系人</el-button>
@@ -197,24 +224,7 @@
         <div slot="footer" class="dialog-footer">
           <el-button @click="healthDataDialogVisible = false">关闭</el-button>
         </div>
-      </el-dialog>
-
-      <!-- 历史数据对话框 -->
-    <!-- <el-dialog :visible.sync="historyDataDialogVisible" title="历史数据" width="50%">
-      <div v-if="historyData">
-        <el-table :data="historyData">
-          <el-table-column prop="type" label="类型" align="center" />
-          <el-table-column prop="data" label="数值" align="center" />
-          <el-table-column prop="date" label="时间" align="center" />
-        </el-table>
-      </div>
-      <div v-else>
-        <p>暂无历史数据</p>
-      </div>
-      <div slot="footer" class="dialog-footer">
-        <el-button @click="historyDataDialogVisible = false">关闭</el-button>
-      </div>
-    </el-dialog> -->
+    </el-dialog>
       
 
     <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
@@ -222,22 +232,32 @@
 
     <!-- 添加或修改设备信息对话框 -->
     <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
-        <el-form-item label="所属公司" prop="companyId">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="所属技术主体">
           <template #label>
-            <span class="required-label">所属公司</span>
+            <span class="required-label">所属技术主体</span>
           </template>
-          <el-select v-model="form.companyId" placeholder="请选择所属公司" clearable size="small" @change="getAddCompanyUserList">
-            <el-option v-for="item in companyList" :key="item.companyId" :label="item.companyName"
-              :value="item.companyId">
-            </el-option>
+          <el-select
+            v-model="selectedCompanyIds"
+            placeholder="请选择所属技术主体"
+            clearable
+            filterable
+            multiple
+            size="small"
+          >
+            <el-option
+              v-for="item in companyList"
+              :key="item.companyId"
+              :label="item.companyName"
+              :value="Number(item.companyId)"
+            ></el-option>
           </el-select>
         </el-form-item>
-        <el-form-item label="所属员工" prop="companyUserId">
+        <el-form-item label="所属导医">
           <template #label>
-            <span class="required-label">所属员工</span>
+            <span class="required-label">所属导医</span>
           </template>
-          <el-select v-model="form.companyUserId" placeholder="请选择所属员工" clearable size="small">
+          <el-select v-model="form.companyUserId" placeholder="请选择所属导医" clearable size="small">
             <el-option v-for="item in companyUserList" :key="item.userId" :label="item.nickName"
               :value="item.userId">
             </el-option>
@@ -369,7 +389,7 @@ export default {
       },
       // 表单参数
       form: {
-        companyId: '',
+        companyId: '', //原始的以逗号分隔的字符串
         companyUserId:"",
         doctorId:"",
         deviceNumber: '',
@@ -377,6 +397,7 @@ export default {
         rssi: '',
         status: 1,
       },
+      selectedCompanyIds: [], // 用于绑定选择框的数组
       // 表单校验
       rules: {
         // companyId: [
@@ -387,6 +408,13 @@ export default {
           { pattern: /^\d{15}$/, message: "设备编号必须是15位数字", trigger: "blur" }
         ],
       },
+      healthOptions: [{
+          value: 0,
+          label: '异常'
+        }, {
+          value: 1,
+          label: '正常'
+        }],
       options: [{
         value: '0',
         label: 'OFFLINE'
@@ -584,10 +612,16 @@ export default {
     this.getCompanyUserList();
     this.getDoctorList();
   },
+
   methods: {
+    parseCompanyId() {
+      // 将原始的以逗号分隔的字符串解析为数组
+      if (this.form.companyId) {
+        this.selectedCompanyIds = this.form.companyId.split(',').map(Number);
+      }
+    },
     rowClassName({row}) {
-      if (row.status && row.status.includes('偏')) {
-        // console.log("=============================" + JSON.stringify(row.status, null, 2))
+      if (row.status && (!row.status.includes('正常') && !row.status.includes('得分') && !row.status.includes('无结果') && !row.status.includes('窦性心率'))) {
         return 'text-red';
         // return true
       }
@@ -693,9 +727,18 @@ export default {
 
       }
     },
+    handleDetailsAndType(row){
+      if(row.userId != null){
+        this.handleDetails(row.deviceId,row.userId)
+      } else if(row.familyUsers !=null && row.familyUsers.length >0){
+        this.handleDetails(row.deviceId,row.familyUsers[0].userId)
+      } else {
+        this.handleDetails(row.deviceId,null);
+      }
+    },
     /** 处理查询详情按钮点击事件 */
-    handleDetails(deviceId) {
-      this.$router.push({ name: 'details', params: { deviceId } });
+    handleDetails(deviceId,userId) {
+      this.$router.push({ name: 'details', params: { deviceId:deviceId,userId:userId} });
     },
 
     /** 查询设备信息列表 */
@@ -726,7 +769,8 @@ export default {
         deviceNumber: null,
         battery: null,
         rssi: null,
-        status: 0
+        status: 0,
+        selectedCompanyIds: [],
       };
       this.resetForm("form");
     },
@@ -759,6 +803,7 @@ export default {
       const deviceId = row.deviceId || this.ids
       getDeviceInfo(deviceId).then(response => {
         this.form = response.data;
+        this.parseCompanyId()
         this.open = true;
         this.title = "修改设备信息";
       });
@@ -767,6 +812,8 @@ export default {
     submitForm() {
       this.$refs["form"].validate(valid => {
         if (valid) {
+          // 将 selectedCompanyIds 数组转换为以逗号分隔的字符串
+          this.form.companyId = this.selectedCompanyIds.join(',');
           if (this.form.deviceId != null) {
             console.log("===============================" + JSON.stringify(this.form, null, 2))
             updateDeviceInfo(this.form).then(response => {

+ 11 - 3
src/views/watch/iot/index.vue

@@ -22,7 +22,15 @@
         <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
       </el-form-item>
     </el-form>
-
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          icon="el-icon-download"
+          size="mini"
+          @click="handleExport"
+          v-hasPermi="['store:storePayment:export']"
+        >导出</el-button>
+      </el-col>
     <!-- <el-row :gutter="10" class="mb8">
       
       <el-col :span="1.5">
@@ -139,7 +147,7 @@
 </template>
 
 <script>
-import {importTemplate, listDeviceInfo, getDeviceInfo, delDeviceInfo, addDeviceInfo, updateDeviceInfo, exportDeviceInfo,queryIotList } from "@/api/watch/deviceInfo";
+import {importTemplate, listDeviceInfo, getDeviceInfo, delDeviceInfo, addDeviceInfo, updateDeviceInfo, exportDeviceInfo,queryIotList,exportDeviceIdAndIotInfo} from "@/api/watch/deviceInfo";
 import { listCompany } from "@/api/company/company";
 
 import { getToken } from "@/utils/auth";
@@ -316,7 +324,7 @@ export default {
         type: "warning"
       }).then(() => {
         this.exportLoading = true;
-        return exportDeviceInfo(queryParams);
+        return exportDeviceIdAndIotInfo(queryParams);
       }).then(response => {
         this.download(response.msg);
         this.exportLoading = false;

+ 58 - 45
src/views/watch/isBind/index.vue

@@ -1,31 +1,31 @@
 <template>
   <div class="app-container">
     <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="所属公司" prop="companyId" v-if="shouldShowCompanySelect">
-        <el-select v-model="queryParams.companyId" placeholder="请选择所属公司" clearable size="small" @change="getCompanyUserList">
+      <el-form-item label="所属技术主体" prop="companyId" v-if="shouldShowCompanySelect">
+        <el-select v-model="queryParams.companyId" placeholder="请选择所属技术主体" clearable filterable size="small" @change="getCompanyUserList">
           <el-option v-for="item in companyList" :key="item.companyId" :label="item.companyName"
             :value="item.companyId">
           </el-option>
         </el-select>
       </el-form-item>
-      <el-form-item label="所属员工" prop="companyUserId">
-        <el-select v-model="queryParams.companyUserId" placeholder="请选择所属员工" clearable size="small">
+      <el-form-item label="所属导医" prop="companyUserId">
+        <el-select v-model="queryParams.companyUserId" placeholder="请选择所属导医" clearable filterable size="small">
           <el-option v-for="item in companyUserList" :key="item.userId" :label="item.nickName"
             :value="item.userId">
           </el-option>
         </el-select>
       </el-form-item>
       <el-form-item label="所属医生" prop="doctorId">
-        <el-select v-model="queryParams.doctorId" placeholder="请选择所属医生" clearable size="small">
+        <el-select v-model="queryParams.doctorId" placeholder="请选择所属医生" clearable filterable size="small">
           <el-option v-for="item in doctorList" :key="item.doctorId" :label="item.doctorName"
             :value="item.doctorId">
           </el-option>
         </el-select>
       </el-form-item>
-      <el-form-item label="绑定用户" prop="watchUserName">
+      <!-- <el-form-item label="绑定用户" prop="watchUserName">
         <el-input v-model="queryParams.watchUserName" placeholder="请输入绑定用户" clearable size="small"
           @keyup.enter.native="handleQuery" />
-      </el-form-item>
+      </el-form-item> -->
       <el-form-item label="设备编号" prop="deviceNumber">
         <el-input v-model="queryParams.deviceNumber" placeholder="请输入设备编号" clearable size="small"
           @keyup.enter.native="handleQuery" />
@@ -38,6 +38,13 @@
           </el-option>
         </el-select>
       </el-form-item>
+      
+      <el-form-item label="健康状态" prop="isNormal">
+        <el-select v-model="queryParams.isNormal" placeholder="请选择健康状态" clearable size="small">
+          <el-option v-for="item in healthOptions" :key="item.value" :label="item.label" :value="item.value">
+          </el-option>
+        </el-select>
+      </el-form-item>
       <el-form-item>
         <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
         <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@@ -50,35 +57,6 @@
           @click="handleExport" v-hasPermi="['watchApi:deviceInfo:export']">导出</el-button>
       </el-col>
 
-      <!-- <el-col :span="1.5">
-        <el-popover placement="right" width="400" trigger="click" @show="refreshPageAlarm">
-          <div>
-            <div class="clearfix" style="padding: 10px; border-bottom: 1px solid #ebeef5;">
-              <span>预警通知</span>
-              <el-button style="float: right; padding: 3px 0" type="text" @click="markAllAsRead"
-                v-if="alarmData && alarmData.length > 0">一键已读</el-button>
-            </div>
-            <div class="notification-container" v-infinite-scroll="load">
-              <el-card v-for="(notification, index) in alarmData" :key="index" class="notification-card">
-                <div v-if="notification.status === 0" class="red-dot"></div>
-                <el-row @click="goDetailPage(notification)">
-                  <el-col :span="20" :style="{ opacity: notification.status == 1 ? 0.6 : 1 }">
-                    <div class="notification-title">{{ notification.title }}</div>
-                    <div class="notification-desc">{{ notification.description }}</div>
-                    <div class="notification-time">{{ notification.dateTime }}</div>
-                  </el-col>
-                  <el-col :span="4" class="notification-status">
-                    <el-button style="float: right; padding: 3px 0" type="text" size="mini"
-                      @click="readItem(notification)">阅读</el-button>
-                  </el-col>
-                </el-row>
-              </el-card>
-
-            </div>
-          </div>
-          <el-button type="info" plain icon="el-icon-bell" size="mini" slot="reference">预警通知</el-button>
-        </el-popover>
-      </el-col> -->
 
       <el-col :span="1.5">
         <el-button plain icon="el-icon-setting" size="mini" @click="showSettingBtn = !showSettingBtn"
@@ -112,10 +90,30 @@
     <el-table v-loading="loading" :data="deviceInfoList" @selection-change="handleSelectionChange">
       <el-table-column type="selection" width="55" align="center" />
       <el-table-column label="设备id" align="center" prop="deviceId" />
-      <el-table-column label="所属公司" align="center" prop="companyName" />
-      <el-table-column label="所属员工" align="center" prop="companyUserName" />
+      <el-table-column label="所属技术主体" align="center" prop="companyName" />
+      <el-table-column label="所属导医" align="center" prop="companyUserName" />
       <el-table-column label="所属医生" align="center" prop="doctorName" />
-      <el-table-column label="绑定用户" align="center" prop="watchUserName" />
+      <el-table-column  label="绑定用户"  align="center" prop="watchUserName">
+        <!-- <template slot-scope="scope"> 
+          <div v-if="scope.row.users && scope.row.users!=null">
+              <el-link :underline="false" @click="handleDetails(scope.row.deviceId, scope.row.users.userId)">
+                {{ scope.row.users.nickName }}
+              </el-link>
+          </div>
+        </template> -->
+        
+      </el-table-column>
+      <el-table-column  label="绑定家人用户"  align="center" prop="familyUserName">
+          <!-- <template slot-scope="scope">
+            <div v-if="scope.row.familyUsers">
+              <div v-for="(item, index) in scope.row.familyUsers" :key="index">
+                <el-link :underline="false" @click="handleDetails(scope.row.deviceId, item.userId)">
+                  {{ item.nickName }}
+                </el-link>
+              </div>
+            </div>
+          </template> -->
+      </el-table-column>
       <el-table-column label="设备编号" align="center" prop="deviceNumber" width="150"/>
       <el-table-column label="电量" align="center" prop="battery" />
       <el-table-column label="信号" align="center" prop="rssi" />
@@ -130,13 +128,13 @@
           >
             最新数据
           </span>
-          <el-tag size="mini" :type="scope.row.isNormal == '异常'?'danger':'primary'">{{scope.row.isNormal}}</el-tag>
+          <el-tag size="mini" :type="scope.row.isNormal == 0?'danger':'primary'">{{ scope.row.isNormal === 1 ? '正常' : '异常' }}</el-tag>
 
         </template>
       </el-table-column>
       <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
         <template slot-scope="scope">
-          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetails(scope.row.deviceId)"
+          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetailsAndType(scope.row)"
             v-hasPermi="['watchApi:deviceInfo:query']">查询详情</el-button>
           <el-button size="mini" type="text" icon="el-icon-phone-outline" @click="handleSoSPeople(scope.row.deviceNumber)"
             v-hasPermi="['watchApi:deviceInfo:query']">查看紧急联系人</el-button>
@@ -298,6 +296,13 @@ export default {
           { pattern: /^\d{15}$/, message: "设备编号必须是15位数字", trigger: "blur" }
         ],
       },
+      healthOptions: [{
+          value: 0,
+          label: '异常'
+        }, {
+          value: 1,
+          label: '正常'
+        }],
       options: [{
         value: '0',
         label: 'OFFLINE'
@@ -497,8 +502,7 @@ export default {
   },
   methods: {
     rowClassName({row}) {
-      if (row.status && row.status.includes('偏')) {
-        // console.log("=============================" + JSON.stringify(row.status, null, 2))
+      if (row.status && (!row.status.includes('正常') && !row.status.includes('得分') && !row.status.includes('无结果') && !row.status.includes('窦性心率'))) {
         return 'text-red';
         // return true
       }
@@ -604,9 +608,18 @@ export default {
 
     //   }
     // },
+    handleDetailsAndType(row){
+      if(row.userId != null){
+        this.handleDetails(row.deviceId,row.userId,0)
+      } else if(row.familyUsers !=null && row.familyUsers.length >0){
+        this.handleDetails(row.deviceId,row.familyUsers[0].userId,1)
+      } else {
+        this.handleDetails(row.deviceId,null,0);
+      }
+    },
     /** 处理查询详情按钮点击事件 */
-    handleDetails(deviceId) {
-      this.$router.push({ name: 'details', params: { deviceId } });
+    handleDetails(deviceId,userId,type) {
+      this.$router.push({ name: 'details', params: { deviceId:deviceId,userId:userId,selectType:type } });
     },
 
     /** 查询设备信息列表 */

+ 4 - 59
src/views/watch/isSend/index.vue

@@ -1,31 +1,6 @@
 <template>
   <div class="app-container">
     <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="所属公司" prop="companyId" v-if="shouldShowCompanySelect">
-        <el-select v-model="queryParams.companyId" placeholder="请选择所属公司" clearable size="small" @change="getCompanyUserList">
-          <el-option v-for="item in companyList" :key="item.companyId" :label="item.companyName"
-            :value="item.companyId">
-          </el-option>
-        </el-select>
-      </el-form-item>
-      <el-form-item label="所属员工" prop="companyUserId">
-        <el-select v-model="queryParams.companyUserId" placeholder="请选择所属员工" clearable size="small">
-          <el-option v-for="item in companyUserList" :key="item.userId" :label="item.nickName"
-            :value="item.userId">
-          </el-option>
-        </el-select>
-      </el-form-item>
-      <el-form-item label="所属医生" prop="doctorId">
-        <el-select v-model="queryParams.doctorId" placeholder="请选择所属医生" clearable size="small">
-          <el-option v-for="item in doctorList" :key="item.doctorId" :label="item.doctorName"
-            :value="item.doctorId">
-          </el-option>
-        </el-select>
-      </el-form-item>
-      <el-form-item label="绑定用户" prop="watchUserName">
-        <el-input v-model="queryParams.watchUserName" placeholder="请输入绑定用户" clearable size="small"
-          @keyup.enter.native="handleQuery" />
-      </el-form-item>
       <el-form-item label="设备编号" prop="deviceNumber">
         <el-input v-model="queryParams.deviceNumber" placeholder="请输入设备编号" clearable size="small"
           @keyup.enter.native="handleQuery" />
@@ -50,35 +25,6 @@
           @click="handleExport" v-hasPermi="['watchApi:deviceInfo:export']">导出</el-button>
       </el-col>
 
-      <!-- <el-col :span="1.5">
-        <el-popover placement="right" width="400" trigger="click" @show="refreshPageAlarm">
-          <div>
-            <div class="clearfix" style="padding: 10px; border-bottom: 1px solid #ebeef5;">
-              <span>预警通知</span>
-              <el-button style="float: right; padding: 3px 0" type="text" @click="markAllAsRead"
-                v-if="alarmData && alarmData.length > 0">一键已读</el-button>
-            </div>
-            <div class="notification-container" v-infinite-scroll="load">
-              <el-card v-for="(notification, index) in alarmData" :key="index" class="notification-card">
-                <div v-if="notification.status === 0" class="red-dot"></div>
-                <el-row @click="goDetailPage(notification)">
-                  <el-col :span="20" :style="{ opacity: notification.status == 1 ? 0.6 : 1 }">
-                    <div class="notification-title">{{ notification.title }}</div>
-                    <div class="notification-desc">{{ notification.description }}</div>
-                    <div class="notification-time">{{ notification.dateTime }}</div>
-                  </el-col>
-                  <el-col :span="4" class="notification-status">
-                    <el-button style="float: right; padding: 3px 0" type="text" size="mini"
-                      @click="readItem(notification)">阅读</el-button>
-                  </el-col>
-                </el-row>
-              </el-card>
-
-            </div>
-          </div>
-          <el-button type="info" plain icon="el-icon-bell" size="mini" slot="reference">预警通知</el-button>
-        </el-popover>
-      </el-col> -->
 
       <el-col :span="1.5">
         <el-button plain icon="el-icon-setting" size="mini" @click="showSettingBtn = !showSettingBtn"
@@ -99,10 +45,10 @@
     <el-table v-loading="loading" :data="deviceInfoList" @selection-change="handleSelectionChange">
       <el-table-column type="selection" width="55" align="center" />
       <el-table-column label="设备id" align="center" prop="deviceId" />
-      <el-table-column label="所属公司" align="center" prop="companyName" />
+      <!-- <el-table-column label="所属公司" align="center" prop="companyName" />
       <el-table-column label="所属员工" align="center" prop="companyUserName" />
       <el-table-column label="所属医生" align="center" prop="doctorName" />
-      <el-table-column label="绑定用户" align="center" prop="watchUserName" />
+      <el-table-column label="绑定用户" align="center" prop="watchUserName" /> -->
       <el-table-column label="设备编号" align="center" prop="deviceNumber" width="150"/>
       <el-table-column label="电量" align="center" prop="battery" />
       <el-table-column label="信号" align="center" prop="rssi" />
@@ -117,7 +63,7 @@
           >
             最新数据
           </span>
-          <el-tag size="mini" :type="scope.row.isNormal == '异常'?'danger':'primary'">{{scope.row.isNormal}}</el-tag>
+          <el-tag size="mini" :type="scope.row.isNormal == 0?'danger':'primary'">{{ scope.row.isNormal === 1 ? '正常' : '异常' }}</el-tag>
 
         </template>
       </el-table-column>
@@ -468,8 +414,7 @@ export default {
   },
   methods: {
     rowClassName({row}) {
-      if (row.status && row.status.includes('偏')) {
-        // console.log("=============================" + JSON.stringify(row.status, null, 2))
+      if (row.status && (!row.status.includes('正常') && !row.status.includes('得分') && !row.status.includes('无结果') && !row.status.includes('窦性心率'))) {
         return 'text-red';
         // return true
       }

+ 1 - 1
vue.config.js

@@ -34,7 +34,7 @@ module.exports = {
     proxy: {
       // 为 watch 模块单独设置的代理
       '/watch-api': {
-        target: 'http://localhost:8004', // 另一个目标服务器
+        target: 'http://localhost:8114', // 另一个目标服务器
         changeOrigin: true,
         pathRewrite: {
           '^/watch-api': '' // 将 /watch-api 替换为空