courseStatistics.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. <template>
  2. <div class="course-statistics-container">
  3. <!-- 筛选条件 -->
  4. <el-form :inline="true" :model="queryParams" class="demo-form-inline" style="margin-bottom: 20px;">
  5. <el-form-item label="小节名称">
  6. <el-input
  7. v-model="queryParams.videoName"
  8. placeholder="请输入小节名称关键字"
  9. clearable
  10. size="small"
  11. style="width: 300px;"
  12. @keyup.enter.native="handleQuery"
  13. />
  14. </el-form-item>
  15. <el-form-item>
  16. <el-button type="primary" size="small" @click="handleQuery">查询</el-button>
  17. <el-button size="small" @click="resetQuery">重置</el-button>
  18. </el-form-item>
  19. </el-form>
  20. <!-- 数据表格 -->
  21. <el-table
  22. v-loading="loading"
  23. :data="list"
  24. border
  25. style="width: 100%"
  26. >
  27. <el-table-column label="课程名称" align="center" prop="courseName" width="200" />
  28. <el-table-column label="小节" align="center" prop="videoName" width="210" />
  29. <el-table-column label="开课状态" align="center" prop="openStatus" width="120">
  30. <template slot-scope="scope">
  31. <el-tag :type="scope.row.openStatus === '已开课' ? 'success' : scope.row.openStatus === '已结束' ? 'info' : 'warning'">
  32. {{ scope.row.openStatus }}
  33. </el-tag>
  34. </template>
  35. </el-table-column>
  36. <el-table-column label="营期时间" align="center" prop="periodDate" width="120" />
  37. <el-table-column label="开始时间" align="center" prop="startTime" width="180" />
  38. <el-table-column label="结束时间" align="center" prop="endTime" width="180" />
  39. <el-table-column label="操作" align="center" fixed="right">
  40. <template slot-scope="scope">
  41. <el-button
  42. type="text"
  43. size="small"
  44. @click="handleViewDetail(scope.row)"
  45. >
  46. 查看详情
  47. </el-button>
  48. </template>
  49. </el-table-column>
  50. </el-table>
  51. <!-- 分页 -->
  52. <pagination
  53. v-show="total > 0"
  54. :total="total"
  55. :page.sync="queryParams.pageNum"
  56. :limit.sync="queryParams.pageSize"
  57. @pagination="getList"
  58. />
  59. <!-- 课程详情抽屉 -->
  60. <el-drawer
  61. title="课程小结详情"
  62. :visible.sync="detailDialog.visible"
  63. direction="rtl"
  64. size="60%"
  65. :close-on-click-modal="false"
  66. append-to-body
  67. class="course-detail-drawer"
  68. >
  69. <div v-loading="detailDialog.loading" style="padding: 20px;">
  70. <!-- 第一块:总体数据 -->
  71. <el-card class="detail-card" shadow="never">
  72. <div slot="header" class="card-header">
  73. <span>总体数据</span>
  74. <el-button type="primary" size="small" @click="handleViewUserDetail">查看用户详情</el-button>
  75. </div>
  76. <el-row :gutter="20">
  77. <el-col :span="6">
  78. <div class="stat-item">
  79. <div class="stat-label">视频时长</div>
  80. <div class="stat-value">{{ formatDuration(detailDialog.data.videoDuration) }}</div>
  81. </div>
  82. </el-col>
  83. <el-col :span="6">
  84. <div class="stat-item">
  85. <div class="stat-label">累计观看人数</div>
  86. <div class="stat-value">{{ detailDialog.data.totalWatchCount || 0 }}</div>
  87. </div>
  88. </el-col>
  89. <el-col :span="6">
  90. <div class="stat-item">
  91. <div class="stat-label">累计完课人数</div>
  92. <div class="stat-value">{{ detailDialog.data.totalCompleteCount || 0 }}</div>
  93. </div>
  94. </el-col>
  95. <el-col :span="6">
  96. <div class="stat-item">
  97. <div class="stat-label">到课完课率</div>
  98. <div class="stat-value">{{ detailDialog.data.completeRate || '0%' }}</div>
  99. </div>
  100. </el-col>
  101. </el-row>
  102. </el-card>
  103. <!-- 第二块:首次点播数据 -->
  104. <!-- <el-card class="detail-card" shadow="never">-->
  105. <!-- <div slot="header" class="card-header">-->
  106. <!-- <span>首次点播数据</span>-->
  107. <!-- </div>-->
  108. <!-- <el-row :gutter="20">-->
  109. <!-- <el-col :span="6">-->
  110. <!-- <div class="stat-item">-->
  111. <!-- <div class="stat-label">观看人数</div>-->
  112. <!-- <div class="stat-value">{{ detailDialog.data.firstWatchCount || 0 }}</div>-->
  113. <!-- </div>-->
  114. <!-- </el-col>-->
  115. <!-- <el-col :span="6">-->
  116. <!-- <div class="stat-item">-->
  117. <!-- <div class="stat-label">>=20分钟人数(首次)</div>-->
  118. <!-- <div class="stat-value">{{ detailDialog.data.firstWatch20MinCount || 0 }}</div>-->
  119. <!-- </div>-->
  120. <!-- </el-col>-->
  121. <!-- <el-col :span="6">-->
  122. <!-- <div class="stat-item">-->
  123. <!-- <div class="stat-label">>=30分钟人数(首次)</div>-->
  124. <!-- <div class="stat-value">{{ detailDialog.data.firstWatch30MinCount || 0 }}</div>-->
  125. <!-- </div>-->
  126. <!-- </el-col>-->
  127. <!-- <el-col :span="6">-->
  128. <!-- <div class="stat-item">-->
  129. <!-- <div class="stat-label">到课完课率首次(>=20分钟)</div>-->
  130. <!-- <div class="stat-value">{{ detailDialog.data.firstCompleteRate20Min || '0%' }}</div>-->
  131. <!-- </div>-->
  132. <!-- </el-col>-->
  133. <!-- <el-col :span="6">-->
  134. <!-- <div class="stat-item">-->
  135. <!-- <div class="stat-label">到课完课率首次(>=30分钟)</div>-->
  136. <!-- <div class="stat-value">{{ detailDialog.data.firstCompleteRate30Min || '0%' }}</div>-->
  137. <!-- </div>-->
  138. <!-- </el-col>-->
  139. <!-- </el-row>-->
  140. <!-- </el-card>-->
  141. <!-- 第三块:实际看课数据(修复后) -->
  142. <el-card class="detail-card" shadow="never">
  143. <div slot="header" class="card-header">
  144. <span>实际看课数据</span>
  145. </div>
  146. <el-row :gutter="20">
  147. <el-col :span="6">
  148. <div class="stat-item">
  149. <div class="stat-label">实际到课人数</div>
  150. <div class="stat-value">{{ detailDialog.data.totalStudents || 0 }}</div>
  151. </div>
  152. </el-col>
  153. <el-col :span="6">
  154. <div class="stat-item">
  155. <div class="stat-label">实际完课人数</div>
  156. <div class="stat-value">{{ detailDialog.data.completedCount || 0 }}</div>
  157. </div>
  158. </el-col>
  159. <el-col :span="6">
  160. <div class="stat-item">
  161. <div class="stat-label">实际完课率</div>
  162. <div class="stat-value">{{ detailDialog.data.actualCompletionRate ? detailDialog.data.actualCompletionRate + '%' : '0%' }}</div>
  163. </div>
  164. </el-col>
  165. <el-col :span="6">
  166. <div class="stat-item">
  167. <div class="stat-label">人均看课时长</div>
  168. <div class="stat-value">{{ formatDuration(detailDialog.data.avgWatchDurationMinutes)}}</div>
  169. </div>
  170. </el-col>
  171. <el-col :span="6">
  172. <div class="stat-item">
  173. <div class="stat-label">人均完课时长</div>
  174. <div class="stat-value">{{ formatDuration(detailDialog.data.avgCompletedDuration)}}</div>
  175. </div>
  176. </el-col>
  177. <el-col :span="6">
  178. <div class="stat-item">
  179. <div class="stat-label">人均完课完播率</div>
  180. <div class="stat-value">{{ detailDialog.data.avgCompletionPlaybackRate ? detailDialog.data.avgCompletionPlaybackRate + '%' : '0%' }}</div>
  181. </div>
  182. </el-col>
  183. </el-row>
  184. </el-card>
  185. <!-- 第四块:订单数据 -->
  186. <el-card class="detail-card" shadow="never">
  187. <div slot="header" class="card-header">
  188. <span>订单数据</span>
  189. </div>
  190. <el-row :gutter="20">
  191. <el-col :span="6">
  192. <div class="stat-item">
  193. <div class="stat-label">GMV</div>
  194. <div class="stat-value">¥{{ detailDialog.data.gmv || '0.00' }}</div>
  195. </div>
  196. </el-col>
  197. <el-col :span="6">
  198. <div class="stat-item">
  199. <div class="stat-label">付费人数</div>
  200. <div class="stat-value">{{ detailDialog.data.paidUserCount || 0 }}</div>
  201. </div>
  202. </el-col>
  203. <el-col :span="6">
  204. <div class="stat-item">
  205. <div class="stat-label">付费单数</div>
  206. <div class="stat-value">{{ detailDialog.data.paidOrderCount || 0 }}</div>
  207. </div>
  208. </el-col>
  209. <el-col :span="6">
  210. <div class="stat-item">
  211. <div class="stat-label">总付费转换率</div>
  212. <div class="stat-value">{{ detailDialog.data.totalPaidConversionRate || '0%' }}</div>
  213. </div>
  214. </el-col>
  215. <el-col :span="6">
  216. <div class="stat-item">
  217. <div class="stat-label">20min付费转化率</div>
  218. <div class="stat-value">{{ detailDialog.data.paidConversionRate20Min || '0%' }}</div>
  219. </div>
  220. </el-col>
  221. <el-col :span="6">
  222. <div class="stat-item">
  223. <div class="stat-label">完课R值</div>
  224. <div class="stat-value">{{ detailDialog.data.completeRValue || '0.00' }}</div>
  225. </div>
  226. </el-col>
  227. <el-col :span="6">
  228. <div class="stat-item">
  229. <div class="stat-label">领取红包/积分人数</div>
  230. <div class="stat-value">{{ detailDialog.data.redPacketUserCount || 0 }}</div>
  231. </div>
  232. </el-col>
  233. <el-col :span="6">
  234. <div class="stat-item">
  235. <div class="stat-label">答题人数</div>
  236. <div class="stat-value">{{ detailDialog.data.answerUserCount || 0 }}</div>
  237. </div>
  238. </el-col>
  239. </el-row>
  240. </el-card>
  241. <!-- 第五块:单品销量统计 -->
  242. <el-card class="detail-card" shadow="never">
  243. <div slot="header" class="card-header">
  244. <span>商品销量统计</span>
  245. </div>
  246. <el-table
  247. :data="detailDialog.data.productList || []"
  248. border
  249. style="width: 100%"
  250. >
  251. <el-table-column label="商品名称" align="center" prop="productName" />
  252. <el-table-column label="销量" align="center" prop="salesCount" />
  253. <el-table-column label="销售额" align="center" prop="salesAmount">
  254. <template slot-scope="scope">
  255. ¥{{ scope.row.salesAmount || '0.00' }}
  256. </template>
  257. </el-table-column>
  258. </el-table>
  259. </el-card>
  260. </div>
  261. </el-drawer>
  262. <!-- 用户详情抽屉 -->
  263. <el-drawer
  264. title="用户看课数据"
  265. :visible.sync="userDetailDialog.visible"
  266. direction="rtl"
  267. size="60%"
  268. :close-on-click-modal="false"
  269. append-to-body
  270. >
  271. <div v-loading="userDetailDialog.loading" style="padding: 20px;">
  272. <div style="margin-bottom: 20px; text-align: right;">
  273. <el-button type="primary" size="small" @click="handleExportUserDetail">导出用户详情</el-button>
  274. </div>
  275. <el-table
  276. :data="userDetailDialog.list"
  277. border
  278. style="width: 100%"
  279. >
  280. <el-table-column label="用户名称" align="center" prop="userName" width="150" />
  281. <!-- <el-table-column label="观看时长" align="center" prop="watchDuration" width="120">-->
  282. <!-- <template slot-scope="scope">-->
  283. <!-- {{ formatDuration(scope.row.watchDuration) }}-->
  284. <!-- </template>-->
  285. <!-- </el-table-column>-->
  286. <el-table-column label="观看时长" align="center" prop="repeatWatchDuration" width="150">
  287. <template slot-scope="scope">
  288. {{ formatDuration(scope.row.repeatWatchDuration) }}
  289. </template>
  290. </el-table-column>
  291. <el-table-column label="订单数" align="center" prop="orderCount" width="100" />
  292. <el-table-column label="订单金额" align="center" prop="orderAmount" width="120">
  293. <template slot-scope="scope">
  294. ¥{{ scope.row.orderAmount || '0.00' }}
  295. </template>
  296. </el-table-column>
  297. <el-table-column label="分公司名称" align="center" prop="companyName" width="150" />
  298. <el-table-column label="销售名称" align="center" prop="salesName" width="120" />
  299. <el-table-column
  300. label="课程评分"
  301. align="center"
  302. prop="courseRating"
  303. min-width="260"
  304. show-overflow-tooltip
  305. >
  306. <template slot-scope="scope">
  307. <span class="course-rating-cell">{{ formatCourseRating(scope.row.courseRating) }}</span>
  308. </template>
  309. </el-table-column>
  310. </el-table>
  311. <!-- 分页 -->
  312. <pagination
  313. v-show="userDetailDialog.total > 0"
  314. :total="userDetailDialog.total"
  315. :page.sync="userDetailDialog.queryParams.pageNum"
  316. :limit.sync="userDetailDialog.queryParams.pageSize"
  317. @pagination="getUserDetailList"
  318. />
  319. </div>
  320. </el-drawer>
  321. </div>
  322. </template>
  323. <script>
  324. import { getDays } from "@/api/course/userCoursePeriod";
  325. import { getCourseStatisticsDetail, getCourseStatisticsUserDetailList, exportCourseStatisticsUserDetail } from "@/api/course/courseWatchLog";
  326. import { download } from "@/utils/common";
  327. export default {
  328. name: "CourseStatistics",
  329. props: {
  330. periodId: {
  331. type: [String, Number],
  332. default: null
  333. },
  334. active: {
  335. type: Boolean,
  336. default: false
  337. }
  338. },
  339. data() {
  340. return {
  341. loading: false,
  342. list: [],
  343. total: 0,
  344. queryParams: {
  345. pageNum: 1,
  346. pageSize: 10,
  347. videoName: null,
  348. periodId: null
  349. },
  350. // 详情弹窗
  351. detailDialog: {
  352. visible: false,
  353. loading: false,
  354. data: {}
  355. },
  356. // 用户详情弹窗
  357. userDetailDialog: {
  358. visible: false,
  359. loading: false,
  360. list: [],
  361. total: 0,
  362. queryParams: {
  363. pageNum: 1,
  364. pageSize: 10,
  365. videoId: null,
  366. periodId: null
  367. }
  368. }
  369. };
  370. },
  371. watch: {
  372. active(newVal) {
  373. if (newVal && this.periodId) {
  374. this.queryParams.periodId = this.periodId;
  375. this.getList();
  376. }
  377. },
  378. periodId(newVal) {
  379. if (newVal && this.active) {
  380. this.queryParams.periodId = newVal;
  381. this.getList();
  382. }
  383. }
  384. },
  385. mounted() {
  386. if (this.active && this.periodId) {
  387. this.queryParams.periodId = this.periodId;
  388. this.getList();
  389. }
  390. },
  391. methods: {
  392. /** 查询列表 */
  393. getList() {
  394. if (!this.queryParams.periodId) {
  395. this.loading = false;
  396. return;
  397. }
  398. this.loading = true;
  399. getDays(this.queryParams).then(response => {
  400. if (response.code === 200) {
  401. // 映射字段并格式化数据
  402. this.list = (response.rows || []).map(item => {
  403. return {
  404. ...item,
  405. // 映射字段
  406. periodDate: item.dayDate,
  407. startTime: item.startDateTime,
  408. endTime: item.endDateTime,
  409. // 格式化开课状态
  410. openStatus: this.formatCourseStatus(item.status),
  411. // 小节状态(可以根据需要设置,这里先使用开课状态)
  412. videoStatus: this.formatCourseStatus(item.status)
  413. };
  414. });
  415. this.total = response.total || 0;
  416. } else {
  417. this.$message.error(response.msg || '查询失败');
  418. this.list = [];
  419. this.total = 0;
  420. }
  421. this.loading = false;
  422. }).catch(error => {
  423. this.$message.error('查询失败:' + (error.message || '未知错误'));
  424. this.loading = false;
  425. this.list = [];
  426. this.total = 0;
  427. });
  428. },
  429. /** 格式化课程状态 */
  430. formatCourseStatus(status) {
  431. const statusMap = {
  432. 0: '未开始',
  433. 1: '已开课',
  434. 2: '已结束'
  435. };
  436. return statusMap[status] || '未知状态';
  437. },
  438. /** 搜索按钮操作 */
  439. handleQuery() {
  440. this.queryParams.pageNum = 1;
  441. this.getList();
  442. },
  443. /** 重置按钮操作 */
  444. resetQuery() {
  445. this.queryParams.videoName = null;
  446. this.queryParams.pageNum = 1;
  447. this.getList();
  448. },
  449. /** 查看详情 */
  450. handleViewDetail(row) {
  451. this.detailDialog.visible = true;
  452. this.detailDialog.loading = true;
  453. const videoId = row.videoId || row.id;
  454. const periodId = this.queryParams.periodId;
  455. if (!videoId || !periodId) {
  456. this.$message.error('视频ID或营期ID不能为空');
  457. this.detailDialog.loading = false;
  458. return;
  459. }
  460. getCourseStatisticsDetail(videoId, periodId).then(response => {
  461. if (response.code === 200 && response.data) {
  462. const data = response.data;
  463. // 安全获取实际看课数据对象
  464. const actualVO = data.fsActualCompletionVO || {};
  465. // 设置总体数据
  466. this.detailDialog.data = {
  467. // 总体数据
  468. videoDuration: data.videoDuration ?? 0,
  469. totalWatchCount: data.totalWatchCount ?? 0,
  470. totalCompleteCount: data.totalCompleteCount ?? 0,
  471. completeRate: data.completeRate != null ? Number(data.completeRate).toFixed(2) + '%' : '0%',
  472. // 首次点播数据
  473. firstWatchCount: data.firstWatchCount ?? 0,
  474. firstWatch20MinCount: data.firstWatch20MinCount ?? 0,
  475. firstWatch30MinCount: data.firstWatch30MinCount ?? 0,
  476. firstCompleteRate20Min: data.firstCompleteRate20Min != null ? Number(data.firstCompleteRate20Min).toFixed(2) + '%' : '0%',
  477. firstCompleteRate30Min: data.firstCompleteRate30Min != null ? Number(data.firstCompleteRate30Min).toFixed(2) + '%' : '0%',
  478. // 实际看课数据(修复后,所有字段都有默认值)
  479. totalStudents: actualVO.totalStudents ?? 0,
  480. completedCount: actualVO.completedCount ?? 0,
  481. actualCompletionRate: actualVO.actualCompletionRate != null ? Number(actualVO.actualCompletionRate).toFixed(2) : '0',
  482. avgWatchDurationMinutes: actualVO.avgWatchDurationMinutes ?? 0,
  483. avgCompletedDuration: actualVO.avgCompletedDuration ?? 0,
  484. avgCompletionPlaybackRate: actualVO.avgCompletionPlaybackRate != null ? Number(actualVO.avgCompletionPlaybackRate).toFixed(2) : '0',
  485. // 订单数据
  486. gmv: data.gmv != null ? Number(data.gmv).toFixed(2) : '0.00',
  487. paidUserCount: data.paidUserCount ?? 0,
  488. paidOrderCount: data.paidOrderCount ?? 0,
  489. totalPaidConversionRate: data.totalPaidConversionRate != null ? Number(data.totalPaidConversionRate).toFixed(2) + '%' : '0%',
  490. paidConversionRate20Min: data.paidConversionRate20Min != null ? Number(data.paidConversionRate20Min).toFixed(2) + '%' : '0%',
  491. completeRValue: data.completeRValue != null ? Number(data.completeRValue).toFixed(2) : '0.00',
  492. redPacketUserCount: data.redPacketUserCount ?? 0,
  493. answerUserCount: data.answerUserCount ?? 0,
  494. productList: (data.productList || []).map(p => ({
  495. productName: p.productName || '未知商品',
  496. salesCount: p.salesCount ?? 0,
  497. salesAmount: p.salesAmount != null ? Number(p.salesAmount).toFixed(2) : '0.00'
  498. })),
  499. videoId: videoId,
  500. id: row.id
  501. };
  502. } else {
  503. this.$message.error(response.msg || '获取数据失败');
  504. }
  505. this.detailDialog.loading = false;
  506. }).catch(error => {
  507. this.$message.error('获取数据失败:' + (error.message || '未知错误'));
  508. this.detailDialog.loading = false;
  509. });
  510. },
  511. /** 查看用户详情 */
  512. handleViewUserDetail() {
  513. this.userDetailDialog.visible = true;
  514. this.userDetailDialog.queryParams.videoId = this.detailDialog.data.videoId || this.detailDialog.data.id;
  515. this.userDetailDialog.queryParams.periodId = this.queryParams.periodId;
  516. this.userDetailDialog.queryParams.pageNum = 1;
  517. this.getUserDetailList();
  518. },
  519. /** 获取用户详情列表 */
  520. getUserDetailList() {
  521. const videoId = this.userDetailDialog.queryParams.videoId;
  522. const periodId = this.userDetailDialog.queryParams.periodId;
  523. if (!videoId || !periodId) {
  524. this.$message.error('视频ID或营期ID不能为空');
  525. this.userDetailDialog.loading = false;
  526. return;
  527. }
  528. this.userDetailDialog.loading = true;
  529. getCourseStatisticsUserDetailList(this.userDetailDialog.queryParams).then(response => {
  530. if (response.code === 200 && response.data) {
  531. const raw = response.data;
  532. // 兼容 PageInfo 直接放在 data,或再包一层 data
  533. const d = raw.data != null && raw.list == null && raw.rows == null ? raw.data : raw;
  534. this.userDetailDialog.list = d.list || d.rows || [];
  535. this.userDetailDialog.total = d.total != null ? d.total : 0;
  536. } else {
  537. this.userDetailDialog.list = [];
  538. this.userDetailDialog.total = 0;
  539. }
  540. this.userDetailDialog.loading = false;
  541. }).catch(() => {
  542. this.userDetailDialog.list = [];
  543. this.userDetailDialog.total = 0;
  544. this.userDetailDialog.loading = false;
  545. });
  546. },
  547. /** 导出用户详情(按创建时间倒序,最多50000条) */
  548. handleExportUserDetail() {
  549. const videoId = this.userDetailDialog.queryParams.videoId;
  550. const periodId = this.userDetailDialog.queryParams.periodId;
  551. if (!videoId || !periodId) {
  552. this.$message.error('请先查看用户详情');
  553. return;
  554. }
  555. this.$confirm('是否确认导出用户看课数据?(按创建时间倒序,最多50000条)', '提示', {
  556. confirmButtonText: '确定',
  557. cancelButtonText: '取消',
  558. type: 'warning'
  559. }).then(() => {
  560. exportCourseStatisticsUserDetail(videoId, periodId).then(response => {
  561. if (response.code === 200 && response.msg) {
  562. download(response.msg);
  563. this.$message.success('导出成功');
  564. } else {
  565. this.$message.error(response.msg || '导出失败');
  566. }
  567. }).catch(() => {
  568. this.$message.error('导出失败');
  569. });
  570. }).catch(() => {});
  571. },
  572. /**
  573. * 课程评分:后端在关闭「看课校验答案」时返回答题 JSON(courseRating);否则为空
  574. */
  575. formatCourseRating(val) {
  576. if (val == null || val === '') {
  577. return '—';
  578. }
  579. if (typeof val === 'string') {
  580. const s = val.trim();
  581. if (!s) return '—';
  582. try {
  583. const parsed = JSON.parse(s);
  584. return typeof parsed === 'object' ? JSON.stringify(parsed) : String(val);
  585. } catch (e) {
  586. return val;
  587. }
  588. }
  589. try {
  590. return typeof val === 'object' ? JSON.stringify(val) : String(val);
  591. } catch (e) {
  592. return String(val);
  593. }
  594. },
  595. /** 格式化时长 */
  596. formatDuration(seconds) {
  597. if (seconds == null || isNaN(seconds)) return '0秒';
  598. let total = Math.abs(seconds);
  599. const hours = Math.floor(total / 3600);
  600. const minutes = Math.floor((total % 3600) / 60);
  601. const secs = Math.floor(total % 60);
  602. const parts = [];
  603. if (hours > 0) parts.push(`${hours}小时`);
  604. if (minutes > 0) parts.push(`${minutes}分`);
  605. if (secs > 0 || parts.length === 0) {
  606. parts.push(`${secs}秒`);
  607. }
  608. return parts.join('');
  609. }
  610. }
  611. };
  612. </script>
  613. <style scoped lang="scss">
  614. .course-statistics-container {
  615. padding: 20px;
  616. }
  617. .detail-card {
  618. margin-bottom: 20px;
  619. .card-header {
  620. display: flex;
  621. justify-content: space-between;
  622. align-items: center;
  623. font-weight: bold;
  624. font-size: 16px;
  625. }
  626. .stat-item {
  627. text-align: center;
  628. padding: 20px;
  629. border: 1px solid #EBEEF5;
  630. border-radius: 4px;
  631. background-color: #F5F7FA;
  632. margin-top: 10px;
  633. .stat-label {
  634. font-size: 14px;
  635. color: #606266;
  636. margin-bottom: 10px;
  637. }
  638. .stat-value {
  639. font-size: 24px;
  640. font-weight: bold;
  641. color: #303133;
  642. }
  643. }
  644. }
  645. .course-detail-drawer {
  646. ::v-deep .el-drawer__body {
  647. overflow-y: auto;
  648. }
  649. }
  650. .course-rating-cell {
  651. display: inline-block;
  652. max-width: 100%;
  653. text-align: left;
  654. white-space: nowrap;
  655. overflow: hidden;
  656. text-overflow: ellipsis;
  657. font-size: 12px;
  658. line-height: 1.4;
  659. vertical-align: middle;
  660. }
  661. </style>