|
|
@@ -0,0 +1,1175 @@
|
|
|
+<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>
|
|
|
+</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'
|
|
|
+
|
|
|
+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' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</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>
|