Browse Source

✨ feat: 增加乐跑账号详情卡片

Pchen0 1 month ago
parent
commit
3e0a2a55cf

+ 471 - 0
src/components/LepaoAccountCard/accountDetailCard.vue

@@ -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>

+ 34 - 34
src/components/index.js

@@ -1,43 +1,43 @@
-// import { use } from 'echarts/core'
-// import { CanvasRenderer } from 'echarts/renderers'
-// import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart, GraphChart } from 'echarts/charts'
-// import {
-//   GridComponent,
-//   TooltipComponent,
-//   LegendComponent,
-//   DataZoomComponent,
-//   GraphicComponent,
-//   TitleComponent,
-//   CalendarComponent,
-//   VisualMapComponent,
-//   ToolboxComponent
-// } from 'echarts/components'
-// import Chart from './Chart/index.vue'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart, GraphChart } from 'echarts/charts'
+import {
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  TitleComponent,
+  CalendarComponent,
+  VisualMapComponent,
+  ToolboxComponent
+} from 'echarts/components'
+import Chart from './Chart/index.vue'
 
 import Breadcrumb from './Breadcrumb/index.vue'
 
-// use([
-//   CanvasRenderer,
-//   BarChart,
-//   LineChart,
-//   PieChart,
-//   RadarChart,
-//   HeatmapChart,
-//   GraphChart,
-//   GridComponent,
-//   TooltipComponent,
-//   LegendComponent,
-//   DataZoomComponent,
-//   GraphicComponent,
-//   CalendarComponent,
-//   VisualMapComponent ,
-//   TitleComponent,
-//   ToolboxComponent
-// ])
+use([
+  CanvasRenderer,
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  HeatmapChart,
+  GraphChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  CalendarComponent,
+  VisualMapComponent,
+  TitleComponent,
+  ToolboxComponent
+])
 
 export default {
   install(Vue) {
-    // Vue.component('Chart', Chart)
+    Vue.component('Chart', Chart)
     Vue.component('Breadcrumb', Breadcrumb)
   },
 }

+ 16 - 2
src/pages/admin/lepaoAccount/accountList.vue

@@ -97,7 +97,7 @@
                 </a-col>
             </a-row>
 
-            <a-table :data="data" :bordered="false" class="table" :loading="loading" :columns="columns" :pagination="{
+            <a-table :data="data" :bordered="false" hoverable class="table table-clickable" :loading="loading" :columns="columns" @row-click="onRowClick" :pagination="{
                 showPageSize: true,
                 showJumper: true,
                 showTotal: true,
@@ -179,7 +179,7 @@
                     {{ record.update_time ? stramptoTime(record.update_time) : '待登录' }}
                 </template>
                 <template #optional="{ record }">
-                    <a-dropdown :popup-max-height="false" trigger="hover">
+                    <a-dropdown :popup-max-height="false" trigger="hover" @click.stop>
                         <a-button>操作 <icon-down /></a-button>
                         <template #content>
                             <a-doption v-if="hasPermission('action.lepao.admin.updateAccount')" @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
@@ -249,6 +249,7 @@
 
     <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
     <bindBot ref="bindBotRef" />
+    <accountDetailCard ref="accountDetailRef" admin />
 
     <a-modal v-model:visible="bindAuditVisible" title="绑定解绑记录" :footer="false" width="980px" draggable>
         <a-table :bordered="false" :data="bindAuditData" :columns="bindAuditColumns" :loading="bindAuditLoading" :pagination="false"
@@ -302,11 +303,13 @@ import { addAccount, adminAccountList, deleteAccount, changeAutoRun, singleRun,
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
+import accountDetailCard from '@/components/LepaoAccountCard/accountDetailCard.vue'
 import { getSemesterTimestamps } from '@/utils/util'
 import { hasPermission } from '@/utils/permission'
 
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)
+const accountDetailRef = ref(null)
 const bindAuditVisible = ref(false)
 const bindAuditLoading = ref(false)
 const bindAuditData = ref([])
