practice-table.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. <template>
  2. <view class="px-30 py-38 bg-white">
  3. <view class="flex gap-x-20">
  4. <view class="flex items-center gap-x-20 flex-wrap">
  5. <view class="picker-wrap">
  6. <ie-picker ref="pickerRef" v-model="form.year" :list="yearList" placeholder="请选择" title="选择年份" :fontSize="14"
  7. icon="arrow-down" key-label="label" key-value="value" @change="handleYearChange">
  8. <template #default="{ label }">
  9. <text>{{ label }}年</text>
  10. </template>
  11. </ie-picker>
  12. </view>
  13. <view class="picker-wrap">
  14. <ie-picker ref="pickerRef" :disabled="!form.year" v-model="form.month" :list="monthList" placeholder="请选择"
  15. title="选择月份" :fontSize="14" icon="arrow-down" key-label="label" key-value="value"
  16. @change="handleMonthChange">
  17. <template #default="{ label }">
  18. <text>{{ label }}</text>
  19. </template>
  20. </ie-picker>
  21. </view>
  22. <view :class="calendarButtonClass" @click="canOpenCalendar ? handleOpenCalendar() : null">
  23. <text class="text-[14px]">刷题日历</text>
  24. <uv-icon name="search" size="16" :color="canOpenCalendar ? 'white' : '#CCCCCC'" />
  25. </view>
  26. </view>
  27. </view>
  28. <view class="mt-30 flex h-280 gap-x-20">
  29. <view class="flex-1 h-full">
  30. <ie-echart :option="options1" />
  31. </view>
  32. <view class="flex-1 h-full">
  33. <ie-echart :option="options2" />
  34. </view>
  35. </view>
  36. <view class="mt-30 flex items-center">
  37. <text>已累计刷题</text>
  38. <text class="text-32 text-primary">{{ practiceDays }}</text>
  39. <text>天~</text>
  40. </view>
  41. <view v-if="(form.month) && displayMode !== 'year'" class="mt-30">
  42. <ie-table :tableColumns="tableColumns" :data="tableDate">
  43. <template #date="{ item }">
  44. <text class="font-bold">{{ item.date }}</text>
  45. </template>
  46. <template #questionNum="{ item }">
  47. <text class="font-bold">{{ item.questionNum }}</text>
  48. </template>
  49. <template #correctNum="{ item }">
  50. <text class="font-bold" :class="[item.info < 70 ? 'text-danger' : 'text-fore-title']">{{ item.info }}%</text>
  51. </template>
  52. </ie-table>
  53. </view>
  54. <!-- #ifdef H5 -->
  55. <teleport to="body">
  56. <!-- #endif -->
  57. <!-- #ifdef MP-WEIXIN -->
  58. <root-portal externalClass="theme-ie">
  59. <!-- #endif -->
  60. <uv-popup ref="calendarPopupRef" mode="bottom" :round="16" v-if="canOpenCalendar">
  61. <view class="h-[480px]">
  62. <view class="h-108 flex items-center justify-center border-0 border-b border-solid border-border">
  63. <view :class="prevButtonClass" @click="canGoPrev && !loading ? handlePrev() : null">
  64. <uv-icon name="arrow-left" size="10" :color="canGoPrev && !loading ? '#808080' : '#CCCCCC'" />
  65. </view>
  66. <view class="mx-40 text-30 text-fore-title font-bold">
  67. <text>{{ calendarTitle }}</text>
  68. </view>
  69. <view :class="nextButtonClass" @click="canGoNext && !loading ? handleNext() : null">
  70. <uv-icon name="arrow-right" size="10" :color="canGoNext && !loading ? '#808080' : '#CCCCCC'" />
  71. </view>
  72. </view>
  73. <view class="relative">
  74. <view class="px-40 py-20 flex items-center justify-between">
  75. <view class=" text-28">
  76. <text>{{ calendarSubTitle }}</text>
  77. <text class="text-32 text-primary font-bold">{{ practiceDays }}</text>
  78. <text>天~</text>
  79. </view>
  80. <!-- <uv-icon name="question-circle" size="18" color="#31a0fc" /> -->
  81. </view>
  82. <uni-calendar ref="calendarRef" :insert="true" :lunar="false" :readonly="true" :showMonth="false"
  83. :sundayFirst="false" :highlightToday="false" :showToolbar="false" :displayMode="displayMode"
  84. :selected="selected" :date="currentDate" @change-week="handleCalendarWeekChange"
  85. @monthSwitch="handleCalendarMonthSwitch">
  86. <template #calendar-item="{ weeks }">
  87. <view class="calendar-item" :class="{
  88. 'calendar-item--week-mode-disabled': weeks.isWeekModeDisabled,
  89. 'uni-calendar-item--disable': !weeks.isCurrentMonth,
  90. 'calendar-item--valid': weeks.extraInfo && weeks.extraInfo.info >= 70,
  91. 'calendar-item--invalid': weeks.extraInfo && weeks.extraInfo.info < 70
  92. }">
  93. <view class="date">{{ weeks.date }}</view>
  94. <view class="info">
  95. <text v-if="weeks.extraInfo && weeks.extraInfo.info">{{ weeks.extraInfo.info }}%</text>
  96. </view>
  97. </view>
  98. </template>
  99. </uni-calendar>
  100. <!-- Loading 覆盖层 -->
  101. <view v-if="loading" class="calendar-loading-overlay">
  102. <uv-loading-icon mode="circle" size="32" color="#31a0fc" />
  103. </view>
  104. </view>
  105. </view>
  106. </uv-popup>
  107. <!-- #ifdef MP-WEIXIN -->
  108. </root-portal>
  109. <!-- #endif -->
  110. <!-- #ifdef H5 -->
  111. </teleport>
  112. <!-- #endif -->
  113. </view>
  114. </template>
  115. <script lang="ts" setup>
  116. import { TableColumnConfig } from '@/types';
  117. import ieEchart from './ie-echart/ie-echart.vue';
  118. import { useCalendar } from '@/composables/useCalendar';
  119. const props = defineProps<{
  120. recordId?: number;
  121. }>();
  122. // 使用 useCalendar composable
  123. const {
  124. selected,
  125. statistics,
  126. currentDate,
  127. yearList,
  128. displayMode,
  129. currentMonthRange,
  130. canGoPrev,
  131. canGoNext,
  132. loading,
  133. currentYear,
  134. currentMonth,
  135. goToPrevMonth,
  136. goToNextMonth,
  137. goToYear,
  138. goToYearMonth,
  139. initializeFormData,
  140. init: initCalendar
  141. } = useCalendar();
  142. // 表单数据 - 与日历同步
  143. const form = ref({
  144. year: '',
  145. month: '',
  146. })
  147. // 月份列表(不能超过当前月份)
  148. const monthList = computed(() => {
  149. const today = new Date();
  150. const currentYearToday = today.getFullYear();
  151. const currentMonthToday = today.getMonth() + 1;
  152. const selectedYear = parseInt(form.value.year) || currentYear.value;
  153. const months = [];
  154. const maxMonth = selectedYear === currentYearToday ? currentMonthToday : 12;
  155. for (let i = 1; i <= maxMonth; i++) {
  156. months.push({
  157. label: `${i}月`,
  158. value: i.toString()
  159. });
  160. }
  161. return months;
  162. });
  163. // 检查是否可以选择日历
  164. const canOpenCalendar = computed(() => {
  165. // 必须选择年份
  166. if (!form.value.year) {
  167. return false;
  168. }
  169. // 如果是month模式,必须选择月份
  170. if (displayMode.value === 'month') {
  171. return !!form.value.month;
  172. }
  173. // 如果是year模式,不能打开日历(年份模式不显示记录表)
  174. return false;
  175. });
  176. // 导航按钮样式计算属性
  177. const prevButtonClass = computed(() => {
  178. return {
  179. 'w-34 h-34 rounded-full flex items-center justify-center transition-all duration-200': true,
  180. 'bg-[#EEF4FA] cursor-pointer': canGoPrev.value && !loading.value,
  181. 'bg-[#F5F5F5] cursor-not-allowed': !canGoPrev.value || loading.value,
  182. 'opacity-50': loading.value
  183. };
  184. });
  185. const nextButtonClass = computed(() => {
  186. return {
  187. 'w-34 h-34 rounded-full flex items-center justify-center transition-all duration-200': true,
  188. 'bg-[#EEF4FA] cursor-pointer': canGoNext.value && !loading.value,
  189. 'bg-[#F5F5F5] cursor-not-allowed': !canGoNext.value || loading.value,
  190. 'opacity-50': loading.value
  191. };
  192. });
  193. const calendarButtonClass = computed(() => {
  194. return {
  195. 'btn-wrap transition-all duration-200': true,
  196. 'opacity-50 cursor-not-allowed': !canOpenCalendar.value,
  197. 'cursor-pointer': canOpenCalendar.value
  198. };
  199. });
  200. // 图表配置
  201. const options1 = computed(() => {
  202. return {
  203. title: {
  204. text: statistics.value.total.toString(),
  205. subtext: '{a|刷题总量}',
  206. left: 'center',
  207. top: '34%',
  208. textStyle: {
  209. fontSize: 20,
  210. fontWeight: 'bold',
  211. color: '#222'
  212. },
  213. subtextStyle: {
  214. fontSize: 12,
  215. color: '#666',
  216. lineHeight: 12,
  217. rich: {
  218. a: {
  219. color: '#B3B3B3',
  220. padding: [0, 0, 10, 0]
  221. },
  222. }
  223. }
  224. },
  225. series: [
  226. {
  227. type: 'pie',
  228. radius: ['60%', '80%'],
  229. startAngle: 90,
  230. silent: true,
  231. label: { show: false },
  232. data: [
  233. {
  234. value: 45,
  235. itemStyle: {
  236. color: {
  237. type: 'linear',
  238. x: 0,
  239. y: 0,
  240. x2: 1,
  241. y2: 1,
  242. colorStops: [
  243. { offset: 0, color: '#FEC048' },
  244. { offset: 1, color: '#F9942F' }
  245. ]
  246. }
  247. }
  248. },
  249. {
  250. value: 55,
  251. itemStyle: { color: '#FFF5DE' }
  252. }
  253. ]
  254. }
  255. ]
  256. };
  257. });
  258. const options2 = computed(() => {
  259. const accuracy = statistics.value.rate;
  260. return {
  261. title: {
  262. text: `${accuracy}%`,
  263. subtext: '{a|正确率}',
  264. left: 'center',
  265. top: '34%',
  266. textStyle: {
  267. fontSize: 20,
  268. fontWeight: 'bold',
  269. color: '#222'
  270. },
  271. subtextStyle: {
  272. fontSize: 12,
  273. color: '#666',
  274. lineHeight: 12,
  275. rich: {
  276. a: {
  277. color: '#B3B3B3',
  278. padding: [0, 0, 10, 0]
  279. },
  280. }
  281. }
  282. },
  283. series: [
  284. {
  285. type: 'pie',
  286. radius: ['60%', '80%'],
  287. startAngle: 90,
  288. silent: true,
  289. label: { show: false },
  290. data: [
  291. {
  292. value: accuracy,
  293. itemStyle: {
  294. color: {
  295. type: 'linear',
  296. x: 0,
  297. y: 0,
  298. x2: 1,
  299. y2: 1,
  300. colorStops: [
  301. { offset: 0, color: '#70C8FD' },
  302. { offset: 1, color: '#31A0FC' }
  303. ]
  304. }
  305. }
  306. },
  307. {
  308. value: 100 - accuracy,
  309. itemStyle: { color: '#EBF9FF' }
  310. }
  311. ]
  312. }
  313. ]
  314. };
  315. });
  316. // 表格配置
  317. const tableColumns = ref<TableColumnConfig[]>([
  318. {
  319. prop: 'date',
  320. label: '日期',
  321. flex: 1,
  322. slot: 'date'
  323. },
  324. {
  325. prop: 'questionNum',
  326. label: '题量',
  327. flex: 1,
  328. slot: 'questionNum'
  329. },
  330. {
  331. prop: 'correctNum',
  332. label: '正确率',
  333. flex: 1,
  334. slot: 'correctNum'
  335. }
  336. ]);
  337. // 表格数据(从 selected 数据转换)
  338. const tableDate = computed(() => {
  339. return statistics.value.list;
  340. });
  341. // 练习天数统计
  342. const practiceDays = computed(() => {
  343. return statistics.value.studyDays;
  344. });
  345. // 日历标题
  346. const calendarTitle = computed(() => {
  347. const year = currentMonthRange.value?.startDate?.split('-')[0] || new Date().getFullYear();
  348. const month = currentMonthRange.value?.startDate?.split('-')[1] || new Date().getMonth() + 1;
  349. return `${year}年-${month}月`;
  350. });
  351. // 日历副标题
  352. const calendarSubTitle = computed(() => {
  353. const month = currentMonthRange.value?.startDate?.split('-')[1] || new Date().getMonth() + 1;
  354. return `本月(${month}月)已累计刷题`;
  355. });
  356. // 导航方法
  357. const handlePrev = async () => {
  358. if (loading.value) return; // 加载中时禁止操作
  359. uni.$ie.showLoading();
  360. await goToPrevMonth();
  361. uni.$ie.hideLoading();
  362. };
  363. const handleNext = async () => {
  364. if (loading.value) return; // 加载中时禁止操作
  365. uni.$ie.showLoading();
  366. await goToNextMonth();
  367. uni.$ie.hideLoading();
  368. };
  369. // 表单处理 - 与日历同步
  370. const handleYearChange = async () => {
  371. // 年份切换时清空月份和周
  372. form.value.month = '';
  373. // 年份切换时请求年度数据,但不显示记录表
  374. if (form.value.year) {
  375. const year = parseInt(form.value.year);
  376. // 请求年度数据来更新统计信息
  377. await goToYear(year);
  378. }
  379. };
  380. const handleMonthChange = async () => {
  381. if (form.value.year && form.value.month) {
  382. // 切换到月份模式并跳转到指定年月
  383. await goToYearMonth(parseInt(form.value.year), parseInt(form.value.month));
  384. }
  385. };
  386. // 监听日历状态变化,同步到表单
  387. watch([currentYear, currentMonth, displayMode], ([year, month, mode]) => {
  388. form.value.year = year.toString();
  389. // 只有在非年份模式下才同步月份,年份模式下保持用户选择
  390. if (mode !== 'year') {
  391. form.value.month = month.toString();
  392. }
  393. }, { immediate: true });
  394. // 初始化默认选中当前时间
  395. const initializeDefaultSelection = () => {
  396. const formData = initializeFormData();
  397. form.value.year = formData.year;
  398. form.value.month = formData.month;
  399. };
  400. // 监听日历组件内部事件,确保数据同步
  401. const handleCalendarWeekChange = (event: any) => {
  402. };
  403. const handleCalendarMonthSwitch = (event: any) => {
  404. // 更新外部状态以保持同步
  405. if (event.year && event.month) {
  406. // 通过 goToYearMonth 方法来更新状态
  407. goToYearMonth(event.year, event.month);
  408. }
  409. };
  410. const calendarRef = ref();
  411. const calendarPopupRef = ref();
  412. const handleOpenCalendar = () => {
  413. if (canOpenCalendar.value && calendarPopupRef.value) {
  414. calendarPopupRef.value.open();
  415. }
  416. };
  417. // 初始化
  418. onMounted(async () => {
  419. // 先初始化默认选中
  420. initializeDefaultSelection();
  421. // 显示全屏loading
  422. uni.$ie.showLoading();
  423. try {
  424. // 初始化日历
  425. await initCalendar(props.recordId);
  426. } finally {
  427. // 隐藏loading
  428. uni.$ie.hideLoading();
  429. }
  430. });
  431. </script>
  432. <style lang="scss" scoped>
  433. .picker-wrap {
  434. @apply flex items-center px-12 w-fit border border-solid border-border rounded-4 h-56;
  435. }
  436. .btn-wrap {
  437. @apply flex items-center gap-x-10 bg-primary text-white text-26 px-10 h-56 rounded-4;
  438. }
  439. .calendar-item {
  440. @apply rounded-5 text-center w-64 h-56 mx-auto py-8;
  441. .date {
  442. color: #222;
  443. font-size: 30rpx;
  444. line-height: 30rpx;
  445. }
  446. .info {
  447. font-size: 20rpx;
  448. line-height: 20rpx;
  449. margin-top: 4rpx;
  450. }
  451. }
  452. .uni-calendar-item--disable {
  453. opacity: 0;
  454. }
  455. .calendar-item--week-mode-disabled {
  456. .date {
  457. color: #cccccc;
  458. }
  459. }
  460. .calendar-item--valid {
  461. --valid-color: #22C55E;
  462. background-color: #F0FDF4;
  463. border: 1px solid var(--valid-color);
  464. .info {
  465. color: var(--valid-color);
  466. }
  467. }
  468. .calendar-item--invalid {
  469. --invalid-color: #FF5B5C;
  470. background-color: #FEEDE9;
  471. border: 1px solid var(--invalid-color);
  472. .info {
  473. color: var(--invalid-color);
  474. }
  475. }
  476. .calendar-loading-overlay {
  477. position: absolute;
  478. top: 0;
  479. left: 0;
  480. right: 0;
  481. bottom: 0;
  482. background-color: rgba(255, 255, 255, 0.8);
  483. display: flex;
  484. align-items: center;
  485. justify-content: center;
  486. z-index: 10;
  487. }
  488. </style>