index.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. <template>
  2. <div class="p-6 flex justify-center min-h-screen">
  3. <div class="w-full mx-auto">
  4. <!-- 顶部筛选区 -->
  5. <div class="bg-white rounded-lg shadow-sm p-6 mb-6 h-25 flex items-center">
  6. <!-- 课程ID输入 -->
  7. <div class="flex-1 mr-4">
  8. <label class="block text-sm font-medium text-gray-700 mb-1">课程ID</label>
  9. <a-select class="w-full" v-model:value="filters.courseId" placeholder="请选择课程" allow-clear>
  10. <a-select-option v-for="item in courseinfoAllListOptions" :key="item.courseId" :value="item.courseId">
  11. {{ item.courseName }}
  12. </a-select-option>
  13. </a-select>
  14. </div>
  15. <!-- 日期范围选择 -->
  16. <div class="flex-1 mr-4">
  17. <label class="block text-sm font-medium text-gray-700 mb-1">开始时间</label>
  18. <a-date-picker v-model:value="startDate" class="w-full" placeholder="开始时间" />
  19. </div>
  20. <div class="flex-1 mr-4">
  21. <label class="block text-sm font-medium text-gray-700 mb-1">结束时间</label>
  22. <a-date-picker v-model:value="endDate" class="w-full" placeholder="结束时间" />
  23. </div>
  24. <!-- 操作按钮 -->
  25. <div class="flex space-x-2 ml-4 mt-6">
  26. <a-button @click="refreshData" :loading="loading">
  27. <template #icon>
  28. <ReloadOutlined />
  29. </template>
  30. 刷新
  31. </a-button>
  32. </div>
  33. </div>
  34. <!-- 核心数据看板 -->
  35. <div class="mb-6">
  36. <!-- 数据卡片 -->
  37. <div class="grid grid-cols-4 gap-6 mb-6">
  38. <div
  39. v-for="(card, index) in statsCards"
  40. :key="index"
  41. class="card-header bg-white p-5 rounded-lg shadow-sm"
  42. :class="card.borderClass"
  43. >
  44. <div class="flex justify-between items-start">
  45. <div>
  46. <div class="text-gray-500 text-sm">{{ card.title }}</div>
  47. <div class="font-size-30 font-bold text-gray-800 mt-2">{{ card.value.toLocaleString() }}</div>
  48. </div>
  49. <div :class="card.iconBgClass" class="p-2 rounded-full">
  50. <component :is="card.icon" :class="card.iconClass" class="text-xl" />
  51. </div>
  52. </div>
  53. </div>
  54. </div>
  55. <!-- 图表区 -->
  56. <div class="grid grid-cols-2 gap-6">
  57. <!-- 折线图 -->
  58. <div class="chart-container p-4 bg-white border border-gray-200 rounded">
  59. <h3 class="font-bold text-gray-800 mb-4">访问人数趋势</h3>
  60. <div ref="lineChartRef" class="h-48"></div>
  61. </div>
  62. <!-- 柱状图 -->
  63. <div class="chart-container p-4 bg-white border border-gray-200 rounded">
  64. <h3 class="font-bold text-gray-800 mb-4">练习平均提交数</h3>
  65. <div ref="barChartRef" class="h-48"></div>
  66. </div>
  67. </div>
  68. </div>
  69. <!-- 明细表格区 -->
  70. <div class="bg-white rounded-lg shadow-sm p-6">
  71. <div class="flex justify-between items-center mb-4">
  72. <h2 class="text-xl font-bold text-gray-800">学习明细数据</h2>
  73. <div class="text-sm text-gray-500">({{ formatDateRange() }})注:从查询条件时间范围落下来的</div>
  74. </div>
  75. <a-table
  76. :columns="tableColumns"
  77. :data-source="tableData"
  78. :pagination="false"
  79. :loading="loading"
  80. size="small"
  81. ></a-table>
  82. </div>
  83. </div>
  84. </div>
  85. </template>
  86. <script setup>
  87. import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
  88. import { ReloadOutlined, UserOutlined, EyeOutlined, FileTextOutlined, MessageOutlined } from '@ant-design/icons-vue'
  89. import * as echarts from 'echarts'
  90. import dayjs from 'dayjs'
  91. import { overviewLearningProgressApi } from '@/api/statisticalAnalysis/overviewLearningProgress'
  92. import { courseinfoAllList } from '@/api/semester/index.js'
  93. import { message } from 'ant-design-vue'
  94. // 响应式数据
  95. const loading = ref(false)
  96. const lineChartRef = ref(null)
  97. const barChartRef = ref(null)
  98. const courseinfoAllListOptions = ref([])
  99. let lineChart = null
  100. let barChart = null
  101. // 日期选择器
  102. const startDate = ref(dayjs('2025-08-04'))
  103. const endDate = ref(dayjs('2025-08-10'))
  104. // 筛选条件
  105. const filters = reactive({
  106. courseId: '',
  107. startTime: '2025-08-04',
  108. endTime: '2025-08-10'
  109. })
  110. // 统计卡片数据
  111. const statsCards = reactive([
  112. {
  113. title: '开课人数',
  114. value: 0,
  115. key: 'courseOpenStuNum',
  116. icon: UserOutlined,
  117. borderClass: 'border-l-4 !border-blue-500',
  118. iconBgClass: 'bg-blue-100',
  119. iconClass: 'text-blue-600'
  120. },
  121. {
  122. title: '课程访问次数',
  123. value: 0,
  124. key: 'courseViewNum',
  125. icon: EyeOutlined,
  126. borderClass: 'border-l-4 !border-green-500',
  127. iconBgClass: 'bg-green-100',
  128. iconClass: 'text-green-600'
  129. },
  130. {
  131. title: '提交数(作业、测验)',
  132. value: 0,
  133. key: 'paperSubmitNum',
  134. icon: FileTextOutlined,
  135. borderClass: 'border-l-4 !border-orange-500',
  136. iconBgClass: 'bg-orange-100',
  137. iconClass: 'text-orange-500'
  138. },
  139. {
  140. title: '互动数(发帖/回帖)',
  141. value: 0,
  142. key: 'interactionNum',
  143. icon: MessageOutlined,
  144. borderClass: 'border-l-4 !border-blue-500',
  145. iconBgClass: 'bg-blue-100',
  146. iconClass: 'text-blue-600'
  147. }
  148. ])
  149. const tableColumns = [
  150. {
  151. title: '课程名称',
  152. dataIndex: 'courseName',
  153. key: 'courseName'
  154. },
  155. {
  156. title: '开课人数',
  157. dataIndex: 'openStuNum',
  158. key: 'openStuNum'
  159. },
  160. {
  161. title: '课程访问量',
  162. dataIndex: 'viewCount',
  163. key: 'viewCount'
  164. },
  165. {
  166. title: '讲义访问量',
  167. dataIndex: 'stayTime',
  168. key: 'stayTime'
  169. },
  170. {
  171. title: '练习提交量',
  172. dataIndex: 'teachMaterialsNum',
  173. key: 'teachMaterialsNum'
  174. }
  175. ]
  176. // 图表数据
  177. const chartData = reactive({
  178. visitTrend: [],
  179. submissionTrend: []
  180. })
  181. // 表格数据
  182. const tableData = ref([])
  183. // 初始化折线图
  184. const initLineChart = () => {
  185. if (!lineChartRef.value) return
  186. lineChart = echarts.init(lineChartRef.value)
  187. updateLineChart()
  188. }
  189. // 更新折线图数据
  190. const updateLineChart = () => {
  191. if (!lineChart) return
  192. const option = {
  193. grid: { top: 30, right: 20, bottom: 20, left: 40 },
  194. tooltip: { trigger: 'axis' },
  195. xAxis: {
  196. type: 'category',
  197. data: chartData.visitTrend.map((item) => item.month)
  198. },
  199. yAxis: { type: 'value', name: '访问人数' },
  200. series: [
  201. {
  202. name: '访问人数',
  203. type: 'line',
  204. smooth: true,
  205. symbol: 'circle',
  206. symbolSize: 8,
  207. data: chartData.visitTrend.map((item) => item.viewTendencyNum),
  208. lineStyle: { color: '#3A7BFF', width: 3 },
  209. itemStyle: { color: '#3A7BFF' },
  210. areaStyle: {
  211. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  212. { offset: 0, color: 'rgba(58, 123, 255, 0.3)' },
  213. { offset: 1, color: 'rgba(58, 123, 255, 0.05)' }
  214. ])
  215. }
  216. }
  217. ]
  218. }
  219. lineChart.setOption(option)
  220. }
  221. // 初始化柱状图
  222. const initBarChart = () => {
  223. if (!barChartRef.value) return
  224. barChart = echarts.init(barChartRef.value)
  225. updateBarChart()
  226. }
  227. // 更新柱状图数据
  228. const updateBarChart = () => {
  229. if (!barChart) return
  230. const option = {
  231. grid: { top: 30, right: 20, bottom: 20, left: 40 },
  232. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  233. xAxis: {
  234. type: 'category',
  235. data: chartData.submissionTrend.map((item) => item.month)
  236. },
  237. yAxis: { type: 'value', name: '提交数' },
  238. series: [
  239. {
  240. name: '练习提交数',
  241. type: 'bar',
  242. barWidth: 28,
  243. data: chartData.submissionTrend.map((item) => item.SubmeitTendency),
  244. itemStyle: {
  245. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  246. { offset: 0, color: '#5B8EFF' },
  247. { offset: 1, color: '#3A7BFF' }
  248. ]),
  249. borderRadius: [4, 4, 0, 0]
  250. }
  251. }
  252. ]
  253. }
  254. barChart.setOption(option)
  255. }
  256. // 格式化日期范围显示
  257. const formatDateRange = () => {
  258. const start = startDate.value ? startDate.value.format('YYYY/MM/DD') : filters.startTime
  259. const end = endDate.value ? endDate.value.format('YYYY/MM/DD') : filters.endTime
  260. return `${start}至${end}`
  261. }
  262. // 获取统计概览数据
  263. const fetchOverviewStats = async () => {
  264. try {
  265. const params = {
  266. courseId: filters.courseId,
  267. startTime: startDate.value ? startDate.value.format('YYYY-MM-DD') : filters.startTime,
  268. endTime: endDate.value ? endDate.value.format('YYYY-MM-DD') : filters.endTime
  269. }
  270. const data = await overviewLearningProgressApi.getTopFundamentalDetail(params)
  271. // 更新统计卡片数据
  272. statsCards.forEach((card) => {
  273. card.value = data[card.key] || 0
  274. })
  275. } catch (error) {
  276. console.error('获取统计数据失败:', error)
  277. message.error('获取统计数据失败')
  278. }
  279. }
  280. // 获取趋势数据
  281. const fetchTrendData = async () => {
  282. try {
  283. const params = {
  284. courseId: filters.courseId,
  285. startTime: startDate.value ? startDate.value.format('YYYY-MM-DD') : filters.startTime,
  286. endTime: endDate.value ? endDate.value.format('YYYY-MM-DD') : filters.endTime
  287. }
  288. // 并行获取访问趋势和提交趋势数据
  289. const [visitData, submitData] = await Promise.all([
  290. overviewLearningProgressApi.getViewTendency(params),
  291. overviewLearningProgressApi.getPaperSubmitTendency(params)
  292. ])
  293. // 更新图表数据
  294. chartData.visitTrend = visitData || []
  295. chartData.submissionTrend = submitData || []
  296. // 更新图表
  297. updateLineChart()
  298. updateBarChart()
  299. } catch (error) {
  300. console.error('获取趋势数据失败:', error)
  301. message.error('获取趋势数据失败')
  302. }
  303. }
  304. // 获取学习明细数据
  305. const fetchStudyDetail = async () => {
  306. try {
  307. const params = {
  308. courseId: filters.courseId,
  309. startTime: startDate.value ? startDate.value.format('YYYY-MM-DD') : filters.startTime,
  310. endTime: endDate.value ? endDate.value.format('YYYY-MM-DD') : filters.endTime
  311. }
  312. const data = await overviewLearningProgressApi.getStudyDetail(params)
  313. // 更新表格数据
  314. tableData.value = (data.records || []).map((item, index) => ({
  315. ...item,
  316. key: item.courseId || index + 1
  317. }))
  318. } catch (error) {
  319. console.error('获取学习明细数据失败:', error)
  320. message.error('获取学习明细数据失败')
  321. }
  322. }
  323. // 刷新所有数据
  324. const refreshData = async () => {
  325. loading.value = true
  326. try {
  327. // 更新筛选条件
  328. filters.startTime = startDate.value ? startDate.value.format('YYYY-MM-DD') : filters.startTime
  329. filters.endTime = endDate.value ? endDate.value.format('YYYY-MM-DD') : filters.endTime
  330. await Promise.all([fetchOverviewStats(), fetchTrendData(), fetchStudyDetail()])
  331. } catch (error) {
  332. console.error('刷新数据失败:', error)
  333. } finally {
  334. loading.value = false
  335. }
  336. }
  337. // 窗口大小变化处理
  338. const handleResize = () => {
  339. if (lineChart) lineChart.resize()
  340. if (barChart) barChart.resize()
  341. }
  342. const getCourseinfoAllList = () => {
  343. courseinfoAllList()
  344. .then((res) => {
  345. courseinfoAllListOptions.value = res.data
  346. })
  347. .catch((err) => {
  348. console.log(err)
  349. })
  350. }
  351. // 监听筛选条件变化
  352. watch(
  353. () => [filters.courseId, startDate.value, endDate.value],
  354. () => {
  355. refreshData()
  356. },
  357. { deep: true }
  358. )
  359. // 生命周期
  360. onMounted(async () => {
  361. await nextTick()
  362. initLineChart()
  363. initBarChart()
  364. window.addEventListener('resize', handleResize)
  365. // 初始化数据
  366. await refreshData()
  367. getCourseinfoAllList()
  368. })
  369. onUnmounted(() => {
  370. if (lineChart) {
  371. lineChart.dispose()
  372. lineChart = null
  373. }
  374. if (barChart) {
  375. barChart.dispose()
  376. barChart = null
  377. }
  378. window.removeEventListener('resize', handleResize)
  379. })
  380. </script>
  381. <style scoped>
  382. .card-header {
  383. border-left: 4px solid;
  384. }
  385. .chart-container {
  386. background-color: white;
  387. border: 1px solid #e4e7ed;
  388. border-radius: 4px;
  389. }
  390. .font-size-30 {
  391. font-size: 30px;
  392. }
  393. </style>