@@ -513,6 +516,11 @@ const getAccounts = async () => {
     }
 }
 
+const onRowClick = (record, ev) => {
+    if (ev?.target?.closest?.('.arco-dropdown, .arco-btn')) return
+    accountDetailRef.value?.openModal(record)
+}
+
 const editAccount = (item) => {
     if (item) {
         form.id = item.id
@@ -813,6 +821,12 @@ const autoTimeLabel = (record) => {
     padding: 0 20px 20px 20px;
 }
 
+.table-clickable {
+    :deep(.arco-table-tr) {
+        cursor: pointer;
+    }
+}
+
 .table {
     font-family: -apple-system, BlinkMacSystemFont;
     margin-top: 15px;

+ 17 - 3
src/pages/lepao/accountList/index.vue

@@ -122,9 +122,9 @@
 
       <a-alert v-if="notice" style="margin-bottom: 15px;">{{ notice }}</a-alert>
 
-      <a-table :data="data" :bordered="false" hoverable class="table" :loading="loading" expandable :scroll="{
+      <a-table :data="data" :bordered="false" hoverable class="table table-clickable" :loading="loading" expandable :scroll="{
         x: 1600
-      }" :pagination="{
+      }" @row-click="onRowClick" :pagination="{
         showPageSize: true,
         showJumper: true,
         showTotal: true,
@@ -266,7 +266,7 @@
 
           <a-table-column title="" fixed="right" :width="100">
             <template #cell="{ record }">
-              <a-dropdown :popup-max-height="false" trigger="hover">
+              <a-dropdown :popup-max-height="false" trigger="hover" @click.stop>
                 <a-button>操作 <icon-down /></a-button>
                 <template #content>
                   <a-doption v-if="hasPermission('action.lepao.updateAccount')" @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
@@ -354,6 +354,7 @@
 
   <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
   <bindBot ref="bindBotRef" />
+  <accountDetailCard ref="accountDetailRef" />
 </template>
 
 <script setup>
@@ -365,6 +366,7 @@ import userCard from '@/components/userCard/userCard.vue'
 import { isElectron } from '@/utils/electron'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
+import accountDetailCard from '@/components/LepaoAccountCard/accountDetailCard.vue'
 import { useRoute } from 'vue-router'
 import { getNotice, getSemesterTimestamps } from '@/utils/util'
 import { hasPermission } from '@/utils/permission'
@@ -374,6 +376,7 @@ const notice = ref('')
 const email = ref([])
 const faceInfo = ref({})
 const bindBotRef = ref(null)
+const accountDetailRef = ref(null)
 
 const queryDataForm = reactive({
   area: '',
@@ -611,6 +614,11 @@ const handleAutoFill = () => {
   Message.success(`识别成功,已填入 ${hitCount} 项信息`)
 }
 
+const onRowClick = (record, ev) => {
+  if (ev?.target?.closest?.('.arco-dropdown, .arco-btn')) return
+  accountDetailRef.value?.openModal(record)
+}
+
 const editAccount = (item) => {
   if (item) {
     form.id = item.id
@@ -863,6 +871,12 @@ onUnmounted(() => {
   padding: 0 15px
 }
 
+.table-clickable {
+  :deep(.arco-table-tr) {
+    cursor: pointer;
+  }
+}
+
 .table {
   font-family: -apple-system, BlinkMacSystemFont;
 

+ 1 - 1
src/router/index.js

@@ -172,7 +172,7 @@ const routes = [
                 name: 'lepao.recordDetail',
                 component: () => import('../pages/lepao/lepaoRecords/recordDetail.vue'),
                 meta: {
-                    title: '路线详情',
+                    title: '乐跑记录详情',
                     hideInMenu: true,
                     parent: 'lepao.lepaoRecords'
                 }