| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639 |
- <template>
- <a-modal v-model:visible="visible" :title="modalTitle" :width="980" :footer="false" unmount-on-close esc-to-close
- draggable class="account-detail-modal">
- <a-spin :loading="loading" style="width: 100%">
- <template v-if="account">
- <div class="account-header">
- <a-avatar :size="64">
- <img :alt="account.name ?? ''" :src="account.user_avatar || defaultAvatar" />
- </a-avatar>
- <div class="account-header-main">
- <div class="account-name">{{ account.name || '未同步姓名' }}</div>
- <div class="account-meta">
- <span>学号 {{ account.student_num }}</span>
- <a-divider direction="vertical" />
- <span>{{ sexLabel }}</span>
- <a-divider direction="vertical" />
- <span>{{ account.grade_id || '-' }} 级</span>
- </div>
- </div>
- <a-tag :color="stateTag.color">{{ stateTag.text }}</a-tag>
- </div>
- <a-descriptions :column="isMobile ? 1 : 3" bordered size="small" class="account-desc">
- <a-descriptions-item v-if="admin" label="创建用户">{{ account.create_user || '-' }}</a-descriptions-item>
- <a-descriptions-item v-if="admin" label="人脸状态">{{ faceStateLabel }}</a-descriptions-item>
- <a-descriptions-item v-if="admin" label="手机型号">{{ account.deviceModel || '-' }}</a-descriptions-item>
- <a-descriptions-item label="学院">{{ account.academy_name || '-' }}</a-descriptions-item>
- <a-descriptions-item label="跑区">{{ account.area || '随机分配' }}</a-descriptions-item>
- <a-descriptions-item label="通知方式">{{ noticeLabel }}</a-descriptions-item>
- <a-descriptions-item label="自动乐跑">
- <a-tag v-if="account.auto_run" color="green">{{ autoRunLabel }}</a-tag>
- <a-tag v-else color="red">关闭</a-tag>
- </a-descriptions-item>
- <a-descriptions-item label="自动星期">{{ autoDayLabel }}</a-descriptions-item>
- <a-descriptions-item label="自动时段">{{ autoTimeLabel }}</a-descriptions-item>
- <a-descriptions-item label="学期目标">{{ termProgressLabel }}</a-descriptions-item>
- <a-descriptions-item label="添加时间">{{ formatTime(account.create_time) }}</a-descriptions-item>
- <a-descriptions-item label="上次更新">{{ account.update_time ? formatTime(account.update_time) : '待登录'
- }}</a-descriptions-item>
- <a-descriptions-item v-if="admin" label="UA" :span="3">{{ account.userAgent || '-' }}</a-descriptions-item>
- <a-descriptions-item label="备注" :span="3">{{ account.notes || '-' }}</a-descriptions-item>
- </a-descriptions>
- <div class="section-title">
- <span>乐跑日历</span>
- <a-select v-model="calendarYear" :options="yearOptions" size="small" style="width: 110px" />
- </div>
- <Chart :options="calendarOption" height="180px" width="100%" />
- <div class="section-title">
- <span>乐跑记录</span>
- <span class="record-count">{{ activeRecordCountLabel }}</span>
- </div>
- <a-tabs v-model:active-key="recordTab" @change="handleRecordTabChange">
- <a-tab-pane key="platform" title="平台记录">
- <a-table :data="records" :pagination="{ pageSize: 8, showTotal: true }" :scroll="{ x: 900, y: 280 }"
- size="small" row-key="id" :bordered="false">
- <template #columns>
- <a-table-column v-if="admin" title="所属用户" :width="120">
- <template #cell="{ record }">{{ record.username || '-' }}</template>
- </a-table-column>
- <a-table-column title="状态" :width="200">
- <template #cell="{ record }">
- <div class="state">
- <div class="circle" :class="record.result?.record_failed_reason === '自动确认有效' ? 'one' : 'else'" />
- {{ record.result?.record_failed_reason }}
- </div>
- </template>
- </a-table-column>
- <a-table-column title="跑区" :width="180">
- <template #cell="{ record }">{{ record.result?.pass_tit }}</template>
- </a-table-column>
- <a-table-column title="距离" :width="90">
- <template #cell="{ record }">{{ record.result?.distance }} Km</template>
- </a-table-column>
- <a-table-column title="时长" :width="100">
- <template #cell="{ record }">{{ formatSecondsToMinSec(record.result?.time) }}</template>
- </a-table-column>
- <a-table-column title="配速" :width="80">
- <template #cell="{ record }">
- {{ calculatePace(record.result?.time, record.result?.distance) }}
- </template>
- </a-table-column>
- <a-table-column title="乐跑时间" :width="160">
- <template #cell="{ record }">{{ formatTime(record.time) }}</template>
- </a-table-column>
- <a-table-column title="" :width="90" :fixed="tableFixed('right')">
- <template #cell="{ record }">
- <a-button type="text" size="small" @click="openPlatformRecordDetail(record)">详情</a-button>
- </template>
- </a-table-column>
- </template>
- </a-table>
- </a-tab-pane>
- <a-tab-pane key="official" title="官方记录" :disabled="!canViewOfficialRecords">
- <a-alert v-if="!canViewOfficialRecords" type="warning" class="official-record-alert"
- content="仅状态为正常的账号可查看官方记录,请使用登录器更新账号后再试。" />
- <a-table :data="officialRecords" :loading="officialLoading" :pagination="officialPaginationConfig"
- :scroll="{ x: 900, y: 280 }" size="small" row-key="id" :bordered="false"
- @page-change="handleOfficialPageChange">
- <template #columns>
- <a-table-column title="状态" :width="70">
- <template #cell="{ record }">
- <a-tag :color="String(record.record_status) === '1' ? 'green' : 'orangered'">
- {{ officialRecordStatus(record) }}
- </a-tag>
- </template>
- </a-table-column>
- <a-table-column title="跑区" :width="160">
- <template #cell="{ record }">{{ record.title || '-' }}</template>
- </a-table-column>
- <a-table-column title="类型" :width="100">
- <template #cell="{ record }">{{ record.tag || record.point_type_str || '-' }}</template>
- </a-table-column>
- <a-table-column title="配速" :width="80">
- <template #cell="{ record }">
- {{ calculatePace(Number(record.end_time) - Number(record.start_time), record.distance) }}
- </template>
- </a-table-column>
- <a-table-column title="距离" :width="90">
- <template #cell="{ record }">{{ record.distance || '-' }} Km</template>
- </a-table-column>
- <a-table-column title="时长" :width="100">
- <template #cell="{ record }">{{ formatOfficialDuration(record) }}</template>
- </a-table-column>
- <a-table-column title="乐跑时间" :width="160">
- <template #cell="{ record }">{{ formatUnixTime(record.start_time) }}</template>
- </a-table-column>
- </template>
- </a-table>
- </a-tab-pane>
- </a-tabs>
- </template>
- </a-spin>
- </a-modal>
- </template>
- <script setup>
- import { ref, computed } from 'vue'
- import { useRouter } from 'vue-router'
- import {
- lepaoRecords,
- adminLepaoRecords,
- officialTermRecords,
- adminOfficialTermRecords
- } from '@/api/lepao'
- import { Notification, Message } from '@arco-design/web-vue'
- import useChartOption from '@/hooks/chart-option'
- import { useResponsiveTable } from '@/hooks/useResponsiveTable'
- const props = defineProps({
- admin: {
- type: Boolean,
- default: false
- }
- })
- const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
- const auto_day = [
- { label: '周一', value: 1 },
- { label: '周二', value: 2 },
- { label: '周三', value: 3 },
- { label: '周四', value: 4 },
- { label: '周五', value: 5 },
- { label: '周六', value: 6 },
- { label: '周日', value: 0 }
- ]
- const auto_time_options = [
- { label: '随机分配', value: -1 },
- ...Array.from({ length: 17 }, (_, i) => {
- const hour = i + 7
- return { label: `${hour} ~ ${hour + 1}时`, value: hour }
- })
- ]
- const visible = ref(false)
- const loading = ref(false)
- const account = ref(null)
- const records = ref([])
- const officialRecords = ref([])
- const officialLoading = ref(false)
- const officialSummary = ref({})
- const officialPagination = ref({ current: 1, pageSize: 10, total: 0 })
- const recordTab = ref('platform')
- const calendarYear = ref(new Date().getFullYear())
- const { isMobile, tableFixed } = useResponsiveTable()
- const router = useRouter()
- const modalTitle = computed(() => {
- if (!account.value) return '账号详情'
- const name = account.value.name || account.value.student_num
- return `${name} · 账号详情`
- })
- const sexLabel = computed(() => {
- if (!account.value) return '-'
- if (account.value.sex === 1) return '男'
- if (account.value.sex === 2) return '女'
- return '-'
- })
- const stateTag = computed(() => {
- const state = account.value?.state
- if (state === 0) return { text: '需登录', color: 'orangered' }
- if (state === 1) return { text: '正常', color: 'green' }
- return { text: '状态异常', color: 'red' }
- })
- const noticeLabel = computed(() => {
- const item = account.value
- if (!item) return '-'
- if (item.notice_type === 'email') return `📧 ${item.email || '未绑定'}`
- if (item.notice_type === 'bot') return `🤖 ${item.bot_account || '未绑定'}`
- return '无通知'
- })
- const autoRunLabel = computed(() => {
- const item = account.value
- if (!item?.auto_run) return '关闭'
- return item.target_count === 0 ? '开启 · 不限次' : `开启 · ${item.target_count} 次`
- })
- const autoDayLabel = computed(() => {
- const item = account.value
- if (!item?.auto_run || !item.auto_day?.length) return '-'
- return item.auto_day
- .slice()
- .sort((a, b) => {
- if (a === 0) return 1
- if (b === 0) return -1
- return a - b
- })
- .map((day) => auto_day.find((d) => d.value === day)?.label)
- .join('、')
- })
- const autoTimeLabel = computed(() => {
- const item = account.value
- if (!item?.auto_run) return '-'
- if (item.auto_time === -1) {
- if (item.today_auto_time) return `随机 · 今日 ${item.today_auto_time} 时`
- return '随机 · 待分配'
- }
- return auto_time_options.find((t) => t.value === item.auto_time)?.label || '-'
- })
- const termProgressLabel = computed(() => {
- const item = account.value
- if (!item) return '-'
- if (item.term_num != null && item.total_num != null && item.term_num !== item.total_num) {
- return `${item.total_num} / ${item.term_num}`
- }
- return '已完成'
- })
- const faceStateLabel = computed(() => {
- const faceState = account.value?.face_state
- if (faceState === 0) return '未采集'
- if (faceState === 1) return '已通过'
- if (faceState != null) return '不通过'
- return '-'
- })
- const canViewOfficialRecords = computed(() => account.value?.state === 1)
- const activeRecordCountLabel = computed(() => {
- if (recordTab.value === 'official') {
- const total = officialPagination.value.total || officialRecords.value.length
- return `官方记录共 ${total} 条,有效次数 ${officialSummary.value?.total_score_num ?? 0} 次(${officialSummary.value?.term_name ?? '本学期'})`
- }
- return `平台记录共 ${records.value.length} 条`
- })
- const officialPaginationConfig = computed(() => ({
- current: officialPagination.value.current,
- pageSize: officialPagination.value.pageSize,
- total: officialPagination.value.total || officialRecords.value.length,
- showTotal: true,
- showPageSize: false
- }))
- const yearOptions = computed(() => {
- const years = new Set([calendarYear.value, new Date().getFullYear()])
- records.value.forEach((record) => {
- years.add(new Date(record.time).getFullYear())
- })
- return Array.from(years)
- .sort((a, b) => b - a)
- .map((year) => ({ label: `${year} 年`, value: year }))
- })
- const formatDateKey = (timestamp) => {
- const d = new Date(timestamp)
- const y = d.getFullYear()
- const m = String(d.getMonth() + 1).padStart(2, '0')
- const day = String(d.getDate()).padStart(2, '0')
- return `${y}-${m}-${day}`
- }
- const buildCalendarSeriesData = () => {
- const counter = new Map()
- records.value.forEach((record) => {
- const date = new Date(record.time)
- if (date.getFullYear() !== calendarYear.value) return
- const key = formatDateKey(record.time)
- counter.set(key, (counter.get(key) || 0) + 1)
- })
- return Array.from(counter.entries()).map(([date, count]) => [date, count])
- }
- const { chartOption: calendarOption } = useChartOption(() => {
- const seriesData = buildCalendarSeriesData()
- const maxCount = Math.max(...seriesData.map((item) => item[1]), 1)
- return {
- tooltip: {
- position: 'top',
- formatter(params) {
- if (!params?.data) return ''
- return `${params.data[0]}<br/>乐跑 ${params.data[1]} 次`
- }
- },
- visualMap: {
- min: 0,
- max: maxCount,
- show: false,
- inRange: {
- color: ['#fff0f5', '#FE82A5']
- }
- },
- calendar: {
- top: 36,
- left: 40,
- right: 20,
- cellSize: ['auto', 18],
- range: String(calendarYear.value),
- itemStyle: {
- borderWidth: 0.5,
- borderColor: 'var(--color-border-2)'
- },
- yearLabel: { show: true, fontSize: 12 },
- monthLabel: { fontSize: 11 },
- dayLabel: { fontSize: 10 }
- },
- series: [
- {
- type: 'heatmap',
- coordinateSystem: 'calendar',
- data: seriesData
- }
- ]
- }
- })
- const formatTime = (time) => {
- if (!time) return '-'
- return new Date(time).toLocaleString('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- })
- }
- const formatUnixTime = (time) => {
- const timestamp = Number(time)
- if (!Number.isFinite(timestamp) || timestamp <= 0) return '-'
- return formatTime(timestamp * 1000)
- }
- function calculatePace(seconds, kilometers) {
- if (!seconds || !kilometers) return '-'
- const paceInSeconds = seconds / kilometers
- const minutes = Math.floor(paceInSeconds / 60)
- const remainingSeconds = Math.round(paceInSeconds % 60)
- return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`
- }
- function formatSecondsToMinSec(totalSeconds) {
- if (!totalSeconds && totalSeconds !== 0) return '-'
- const minutes = Math.floor(totalSeconds / 60)
- const seconds = totalSeconds % 60
- return `${minutes}分${seconds.toString().padStart(2, '0')}秒`
- }
- const formatOfficialDuration = (record) => {
- const start = Number(record?.start_time)
- const end = Number(record?.end_time)
- if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return '-'
- return formatSecondsToMinSec(end - start)
- }
- const officialRecordStatus = (record) => {
- const status = String(record?.record_status ?? '')
- if (status === '1') return '有效'
- return '无效'
- }
- const fetchAllRecords = async (studentNum) => {
- const all = []
- let current = 1
- const pagesize = 100
- let total = 0
- const fetchRecords = props.admin ? adminLepaoRecords : lepaoRecords
- do {
- const res = await fetchRecords({
- lepao_account: studentNum,
- pagesize,
- current
- })
- if (!res || res.code !== 0) {
- throw new Error(res?.msg || '获取乐跑记录失败')
- }
- all.push(...(res.data || []))
- total = res.pagination?.total ?? all.length
- current += 1
- } while (all.length < total)
- return all.sort((a, b) => b.time - a.time)
- }
- const fetchOfficialRecords = async (page = 1) => {
- if (!account.value?.student_num) return
- if (!canViewOfficialRecords.value) {
- Message.warning('请使用登录器更新账号后查看官方乐跑记录')
- return
- }
- officialLoading.value = true
- try {
- const fetchRecords = props.admin ? adminOfficialTermRecords : officialTermRecords
- const res = await fetchRecords({
- student_num: account.value.student_num,
- page
- })
- if (!res || res.code !== 0) {
- if (account.value && String(res?.msg || '').includes('登录')) {
- account.value.state = 0
- }
- throw new Error(res?.msg || '获取乐跑记录失败')
- }
- const data = res.data || {}
- officialSummary.value = data
- officialRecords.value = data.list || []
- officialPagination.value = {
- ...officialPagination.value,
- current: data.page || page,
- total: Number(data.total_num || officialRecords.value.length)
- }
- } catch (error) {
- Notification.error({
- title: '获取官方乐跑记录失败',
- content: error.message || '请稍后再试'
- })
- } finally {
- officialLoading.value = false
- }
- }
- const handleRecordTabChange = (key) => {
- if (key !== 'official') return
- if (!canViewOfficialRecords.value) {
- recordTab.value = 'platform'
- Message.warning('请使用登录器更新账号后查看官方记录')
- return
- }
- if (officialRecords.value.length === 0) {
- fetchOfficialRecords(1)
- }
- }
- const handleOfficialPageChange = (page) => {
- fetchOfficialRecords(page)
- }
- const openPlatformRecordDetail = (record) => {
- const key = record.public_id || record.id
- if (!key) {
- Message.warning('该记录缺少可用标识,无法打开详情')
- return
- }
- visible.value = false
- const path = props.admin
- ? `/admin/lepaoRecords/${encodeURIComponent(String(key))}`
- : `/lepao/recordDetail/${encodeURIComponent(String(key))}`
- router.push(path)
- }
- const openModal = async (record) => {
- if (!record?.student_num) return
- account.value = record
- visible.value = true
- loading.value = true
- records.value = []
- officialRecords.value = []
- officialSummary.value = {}
- officialPagination.value = { current: 1, pageSize: 10, total: 0 }
- recordTab.value = 'platform'
- try {
- const list = await fetchAllRecords(record.student_num)
- records.value = list
- if (list.length > 0) {
- calendarYear.value = new Date(list[0].time).getFullYear()
- } else {
- calendarYear.value = new Date().getFullYear()
- }
- } catch (error) {
- Notification.error({
- title: '获取乐跑记录失败',
- content: error.message || '请稍后再试'
- })
- } finally {
- loading.value = false
- }
- }
- defineExpose({ openModal })
- </script>
- <style scoped lang="less">
- .account-detail-modal {
- :deep(.arco-modal-body) {
- max-height: 78vh;
- overflow-y: auto;
- }
- }
- .account-header {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-bottom: 16px;
- }
- .account-header-main {
- flex: 1;
- min-width: 0;
- }
- .account-name {
- font-size: 18px;
- font-weight: 600;
- line-height: 1.4;
- }
- .account-meta {
- margin-top: 4px;
- color: var(--color-text-3);
- font-size: 13px;
- }
- .account-desc {
- margin-bottom: 20px;
- }
- .section-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin: 16px 0 8px;
- font-size: 14px;
- font-weight: 600;
- color: var(--color-text-1);
- }
- .record-count {
- font-size: 12px;
- font-weight: 400;
- color: var(--color-text-3);
- }
- .official-record-alert {
- margin-bottom: 12px;
- }
- .state {
- display: flex;
- align-items: center;
- .circle {
- border-radius: 50%;
- height: 8px;
- width: 8px;
- margin-right: 6px;
- flex-shrink: 0;
- }
- .one {
- background-color: rgb(var(--green-6));
- }
- .else {
- background-color: rgb(var(--red-6));
- }
- }
- @media (max-width: 768px) {
- .account-header {
- flex-wrap: wrap;
- align-items: flex-start;
- gap: 10px;
- }
- .section-title {
- flex-wrap: wrap;
- gap: 8px;
- }
- .record-count {
- width: 100%;
- }
- .account-detail-modal {
- :deep(.arco-modal) {
- width: calc(100vw - 16px) !important;
- max-width: calc(100vw - 16px) !important;
- margin: 8px auto !important;
- }
- :deep(.arco-descriptions-bordered .arco-descriptions-item) {
- width: 100%;
- }
- :deep(.arco-descriptions-bordered .arco-descriptions-item-label),
- :deep(.arco-descriptions-bordered .arco-descriptions-item-value) {
- padding: 8px 10px;
- font-size: 12px;
- line-height: 1.45;
- }
- }
- }
- </style>
|