Forráskód Böngészése

Merge branch 'master' of http://1.14.104.71:10880/root/ylrz_his_scrm_companyUI

lmx 3 napja
szülő
commit
9404e91a34

+ 52 - 54
src/api/live/liveData.js

@@ -122,58 +122,56 @@ export function exportLiveDataCompany(data) {
   })
 }
 
+// 直播数据统计-数据概览(多直播间聚合指标)
+export function getLiveStatisticsOverview(liveIds) {
+  return request({
+    url: '/liveData/liveData/getLiveStatisticsOverview',
+    method: 'post',
+    data: liveIds || []
+  })
+}
+
+// 直播趋势-进入人数折线图
+export function getLiveEntryTrend(liveIds) {
+  return request({
+    url: '/liveData/liveData/getLiveEntryTrend',
+    method: 'post',
+    data: liveIds || []
+  })
+}
+
+// 直播间学员列表(分页)
+export function listLiveRoomStudents(data) {
+  return request({
+    url: '/liveData/liveData/listLiveRoomStudents',
+    method: 'post',
+    data: data || {}
+  })
+}
 
-//
-// // 直播数据统计-数据概览(12项指标)
-// export function getLiveStatisticsOverview(liveIds) {
-//     return request({
-//         url: '/liveData/liveData/getLiveStatisticsOverview',
-//         method: 'post',
-//         data: liveIds || []
-//     })
-// }
-//
-// // 直播趋势-进入人数折线图(基于 live_user_first_entry 与直播开始时间的相对时间)
-// export function getLiveEntryTrend(liveIds) {
-//     return request({
-//         url: '/liveData/liveData/getLiveEntryTrend',
-//         method: 'post',
-//         data: liveIds || []
-//     })
-// }
-//
-// // 直播间学员列表(分页)
-// export function listLiveRoomStudents(data) {
-//     return request({
-//         url: '/liveData/liveData/listLiveRoomStudents',
-//         method: 'post',
-//         data: data || {}
-//     })
-// }
-//
-// // 商品对比统计(商品名称、下单未支付人数、成交人数、成交金额)
-// export function listProductCompareStats(data) {
-//     return request({
-//         url: '/liveData/liveData/listProductCompareStats',
-//         method: 'post',
-//         data: data || {}
-//     })
-// }
-//
-// // 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
-// export function listInviteSalesOptions(liveIds) {
-//     return request({
-//         url: '/liveData/liveData/listInviteSalesOptions',
-//         method: 'post',
-//         data: liveIds || []
-//     })
-// }
-//
-// // 邀课对比统计(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
-// export function listInviteCompareStats(data) {
-//     return request({
-//         url: '/liveData/liveData/listInviteCompareStats',
-//         method: 'post',
-//         data: data || {}
-//     })
-// }
+// 商品对比统计
+export function listProductCompareStats(data) {
+  return request({
+    url: '/liveData/liveData/listProductCompareStats',
+    method: 'post',
+    data: data || {}
+  })
+}
+
+// 邀课对比-分享人选项列表
+export function listInviteSalesOptions(liveIds) {
+  return request({
+    url: '/liveData/liveData/listInviteSalesOptions',
+    method: 'post',
+    data: liveIds || []
+  })
+}
+
+// 邀课对比统计
+export function listInviteCompareStats(data) {
+  return request({
+    url: '/liveData/liveData/listInviteCompareStats',
+    method: 'post',
+    data: data || {}
+  })
+}

+ 45 - 0
src/api/live/trainingCamp.js

@@ -0,0 +1,45 @@
+import request from '@/utils/request'
+
+const base = '/live/trainingCamp'
+
+// 训练营
+export function listTrainingCamp(query) {
+  return request({ url: base + '/camp/list', method: 'get', params: query })
+}
+export function getTrainingCamp(campId) {
+  return request({ url: base + '/camp/' + campId, method: 'get' })
+}
+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) {
+  return request({ url: base + '/camp/' + campIds, method: 'delete' })
+}
+
+// 营期
+export function listTrainingPeriod(query) {
+  return request({ url: base + '/period/list', method: 'get', params: query })
+}
+export function getTrainingPeriod(periodId) {
+  return request({ url: base + '/period/' + periodId, method: 'get' })
+}
+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) {
+  return request({ url: base + '/period/' + periodIds, method: 'delete' })
+}
+
+// 营期下直播间
+export function listTrainingLive(query) {
+  return request({ url: base + '/live/list', method: 'get', params: query })
+}
+export function addTrainingLive(data) {
+  return request({ url: base + '/live', method: 'post', data })
+}

