|
|
@@ -0,0 +1,471 @@
|
|
|
+<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="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="220px" width="100%" />
|
|
|
+
|
|
|
+ <div class="section-title">
|
|
|
+ <span>历史乐跑记录</span>
|
|
|
+ <span class="record-count">共 {{ records.length }} 条</span>
|
|
|
+ </div>
|
|
|
+ <a-table
|
|
|
+ :data="records"
|
|
|
+ :pagination="{ pageSize: 8, showTotal: true }"
|
|
|
+ :scroll="{ x: 900, y: 280 }"
|
|
|
+ size="small"
|
|
|
+ row-key="id"
|
|
|
+ >
|
|
|
+ <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="80">
|
|
|
+ <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="right">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <a-button type="text" size="small" @click="openRecordDetail(record)">详情</a-button>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ </template>
|
|
|
+ </a-table>
|
|
|
+ </template>
|
|
|
+ </a-spin>
|
|
|
+ </a-modal>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed } from 'vue'
|
|
|
+import { useRouter } from 'vue-router'
|
|
|
+import { lepaoRecords, adminLepaoRecords } from '@/api/lepao'
|
|
|
+import { Notification, Message } from '@arco-design/web-vue'
|
|
|
+import useChartOption from '@/hooks/chart-option'
|
|
|
+
|
|
|
+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 calendarYear = ref(new Date().getFullYear())
|
|
|
+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 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,
|
|
|
+ calculable: true,
|
|
|
+ orient: 'horizontal',
|
|
|
+ left: 'center',
|
|
|
+ bottom: 4,
|
|
|
+ inRange: {
|
|
|
+ color: ['#e8f3ff', '#165dff']
|
|
|
+ }
|
|
|
+ },
|
|
|
+ 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'
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+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 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 openRecordDetail = (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 = []
|
|
|
+
|
|
|
+ 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);
|
|
|
+}
|
|
|
+
|
|
|
+.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));
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|