accountDetailCard.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. <template>
  2. <a-modal v-model:visible="visible" :title="modalTitle" :width="980" :footer="false" unmount-on-close esc-to-close
  3. draggable class="account-detail-modal">
  4. <a-spin :loading="loading" style="width: 100%">
  5. <template v-if="account">
  6. <div class="account-header">
  7. <a-avatar :size="64">
  8. <img :alt="account.name ?? ''" :src="account.user_avatar || defaultAvatar" />
  9. </a-avatar>
  10. <div class="account-header-main">
  11. <div class="account-name">{{ account.name || '未同步姓名' }}</div>
  12. <div class="account-meta">
  13. <span>学号 {{ account.student_num }}</span>
  14. <a-divider direction="vertical" />
  15. <span>{{ sexLabel }}</span>
  16. <a-divider direction="vertical" />
  17. <span>{{ account.grade_id || '-' }} 级</span>
  18. </div>
  19. </div>
  20. <a-tag :color="stateTag.color">{{ stateTag.text }}</a-tag>
  21. </div>
  22. <a-descriptions :column="isMobile ? 1 : 3" bordered size="small" class="account-desc">
  23. <a-descriptions-item v-if="admin" label="创建用户">{{ account.create_user || '-' }}</a-descriptions-item>
  24. <a-descriptions-item v-if="admin" label="人脸状态">{{ faceStateLabel }}</a-descriptions-item>
  25. <a-descriptions-item v-if="admin" label="手机型号">{{ account.deviceModel || '-' }}</a-descriptions-item>
  26. <a-descriptions-item label="学院">{{ account.academy_name || '-' }}</a-descriptions-item>
  27. <a-descriptions-item label="跑区">{{ account.area || '随机分配' }}</a-descriptions-item>
  28. <a-descriptions-item label="通知方式">{{ noticeLabel }}</a-descriptions-item>
  29. <a-descriptions-item label="自动乐跑">
  30. <a-tag v-if="account.auto_run" color="green">{{ autoRunLabel }}</a-tag>
  31. <a-tag v-else color="red">关闭</a-tag>
  32. </a-descriptions-item>
  33. <a-descriptions-item label="自动星期">{{ autoDayLabel }}</a-descriptions-item>
  34. <a-descriptions-item label="自动时段">{{ autoTimeLabel }}</a-descriptions-item>
  35. <a-descriptions-item label="学期目标">{{ termProgressLabel }}</a-descriptions-item>
  36. <a-descriptions-item label="添加时间">{{ formatTime(account.create_time) }}</a-descriptions-item>
  37. <a-descriptions-item label="上次更新">{{ account.update_time ? formatTime(account.update_time) : '待登录'
  38. }}</a-descriptions-item>
  39. <a-descriptions-item v-if="admin" label="UA" :span="3">{{ account.userAgent || '-' }}</a-descriptions-item>
  40. <a-descriptions-item label="备注" :span="3">{{ account.notes || '-' }}</a-descriptions-item>
  41. </a-descriptions>
  42. <div class="section-title">
  43. <span>乐跑日历</span>
  44. <a-select v-model="calendarYear" :options="yearOptions" size="small" style="width: 110px" />
  45. </div>
  46. <Chart :options="calendarOption" height="180px" width="100%" />
  47. <div class="section-title">
  48. <span>乐跑记录</span>
  49. <span class="record-count">{{ activeRecordCountLabel }}</span>
  50. </div>
  51. <a-tabs v-model:active-key="recordTab" @change="handleRecordTabChange">
  52. <a-tab-pane key="platform" title="平台记录">
  53. <a-table :data="records" :pagination="{ pageSize: 8, showTotal: true }" :scroll="{ x: 900, y: 280 }"
  54. size="small" row-key="id" :bordered="false">
  55. <template #columns>
  56. <a-table-column v-if="admin" title="所属用户" :width="120">
  57. <template #cell="{ record }">{{ record.username || '-' }}</template>
  58. </a-table-column>
  59. <a-table-column title="状态" :width="200">
  60. <template #cell="{ record }">
  61. <div class="state">
  62. <div class="circle" :class="record.result?.record_failed_reason === '自动确认有效' ? 'one' : 'else'" />
  63. {{ record.result?.record_failed_reason }}
  64. </div>
  65. </template>
  66. </a-table-column>
  67. <a-table-column title="跑区" :width="180">
  68. <template #cell="{ record }">{{ record.result?.pass_tit }}</template>
  69. </a-table-column>
  70. <a-table-column title="距离" :width="90">
  71. <template #cell="{ record }">{{ record.result?.distance }} Km</template>
  72. </a-table-column>
  73. <a-table-column title="时长" :width="100">
  74. <template #cell="{ record }">{{ formatSecondsToMinSec(record.result?.time) }}</template>
  75. </a-table-column>
  76. <a-table-column title="配速" :width="80">
  77. <template #cell="{ record }">
  78. {{ calculatePace(record.result?.time, record.result?.distance) }}
  79. </template>
  80. </a-table-column>
  81. <a-table-column title="乐跑时间" :width="160">
  82. <template #cell="{ record }">{{ formatTime(record.time) }}</template>
  83. </a-table-column>
  84. <a-table-column title="" :width="90" :fixed="tableFixed('right')">
  85. <template #cell="{ record }">
  86. <a-button type="text" size="small" @click="openPlatformRecordDetail(record)">详情</a-button>
  87. </template>
  88. </a-table-column>
  89. </template>
  90. </a-table>
  91. </a-tab-pane>
  92. <a-tab-pane key="official" title="官方记录" :disabled="!canViewOfficialRecords">
  93. <a-alert v-if="!canViewOfficialRecords" type="warning" class="official-record-alert"
  94. content="仅状态为正常的账号可查看官方记录,请使用登录器更新账号后再试。" />
  95. <a-table :data="officialRecords" :loading="officialLoading" :pagination="officialPaginationConfig"
  96. :scroll="{ x: 900, y: 280 }" size="small" row-key="id" :bordered="false"
  97. @page-change="handleOfficialPageChange">
  98. <template #columns>
  99. <a-table-column title="状态" :width="70">
  100. <template #cell="{ record }">
  101. <a-tag :color="String(record.record_status) === '1' ? 'green' : 'orangered'">
  102. {{ officialRecordStatus(record) }}
  103. </a-tag>
  104. </template>
  105. </a-table-column>
  106. <a-table-column title="跑区" :width="160">
  107. <template #cell="{ record }">{{ record.title || '-' }}</template>
  108. </a-table-column>
  109. <a-table-column title="类型" :width="100">
  110. <template #cell="{ record }">{{ record.tag || record.point_type_str || '-' }}</template>
  111. </a-table-column>
  112. <a-table-column title="配速" :width="80">
  113. <template #cell="{ record }">
  114. {{ calculatePace(Number(record.end_time) - Number(record.start_time), record.distance) }}
  115. </template>
  116. </a-table-column>
  117. <a-table-column title="距离" :width="90">
  118. <template #cell="{ record }">{{ record.distance || '-' }} Km</template>
  119. </a-table-column>
  120. <a-table-column title="时长" :width="100">
  121. <template #cell="{ record }">{{ formatOfficialDuration(record) }}</template>
  122. </a-table-column>
  123. <a-table-column title="乐跑时间" :width="160">
  124. <template #cell="{ record }">{{ formatUnixTime(record.start_time) }}</template>
  125. </a-table-column>
  126. </template>
  127. </a-table>
  128. </a-tab-pane>
  129. </a-tabs>
  130. </template>
  131. </a-spin>
  132. </a-modal>
  133. </template>
  134. <script setup>
  135. import { ref, computed } from 'vue'
  136. import { useRouter } from 'vue-router'
  137. import {
  138. lepaoRecords,
  139. adminLepaoRecords,
  140. officialTermRecords,
  141. adminOfficialTermRecords
  142. } from '@/api/lepao'
  143. import { Notification, Message } from '@arco-design/web-vue'
  144. import useChartOption from '@/hooks/chart-option'
  145. import { useResponsiveTable } from '@/hooks/useResponsiveTable'
  146. const props = defineProps({
  147. admin: {
  148. type: Boolean,
  149. default: false
  150. }
  151. })
  152. const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
  153. const auto_day = [
  154. { label: '周一', value: 1 },
  155. { label: '周二', value: 2 },
  156. { label: '周三', value: 3 },
  157. { label: '周四', value: 4 },
  158. { label: '周五', value: 5 },
  159. { label: '周六', value: 6 },
  160. { label: '周日', value: 0 }
  161. ]
  162. const auto_time_options = [
  163. { label: '随机分配', value: -1 },
  164. ...Array.from({ length: 17 }, (_, i) => {
  165. const hour = i + 7
  166. return { label: `${hour} ~ ${hour + 1}时`, value: hour }
  167. })
  168. ]
  169. const visible = ref(false)
  170. const loading = ref(false)
  171. const account = ref(null)
  172. const records = ref([])
  173. const officialRecords = ref([])
  174. const officialLoading = ref(false)
  175. const officialSummary = ref({})
  176. const officialPagination = ref({ current: 1, pageSize: 10, total: 0 })
  177. const recordTab = ref('platform')
  178. const calendarYear = ref(new Date().getFullYear())
  179. const { isMobile, tableFixed } = useResponsiveTable()
  180. const router = useRouter()
  181. const modalTitle = computed(() => {
  182. if (!account.value) return '账号详情'
  183. const name = account.value.name || account.value.student_num
  184. return `${name} · 账号详情`
  185. })
  186. const sexLabel = computed(() => {
  187. if (!account.value) return '-'
  188. if (account.value.sex === 1) return '男'
  189. if (account.value.sex === 2) return '女'
  190. return '-'
  191. })
  192. const stateTag = computed(() => {
  193. const state = account.value?.state
  194. if (state === 0) return { text: '需登录', color: 'orangered' }
  195. if (state === 1) return { text: '正常', color: 'green' }
  196. return { text: '状态异常', color: 'red' }
  197. })
  198. const noticeLabel = computed(() => {
  199. const item = account.value
  200. if (!item) return '-'
  201. if (item.notice_type === 'email') return `📧 ${item.email || '未绑定'}`
  202. if (item.notice_type === 'bot') return `🤖 ${item.bot_account || '未绑定'}`
  203. return '无通知'
  204. })
  205. const autoRunLabel = computed(() => {
  206. const item = account.value
  207. if (!item?.auto_run) return '关闭'
  208. return item.target_count === 0 ? '开启 · 不限次' : `开启 · ${item.target_count} 次`
  209. })
  210. const autoDayLabel = computed(() => {
  211. const item = account.value
  212. if (!item?.auto_run || !item.auto_day?.length) return '-'
  213. return item.auto_day
  214. .slice()
  215. .sort((a, b) => {
  216. if (a === 0) return 1
  217. if (b === 0) return -1
  218. return a - b
  219. })
  220. .map((day) => auto_day.find((d) => d.value === day)?.label)
  221. .join('、')
  222. })
  223. const autoTimeLabel = computed(() => {
  224. const item = account.value
  225. if (!item?.auto_run) return '-'
  226. if (item.auto_time === -1) {
  227. if (item.today_auto_time) return `随机 · 今日 ${item.today_auto_time} 时`
  228. return '随机 · 待分配'
  229. }
  230. return auto_time_options.find((t) => t.value === item.auto_time)?.label || '-'
  231. })
  232. const termProgressLabel = computed(() => {
  233. const item = account.value
  234. if (!item) return '-'
  235. if (item.term_num != null && item.total_num != null && item.term_num !== item.total_num) {
  236. return `${item.total_num} / ${item.term_num}`
  237. }
  238. return '已完成'
  239. })
  240. const faceStateLabel = computed(() => {
  241. const faceState = account.value?.face_state
  242. if (faceState === 0) return '未采集'
  243. if (faceState === 1) return '已通过'
  244. if (faceState != null) return '不通过'
  245. return '-'
  246. })
  247. const canViewOfficialRecords = computed(() => account.value?.state === 1)
  248. const activeRecordCountLabel = computed(() => {
  249. if (recordTab.value === 'official') {
  250. const total = officialPagination.value.total || officialRecords.value.length
  251. return `官方记录共 ${total} 条,有效次数 ${officialSummary.value?.total_score_num ?? 0} 次(${officialSummary.value?.term_name ?? '本学期'})`
  252. }
  253. return `平台记录共 ${records.value.length} 条`
  254. })
  255. const officialPaginationConfig = computed(() => ({
  256. current: officialPagination.value.current,
  257. pageSize: officialPagination.value.pageSize,
  258. total: officialPagination.value.total || officialRecords.value.length,
  259. showTotal: true,
  260. showPageSize: false
  261. }))
  262. const yearOptions = computed(() => {
  263. const years = new Set([calendarYear.value, new Date().getFullYear()])
  264. records.value.forEach((record) => {
  265. years.add(new Date(record.time).getFullYear())
  266. })
  267. return Array.from(years)
  268. .sort((a, b) => b - a)
  269. .map((year) => ({ label: `${year} 年`, value: year }))
  270. })
  271. const formatDateKey = (timestamp) => {
  272. const d = new Date(timestamp)
  273. const y = d.getFullYear()
  274. const m = String(d.getMonth() + 1).padStart(2, '0')
  275. const day = String(d.getDate()).padStart(2, '0')
  276. return `${y}-${m}-${day}`
  277. }
  278. const buildCalendarSeriesData = () => {
  279. const counter = new Map()
  280. records.value.forEach((record) => {
  281. const date = new Date(record.time)
  282. if (date.getFullYear() !== calendarYear.value) return
  283. const key = formatDateKey(record.time)
  284. counter.set(key, (counter.get(key) || 0) + 1)
  285. })
  286. return Array.from(counter.entries()).map(([date, count]) => [date, count])
  287. }
  288. const { chartOption: calendarOption } = useChartOption(() => {
  289. const seriesData = buildCalendarSeriesData()
  290. const maxCount = Math.max(...seriesData.map((item) => item[1]), 1)
  291. return {
  292. tooltip: {
  293. position: 'top',
  294. formatter(params) {
  295. if (!params?.data) return ''
  296. return `${params.data[0]}<br/>乐跑 ${params.data[1]} 次`
  297. }
  298. },
  299. visualMap: {
  300. min: 0,
  301. max: maxCount,
  302. show: false,
  303. inRange: {
  304. color: ['#fff0f5', '#FE82A5']
  305. }
  306. },
  307. calendar: {
  308. top: 36,
  309. left: 40,
  310. right: 20,
  311. cellSize: ['auto', 18],
  312. range: String(calendarYear.value),
  313. itemStyle: {
  314. borderWidth: 0.5,
  315. borderColor: 'var(--color-border-2)'
  316. },
  317. yearLabel: { show: true, fontSize: 12 },
  318. monthLabel: { fontSize: 11 },
  319. dayLabel: { fontSize: 10 }
  320. },
  321. series: [
  322. {
  323. type: 'heatmap',
  324. coordinateSystem: 'calendar',
  325. data: seriesData
  326. }
  327. ]
  328. }
  329. })
  330. const formatTime = (time) => {
  331. if (!time) return '-'
  332. return new Date(time).toLocaleString('zh-CN', {
  333. year: 'numeric',
  334. month: '2-digit',
  335. day: '2-digit',
  336. hour: '2-digit',
  337. minute: '2-digit'
  338. })
  339. }
  340. const formatUnixTime = (time) => {
  341. const timestamp = Number(time)
  342. if (!Number.isFinite(timestamp) || timestamp <= 0) return '-'
  343. return formatTime(timestamp * 1000)
  344. }
  345. function calculatePace(seconds, kilometers) {
  346. if (!seconds || !kilometers) return '-'
  347. const paceInSeconds = seconds / kilometers
  348. const minutes = Math.floor(paceInSeconds / 60)
  349. const remainingSeconds = Math.round(paceInSeconds % 60)
  350. return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`
  351. }
  352. function formatSecondsToMinSec(totalSeconds) {
  353. if (!totalSeconds && totalSeconds !== 0) return '-'
  354. const minutes = Math.floor(totalSeconds / 60)
  355. const seconds = totalSeconds % 60
  356. return `${minutes}分${seconds.toString().padStart(2, '0')}秒`
  357. }
  358. const formatOfficialDuration = (record) => {
  359. const start = Number(record?.start_time)
  360. const end = Number(record?.end_time)
  361. if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return '-'
  362. return formatSecondsToMinSec(end - start)
  363. }
  364. const officialRecordStatus = (record) => {
  365. const status = String(record?.record_status ?? '')
  366. if (status === '1') return '有效'
  367. return '无效'
  368. }
  369. const fetchAllRecords = async (studentNum) => {
  370. const all = []
  371. let current = 1
  372. const pagesize = 100
  373. let total = 0
  374. const fetchRecords = props.admin ? adminLepaoRecords : lepaoRecords
  375. do {
  376. const res = await fetchRecords({
  377. lepao_account: studentNum,
  378. pagesize,
  379. current
  380. })
  381. if (!res || res.code !== 0) {
  382. throw new Error(res?.msg || '获取乐跑记录失败')
  383. }
  384. all.push(...(res.data || []))
  385. total = res.pagination?.total ?? all.length
  386. current += 1
  387. } while (all.length < total)
  388. return all.sort((a, b) => b.time - a.time)
  389. }
  390. const fetchOfficialRecords = async (page = 1) => {
  391. if (!account.value?.student_num) return
  392. if (!canViewOfficialRecords.value) {
  393. Message.warning('请使用登录器更新账号后查看官方乐跑记录')
  394. return
  395. }
  396. officialLoading.value = true
  397. try {
  398. const fetchRecords = props.admin ? adminOfficialTermRecords : officialTermRecords
  399. const res = await fetchRecords({
  400. student_num: account.value.student_num,
  401. page
  402. })
  403. if (!res || res.code !== 0) {
  404. if (account.value && String(res?.msg || '').includes('登录')) {
  405. account.value.state = 0
  406. }
  407. throw new Error(res?.msg || '获取乐跑记录失败')
  408. }
  409. const data = res.data || {}
  410. officialSummary.value = data
  411. officialRecords.value = data.list || []
  412. officialPagination.value = {
  413. ...officialPagination.value,
  414. current: data.page || page,
  415. total: Number(data.total_num || officialRecords.value.length)
  416. }
  417. } catch (error) {
  418. Notification.error({
  419. title: '获取官方乐跑记录失败',
  420. content: error.message || '请稍后再试'
  421. })
  422. } finally {
  423. officialLoading.value = false
  424. }
  425. }
  426. const handleRecordTabChange = (key) => {
  427. if (key !== 'official') return
  428. if (!canViewOfficialRecords.value) {
  429. recordTab.value = 'platform'
  430. Message.warning('请使用登录器更新账号后查看官方记录')
  431. return
  432. }
  433. if (officialRecords.value.length === 0) {
  434. fetchOfficialRecords(1)
  435. }
  436. }
  437. const handleOfficialPageChange = (page) => {
  438. fetchOfficialRecords(page)
  439. }
  440. const openPlatformRecordDetail = (record) => {
  441. const key = record.public_id || record.id
  442. if (!key) {
  443. Message.warning('该记录缺少可用标识,无法打开详情')
  444. return
  445. }
  446. visible.value = false
  447. const path = props.admin
  448. ? `/admin/lepaoRecords/${encodeURIComponent(String(key))}`
  449. : `/lepao/recordDetail/${encodeURIComponent(String(key))}`
  450. router.push(path)
  451. }
  452. const openModal = async (record) => {
  453. if (!record?.student_num) return
  454. account.value = record
  455. visible.value = true
  456. loading.value = true
  457. records.value = []
  458. officialRecords.value = []
  459. officialSummary.value = {}
  460. officialPagination.value = { current: 1, pageSize: 10, total: 0 }
  461. recordTab.value = 'platform'
  462. try {
  463. const list = await fetchAllRecords(record.student_num)
  464. records.value = list
  465. if (list.length > 0) {
  466. calendarYear.value = new Date(list[0].time).getFullYear()
  467. } else {
  468. calendarYear.value = new Date().getFullYear()
  469. }
  470. } catch (error) {
  471. Notification.error({
  472. title: '获取乐跑记录失败',
  473. content: error.message || '请稍后再试'
  474. })
  475. } finally {
  476. loading.value = false
  477. }
  478. }
  479. defineExpose({ openModal })
  480. </script>
  481. <style scoped lang="less">
  482. .account-detail-modal {
  483. :deep(.arco-modal-body) {
  484. max-height: 78vh;
  485. overflow-y: auto;
  486. }
  487. }
  488. .account-header {
  489. display: flex;
  490. align-items: center;
  491. gap: 16px;
  492. margin-bottom: 16px;
  493. }
  494. .account-header-main {
  495. flex: 1;
  496. min-width: 0;
  497. }
  498. .account-name {
  499. font-size: 18px;
  500. font-weight: 600;
  501. line-height: 1.4;
  502. }
  503. .account-meta {
  504. margin-top: 4px;
  505. color: var(--color-text-3);
  506. font-size: 13px;
  507. }
  508. .account-desc {
  509. margin-bottom: 20px;
  510. }
  511. .section-title {
  512. display: flex;
  513. align-items: center;
  514. justify-content: space-between;
  515. margin: 16px 0 8px;
  516. font-size: 14px;
  517. font-weight: 600;
  518. color: var(--color-text-1);
  519. }
  520. .record-count {
  521. font-size: 12px;
  522. font-weight: 400;
  523. color: var(--color-text-3);
  524. }
  525. .official-record-alert {
  526. margin-bottom: 12px;
  527. }
  528. .state {
  529. display: flex;
  530. align-items: center;
  531. .circle {
  532. border-radius: 50%;
  533. height: 8px;
  534. width: 8px;
  535. margin-right: 6px;
  536. flex-shrink: 0;
  537. }
  538. .one {
  539. background-color: rgb(var(--green-6));
  540. }
  541. .else {
  542. background-color: rgb(var(--red-6));
  543. }
  544. }
  545. @media (max-width: 768px) {
  546. .account-header {
  547. flex-wrap: wrap;
  548. align-items: flex-start;
  549. gap: 10px;
  550. }
  551. .section-title {
  552. flex-wrap: wrap;
  553. gap: 8px;
  554. }
  555. .record-count {
  556. width: 100%;
  557. }
  558. .account-detail-modal {
  559. :deep(.arco-modal) {
  560. width: calc(100vw - 16px) !important;
  561. max-width: calc(100vw - 16px) !important;
  562. margin: 8px auto !important;
  563. }
  564. :deep(.arco-descriptions-bordered .arco-descriptions-item) {
  565. width: 100%;
  566. }
  567. :deep(.arco-descriptions-bordered .arco-descriptions-item-label),
  568. :deep(.arco-descriptions-bordered .arco-descriptions-item-value) {
  569. padding: 8px 10px;
  570. font-size: 12px;
  571. line-height: 1.45;
  572. }
  573. }
  574. }
  575. </style>