| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645 |
- <template>
- <div class="main_container custom_container">
- <div class="page_item end_item">
- <!-- 顶部统计卡片 -->
- <div class="stat_cards">
- <div v-for="card in cardBoardList" :key="card.label" class="stat_card">
- <div class="stat_left">
- <el-icon :size="13" :color="'#666'">
- <component :is="card.icon" />
- </el-icon>
- <span class="stat_name">{{ card.label }}</span>
- </div>
- <div class="stat_value">{{ card.value }}</div>
- </div>
- </div>
- <!-- 在线类型 + 时间范围 切换 -->
- <div class="page_jg_16"></div>
- <div class="tabs_container">
- <TabSearch tabType="box" :tabList="onlineTypeTablist" :defaultTabKey="defaultOnlineTypeTab"
- @tabChange="(val) => HandleTabChange('onlineType', val)">
- </TabSearch>
- <div style="height: 12px;"></div>
- <div class="date_range_area">
- <TabSearch tabType="line" :tabList="dateRangeTablist" :defaultTabKey="defaultDateRangeTab"
- @tabChange="(val) => HandleTabChange('dateRange', val)">
- </TabSearch>
- <div class="custom_date_picker">
- <el-date-picker
- v-model="customDateRange"
- type="daterange"
- range-separator="至"
- start-placeholder="开始日期"
- end-placeholder="结束日期"
- value-format="YYYY-MM-DD"
- :teleported="false"
- @change="handleCustomDateChange"
- />
- </div>
- </div>
- </div>
- <!-- 主折线图:某时间段在线人数统计 -->
- <div class="page_jg_16"></div>
- <div class="chart_panel">
- <div class="panel_title">
- {{ currentOnlineLabel }}人数统计
- </div>
- <LineChart
- reportHeight="280px"
- :datax="mainLineChartData.datax"
- :datay="mainLineChartData.datay"
- :showCheckBox="false"
- :showBackground="true"
- :disableLabelRotate="true"
- :tooltipDetailData="chartDetailDataList"
- :clickDataPoint="true"
- unit="人"
- @dataPointClick="handleDataPointClick"
- />
- </div>
- <!-- 某时刻各学校在线人数统计(独立模块) -->
- <div class="page_jg_16"></div>
- <div class="chart_panel">
- <div class="panel_title">
- {{ currentAxisLabel }}{{ currentAxisLabel ? ' ' : '' }}{{ currentOnlineLabel }}人数统计
- </div>
- <MultipleBarCharts
- :datax="barChartData.datax"
- :datay="barChartData.datay"
- :height="260"
- :isClick="true"
- color="#5470C6"
- :barMaxWidth="50"
- :barMinWidth="50"
- :showDataZoom="false"
- @HandleChartClick="handleBarClick"
- />
- </div>
- <!-- 某学校当天在线人数统计(独立模块) -->
- <div class="page_jg_16"></div>
- <div class="chart_panel">
- <div class="panel_header">
- <div class="panel_title">{{ selectedSchoolName }} 当天在线人数统计</div>
- <div class="panel_actions">
- <el-button class="btn_export" @click="handleExport">
- <el-icon class="btn_icon"><Download /></el-icon>
- 导出Excel
- </el-button>
- <div class="view_toggle">
- <span
- :class="{ active: defaultVisualizationTypeTab === 'chart' }"
- @click="defaultVisualizationTypeTab = 'chart'"
- >分析图</span>
- <span
- :class="{ active: defaultVisualizationTypeTab === 'table' }"
- @click="defaultVisualizationTypeTab = 'table'"
- >分析表</span>
- </div>
- </div>
- </div>
- <template v-if="defaultVisualizationTypeTab === 'chart'">
- <LineChart
- reportHeight="280px"
- :datax="schoolLineChartData.datax"
- :datay="schoolLineChartData.datay"
- :showCheckBox="false"
- :showBackground="true"
- :disableLabelRotate="true"
- :tooltipDetailData="schoolChartDetailDataList"
- :showTooltipDetail="false"
- :showYAxisInteger="true"
- :clickDataPoint="false"
- unit="人"
- />
- </template>
- <template v-else>
- <div class="table_info">
- 共 {{ schoolTableData.length }} 条数据
- </div>
- <Table
- :tableColumns="tableColumns"
- :tableData="pagedTableData"
- :currentPage="currentPage"
- :pageSize="pageSize"
- :totalNum="schoolTableData.length"
- @paginationChange="handleTablePageChange"
- />
- </template>
- </div>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import TabSearch from '@/baseComponents/TabSearch.vue';
- import Table from '@/baseComponents/Table.vue';
- import { computed, reactive, ref, onMounted } from 'vue';
- import { User, Reading, Tickets, EditPen, DataAnalysis, Download } from '@element-plus/icons-vue';
- import { onlineTypeTablist, dateRangeTablist } from './tabSearch';
- import { tableColumns } from './table';
- import { getHeadData, getModuleList, getChartData } from '@/api/performanceMonitor';
- import MultipleBarCharts from '@/baseComponents/MultipleBarCharts.vue';
- import LineChart from '@/baseComponents/LineChart.vue';
- // ==================== 顶部看板 ====================
- const boardMsg = reactive({
- teacherOnline: 0,
- studentOnline: 0,
- scaneOnline: 0,
- examinationPaperOnline: 0,
- analysisOnline: 0,
- })
- const cardBoardList = computed(() => [
- { label: '教师在线', value: boardMsg.teacherOnline, icon: User },
- { label: '学生在线', value: boardMsg.studentOnline, icon: Reading },
- { label: '扫描人数', value: boardMsg.scaneOnline, icon: Tickets },
- { label: '阅卷人数', value: boardMsg.examinationPaperOnline, icon: EditPen },
- { label: '分析人数', value: boardMsg.analysisOnline, icon: DataAnalysis },
- ])
- // ==================== Tab 状态 ====================
- const defaultOnlineTypeTab = ref('1')
- const defaultDateRangeTab = ref('today')
- const defaultVisualizationTypeTab = ref('chart')
- const dateRangeToTimeType: Record<string, number> = {
- today: 1,
- '3': 2,
- '7': 3,
- '14': 4,
- custom: 5,
- }
- const currentOnlineLabel = computed(() => {
- const found = onlineTypeTablist.value.find(
- (item: any) => item.key === defaultOnlineTypeTab.value
- )
- return found?.label || '教师'
- })
- // ==================== 自定义日期范围 ====================
- const customDateRange = ref<[string, string] | null>(null)
- // 给接口传递的日期加上固定时分秒
- const customDateRangeForApi = computed(() => {
- if (customDateRange.value && customDateRange.value.length === 2) {
- return [
- customDateRange.value[0] + ' 00:00:00',
- customDateRange.value[1] + ' 23:59:59'
- ]
- }
- return ['', '']
- })
- const handleCustomDateChange = (val: [string, string] | null) => {
- if (val && val.length === 2) {
- fetchChartData()
- } else {
- // 清空日期选择器
- resetBarAndSchoolChart()
- fetchChartData()
- }
- }
- // ==================== 图表数据 ====================
- const timeHours = ['1时','2时','3时','4时','5时','6时','7时','8时','9时','10时','11时','12时','13时','14时','15时','16时','17时','18时','19时','20时','21时','22时','23时','24时']
- const mainLineChartData = reactive({
- datax: [] as string[],
- datay: [[]] as number[][],
- })
- const chartDetailDataList = ref<any[]>([])
- const schoolChartDetailDataList = ref<any[]>([])
- const handleDataPointClick = async (payload: { dataIndex: number; schoolList: any[]; rawData: any }) => {
- // 更新柱状图数据
- if (payload.schoolList && payload.schoolList.length > 0) {
- barChartData.datax = payload.schoolList.map((item: any) => item.xname ?? '')
- barChartData.datay = payload.schoolList.map((item: any) => item.count ?? 0)
- barChartSchoolList.value = payload.schoolList
- // 柱状图更新后,默认选中第一个柱子,获取其 id 调用接口更新学校折线图
- const firstSchoolId = payload.schoolList[0]?.id
- if (firstSchoolId != null && firstSchoolId !== undefined) {
- selectedSchoolName.value = payload.schoolList[0].xname ?? ''
- await fetchSchoolLineChartData(firstSchoolId)
- }
- } else {
- console.warn('schoolList 为空,无法更新柱状图')
- }
- // 直接使用 tooltip 中已经渲染正确的 title 作为当前轴标签
- if (payload.title) {
- currentAxisLabel.value = payload.title
- }
- }
- // 保存柱状图的完整学校列表数据
- const barChartSchoolList = ref<any[]>([])
- const barChartData = reactive({
- datax: [] as string[],
- datay: [] as number[],
- })
- const currentAxisLabel = ref('') // 当前点击的折线图数据点 x 轴标签(含"时"),直接取自 tooltip title
- const selectedSchoolName = ref('')
- const schoolLineChartData = reactive({
- datax: [] as string[],
- datay: [] as number[][],
- })
- const schoolTableData = ref<any[]>([])
- const currentPage = ref(1)
- const pageSize = ref(20)
- const pagedTableData = computed(() => {
- const start = (currentPage.value - 1) * pageSize.value
- const end = start + pageSize.value
- return schoolTableData.value.slice(start, end)
- })
- const handleTablePageChange = (payload: { type: string; val: number }) => {
- if (payload.type === 'currentPage') {
- currentPage.value = payload.val
- } else if (payload.type === 'pageSize') {
- pageSize.value = payload.val
- currentPage.value = 1
- }
- }
- // ==================== 交互处理 ====================
- const resetBarAndSchoolChart = () => {
- barChartData.datax = []
- barChartData.datay = []
- barChartSchoolList.value = []
- schoolLineChartData.datax = []
- schoolLineChartData.datay = []
- schoolChartDetailDataList.value = []
- selectedSchoolName.value = ''
- currentAxisLabel.value = ''
- }
- const HandleTabChange = (type: string, val: string) => {
- switch (type) {
- case 'onlineType':
- defaultOnlineTypeTab.value = val
- resetBarAndSchoolChart()
- fetchChartData()
- break
- case 'dateRange':
- defaultDateRangeTab.value = val
- if (val !== 'custom') {
- customDateRange.value = null
- resetBarAndSchoolChart()
- fetchChartData()
- }
- break
- case 'visualizationType':
- defaultVisualizationTypeTab.value = val
- break
- }
- }
- const handleBarClick = async (index: number, name: string) => {
- selectedSchoolName.value = name
- currentPage.value = 1
- // 获取当前柱子的 schoolId (schoolList 数组中 id 字段即为 schoolId)
- const schoolItem = barChartSchoolList.value[index]
- const schoolId = schoolItem?.id ?? 0
- // 调用接口获取学校折线图数据
- const timeType = dateRangeToTimeType[defaultDateRangeTab.value] ?? 1
- const isToday = defaultDateRangeTab.value === 'today'
- try {
- const res = await getChartData({
- schoolId,
- timeType,
- moduleCode: Number(defaultOnlineTypeTab.value) || 0,
- startTime: customDateRangeForApi.value[0],
- endTime: customDateRangeForApi.value[1],
- })
- console.log('点击柱子 getChartData 响应:', res)
- const data = res.data || res
- if (Array.isArray(data)) {
- if (isToday) {
- schoolLineChartData.datax = timeHours
- } else {
- schoolLineChartData.datax = data.map((item: any) => formatXname(item.xname ?? '', false))
- }
- schoolLineChartData.datay = [data.map((item: any) => item.count ?? 0)]
- schoolChartDetailDataList.value = data.map((item: any) => ({
- schoolList: item.schoolList || [],
- count: item.count ?? 0,
- }))
- }
- } catch (err) {
- console.error('点击柱子 getChartData 请求失败:', err)
- }
- }
- const handleExport = () => {
- // console.log('导出Excel', selectedSchoolName.value)
- }
- // ==================== 数据请求 ====================
- const fetchHeadData = async () => {
- try {
- const res = await getHeadData()
- const data = res.data || res
- boardMsg.teacherOnline = data.teacherLoginNum ?? 0
- boardMsg.studentOnline = data.studentLoginNum ?? 0
- boardMsg.scaneOnline = data.scannedNum ?? 0
- boardMsg.examinationPaperOnline = data.markingNum ?? 0
- boardMsg.analysisOnline = data.reportNum ?? 0
- } catch (err) {
- console.error('getHeadData 请求失败:', err)
- }
- }
- const fetchModuleList = async () => {
- try {
- const res = await getModuleList()
- const data = res.data || res
- if (!Array.isArray(data)) {
- console.warn('[fetchModuleList] data 不是数组,实际类型:', typeof data, data)
- return
- }
- const onlineList = data.map((item: any, idx: number) => {
- if (!item.code || !item.name) {
- console.warn(`[fetchModuleList] 第${idx}项缺少 code 或 name:`, item)
- }
- return {
- key: String(item.code ?? ''),
- label: String(item.name ?? ''),
- }
- })
- if (onlineList.length) {
- onlineTypeTablist.value = onlineList
- defaultOnlineTypeTab.value = onlineList[0].key
- } else {
- console.warn('[fetchModuleList] onlineList 为空,未更新标签列表')
- }
- fetchChartData()
- } catch (err) {
- console.error('[fetchModuleList] 请求失败:', err)
- }
- }
- const formatXname = (xname: string, isToday: boolean): string => {
- if (isToday) return xname
- if (!xname) return ''
- // Try ISO format: 2026-04-08 12:00:00 or 2026-04-08T12:00:00
- let match = xname.match(/(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2})/)
- if (match) {
- return `${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日 ${parseInt(match[4], 10)}时`
- }
- // Try short format: 4-8 12:00 or 4-8T12:00
- match = xname.match(/^(\d{1,2})-(\d{1,2})[ T](\d{1,2})/)
- if (match) {
- return `${parseInt(match[1], 10)}月${parseInt(match[2], 10)}日 ${parseInt(match[3], 10)}时`
- }
- return xname
- }
- const fetchSchoolLineChartData = async (schoolId: number) => {
- const timeType = dateRangeToTimeType[defaultDateRangeTab.value] ?? 1
- const isToday = defaultDateRangeTab.value === 'today'
- try {
- const res = await getChartData({
- schoolId,
- timeType,
- moduleCode: Number(defaultOnlineTypeTab.value) || 0,
- startTime: customDateRangeForApi.value[0],
- endTime: customDateRangeForApi.value[1],
- })
- const data = res.data || res
- if (Array.isArray(data)) {
- if (isToday) {
- schoolLineChartData.datax = timeHours
- } else {
- schoolLineChartData.datax = data.map((item: any) => formatXname(item.xname ?? '', false))
- }
- schoolLineChartData.datay = [data.map((item: any) => item.count ?? 0)]
- schoolChartDetailDataList.value = data.map((item: any) => ({
- schoolList: item.schoolList || [],
- count: item.count ?? 0,
- }))
- }
- } catch (err) {
- console.error('fetchSchoolLineChartData 请求失败:', err)
- }
- }
- const fetchChartData = async () => {
- try {
- const timeType = dateRangeToTimeType[defaultDateRangeTab.value] ?? 1
- const isToday = defaultDateRangeTab.value === 'today'
- const res = await getChartData({
- schoolId: 0,
- timeType,
- moduleCode: Number(defaultOnlineTypeTab.value) || 0,
- startTime: customDateRangeForApi.value[0],
- endTime: customDateRangeForApi.value[1],
- })
- const data = res.data || res
- if (Array.isArray(data)) {
- if (isToday) {
- mainLineChartData.datax = timeHours
- } else {
- mainLineChartData.datax = data.map((item: any) => formatXname(item.xname ?? '', false))
- }
- mainLineChartData.datay = [data.map((item: any) => item.count ?? 0)]
- chartDetailDataList.value = data.map((item: any) => ({
- schoolList: item.schoolList || [],
- count: item.count ?? 0,
- }))
- // 初始化柱状图数据:取第一个数据点的 schoolList
- if (data.length > 0) {
- const firstItem = data[0]
- const schoolList = firstItem.schoolList || []
- if (schoolList.length > 0) {
- barChartData.datax = schoolList.map((item: any) => item.xname ?? '')
- barChartData.datay = schoolList.map((item: any) => item.count ?? 0)
- barChartSchoolList.value = schoolList
- }
- }
- }
- } catch (err) {
- console.error('getChartData 请求失败:', err)
- }
- }
- onMounted(() => {
- fetchHeadData()
- fetchModuleList()
- })
- </script>
- <style lang="scss" scoped>
- :deep(.page_item.end_item) {
- background: transparent;
- padding: 0;
- }
- .stat_cards {
- display: grid;
- grid-template-columns: repeat(5, 1fr);
- gap: 16px;
- .stat_card {
- height: 72px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 20px;
- background: #fff;
- border-radius: 10px;
- overflow: hidden;
- border: 1px solid #E4E4E4;
- .stat_left {
- display: flex;
- align-items: center;
- gap: 8px;
- .stat_name {
- font-size: 16px;
- color: #666666;
- }
- }
- .stat_value {
- font-size: 30px;
- font-weight: 500;
- color: #333333;
- line-height: 1;
- }
- }
- }
- .tabs_container {
- background: #fff;
- border-radius: 10px;
- padding: 10px 16px;
- .date_range_area {
- display: flex;
- align-items: center;
- gap: 12px;
- .custom_date_picker {
- :deep(.el-input__wrapper) {
- width: 230px !important;
- height: 36px;
- line-height: 36px;
- }
- }
- }
- }
- .chart_panel {
- background: #fff;
- border: 1px solid #edf0f5;
- border-radius: 8px;
- padding: 20px;
- width: 100%;
- box-sizing: border-box;
- :deep(.echart_content) {
- width: 100%;
- .chart_box {
- width: 100%;
- }
- }
- .panel_title {
- font-size: 16px;
- font-weight: 600;
- color: #303133;
- margin-bottom: 12px;
- }
- .panel_header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
- .panel_title {
- margin-bottom: 0;
- }
- .panel_actions {
- display: flex;
- align-items: center;
- gap: 12px;
- .btn_export {
- height: 32px;
- border: 1px solid #dcdfe6;
- background: #fff;
- color: #606266;
- display: flex;
- align-items: center;
- gap: 6px;
- .btn_icon {
- font-size: 14px;
- }
- }
- .view_toggle {
- display: flex;
- border: 1px solid #dcdfe6;
- border-radius: 4px;
- overflow: hidden;
- span {
- padding: 6px 16px;
- font-size: 14px;
- color: #606266;
- cursor: pointer;
- background: #fff;
- transition: all 0.2s;
- &.active {
- background: #2E64FA;
- color: #fff;
- }
- }
- }
- }
- }
- }
- .table_info {
- font-size: 13px;
- color: #909399;
- margin-bottom: 8px;
- }
- </style>
|