index.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. <template>
  2. <div class="main_container custom_container">
  3. <div class="page_item end_item">
  4. <!-- 顶部统计卡片 -->
  5. <div class="stat_cards">
  6. <div v-for="card in cardBoardList" :key="card.label" class="stat_card">
  7. <div class="stat_left">
  8. <el-icon :size="13" :color="'#666'">
  9. <component :is="card.icon" />
  10. </el-icon>
  11. <span class="stat_name">{{ card.label }}</span>
  12. </div>
  13. <div class="stat_value">{{ card.value }}</div>
  14. </div>
  15. </div>
  16. <!-- 在线类型 + 时间范围 切换 -->
  17. <div class="page_jg_16"></div>
  18. <div class="tabs_container">
  19. <TabSearch tabType="box" :tabList="onlineTypeTablist" :defaultTabKey="defaultOnlineTypeTab"
  20. @tabChange="(val) => HandleTabChange('onlineType', val)">
  21. </TabSearch>
  22. <div style="height: 12px;"></div>
  23. <div class="date_range_area">
  24. <TabSearch tabType="line" :tabList="dateRangeTablist" :defaultTabKey="defaultDateRangeTab"
  25. @tabChange="(val) => HandleTabChange('dateRange', val)">
  26. </TabSearch>
  27. <div class="custom_date_picker">
  28. <el-date-picker
  29. v-model="customDateRange"
  30. type="daterange"
  31. range-separator="至"
  32. start-placeholder="开始日期"
  33. end-placeholder="结束日期"
  34. value-format="YYYY-MM-DD"
  35. :teleported="false"
  36. @change="handleCustomDateChange"
  37. />
  38. </div>
  39. </div>
  40. </div>
  41. <!-- 主折线图:某时间段在线人数统计 -->
  42. <div class="page_jg_16"></div>
  43. <div class="chart_panel">
  44. <div class="panel_title">
  45. {{ currentOnlineLabel }}人数统计
  46. </div>
  47. <LineChart
  48. reportHeight="280px"
  49. :datax="mainLineChartData.datax"
  50. :datay="mainLineChartData.datay"
  51. :showCheckBox="false"
  52. :showBackground="true"
  53. :disableLabelRotate="true"
  54. :tooltipDetailData="chartDetailDataList"
  55. :clickDataPoint="true"
  56. unit="人"
  57. @dataPointClick="handleDataPointClick"
  58. />
  59. </div>
  60. <!-- 某时刻各学校在线人数统计(独立模块) -->
  61. <div class="page_jg_16"></div>
  62. <div class="chart_panel">
  63. <div class="panel_title">
  64. {{ currentAxisLabel }}{{ currentAxisLabel ? ' ' : '' }}{{ currentOnlineLabel }}人数统计
  65. </div>
  66. <MultipleBarCharts
  67. :datax="barChartData.datax"
  68. :datay="barChartData.datay"
  69. :height="260"
  70. :isClick="true"
  71. color="#5470C6"
  72. :barMaxWidth="50"
  73. :barMinWidth="50"
  74. :showDataZoom="false"
  75. @HandleChartClick="handleBarClick"
  76. />
  77. </div>
  78. <!-- 某学校当天在线人数统计(独立模块) -->
  79. <div class="page_jg_16"></div>
  80. <div class="chart_panel">
  81. <div class="panel_header">
  82. <div class="panel_title">{{ selectedSchoolName }} 当天在线人数统计</div>
  83. <div class="panel_actions">
  84. <el-button class="btn_export" @click="handleExport">
  85. <el-icon class="btn_icon"><Download /></el-icon>
  86. 导出Excel
  87. </el-button>
  88. <div class="view_toggle">
  89. <span
  90. :class="{ active: defaultVisualizationTypeTab === 'chart' }"
  91. @click="defaultVisualizationTypeTab = 'chart'"
  92. >分析图</span>
  93. <span
  94. :class="{ active: defaultVisualizationTypeTab === 'table' }"
  95. @click="defaultVisualizationTypeTab = 'table'"
  96. >分析表</span>
  97. </div>
  98. </div>
  99. </div>
  100. <template v-if="defaultVisualizationTypeTab === 'chart'">
  101. <LineChart
  102. reportHeight="280px"
  103. :datax="schoolLineChartData.datax"
  104. :datay="schoolLineChartData.datay"
  105. :showCheckBox="false"
  106. :showBackground="true"
  107. :disableLabelRotate="true"
  108. :tooltipDetailData="schoolChartDetailDataList"
  109. :showTooltipDetail="false"
  110. :showYAxisInteger="true"
  111. :clickDataPoint="false"
  112. unit="人"
  113. />
  114. </template>
  115. <template v-else>
  116. <div class="table_info">
  117. 共 {{ schoolTableData.length }} 条数据
  118. </div>
  119. <Table
  120. :tableColumns="tableColumns"
  121. :tableData="pagedTableData"
  122. :currentPage="currentPage"
  123. :pageSize="pageSize"
  124. :totalNum="schoolTableData.length"
  125. @paginationChange="handleTablePageChange"
  126. />
  127. </template>
  128. </div>
  129. </div>
  130. </div>
  131. </template>
  132. <script setup lang="ts">
  133. import TabSearch from '@/baseComponents/TabSearch.vue';
  134. import Table from '@/baseComponents/Table.vue';
  135. import { computed, reactive, ref, onMounted } from 'vue';
  136. import { User, Reading, Tickets, EditPen, DataAnalysis, Download } from '@element-plus/icons-vue';
  137. import { onlineTypeTablist, dateRangeTablist } from './tabSearch';
  138. import { tableColumns } from './table';
  139. import { getHeadData, getModuleList, getChartData } from '@/api/performanceMonitor';
  140. import MultipleBarCharts from '@/baseComponents/MultipleBarCharts.vue';
  141. import LineChart from '@/baseComponents/LineChart.vue';
  142. // ==================== 顶部看板 ====================
  143. const boardMsg = reactive({
  144. teacherOnline: 0,
  145. studentOnline: 0,
  146. scaneOnline: 0,
  147. examinationPaperOnline: 0,
  148. analysisOnline: 0,
  149. })
  150. const cardBoardList = computed(() => [
  151. { label: '教师在线', value: boardMsg.teacherOnline, icon: User },
  152. { label: '学生在线', value: boardMsg.studentOnline, icon: Reading },
  153. { label: '扫描人数', value: boardMsg.scaneOnline, icon: Tickets },
  154. { label: '阅卷人数', value: boardMsg.examinationPaperOnline, icon: EditPen },
  155. { label: '分析人数', value: boardMsg.analysisOnline, icon: DataAnalysis },
  156. ])
  157. // ==================== Tab 状态 ====================
  158. const defaultOnlineTypeTab = ref('1')
  159. const defaultDateRangeTab = ref('today')
  160. const defaultVisualizationTypeTab = ref('chart')
  161. const dateRangeToTimeType: Record<string, number> = {
  162. today: 1,
  163. '3': 2,
  164. '7': 3,
  165. '14': 4,
  166. custom: 5,
  167. }
  168. const currentOnlineLabel = computed(() => {
  169. const found = onlineTypeTablist.value.find(
  170. (item: any) => item.key === defaultOnlineTypeTab.value
  171. )
  172. return found?.label || '教师'
  173. })
  174. // ==================== 自定义日期范围 ====================
  175. const customDateRange = ref<[string, string] | null>(null)
  176. // 给接口传递的日期加上固定时分秒
  177. const customDateRangeForApi = computed(() => {
  178. if (customDateRange.value && customDateRange.value.length === 2) {
  179. return [
  180. customDateRange.value[0] + ' 00:00:00',
  181. customDateRange.value[1] + ' 23:59:59'
  182. ]
  183. }
  184. return ['', '']
  185. })
  186. const handleCustomDateChange = (val: [string, string] | null) => {
  187. if (val && val.length === 2) {
  188. fetchChartData()
  189. } else {
  190. // 清空日期选择器
  191. resetBarAndSchoolChart()
  192. fetchChartData()
  193. }
  194. }
  195. // ==================== 图表数据 ====================
  196. 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时']
  197. const mainLineChartData = reactive({
  198. datax: [] as string[],
  199. datay: [[]] as number[][],
  200. })
  201. const chartDetailDataList = ref<any[]>([])
  202. const schoolChartDetailDataList = ref<any[]>([])
  203. const handleDataPointClick = async (payload: { dataIndex: number; schoolList: any[]; rawData: any }) => {
  204. // 更新柱状图数据
  205. if (payload.schoolList && payload.schoolList.length > 0) {
  206. barChartData.datax = payload.schoolList.map((item: any) => item.xname ?? '')
  207. barChartData.datay = payload.schoolList.map((item: any) => item.count ?? 0)
  208. barChartSchoolList.value = payload.schoolList
  209. // 柱状图更新后,默认选中第一个柱子,获取其 id 调用接口更新学校折线图
  210. const firstSchoolId = payload.schoolList[0]?.id
  211. if (firstSchoolId != null && firstSchoolId !== undefined) {
  212. selectedSchoolName.value = payload.schoolList[0].xname ?? ''
  213. await fetchSchoolLineChartData(firstSchoolId)
  214. }
  215. } else {
  216. console.warn('schoolList 为空,无法更新柱状图')
  217. }
  218. // 直接使用 tooltip 中已经渲染正确的 title 作为当前轴标签
  219. if (payload.title) {
  220. currentAxisLabel.value = payload.title
  221. }
  222. }
  223. // 保存柱状图的完整学校列表数据
  224. const barChartSchoolList = ref<any[]>([])
  225. const barChartData = reactive({
  226. datax: [] as string[],
  227. datay: [] as number[],
  228. })
  229. const currentAxisLabel = ref('') // 当前点击的折线图数据点 x 轴标签(含"时"),直接取自 tooltip title
  230. const selectedSchoolName = ref('')
  231. const schoolLineChartData = reactive({
  232. datax: [] as string[],
  233. datay: [] as number[][],
  234. })
  235. const schoolTableData = ref<any[]>([])
  236. const currentPage = ref(1)
  237. const pageSize = ref(20)
  238. const pagedTableData = computed(() => {
  239. const start = (currentPage.value - 1) * pageSize.value
  240. const end = start + pageSize.value
  241. return schoolTableData.value.slice(start, end)
  242. })
  243. const handleTablePageChange = (payload: { type: string; val: number }) => {
  244. if (payload.type === 'currentPage') {
  245. currentPage.value = payload.val
  246. } else if (payload.type === 'pageSize') {
  247. pageSize.value = payload.val
  248. currentPage.value = 1
  249. }
  250. }
  251. // ==================== 交互处理 ====================
  252. const resetBarAndSchoolChart = () => {
  253. barChartData.datax = []
  254. barChartData.datay = []
  255. barChartSchoolList.value = []
  256. schoolLineChartData.datax = []
  257. schoolLineChartData.datay = []
  258. schoolChartDetailDataList.value = []
  259. selectedSchoolName.value = ''
  260. currentAxisLabel.value = ''
  261. }
  262. const HandleTabChange = (type: string, val: string) => {
  263. switch (type) {
  264. case 'onlineType':
  265. defaultOnlineTypeTab.value = val
  266. resetBarAndSchoolChart()
  267. fetchChartData()
  268. break
  269. case 'dateRange':
  270. defaultDateRangeTab.value = val
  271. if (val !== 'custom') {
  272. customDateRange.value = null
  273. resetBarAndSchoolChart()
  274. fetchChartData()
  275. }
  276. break
  277. case 'visualizationType':
  278. defaultVisualizationTypeTab.value = val
  279. break
  280. }
  281. }
  282. const handleBarClick = async (index: number, name: string) => {
  283. selectedSchoolName.value = name
  284. currentPage.value = 1
  285. // 获取当前柱子的 schoolId (schoolList 数组中 id 字段即为 schoolId)
  286. const schoolItem = barChartSchoolList.value[index]
  287. const schoolId = schoolItem?.id ?? 0
  288. // 调用接口获取学校折线图数据
  289. const timeType = dateRangeToTimeType[defaultDateRangeTab.value] ?? 1
  290. const isToday = defaultDateRangeTab.value === 'today'
  291. try {
  292. const res = await getChartData({
  293. schoolId,
  294. timeType,
  295. moduleCode: Number(defaultOnlineTypeTab.value) || 0,
  296. startTime: customDateRangeForApi.value[0],
  297. endTime: customDateRangeForApi.value[1],
  298. })
  299. console.log('点击柱子 getChartData 响应:', res)
  300. const data = res.data || res
  301. if (Array.isArray(data)) {
  302. if (isToday) {
  303. schoolLineChartData.datax = timeHours
  304. } else {
  305. schoolLineChartData.datax = data.map((item: any) => formatXname(item.xname ?? '', false))
  306. }
  307. schoolLineChartData.datay = [data.map((item: any) => item.count ?? 0)]
  308. schoolChartDetailDataList.value = data.map((item: any) => ({
  309. schoolList: item.schoolList || [],
  310. count: item.count ?? 0,
  311. }))
  312. }
  313. } catch (err) {
  314. console.error('点击柱子 getChartData 请求失败:', err)
  315. }
  316. }
  317. const handleExport = () => {
  318. // console.log('导出Excel', selectedSchoolName.value)
  319. }
  320. // ==================== 数据请求 ====================
  321. const fetchHeadData = async () => {
  322. try {
  323. const res = await getHeadData()
  324. const data = res.data || res
  325. boardMsg.teacherOnline = data.teacherLoginNum ?? 0
  326. boardMsg.studentOnline = data.studentLoginNum ?? 0
  327. boardMsg.scaneOnline = data.scannedNum ?? 0
  328. boardMsg.examinationPaperOnline = data.markingNum ?? 0
  329. boardMsg.analysisOnline = data.reportNum ?? 0
  330. } catch (err) {
  331. console.error('getHeadData 请求失败:', err)
  332. }
  333. }
  334. const fetchModuleList = async () => {
  335. try {
  336. const res = await getModuleList()
  337. const data = res.data || res
  338. if (!Array.isArray(data)) {
  339. console.warn('[fetchModuleList] data 不是数组,实际类型:', typeof data, data)
  340. return
  341. }
  342. const onlineList = data.map((item: any, idx: number) => {
  343. if (!item.code || !item.name) {
  344. console.warn(`[fetchModuleList] 第${idx}项缺少 code 或 name:`, item)
  345. }
  346. return {
  347. key: String(item.code ?? ''),
  348. label: String(item.name ?? ''),
  349. }
  350. })
  351. if (onlineList.length) {
  352. onlineTypeTablist.value = onlineList
  353. defaultOnlineTypeTab.value = onlineList[0].key
  354. } else {
  355. console.warn('[fetchModuleList] onlineList 为空,未更新标签列表')
  356. }
  357. fetchChartData()
  358. } catch (err) {
  359. console.error('[fetchModuleList] 请求失败:', err)
  360. }
  361. }
  362. const formatXname = (xname: string, isToday: boolean): string => {
  363. if (isToday) return xname
  364. if (!xname) return ''
  365. // Try ISO format: 2026-04-08 12:00:00 or 2026-04-08T12:00:00
  366. let match = xname.match(/(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2})/)
  367. if (match) {
  368. return `${parseInt(match[2], 10)}月${parseInt(match[3], 10)}日 ${parseInt(match[4], 10)}时`
  369. }
  370. // Try short format: 4-8 12:00 or 4-8T12:00
  371. match = xname.match(/^(\d{1,2})-(\d{1,2})[ T](\d{1,2})/)
  372. if (match) {
  373. return `${parseInt(match[1], 10)}月${parseInt(match[2], 10)}日 ${parseInt(match[3], 10)}时`
  374. }
  375. return xname
  376. }
  377. const fetchSchoolLineChartData = async (schoolId: number) => {
  378. const timeType = dateRangeToTimeType[defaultDateRangeTab.value] ?? 1
  379. const isToday = defaultDateRangeTab.value === 'today'
  380. try {
  381. const res = await getChartData({
  382. schoolId,
  383. timeType,
  384. moduleCode: Number(defaultOnlineTypeTab.value) || 0,
  385. startTime: customDateRangeForApi.value[0],
  386. endTime: customDateRangeForApi.value[1],
  387. })
  388. const data = res.data || res
  389. if (Array.isArray(data)) {
  390. if (isToday) {
  391. schoolLineChartData.datax = timeHours
  392. } else {
  393. schoolLineChartData.datax = data.map((item: any) => formatXname(item.xname ?? '', false))
  394. }
  395. schoolLineChartData.datay = [data.map((item: any) => item.count ?? 0)]
  396. schoolChartDetailDataList.value = data.map((item: any) => ({
  397. schoolList: item.schoolList || [],
  398. count: item.count ?? 0,
  399. }))
  400. }
  401. } catch (err) {
  402. console.error('fetchSchoolLineChartData 请求失败:', err)
  403. }
  404. }
  405. const fetchChartData = async () => {
  406. try {
  407. const timeType = dateRangeToTimeType[defaultDateRangeTab.value] ?? 1
  408. const isToday = defaultDateRangeTab.value === 'today'
  409. const res = await getChartData({
  410. schoolId: 0,
  411. timeType,
  412. moduleCode: Number(defaultOnlineTypeTab.value) || 0,
  413. startTime: customDateRangeForApi.value[0],
  414. endTime: customDateRangeForApi.value[1],
  415. })
  416. const data = res.data || res
  417. if (Array.isArray(data)) {
  418. if (isToday) {
  419. mainLineChartData.datax = timeHours
  420. } else {
  421. mainLineChartData.datax = data.map((item: any) => formatXname(item.xname ?? '', false))
  422. }
  423. mainLineChartData.datay = [data.map((item: any) => item.count ?? 0)]
  424. chartDetailDataList.value = data.map((item: any) => ({
  425. schoolList: item.schoolList || [],
  426. count: item.count ?? 0,
  427. }))
  428. // 初始化柱状图数据:取第一个数据点的 schoolList
  429. if (data.length > 0) {
  430. const firstItem = data[0]
  431. const schoolList = firstItem.schoolList || []
  432. if (schoolList.length > 0) {
  433. barChartData.datax = schoolList.map((item: any) => item.xname ?? '')
  434. barChartData.datay = schoolList.map((item: any) => item.count ?? 0)
  435. barChartSchoolList.value = schoolList
  436. }
  437. }
  438. }
  439. } catch (err) {
  440. console.error('getChartData 请求失败:', err)
  441. }
  442. }
  443. onMounted(() => {
  444. fetchHeadData()
  445. fetchModuleList()
  446. })
  447. </script>
  448. <style lang="scss" scoped>
  449. :deep(.page_item.end_item) {
  450. background: transparent;
  451. padding: 0;
  452. }
  453. .stat_cards {
  454. display: grid;
  455. grid-template-columns: repeat(5, 1fr);
  456. gap: 16px;
  457. .stat_card {
  458. height: 72px;
  459. display: flex;
  460. align-items: center;
  461. justify-content: space-between;
  462. padding: 0 20px;
  463. background: #fff;
  464. border-radius: 10px;
  465. overflow: hidden;
  466. border: 1px solid #E4E4E4;
  467. .stat_left {
  468. display: flex;
  469. align-items: center;
  470. gap: 8px;
  471. .stat_name {
  472. font-size: 16px;
  473. color: #666666;
  474. }
  475. }
  476. .stat_value {
  477. font-size: 30px;
  478. font-weight: 500;
  479. color: #333333;
  480. line-height: 1;
  481. }
  482. }
  483. }
  484. .tabs_container {
  485. background: #fff;
  486. border-radius: 10px;
  487. padding: 10px 16px;
  488. .date_range_area {
  489. display: flex;
  490. align-items: center;
  491. gap: 12px;
  492. .custom_date_picker {
  493. :deep(.el-input__wrapper) {
  494. width: 230px !important;
  495. height: 36px;
  496. line-height: 36px;
  497. }
  498. }
  499. }
  500. }
  501. .chart_panel {
  502. background: #fff;
  503. border: 1px solid #edf0f5;
  504. border-radius: 8px;
  505. padding: 20px;
  506. width: 100%;
  507. box-sizing: border-box;
  508. :deep(.echart_content) {
  509. width: 100%;
  510. .chart_box {
  511. width: 100%;
  512. }
  513. }
  514. .panel_title {
  515. font-size: 16px;
  516. font-weight: 600;
  517. color: #303133;
  518. margin-bottom: 12px;
  519. }
  520. .panel_header {
  521. display: flex;
  522. justify-content: space-between;
  523. align-items: center;
  524. margin-bottom: 12px;
  525. .panel_title {
  526. margin-bottom: 0;
  527. }
  528. .panel_actions {
  529. display: flex;
  530. align-items: center;
  531. gap: 12px;
  532. .btn_export {
  533. height: 32px;
  534. border: 1px solid #dcdfe6;
  535. background: #fff;
  536. color: #606266;
  537. display: flex;
  538. align-items: center;
  539. gap: 6px;
  540. .btn_icon {
  541. font-size: 14px;
  542. }
  543. }
  544. .view_toggle {
  545. display: flex;
  546. border: 1px solid #dcdfe6;
  547. border-radius: 4px;
  548. overflow: hidden;
  549. span {
  550. padding: 6px 16px;
  551. font-size: 14px;
  552. color: #606266;
  553. cursor: pointer;
  554. background: #fff;
  555. transition: all 0.2s;
  556. &.active {
  557. background: #2E64FA;
  558. color: #fff;
  559. }
  560. }
  561. }
  562. }
  563. }
  564. }
  565. .table_info {
  566. font-size: 13px;
  567. color: #909399;
  568. margin-bottom: 8px;
  569. }
  570. </style>