+ 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

@@ -107,7 +107,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: 'task'},

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

@@ -0,0 +1,1203 @@
+<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/trainingCamp'
+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,
+      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()
+      }
+    }
+  },
+  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
+      const req = this.trainingPeriodId != null
+        ? listTrainingLive({ ...params, trainingPeriodId: this.trainingPeriodId })
+        : 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>

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

@@ -0,0 +1,302 @@
+<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:trainingCamp: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="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"
+          :key="'camp-stats-' + workspace.periodId"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import { listTrainingLive } from '@/api/live/trainingCamp'
+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
+      })
+    }
+  },
+  data() {
+    return {
+      activeTab: 'live',
+      liveLoading: false,
+      liveList: [],
+      liveTotal: 0,
+      liveQuery: { pageNum: 1, pageSize: 10 }
+    }
+  },
+  watch: {
+    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) return
+      this.liveLoading = true
+      listTrainingLive({
+        ...this.liveQuery,
+        trainingPeriodId: this.workspace.periodId
+      }).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>

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

@@ -0,0 +1,809 @@
+<template>
+  <div class="training-camp-root">
+    <!-- 训练营列表:左侧 30% + 营期 70% -->
+    <div v-show="!periodWorkspace.periodId" class="app-container training-camp-page">
+      <el-alert
+        title="训练营直播间与普通直播相互独立:此处仅管理「训练营 → 营期」;进入营期后在「直播间」页签中管理直播。普通直播仍在「直播管理」中维护。"
+        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-button
+                v-hasPermi="['live:trainingCamp: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="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:trainingCamp:edit']"
+                    type="text"
+                    size="mini"
+                    @click.stop="openCampForm(scope.row)"
+                  >编辑</el-button>
+                  <el-button
+                    v-hasPermi="['live:trainingCamp: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:trainingCamp: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:trainingCamp:edit']"
+                    type="text"
+                    size="mini"
+                    @click.stop="openPeriodForm(scope.row)"
+                  >编辑</el-button>
+                  <el-button
+                    v-hasPermi="['live:trainingCamp: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"
+      @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 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/trainingCamp'
+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: 'LiveTrainingCamp',
+  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,
+      periodWorkspace: {
+        periodId: null,
+        periodName: '',
+        campName: '',
+        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.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: '',
+        periodImgUrl: '',
+        startTime: null,
+        endTime: null
+      }
+      if (updateRoute !== false) {
+        const q = { ...this.$route.query }
+        delete q.periodId
+        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 || ''
+      this.periodWorkspace = {
+        periodId: row.periodId,
+        periodName: row.periodName || '',
+        campName,
+        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) }
+      }).catch(() => {})
+    },
+    restoreWorkspaceFromRoute(periodId) {
+      getTrainingPeriod(periodId).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 || '',
+          periodImgUrl: data.periodImgUrl || '',
+          startTime: data.startTime || null,
+          endTime: data.endTime || null
+        }
+      }).catch(() => {
+        this.resetWorkspaceState()
+      })
+    },
+    closePeriodWorkspace() {
+      this.resetWorkspaceState(true)
+    },
+    loadCamps() {
+      this.campLoading = true
+      listTrainingCamp({ pageNum: 1, pageSize: 500 }).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, 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 }
+        : { 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
+        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)
+      }).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 }
+        : {
+          campId: this.currentCamp.campId,
+          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
+        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) {
+      this.$confirm('确认删除营期「' + row.periodName + '」?须先删除营期下直播间。').then(() => {
+        return delTrainingPeriod(row.periodId)
+      }).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
+          addTrainingLive(body).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>