Browse Source

✨ feat: 适配健康二师

Pchen. 2 months ago
parent
commit
155952d1a1

+ 10 - 0
src/api/lepao.js

@@ -7,6 +7,7 @@ const api = {
   Records: '/Lepao/Records',
   ChangeAutoRun: '/Lepao/ChangeAutoRun',
   SingleRun: '/Lepao/SingleRun',
+  JkesRunOverview: '/Lepao/JkesRunOverview',
   GetRecordDetail: '/Lepao/GetRecordDetail',
   AdminRecords: '/Admin/Lepao/Records',
   AdminGetRecordDetail: '/Admin/Lepao/GetRecordDetail',
@@ -85,6 +86,15 @@ export function singleRun (parameter) {
   })
 }
 
+/** JKES 本月/累计里程与本地月度缓存(需 student_num) */
+export function jkesRunOverview (parameter) {
+  return request({
+    url: api.JkesRunOverview,
+    method: 'get',
+    params: parameter
+  })
+}
+
 export function GetRecordDetail (parameter) {
   return request({
     url: api.GetRecordDetail,

+ 0 - 97
src/components/ChangeBranch/index.vue

@@ -1,97 +0,0 @@
-<template>
-    <a-drawer :visible="props.visible" class="drawer" width="520px" v-if="status" @cancel="closeChangeBranch"
-    placement="left" :footer="false">
-    <template #title><icon-swap /> 切换分支</template>
-    <a-list hoverable>
-        <a-list-item v-for="(item, index) in props.status.branches" :key="index">
-            <div class="branch-list">
-                <div class="icon">
-                    <icon-branch :size="25" />
-                </div>
-                <div class="info">
-                    <span class="title">
-                        {{ item.name }}
-                    </span>
-                    <span class="label">
-                        <a-tag size="small">{{ item.commit }}</a-tag>
-                        {{ item.label }}
-                    </span>
-                </div>
-                <div class="button">
-                    <a-button type="primary" size="small" v-if="item.name !== status.status.current"
-                        @click="ChangeBranch(item.name)">切换</a-button>
-                    <a-button type="text" size="small" v-else>当前</a-button>
-                </div>
-            </div>
-        </a-list-item>
-    </a-list>
-</a-drawer>
-</template>
-
-<script setup>
-import { Notification, Message } from '@arco-design/web-vue'
-import { useRepoStore } from '@/store'
-import { eventBus } from '@/utils/eventBus'
-
-const repoStore = useRepoStore()
-const props = defineProps({
-    id: {
-        type: Number
-    },
-    visible: {
-        type: Boolean,
-        default: false
-    },
-    status: {
-        type: Object
-    }
-})
-
-const emit = defineEmits(['changeBegin', 'changeFail', 'close'])
-
-const ChangeBranch = async (branch) => {
-    try {
-        emit('changeBegin')
-        emit('close')
-        await repoStore.ChangeBranch(props.id, branch )
-        Message.success(`已切换至分支 ${branch}`)
-        eventBus.emit('changeOK')
-    } catch (error) {
-        console.error(error)
-        Notification.error({
-            title: '切换仓库分支失败!',
-            content: error.message || '请稍后再试'
-        })
-        emit('changeFail')
-    } 
-}
-
-const closeChangeBranch = () => {
-    emit('close')
-}
-
-</script>
-
-<style lang="less" scoped>
-.branch-list {
-    display: flex;
-    min-height: 60px;
-    align-items: center;
-
-    .info {
-        display: flex;
-        flex-direction: column;
-        margin-left: 15px;
-        margin-right: 15px;
-
-
-        .title {
-            font-size: 1.3em;
-        }
-    }
-
-    .button {
-        margin-left: auto;
-    }
-}
-</style>

+ 0 - 113
src/components/CodeBlock/index.vue

@@ -1,113 +0,0 @@
-<template>
-    <div class="code-container">
-        <div class="copy" v-if="!copied" @click="copyCode">复制</div>
-        <div class="copy" v-else><icon-check-circle /> 复制成功!</div>
-        <div class="line">
-            <span class="line-number" v-for="num in line">{{ num + props.startLine - 1 }}</span>
-        </div>
-        <highlightjs :code="code" class="code" />
-    </div>
-</template>
-
-<script setup>
-import { ref, computed, onMounted } from 'vue'
-import hljsVuePlugin from '@highlightjs/vue-plugin'
-// import { useThemeStore } from '@/store'
-import 'highlight.js/lib/common'
-import 'highlight.js/styles/atom-one-light.css'
-
-// const themeStore = useThemeStore()
-const props = defineProps({
-    code: {
-        type: String,
-        required: true
-    },
-    startLine: {
-        type: Number,
-        default: 1
-    }
-})
-const copied = ref(false)
-
-const highlightjs = hljsVuePlugin.component
-const line = computed(() => {
-    const lines = props.code.split('\n')
-    if (lines[lines.length - 1] == '')
-        return lines.length - 1
-    return lines.length
-})
-
-function copyCode() {
-  navigator.clipboard.writeText(props.code).then(() => {
-    copied.value = true
-    setTimeout(() => {
-      copied.value = false
-    }, 2000)
-  })
-}
-
-// function loadThemeCSS(theme) {
-//     import(`highlight.js/styles/${theme}.css`) 
-// }
-
-// onMounted(() => {
-//   loadThemeCSS(themeStore.getTheme())
-// })
-
-</script>
-
-<style scoped lang="less">
-.code-container {
-    position: relative;
-    display: flex;
-    padding: 0;
-    white-space: pre;
-    overflow-x: auto;
-
-    .copy {
-        position: absolute;
-        right: 6px;
-        top: 19px;
-        background: #eee;
-        padding: 4px 10px;
-        font-size: 12px;
-        border-radius: 4px;
-        cursor: pointer;
-        display: none;
-        user-select: none;
-    }
-
-    .code {
-        line-height: 1.5em;
-        margin-bottom: 10px;
-        width: 100%;
-        overflow-x: auto;
-    }
-
-    .line {
-        display: flex;
-        flex-direction: column;
-        line-height: 1.5em;
-        background-color: #f0f0f0;
-        margin-bottom: 10px;
-        margin-top: 15px;
-        padding-top: 15px;
-
-        .line-number {
-            user-select: none;
-            display: inline-block;
-            min-width: 35px;
-            color: #888;
-            text-align: center;
-            font-family: 'Fira Code', 'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace;
-        }
-    }
-}
-
-.code-container:hover {
-    .copy {
-        display: block;
-    }
-}
-
-</style>

+ 175 - 0
src/components/SingleRunModal/SingleRunModal.vue

@@ -0,0 +1,175 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    title="单次乐跑"
+    draggable
+    esc-to-close
+    closable
+    :ok-loading="okLoading"
+    @before-ok="handleBeforeOk"
+    @close="onClose"
+  >
+    <template v-if="account">
+      <p style="margin-bottom: 16px; color: var(--color-text-2)">
+        为 <strong>{{ account.name }}</strong>({{ account.student_num }})发起单次乐跑;会先预扣所选距离的乐跑里程,结算以官方最终计算距离为准。
+      </p>
+      <a-form :model="form" layout="vertical">
+        <a-form-item
+          label="跑步距离(公里)"
+          field="distance_km"
+          :rules="[{ required: true, message: '请填写距离' }]"
+        >
+          <a-input-number
+            v-model="form.distance_km"
+            placeholder="1~5"
+            mode="button"
+            :min="1"
+            :max="5"
+            :step="0.5"
+            :precision="2"
+            style="width: 200px"
+          />
+        </a-form-item>
+        <a-form-item
+          label="目标配速(每公里)"
+          field="pace"
+          :rules="[{ required: true, message: '请填写配速' }]"
+        >
+          <a-input
+            v-model="form.pace"
+            placeholder="如 6:00 或 360(秒/公里)"
+            allow-clear
+          />
+          <template #extra>
+            <span style="color: var(--color-text-3)">推荐: 6:00~10:00 / km</span>
+          </template>
+        </a-form-item>
+      </a-form>
+    </template>
+  </a-modal>
+</template>
+
+<script setup>
+import { reactive, ref, watch } from 'vue'
+import { lepaoRecords, singleRun } from '@/api/lepao'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+
+const visible = defineModel('visible', { type: Boolean, default: false })
+
+const props = defineProps({
+  account: {
+    type: Object,
+    default: null
+  }
+})
+
+const emit = defineEmits(['success'])
+
+const okLoading = ref(false)
+const form = reactive({
+  distance_km: 2,
+  pace: '6:00'
+})
+
+watch(visible, (v) => {
+  if (v) {
+    form.distance_km = 2
+    form.pace = '6:00'
+  }
+})
+
+function onClose() {
+  okLoading.value = false
+}
+
+async function handleBeforeOk(done) {
+  if (!props.account?.student_num) {
+    Message.error('缺少账号信息')
+    return false
+  }
+  const d = Number(form.distance_km)
+  if (!Number.isFinite(d) || d < 1 || d > 5) {
+    Message.error('跑步距离须在 1~5 公里之间')
+    return false
+  }
+  const pace = String(form.pace ?? '').trim()
+  if (!pace) {
+    Message.error('请填写配速')
+    return false
+  }
+
+  const startSingleRun = async () => {
+    okLoading.value = true
+    try {
+      const res = await singleRun({
+        student_num: props.account.student_num,
+        distance_km: d,
+        pace
+      })
+      if (!res || res.code !== 0) {
+        Notification.error({
+          title: '提交乐跑任务失败',
+          content: res?.msg ?? '请稍后再试'
+        })
+        return false
+      }
+      Message.success('提交乐跑任务成功!')
+      emit('success')
+      done()
+      return true
+    } catch (e) {
+      Notification.error({
+        title: '提交乐跑任务失败',
+        content: e.message || '请稍后再试'
+      })
+      return false
+    } finally {
+      okLoading.value = false
+    }
+  }
+
+  okLoading.value = true
+  try {
+    // 若当天已有跑步记录,二次确认再继续(避免误触导致重复预扣)
+    const now = new Date()
+    const start = new Date(now)
+    start.setHours(0, 0, 0, 0)
+    const end = new Date(start)
+    end.setDate(end.getDate() + 1)
+
+    const checkRes = await lepaoRecords({
+      lepao_account: props.account.student_num,
+      queryTime: [String(start.getTime()), String(end.getTime())],
+      pagesize: 1,
+      current: 1
+    })
+    const total = Number(checkRes?.pagination?.total ?? 0)
+
+    if (checkRes && checkRes.code === 0 && total > 0) {
+      okLoading.value = false
+      return await new Promise((resolve) => {
+        Modal.confirm({
+          title: '确认继续乐跑?',
+          content: `检测到该账号今天已存在跑步记录(共 ${total} 条)。继续发起单次乐跑将再次预扣里程,是否继续?`,
+          okText: '继续乐跑',
+          cancelText: '取消',
+          onOk: async () => resolve(await startSingleRun()),
+          onCancel: () => resolve(false)
+        })
+      })
+    }
+
+    // 检查失败/无记录:直接继续
+    okLoading.value = false
+    return await startSingleRun()
+  } catch (e) {
+    Notification.warning({
+      title: '记录检测失败,已继续提交',
+      content: e.message || '请稍后在记录页确认结果'
+    })
+    okLoading.value = false
+    return await startSingleRun()
+    return false
+  }
+}
+</script>

+ 12 - 10
src/components/userCard/userCard.vue

@@ -1,7 +1,9 @@
 <template>
   <a-card class="card">
     <a-space size="large">
-      <a-statistic title="剩余乐跑次数" :value="userCount?.lepao_count" animation show-group-separator />
+      <a-statistic title="剩余乐跑里程" :value="Number(userCount?.lepao_count)" animation show-group-separator  :precision="2">
+        <template #suffix>Km</template>
+      </a-statistic>
       <a-button type="primary" size="large" @click="$router.push('/store/goodsList')" style="margin-left: 20px;"
         v-if="props.type === 'lepao'">
         <span><icon-fire /> 去购买</span>
@@ -11,19 +13,19 @@
         <span><icon-thunderbolt /> 去乐跑</span>
       </a-button>
       <a-button type="primary" size="large" @click="SendCount()">
-        <span><icon-gift /> 赠送次数</span>
+        <span><icon-gift /> 赠送里程</span>
       </a-button>
     </a-space>
   </a-card>
 
-  <a-modal v-model:visible="visible" title="赠送乐跑次数" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
+  <a-modal v-model:visible="visible" title="赠送乐跑里程" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
     :ok-loading="ok_loading" esc-to-close closable>
     <a-form :model="sendform">
       <a-form-item field="username" label="接收人">
         <a-input v-model="sendform.username" placeholder="请填写接收人用户名,如:用户adfg45g" :max-length="20" />
       </a-form-item>
-      <a-form-item field="count" label="赠送次数">
-        <a-input-number v-model="sendform.count" placeholder="请填写要赠送的次数" mode="button" />
+      <a-form-item field="count" label="赠送里程">
+        <a-input-number v-model="sendform.count" placeholder="请填写要赠送的里程" mode="button" :step="0.01" :precision="2" />
       </a-form-item>
     </a-form>
   </a-modal>
@@ -61,10 +63,10 @@ const handleBeforeOk = async (done) => {
       return false
     }
 
-    if (!sendform.count || sendform.count < 1 || sendform.count > 9999) {
+    if (!sendform.count || Number(sendform.count) <= 0 || Number(sendform.count) > 9999) {
       Notification.error({
-        title: '赠送次数失败!',
-        content: '超出赠送的次数范围,请重新选择赠送次数!'
+        title: '赠送里程失败!',
+        content: '超出赠送的里程范围,请重新选择赠送里程!'
       })
       return false
     }
@@ -72,7 +74,7 @@ const handleBeforeOk = async (done) => {
     const res = await sendCount({ username: sendform.username, count: sendform.count })
     if (!res || res.code !== 0) {
       Notification.error({
-        title: '赠送次数失败!',
+        title: '赠送里程失败!',
         content: res?.msg ?? '请稍后再试'
       })
       return false
@@ -83,7 +85,7 @@ const handleBeforeOk = async (done) => {
     GetCount()
   } catch (error) {
     Notification.error({
-      title: '赠送次数失败!',
+      title: '赠送里程失败!',
       content: error.message || '请稍后再试'
     })
     return false

+ 3 - 3
src/pages/admin/goods/addGoods.vue

@@ -12,8 +12,8 @@
                     <a-input-number v-model="form.price" :step="0.01" :precision="2" />
                 </a-form-item>
 
-                <a-form-item field="lepao_count" label="乐跑次数" :style="{ width: '300px' }">
-                    <a-input-number v-model="form.lepao_count" :step="1" :precision="0" />
+                <a-form-item field="lepao_count" label="乐跑里程(km)" :style="{ width: '300px' }">
+                    <a-input-number v-model="form.lepao_count" :step="0.5" :precision="2" />
                 </a-form-item>
 
                 <a-form-item field="num" label="库存数量" :style="{ width: '300px' }">
@@ -65,7 +65,7 @@ const rules = {
     lepao_count: [
         {
             required: true,
-            message: '请填写乐跑次数',
+            message: '请填写乐跑里程(km)',
         }
     ],
     num: [

+ 1 - 1
src/pages/admin/goods/goodsList.vue

@@ -116,7 +116,7 @@ const columns = [
         title: '库存数量',
         dataIndex: 'num',
     }, {
-        title: '乐跑次数',
+        title: '乐跑里程(km)',
         dataIndex: 'lepao_count',
     }, {
         title: '浏览量',

+ 61 - 45
src/pages/admin/lepaoAccount/accountList.vue

@@ -120,8 +120,7 @@
                     {{ record.sex === 1 ? '男' : (record.sex === 2 ? '女' : '') }}
                 </template>
                 <template #auto_run="{ record }">
-                    <a-tag color="green" v-if="record.auto_run">{{ record.target_count === 0 ? '开启-∞次' :
-                        `开启-${record.target_count}次` }}</a-tag>
+                    <a-tag color="green" v-if="record.auto_run">开启</a-tag>
                     <a-tag color="red" v-else>关闭</a-tag>
                 </template>
                 <template #notice_type="{ record }">
@@ -138,9 +137,14 @@
                     </span>
                     <span v-else>-</span>
                 </template>
-                <template #num="{ record }">
-                    {{ record.term_num != record.total_num ? `${record.total_num} / ${record.term_num}` :
-                        '已完成' }}
+                <template #target_count="{ record }">
+                    {{ record.target_count === 0 ? '不限' : formatKm(record.target_count) }}
+                </template>
+                <template #term_num="{ record }">
+                    {{ formatKm(record.term_num) }}
+                </template>
+                <template #total_num="{ record }">
+                    {{ record.total_num ? formatKm(record.total_num) : '-' }}
                 </template>
                 <template #state="{ record }">
                     <div v-if="record.state === 0" class="state">
@@ -221,10 +225,11 @@
             <a-form-item field="auto_run" label="自动乐跑开关">
                 <a-switch v-model="form.auto_run" :checked-value="1" :unchecked-value="0" />
             </a-form-item>
-            <a-form-item field="target_count" label="乐跑目标次数" v-if="form.auto_run">
-                <a-input-number v-model="form.target_count" placeholder="请输入乐跑目标次数" mode="button" />
+            <a-form-item field="target_count" label="目标里程(km)" v-if="form.auto_run">
+                <a-input-number v-model="form.target_count" placeholder="0–200,0 表示不限制" mode="button" :min="0"
+                    :max="200" :precision="2" :step="0.5" />
                 <template #extra>
-                    <div>当学期有效次数达到目标次数时自动乐跑将关闭,0为不限次</div>
+                    <div>累计里程达到目标后自动乐跑将关闭;0 表示不限制(与后端校验一致)</div>
                 </template>
             </a-form-item>
             <a-form-item field="auto_day" label="自动乐跑星期" v-if="form.auto_run">
@@ -242,18 +247,27 @@
 
     <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
     <bindBot ref="bindBotRef" />
+    <SingleRunModal
+        v-model:visible="singleRunVisible"
+        :account="singleRunAccount"
+        @success="getAccounts"
+    />
 </template>
 
 <script setup>
 import { ref, reactive, onMounted, h } from 'vue'
-import { addAccount, adminAccountList, deleteAccount, changeAutoRun, singleRun } from '@/api/lepao'
+import { addAccount, adminAccountList, deleteAccount, changeAutoRun } from '@/api/lepao'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
+import SingleRunModal from '@/components/SingleRunModal/SingleRunModal.vue'
 import { getSemesterTimestamps } from '@/utils/util'
+import { formatKm } from '@/utils/lepaoRecord'
 
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)
+const singleRunVisible = ref(false)
+const singleRunAccount = ref(null)
 
 const queryData = reactive({
     area: '',
@@ -305,7 +319,7 @@ const auto_day = [
 const state = [
     { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
 ]
-const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
+const area = ["学府大道校区", "南山校区"]
 
 const columns = [
     {
@@ -324,19 +338,17 @@ const columns = [
     }, {
         title: '学号',
         dataIndex: 'student_num',
-        width: 115
+        width: 110
     }, {
         title: '性别',
         slotName: 'sex',
         width: 80
-    }, {
-        title: '学院',
-        dataIndex: 'academy_name',
-        width: 200
-    }, {
-        title: '年级',
-        dataIndex: 'grade_id',
-        width: 80
+    },{
+        title: '班级',
+        dataIndex: 'class_id',
+        width: 200,
+        ellipsis: true,
+        tooltip: true
     }, {
         title: '通知方式',
         slotName: 'notice_type',
@@ -345,7 +357,7 @@ const columns = [
     {
         title: '乐跑跑区',
         dataIndex: 'area',
-        width: 150
+        width: 120
     }, {
         title: '帐号状态',
         slotName: 'state',
@@ -353,7 +365,7 @@ const columns = [
     }, {
         title: '自动乐跑',
         slotName: 'auto_run',
-        width: 90
+        width: 100
     }, {
         title: '自动乐跑星期',
         slotName: 'auto_day',
@@ -363,9 +375,17 @@ const columns = [
         slotName: 'auto_time',
         width: 130
     }, {
-        title: '学期目标',
-        slotName: 'num',
-        width: 90
+        title: '目标里程',
+        slotName: 'target_count',
+        width: 130
+    }, {
+        title: '本月里程',
+        slotName: 'term_num',
+        width: 130
+    }, {
+        title: '累计里程',
+        slotName: 'total_num',
+        width: 130
     }, {
         title: '手机型号',
         dataIndex: 'deviceModel',
@@ -455,7 +475,7 @@ const editAccount = (item) => {
         form.auto_time = item.auto_time
         form.auto_day = item.auto_day
         form.notice_type = item.notice_type || 'email'
-        form.target_count = item.target_count
+        form.target_count = Number(item.target_count)
         form.notes = item.notes
     } else {
         form.id = null
@@ -479,9 +499,9 @@ const handleBeforeOk = async (done) => {
             return false
         }
 
-        const studentNumRegex = /^\d{10}$/
-        if (!studentNumRegex.test(student_num)) {
-            Message.error('请检查学号格式是否正确')
+        const sid = String(student_num || '').trim()
+        if (!/^[A-Za-z0-9_-]{2,64}$/.test(sid)) {
+            Message.error('学号/账号编码格式不正确(2–64 位字母数字下划线横线)')
             return false
         }
 
@@ -491,6 +511,14 @@ const handleBeforeOk = async (done) => {
             return false
         }
 
+        if (form.auto_run) {
+            const tk = Number(form.target_count)
+            if (!Number.isFinite(tk) || tk < 0 || tk > 200) {
+                Message.error('目标里程须在 0–200 公里之间')
+                return false
+            }
+        }
+
         let data = {
             ...form
         }
@@ -579,27 +607,15 @@ const ChangeAutoRun = async (record) => {
     }
 }
 
-const SingleRun = async (item) => {
-    if (item.state !== 1)
+const SingleRun = (item) => {
+    if (item.state !== 1) {
         return Notification.warning({
             title: '当前乐跑账号需登录,请登录后再试',
             content: '如有疑问请联系客服'
         })
-    Modal.confirm({
-        title: '开始乐跑',
-        content: () => h('div', [
-            h('p', `您是否要为${item.name}(${item.student_num})乐跑?若乐跑成功将扣减乐跑次数`)
-        ]),
-        onOk: async () => {
-            const res = await singleRun({ student_num: item.student_num })
-            if (!res || res.code !== 0)
-                return Notification.error({
-                    title: '提交乐跑任务失败',
-                    content: res?.msg ?? '请稍后再试'
-                })
-            Message.success('提交乐跑任务成功!')
-        }
-    })
+    }
+    singleRunAccount.value = item
+    singleRunVisible.value = true
 }
 
 onMounted(() => {

+ 38 - 29
src/pages/admin/lepaoRecords/lepaoRecords.vue

@@ -20,8 +20,7 @@
               </a-col>
               <a-col :span="12">
                 <a-form-item field="lepao_account" label="学号">
-                  <a-input-number v-model="queryData.lepao_account" placeholder="请输入学号" :step="1" :precision="0"
-                    allow-clear />
+                  <a-input v-model="queryData.lepao_account" placeholder="请输入学号" allow-clear />
                 </a-form-item>
               </a-col>
               <a-col :span="12">
@@ -109,34 +108,47 @@
           <a-table-column title="状态" ellipsis tooltip :width="230">
             <template #cell="{ record }">
               <div class="state">
-                <div class="circle one" v-if="record.result.record_failed_reason === '自动确认有效'"></div>
-                <div class="circle else" v-else></div>
-                {{ record.result.record_failed_reason }}
+                <div
+                  class="circle"
+                  :class="recordDisplay(record).statusLevel"
+                ></div>
+                {{ recordDisplay(record).statusText }}
               </div>
             </template>
           </a-table-column>
-          <a-table-column title="跑区" :filterable="{
-            filter: (value, record) => (record.result.pass_tit ?? '').includes(value),
+          <a-table-column title="路线跑区" :filterable="{
+            filter: (value, record) => (recordDisplay(record).routeRunZoneName ?? '').includes(value),
             slotName: 'name-filter',
             icon: () => h(IconSearch)
-          }" :width="220">
+          }" :width="160">
             <template #cell="{ record }">
-              {{ record.result.pass_tit }}
+              <a-tag v-if="recordDisplay(record).routeRunZoneName" size="small">
+                {{ recordDisplay(record).routeRunZoneName }}
+              </a-tag>
+              <span v-else>—</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="预计距离" :width="120" ellipsis tooltip>
+            <template #cell="{ record }">
+              <span v-if="recordDisplay(record).expectedKm != null">
+                {{ formatKm(recordDisplay(record).expectedKm) }}
+              </span>
+              <span v-else>—</span>
             </template>
           </a-table-column>
           <a-table-column title="乐跑距离" :width="120" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ record.result.distance }} Km
+              {{ formatKm(recordDisplay(record).distance) }}
             </template>
           </a-table-column>
           <a-table-column title="跑步时长" :width="120" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ formatSecondsToMinSec(record.result.time) }}
+              {{ formatSecondsToMinSec(recordDisplay(record).timeSec) }}
             </template>
           </a-table-column>
           <a-table-column title="平均配速" :width="120" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ calculatePace(record.result.time, record.result.distance) }}
+              {{ formatPace(recordDisplay(record).timeSec, recordDisplay(record).distance) }}
             </template>
           </a-table-column>
           <a-table-column title="乐跑时间" :width="170" ellipsis tooltip>
@@ -159,6 +171,12 @@
 <script setup>
 import { ref, reactive, onMounted, h } from 'vue'
 import { adminLepaoRecords } from '@/api/lepao'
+import {
+  parseLepaoRecordRow,
+  formatSecondsToMinSec,
+  formatPace,
+  formatKm
+} from '@/utils/lepaoRecord'
 import { Notification } from '@arco-design/web-vue'
 import { IconSearch } from '@arco-design/web-vue/es/icon'
 import { getSemesterTimestamps } from '@/utils/util'
@@ -239,20 +257,7 @@ const stramptoTime = (time) => {
   return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
 }
 
-function calculatePace(seconds, kilometers) {
-  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) {
-  const minutes = Math.floor(totalSeconds / 60);
-  const seconds = totalSeconds % 60;
-
-  return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
-}
+const recordDisplay = (record) => parseLepaoRecordRow(record)
 
 onMounted(() => {
   queryData.queryTime = getSemesterTimestamps()
@@ -282,15 +287,19 @@ onMounted(() => {
       margin-right: 5px;
     }
 
-    .zero {
+    .running {
       background-color: rgb(var(--orange-6));
     }
 
-    .one {
+    .success {
       background-color: rgb(var(--green-6));
     }
 
-    .else {
+    .sync {
+      background-color: rgb(var(--blue-6));
+    }
+
+    .fail {
       background-color: rgb(var(--red-6));
     }
   }

+ 37 - 40
src/pages/admin/lepaoRecords/recordDetail.vue

@@ -9,18 +9,29 @@
                 </a-space>
             </a-skeleton>
             <a-descriptions :data="info" :column="2" />
-            <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
-                style="margin-top: 10px;" />
+            <MapContainer
+                v-if="showMap"
+                :point_list="pointListForMap"
+                :log_list="logListForMap"
+                :pathData="pathForMap"
+                threeD
+                style="margin-top: 10px;"
+            />
         </a-card>
     </div>
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import { adminGetRecordDetail } from '@/api/lepao'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import MapContainer from '@/components/Map/MapContainer.vue'
+import {
+    buildRecordDetailInfo,
+    parseLepaoRecordRow,
+    normalizePointData
+} from '@/utils/lepaoRecord'
 
 const route = useRoute()
 
@@ -29,7 +40,26 @@ const showMap = ref(false)
 const data = ref({})
 const info = ref([])
 
-const GetRecordDetail = async (id) => {
+const pathForMap = computed(() => {
+    const poly = data.value.path_polyline
+    if (Array.isArray(poly) && poly.length > 0) return poly
+    return []
+})
+
+const pointListForMap = computed(() => {
+    const row = parseLepaoRecordRow({
+        result: data.value.result,
+        jkes_record: data.value.jkes_record,
+        run_zone_name: data.value.run_zone_name,
+        path_run_zone_name: data.value.path_run_zone_name,
+        path_distance: data.value.path_distance
+    })
+    return row.pointList || []
+})
+
+const logListForMap = computed(() => normalizePointData(data.value.point_data))
+
+const fetchDetail = async (id) => {
     try {
         loading.value = true
         showMap.value = false
@@ -42,20 +72,7 @@ const GetRecordDetail = async (id) => {
 
         data.value = res.data
         showMap.value = true
-
-        info.value = [
-            { label: '账号名称', value: res.data.name },
-            { label: '乐跑账号', value: res.data.lepao_account },
-            { label: '跑区名称', value: res.data.result.pass_tit },
-            { label: '路线ID', value: res.data.path_id },
-            { label: '记录时间', value: stramptoTime(res.data.time) },
-            { label: '开始时间', value: stramptoTime(res.data.result.start_time * 1000) },
-            { label: '乐跑状态', value: res.data.result.record_failed_reason },
-            { label: '打卡点数量', value: res.data.result.point_list.length },
-            { label: '跑步距离', value: res.data.result.distance + ' Km' },
-            { label: '跑步时长', value: formatSecondsToMinSec(res.data.result.time) },
-            { label: '平均配速', value: calculatePace(res.data.result.time, res.data.result.distance) }
-        ]
+        info.value = buildRecordDetailInfo(res.data, { isAdmin: true })
     } catch (error) {
         Notification.error({
             title: '获取路径数据失败!',
@@ -67,32 +84,12 @@ const GetRecordDetail = async (id) => {
 }
 
 onMounted(() => {
-    GetRecordDetail(route.params.id)
+    fetchDetail(route.params.id)
 })
-
-const stramptoTime = (time) => {
-    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) {
-    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) {
-    const minutes = Math.floor(totalSeconds / 60);
-    const seconds = totalSeconds % 60;
-
-    return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
-}
-
 </script>
 
 <style scoped lang="less">
 .container {
     padding: 0 20px 20px 20px;
 }
-</style>
+</style>

+ 2 - 2
src/pages/admin/mqQueue/index.vue

@@ -136,7 +136,7 @@ import { Notification } from '@arco-design/web-vue'
 
 /** 与后端 ALLOWED_QUEUES 保持一致,用于下拉与展示 */
 const ALLOWED_QUEUES = [
-  'runforge_task_queue',
+  'jkes_runforge_task_queue',
   'runforge_task_result_queue',
   'runforge_task_dead_queue',
   'runforge_message_queue',
@@ -159,7 +159,7 @@ const peekMeta = reactive({
 })
 
 const peekForm = reactive({
-  queue: 'runforge_task_queue',
+  queue: 'jkes_runforge_task_queue',
   limit: 30
 })
 

+ 4 - 4
src/pages/admin/user/userList.vue

@@ -73,7 +73,7 @@
                     {{ stramptoTime(record.lastTime ?? record.registTime) }}
                 </template>
                 <template #optional="{ record }">
-                    <a-button @click="changeCount(record)">更改次数</a-button>
+                    <a-button @click="changeCount(record)">更改里程</a-button>
                 </template>
             </a-table>
         </a-card>
@@ -85,8 +85,8 @@
             <a-form-item field="userid" label="用户UUID">
                 <a-input v-model="change_lepao_count.userid" disabled />
             </a-form-item>
-            <a-form-item field="lepao_count" label="乐跑次数">
-                <a-input-number v-model="change_lepao_count.lepao_count" :step="1" />
+            <a-form-item field="lepao_count" label="乐跑里程(km)">
+                <a-input-number v-model="change_lepao_count.lepao_count" :step="0.01" :precision="2" />
             </a-form-item>
         </a-form>
     </a-modal>
@@ -145,7 +145,7 @@ const columns = [
         title: '上次登录时间',
         slotName: 'lastTime'
     }, {
-        title: '剩余乐跑次数',
+        title: '剩余乐跑里程(km)',
         dataIndex: 'lepao_count',
     }, {
         title: '操作',

+ 51 - 51
src/pages/lepao/accountList/index.vue

@@ -148,8 +148,8 @@
               </a-avatar>
             </template>
           </a-table-column>
-          <a-table-column title="学号" :width="115" data-index="student_num" ellipsis tooltip></a-table-column>
-          <a-table-column title="姓名" :width="130">
+          <a-table-column title="学号" :width="110" data-index="student_num" ellipsis tooltip></a-table-column>
+          <a-table-column title="姓名" :width="120">
             <template #cell="{ record }">
               {{ record.name ?? '请使用乐跑登录器更新账号信息' }}
             </template>
@@ -167,9 +167,8 @@
               {{ record.sex === 1 ? '男' : (record.sex === 2 ? '女' : '') }}
             </template>
           </a-table-column>
-          <a-table-column title="年级" :width="70" data-index="grade_id" tooltip></a-table-column>
-          <a-table-column title="学院" :width="220" data-index="academy_name" tooltip></a-table-column>
-          <a-table-column title="跑区" :width="130">
+          <a-table-column title="班级" :width="200" data-index="class_id" ellipsis tooltip></a-table-column>
+          <a-table-column title="跑区" :width="120">
             <template #cell="{ record }">
               {{ record.area || '随机分配' }}
             </template>
@@ -194,19 +193,6 @@
               </div>
             </template>
           </a-table-column>
-          <!-- <a-table-column title="人脸状态" :width="100" ellipsis tooltip>
-            <template #cell="{ record }">
-              <div v-if="record.face_state === 0" class="state">
-                <div class="circle zero"></div>未采集
-              </div>
-              <div v-else-if="record.face_state === 1" class="state">
-                <div class="circle one"></div>已通过
-              </div>
-              <div v-else class="state">
-                <div class="circle else"></div>不通过
-              </div>
-            </template>
-          </a-table-column> -->
           <a-table-column title="自动乐跑" :width="105" ellipsis tooltip :filterable="{
             filters: [
               { text: '开启', value: 1 },
@@ -215,8 +201,7 @@
             filter: (value, record) => record.auto_run == value
           }">
             <template #cell="{ record }">
-              <a-tag color="green" v-if="record.auto_run">{{ record.target_count === 0 ? '开启-∞次' :
-                `开启-${record.target_count}次` }}</a-tag>
+              <a-tag color="green" v-if="record.auto_run">开启</a-tag>
               <a-tag color="red" v-else>关闭</a-tag>
             </template>
           </a-table-column>
@@ -235,10 +220,19 @@
               {{ record.auto_run ? autoTimeLabel(record) : '-' }}
             </template>
           </a-table-column>
-          <a-table-column title="学期目标" :width="88" ellipsis tooltip>
+          <a-table-column title="目标里程" :width="100" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ record.term_num != record.total_num ? `${record.total_num} / ${record.term_num}` :
-                '已完成' }}
+              {{ record.target_count === 0 ? '不限' : formatKm(record.target_count) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="本月里程" :width="100" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ formatKm(record.term_num) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="累计里程" :width="100" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ formatKm(record.total_num) }}
             </template>
           </a-table-column>
           <a-table-column title="添加时间" :width="145" ellipsis tooltip :sortable="{
@@ -305,7 +299,6 @@
           <a-option v-for="(item, index) in area" :key="index" :value="item">
             <span class="vipcontent">
               <span>{{ item }} </span>
-              <!-- <img src="@/assets/vip.svg" alt="vip" height="20"> -->
             </span>
           </a-option>
         </a-select>
@@ -313,10 +306,11 @@
       <a-form-item field="auto_run" label="自动乐跑开关">
         <a-switch v-model="form.auto_run" :checked-value="1" :unchecked-value="0" />
       </a-form-item>
-      <a-form-item field="target_count" label="乐跑目标次数" v-if="form.auto_run">
-        <a-input-number v-model="form.target_count" placeholder="请输入乐跑目标次数" mode="button" />
+      <a-form-item field="target_count" label="目标里程(km)" v-if="form.auto_run">
+        <a-input-number v-model="form.target_count" placeholder="0–200,0 表示不限制" mode="button" :min="0" :max="200"
+          :precision="2" :step="0.5" />
         <template #extra>
-          <div>当学期有效次数达到目标次数时自动乐跑将关闭,0为不限次</div>
+          <div>月度里程(Km)达到目标后自动乐跑将关闭;填 0 表示不限制</div>
         </template>
       </a-form-item>
       <a-form-item field="auto_day" label="自动乐跑星期" v-if="form.auto_run">
@@ -334,19 +328,26 @@
 
   <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
   <bindBot ref="bindBotRef" />
+  <SingleRunModal
+    v-model:visible="singleRunVisible"
+    :account="singleRunAccount"
+    @success="getAccountsAsync"
+  />
 </template>
 
 <script setup>
 import { ref, reactive, onUnmounted, onMounted, h } from 'vue'
-import { accountList, deleteAccount, addAccount, changeAutoRun, singleRun, updateSelfAccount } from '@/api/lepao'
+import { accountList, deleteAccount, addAccount, changeAutoRun, updateSelfAccount } from '@/api/lepao'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import { IconSearch } from '@arco-design/web-vue/es/icon'
 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 SingleRunModal from '@/components/SingleRunModal/SingleRunModal.vue'
 import { useRoute } from 'vue-router'
 import { getNotice, getSemesterTimestamps } from '@/utils/util'
+import { formatKm } from '@/utils/lepaoRecord'
 
 const notice = ref('')
 
@@ -381,7 +382,7 @@ const pagination = reactive({
 })
 
 const handleSearch = (value) => {
-  const emailSuffix = ["qq.com", "ctbu.edu.cn", "163.com"]
+  const emailSuffix = ["qq.com", "163.com"]
   const input = (value || "").trim()
 
   if (!input) {
@@ -408,7 +409,7 @@ const handleSearch = (value) => {
     .map(suffix => `${prefix}@${suffix}`)
 }
 
-const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
+const area = ["学府大道校区", "南山校区"]
 const state = [
   { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
 ]
@@ -538,7 +539,7 @@ const editAccount = (item) => {
     form.area = item.area
     form.auto_time = item.auto_time
     form.auto_run = item.auto_run
-    form.target_count = item.target_count
+    form.target_count = Number(item.target_count)
     form.notice_type = item.notice_type || 'email'
     form.auto_day = item.auto_day
     form.notes = item.notes
@@ -566,9 +567,9 @@ const handleBeforeOk = async (done) => {
       return false
     }
 
-    const studentNumRegex = /^\d{10}$/
-    if (!studentNumRegex.test(student_num)) {
-      Message.error('请检查学号格式是否正确')
+    const sid = String(student_num || '').trim()
+    if (!/^[A-Za-z0-9_-]{2,64}$/.test(sid)) {
+      Message.error('学号/账号编码格式不正确(2–64 位字母数字下划线横线)')
       return false
     }
 
@@ -578,6 +579,14 @@ const handleBeforeOk = async (done) => {
       return false
     }
 
+    if (form.auto_run) {
+      const tk = Number(form.target_count)
+      if (!Number.isFinite(tk) || tk < 0 || tk > 200) {
+        Message.error('目标里程须在 0–200 公里之间')
+        return false
+      }
+    }
+
     let data = {
       ...form
     }
@@ -649,27 +658,18 @@ const GetNotice = async () => {
   notice.value = res
 }
 
-const SingleRun = async (item) => {
-  if (item.state !== 1)
+const singleRunVisible = ref(false)
+const singleRunAccount = ref(null)
+
+const SingleRun = (item) => {
+  if (item.state !== 1) {
     return Notification.warning({
       title: '当前乐跑账号需登录,请登录后再试',
       content: '如有疑问请联系RunForge客服'
     })
-  Modal.confirm({
-    title: '开始乐跑',
-    content: () => h('div', [
-      h('p', `您是否要为 ${item.name}(${item.student_num}) 乐跑?若乐跑成功将扣减乐跑次数`)
-    ]),
-    onOk: async () => {
-      const res = await singleRun({ student_num: item.student_num })
-      if (!res || res.code !== 0)
-        return Notification.error({
-          title: '提交乐跑任务失败',
-          content: res?.msg ?? '请稍后再试'
-        })
-      Message.success('提交乐跑任务成功!')
-    }
-  })
+  }
+  singleRunAccount.value = item
+  singleRunVisible.value = true
 }
 
 const DeleteAccount = async (item) => {

+ 80 - 35
src/pages/lepao/lepaoRecords/index.vue

@@ -15,8 +15,7 @@
               </a-col>
               <a-col :span="12">
                 <a-form-item field="lepao_account" label="学号">
-                  <a-input-number v-model="queryData.lepao_account" placeholder="请输入学号" :step="1" :precision="0"
-                    allow-clear />
+                  <a-input v-model="queryData.lepao_account" placeholder="请输入学号" allow-clear />
                 </a-form-item>
               </a-col>
               <a-col :span="12">
@@ -84,7 +83,7 @@
               </div>
             </template>
           </a-table-column>
-          <a-table-column title="学号" :width="120" data-index="lepao_account" ellipsis tooltip :filterable="{
+          <a-table-column title="学号" :width="105" data-index="lepao_account" ellipsis tooltip :filterable="{
             filter: (value, record) => (record.lepao_account ?? '').includes(value),
             slotName: 'name-filter',
             icon: () => h(IconSearch)
@@ -106,34 +105,55 @@
           <a-table-column title="状态" ellipsis tooltip :width="230">
             <template #cell="{ record }">
               <div class="state">
-                <div class="circle one" v-if="record.result.record_failed_reason === '自动确认有效'"></div>
-                <div class="circle else" v-else></div>
-                {{ record.result.record_failed_reason }}
+                <div
+                  class="circle"
+                  :class="recordDisplay(record).statusLevel"
+                ></div>
+                {{ recordDisplay(record).statusText }}
               </div>
             </template>
           </a-table-column>
-          <a-table-column title="跑区" :filterable="{
-            filter: (value, record) => (record.result.pass_tit ?? '').includes(value),
+          <a-table-column title="乐跑跑区" :filterable="{
+            filter: (value, record) => (recordDisplay(record).routeRunZoneName ?? '').includes(value),
             slotName: 'name-filter',
             icon: () => h(IconSearch)
-          }" :width="220">
+          }" :width="160">
             <template #cell="{ record }">
-              {{ record.result.pass_tit }}
+              <a-tag v-if="recordDisplay(record).routeRunZoneName" size="small">
+                {{ recordDisplay(record).routeRunZoneName }}
+              </a-tag>
+              <span v-else>—</span>
             </template>
           </a-table-column>
-          <a-table-column title="乐跑距离" :width="100" ellipsis tooltip>
+          <a-table-column title="预计距离" :width="110" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ record.result.distance }} Km
+              <span v-if="recordDisplay(record).expectedKm != null">
+                {{ formatKm(recordDisplay(record).expectedKm) }}
+              </span>
+              <span v-else>—</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="实际距离" :width="110" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ formatKm(recordDisplay(record).distance) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="扣费里程" :width="110" ellipsis tooltip>
+            <template #cell="{ record }">
+              <span v-if="recordDisplay(record).officialKm != null && Number.isFinite(Number(recordDisplay(record).officialKm))">
+                {{ formatKm(recordDisplay(record).officialKm) }}
+              </span>
+              <span v-else>—</span>
             </template>
           </a-table-column>
           <a-table-column title="跑步时长" :width="100" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ formatSecondsToMinSec(record.result.time) }}
+              {{ formatSecondsToMinSec(recordDisplay(record).timeSec) }}
             </template>
           </a-table-column>
           <a-table-column title="平均配速" :width="90" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ calculatePace(record.result.time, record.result.distance) }}
+              {{ formatPace(recordDisplay(record).timeSec, recordDisplay(record).distance) }}
             </template>
           </a-table-column>
           <a-table-column title="乐跑时间" :width="145" ellipsis tooltip>
@@ -154,8 +174,14 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, h } from 'vue'
+import { ref, reactive, onMounted, onUnmounted, h } from 'vue'
 import { lepaoRecords } from '@/api/lepao'
+import {
+  parseLepaoRecordRow,
+  formatSecondsToMinSec,
+  formatPace,
+  formatKm
+} from '@/utils/lepaoRecord'
 import { Notification } from '@arco-design/web-vue'
 import { IconSearch } from '@arco-design/web-vue/es/icon'
 import { useUserStore } from '@/store/modules/user'
@@ -206,12 +232,11 @@ const reset = () => {
   queryData.email = ''
   queryData.area = ''
   queryData.queryTime = getSemesterTimestamps()
-  getRecords()
+  getRecordsAsync()
 }
 
 const getRecords = async () => {
   try {
-    loading.value = true
     const reqData = {
       ...queryData,
       pagesize: pagination.pagesize,
@@ -230,6 +255,13 @@ const getRecords = async () => {
       title: '获取乐跑记录失败!',
       content: error.message || '请稍后再试'
     })
+  }
+}
+
+const getRecordsAsync = async () => {
+  loading.value = true
+  try {
+    await getRecords()
   } finally {
     loading.value = false
   }
@@ -238,41 +270,50 @@ const getRecords = async () => {
 // 分页 - 页码变化
 const handlePageChange = (page) => {
   pagination.current = page
-  getRecords()
+  getRecordsAsync()
 }
 
 // 分页 - 每页条数变化
 const handlePageSizeChange = (size) => {
   pagination.pagesize = size
   pagination.current = 1 // 页大小变化后回到第一页
-  getRecords()
+  getRecordsAsync()
 }
 
 const stramptoTime = (time) => {
   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) {
-  const paceInSeconds = seconds / kilometers;
-  const minutes = Math.floor(paceInSeconds / 60);
-  const remainingSeconds = Math.round(paceInSeconds % 60);
-
-  return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`;
-}
+const recordDisplay = (record) => parseLepaoRecordRow(record)
 
-function formatSecondsToMinSec(totalSeconds) {
-  const minutes = Math.floor(totalSeconds / 60);
-  const seconds = totalSeconds % 60;
+let timer = null
 
-  return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
+// 轮询
+const startPolling = () => {
+  if (!timer) {
+    timer = setInterval(async () => {
+      await getRecords()
+    }, 5000)
+  }
 }
 
-
+// 停止轮询
+const stopPolling = () => {
+  if (timer) {
+    clearInterval(timer)
+    timer = null
+  }
+}
 
 onMounted(() => {
   queryData.queryTime = getSemesterTimestamps()
-  getRecords()
+  getRecordsAsync()
   GetNotice()
+  startPolling()
+})
+
+onUnmounted(() => {
+  stopPolling()
 })
 </script>
 
@@ -298,15 +339,19 @@ onMounted(() => {
       margin-right: 5px;
     }
 
-    .zero {
+    .running {
       background-color: rgb(var(--orange-6));
     }
 
-    .one {
+    .success {
       background-color: rgb(var(--green-6));
     }
 
-    .else {
+    .sync {
+      background-color: rgb(var(--blue-6));
+    }
+
+    .fail {
       background-color: rgb(var(--red-6));
     }
   }

+ 41 - 38
src/pages/lepao/lepaoRecords/recordDetail.vue

@@ -9,18 +9,30 @@
                 </a-space>
             </a-skeleton>
             <a-descriptions :data="info" :column="2" />
-            <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
-                style="margin-top: 10px;" />
+            <MapContainer
+                v-if="showMap"
+                :point_list="pointListForMap"
+                :log_list="logListForMap"
+                :pathData="pathForMap"
+                threeD
+                style="margin-top: 10px;"
+            />
         </a-card>
     </div>
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import { GetRecordDetail } from '@/api/lepao'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import MapContainer from '@/components/Map/MapContainer.vue'
+import {
+    buildRecordDetailInfo,
+    parseLepaoRecordRow,
+    normalizePointData
+} from '@/utils/lepaoRecord'
+import { useUserStore } from '@/store/modules/user'
 
 const route = useRoute()
 
@@ -28,6 +40,26 @@ const loading = ref(false)
 const showMap = ref(false)
 const data = ref({})
 const info = ref([])
+const isAdmin = ref(false)
+
+const pathForMap = computed(() => {
+    const poly = data.value.path_polyline
+    if (Array.isArray(poly) && poly.length > 0) return poly
+    return []
+})
+
+const pointListForMap = computed(() => {
+    const row = parseLepaoRecordRow({
+        result: data.value.result,
+        jkes_record: data.value.jkes_record,
+        run_zone_name: data.value.run_zone_name,
+        path_run_zone_name: data.value.path_run_zone_name,
+        path_distance: data.value.path_distance
+    })
+    return row.pointList || []
+})
+
+const logListForMap = computed(() => normalizePointData(data.value.point_data))
 
 const getRecordDetail = async (id) => {
     try {
@@ -42,19 +74,7 @@ const getRecordDetail = async (id) => {
 
         data.value = res.data
         showMap.value = true
-
-        info.value = [
-            { label: '账号名称', value: res.data.name },
-            { label: '乐跑账号', value: res.data.lepao_account },
-            { label: '跑区名称', value: res.data.result.pass_tit },
-            { label: '记录时间', value: stramptoTime(res.data.time) },
-            { label: '开始时间', value: stramptoTime(res.data.result.start_time * 1000) },
-            { label: '乐跑状态', value: res.data.result.record_failed_reason },
-            { label: '打卡点数量', value: res.data.result.point_list.length },
-            { label: '跑步距离', value: res.data.result.distance + ' Km' },
-            { label: '跑步时长', value: formatSecondsToMinSec(res.data.result.time) },
-            { label: '平均配速', value: calculatePace(res.data.result.time, res.data.result.distance) }
-        ]
+        info.value = buildRecordDetailInfo(res.data, { isAdmin: isAdmin.value })
     } catch (error) {
         Notification.error({
             title: '获取路径数据失败!',
@@ -65,33 +85,16 @@ const getRecordDetail = async (id) => {
     }
 }
 
-onMounted(() => {
+onMounted(async () => {
+    const userStore = useUserStore()
+    const userInfo = await userStore.getInfo()
+    isAdmin.value = Array.isArray(userInfo?.roles) && userInfo.roles.includes('admin')
     getRecordDetail(route.params.id)
 })
-
-const stramptoTime = (time) => {
-    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) {
-    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) {
-    const minutes = Math.floor(totalSeconds / 60);
-    const seconds = totalSeconds % 60;
-
-    return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
-}
-
 </script>
 
 <style scoped lang="less">
 .container {
     padding: 0 20px 20px 20px;
 }
-</style>
+</style>

+ 1 - 1
src/pages/path/pathList.vue

@@ -114,7 +114,7 @@ const data = ref([])
 const state = [
     { label: '全部', value: -1 }, { label: '待审核', value: 0 }, { label: '审核通过', value: 1 }, { label: '审核失败', value: 2 }
 ]
-const areas = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
+const areas = ["学府大道校区", "南山校区"]
 
 const columns = [{
     title: 'ID',

+ 0 - 487
src/pages/power/accountList.vue

@@ -1,487 +0,0 @@
-<template>
-  <div class="container">
-    <Breadcrumb :items="['宿舍电费', '定制电费提醒']" />
-
-    <a-card title="定制电费提醒">
-      <a-button type="primary" size="large" @click="editAccount()">
-        <template #icon>
-          <icon-plus />
-        </template>
-        添加提醒任务
-      </a-button>
-
-      <a-alert v-if="notice" style="margin-top: 15px;">{{ notice }}</a-alert>
-
-      <a-table :data="data" :columns="columns" :bordered="false" hoverable class="table" :loading="loading" :scroll="{
-        x: 1600
-      }" :pagination="{ showPageSize: true, showJumper: true, defaultPageSize: 15 }">
-
-        <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
-          <div class="custom-filter">
-            <a-space direction="vertical">
-              <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
-              <div class="custom-filter-footer">
-                <a-button @click="handleFilterReset">重置</a-button>
-                <a-button @click="handleFilterConfirm">确定</a-button>
-              </div>
-            </a-space>
-          </div>
-        </template>
-        <template #balance="{ record }">
-          <a-tag :color="record.balance <= record.lowest ? 'red' : 'green'">¥{{ record.balance
-          }}</a-tag>
-        </template>
-        <template #lowest="{ record }">
-          ¥{{ record.lowest }}
-        </template>
-        <template #update_time="{ record }">
-          {{ stramptoTime(record.update_time) }}
-        </template>
-        <template #optional="{ record }">
-          <div style="display: flex; gap:10px">
-            <a-button @click="GetChangeRecord(record.id)">账单&nbsp;<icon-select-all /></a-button>
-            <a-dropdown :popup-max-height="false" trigger="hover">
-              <a-button>操作&nbsp;<icon-down /></a-button>
-              <template #content>
-                <a-doption @click="editAccount(record)"><icon-edit /> 编辑任务</a-doption>
-                <a-doption @click="DeleteAccount(record)"><icon-delete /> 删除任务</a-doption>
-              </template>
-            </a-dropdown>
-          </div>
-        </template>
-      </a-table>
-    </a-card>
-  </div>
-
-  <!-- 账号编辑对话框 -->
-  <a-modal v-model:visible="visible" title="编辑电费提醒任务" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
-    :ok-loading="ok_loading" esc-to-close closable>
-    <a-form :model="form">
-      <a-form-item field="area" label="校区" :loading="selectLoading">
-        <a-select v-model="form.area" placeholder="请选择所在校区" default-value="" :options="areas"
-          @change="GetPowerData('dylist', form.area)" />
-      </a-form-item>
-      <a-form-item field="building" label="楼栋" :loading="selectLoading">
-        <a-select v-model="form.building" placeholder="请选择所在楼栋" default-value="" :options="buildings"
-          @change="GetPowerData('mphlist', form.building)" />
-      </a-form-item>
-      <a-form-item field="room" label="宿舍号" :loading="selectLoading">
-        <a-select v-model="form.room" placeholder="请选择所在宿舍" default-value="" :options="rooms" />
-      </a-form-item>
-      <a-form-item field="lowest" label="提醒阈值">
-        <a-input-number v-model="form.lowest" placeholder="请选择提醒阈值" :step="1" :precision="2" />
-      </a-form-item>
-      <a-form-item field="email" label="通知邮箱">
-        <a-auto-complete :data="email" @search="handleSearch" v-model="form.email" placeholder="用于接收电费变更通知"
-          allow-clear />
-      </a-form-item>
-      <a-form-item field="notes" label="备注">
-        <a-textarea v-model="form.notes" placeholder="添加对账号的备注(非必填)" :max-length="{ length: 50 }" allow-clear
-          show-word-limit />
-      </a-form-item>
-    </a-form>
-  </a-modal>
-
-  <!-- 电费变更记录 -->
-  <a-modal v-model:visible="listVisible" title="电费变更记录" esc-to-close closable width="auto" hide-cancel>
-    <a-table :data="changeList" :columns="listColumns" stripe hoverable :loading="listLoading" :pagination="{
-      showPageSize: true,
-      showJumper: true,
-      showTotal: true,
-      pageSize: pagination.pagesize,
-      current: pagination.current,
-      total: pagination.total
-    }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
-      <template #balance="{ record }">
-        ¥{{ record.balance }}
-      </template>
-      <template #old_balance="{ record }">
-        ¥{{ record.old_balance }}
-      </template>
-      <template #change="{ record }">
-        <a-tag :color="Number(record.balance) < Number(record.old_balance) ? 'red' : 'green'">¥{{ Number(record.balance
-          - record.old_balance).toFixed(2)
-        }}</a-tag>
-      </template>
-      <template #time="{ record }">
-        {{ stramptoTime(record.time) }}
-      </template>
-    </a-table>
-  </a-modal>
-</template>
-
-<script setup>
-import { ref, reactive, onMounted, h } from 'vue'
-import { getPowerData, addAccount, deleteAccount, getAccount, getChangeRecord } from '@/api/power'
-import { Modal, Notification, Message } from '@arco-design/web-vue'
-import { useRoute } from 'vue-router'
-import { getNotice } from '@/utils/util'
-
-const notice = ref('')
-
-const GetNotice = async () => {
-  const { path } = useRoute()
-  const res = await getNotice(path)
-  notice.value = res
-}
-
-const data = ref([])
-const loading = ref(false)
-
-const listVisible = ref(false)
-const pagination = reactive({
-  total: 0,
-  current: 1,
-  pagesize: 15
-})
-
-// 分页 - 页码变化
-const handlePageChange = (page) => {
-  pagination.current = page
-}
-
-// 分页 - 每页条数变化
-const handlePageSizeChange = (size) => {
-  pagination.pagesize = size
-  pagination.current = 1 // 页大小变化后回到第一页
-}
-
-const changeList = ref([])
-const listLoading = ref(false)
-const GetChangeRecord = async (id) => {
-  try {
-    listLoading.value = true
-    pagination.current = 1
-    listVisible.value = true
-    changeList.value = []
-    const res = await getChangeRecord({ id })
-    if (!res || res.code !== 0)
-      return Notification.error({
-        title: '获取电费变更记录失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
-    changeList.value = res.data
-    pagination.total = res.data.length
-  } catch (error) {
-    Notification.error({
-      title: '获取电费变更记录失败!',
-      content: error.message || '请稍后再试'
-    })
-  } finally {
-    listLoading.value = false
-  }
-}
-
-const visible = ref(false)
-const ok_loading = ref(false)
-const form = reactive({
-  id: null,
-  area: '',
-  building: '',
-  room: '',
-  email: '',
-  notes: '',
-  lowest: 10.00
-})
-
-const columns = [{
-  title: '校区',
-  dataIndex: 'area',
-  width: 120
-}, {
-  title: '楼栋',
-  dataIndex: 'building',
-  width: 130
-}, {
-  title: '寝室号',
-  dataIndex: 'room',
-}, {
-  title: '通知邮箱',
-  dataIndex: 'email',
-  width: 220
-}, {
-  title: '当前余额',
-  slotName: 'balance',
-  width: 120
-}, {
-  title: '触发提醒阈值',
-  slotName: 'lowest',
-  width: 120
-}, {
-  title: '扣费日期',
-  dataIndex: 'koufei_date',
-  width: 170
-}, {
-  title: '上次更新时间',
-  slotName: 'update_time',
-  width: 170
-}, {
-  title: '备注',
-  dataIndex: 'notes'
-}, {
-  title: '操作',
-  slotName: 'optional',
-  width: 200,
-  fixed: 'right'
-}]
-
-const listColumns = [{
-  title: '记录时间',
-  slotName: 'time',
-  width: 170
-}, {
-  title: '扣费时间',
-  dataIndex: 'change_time',
-  width: 170
-}, {
-  title: '电费余额',
-  slotName: 'balance',
-  width: 120
-}, {
-  title: '原余额',
-  slotName: 'old_balance',
-  width: 120
-}, {
-  title: '变动金额',
-  slotName: 'change',
-  width: 120
-}]
-
-const selectLoading = ref(false)
-const areas = ref([])
-const buildings = ref([])
-const rooms = ref([])
-
-const GetPowerData = async (type, pid = '') => {
-  try {
-    const data = {
-      type,
-      pid
-    }
-    const res = await getPowerData(data)
-    if (!res || res.code !== 0)
-      return Notification.error({
-        title: '获取电费信息失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
-    let resdata = res.data.map(item => ({
-      value: item,
-      label: item
-    }))
-    switch (type) {
-      case 'buildlist':
-        areas.value = resdata
-        break
-      case 'dylist':
-        buildings.value = resdata
-        form.building = ''
-        form.room = ''
-        break
-      case 'mphlist':
-        rooms.value = resdata
-        form.room = ''
-        break
-    }
-  } catch (error) {
-    Notification.error({
-      title: '获取电费信息失败!',
-      content: error.message || '请稍后再试'
-    })
-  }
-}
-
-const email = ref([])
-const handleSearch = (value) => {
-  const emailSuffix = ["qq.com", "ctbu.edu.cn", "163.com"]
-  const input = (value || "").trim()
-
-  if (!input) {
-    email.value = []
-    return
-  }
-
-  // 没有输入 @,直接拼接所有后缀
-  if (!input.includes("@")) {
-    email.value = emailSuffix.map(suffix => `${input}@${suffix}`)
-    return
-  }
-
-  // 输入了 @ 但结尾是 @,拼接所有后缀
-  if (input.endsWith("@")) {
-    email.value = emailSuffix.map(suffix => `${input}${suffix}`)
-    return
-  }
-
-  // 输入了 @ 且有部分后缀,智能匹配
-  const [prefix, suffixPart] = input.split("@")
-  email.value = emailSuffix
-    .filter(suffix => suffix.startsWith(suffixPart))
-    .map(suffix => `${prefix}@${suffix}`)
-}
-
-const editAccount = (item) => {
-  if (item) {
-    form.id = item.id
-    form.area = item.area
-    form.building = item.building
-    form.room = item.room
-    form.email = item.email
-    form.notes = item.notes
-    form.lowest = Number(item.lowest) ?? 10.00
-  } else {
-    form.id = null
-    form.area = ''
-    form.building = ''
-    form.room = ''
-    form.email = ''
-    form.notes = ''
-    form.lowest = 10.00
-  }
-  visible.value = true
-}
-
-const handleBeforeOk = async (done) => {
-  try {
-    ok_loading.value = true
-    const { area, building, room, email } = form
-    if (!area || !building || !room || !email) {
-      Message.error('请填写完整的任务信息')
-      return false
-    }
-
-    const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
-    if (!emailRegex.test(email)) {
-      Message.error('请检查邮箱格式是否正确')
-      return false
-    }
-
-    let data = {
-      ...form
-    }
-
-    const res = await addAccount(data)
-    if (!res || res.code !== 0) {
-      Notification.error({
-        title: '保存电费提醒任务失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
-      return false
-    }
-
-    Message.success('保存成功!')
-    done()
-    getAccounts()
-  } catch (error) {
-    Notification.error({
-      title: '保存电费提醒任务失败!',
-      content: error.message || '请稍后再试'
-    })
-    return false
-  } finally {
-    ok_loading.value = false
-  }
-}
-
-const handleCancel = () => {
-  visible.value = false
-}
-
-const getAccounts = async () => {
-  try {
-    loading.value = true
-    const res = await getAccount()
-    if (!res || res.code !== 0)
-      return Notification.error({
-        title: '获取账号列表失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
-    data.value = res.data
-  } catch (error) {
-    Notification.error({
-      title: '获取账号列表失败!',
-      content: error.message || '请稍后再试'
-    })
-  } finally {
-    loading.value = false
-  }
-}
-
-const DeleteAccount = async (item) => {
-  Modal.confirm({
-    title: '删除任务',
-    content: () => h('div', [
-      h('p', '您是否要删除该电费提醒任务?该操作不可逆')
-    ]),
-    onOk: async () => {
-      const res = await deleteAccount({ id: item.id })
-      if (!res || res.code !== 0)
-        return Notification.error({
-          title: '删除失败',
-          content: res?.msg ?? '请稍后再试'
-        })
-      Message.success('删除成功!')
-      getAccounts()
-    }
-  })
-}
-
-const stramptoTime = (time) => {
-  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
-}
-
-onMounted(() => {
-  GetPowerData('buildlist')
-  getAccounts()
-  GetNotice()
-})
-
-</script>
-
-<style scoped lang="less">
-.container {
-  padding: 0 20px 20px 20px;
-}
-
-.table {
-  margin-top: 15px;
-
-  .state {
-    display: flex;
-    align-items: center;
-
-    .circle {
-      border-radius: 50%;
-      height: 8px;
-      min-height: 8px;
-      width: 8px;
-      min-width: 8px;
-      margin-right: 5px;
-    }
-
-    .zero {
-      background-color: rgb(var(--orange-6));
-    }
-
-    .one {
-      background-color: rgb(var(--green-6));
-    }
-
-    .else {
-      background-color: rgb(var(--red-6));
-    }
-  }
-}
-
-.custom-filter {
-  padding: 20px;
-  background: var(--color-bg-5);
-  border: 1px solid var(--color-neutral-3);
-  border-radius: var(--border-radius-medium);
-  box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
-}
-
-.custom-filter-footer {
-  display: flex;
-  justify-content: space-between;
-}
-</style>

+ 0 - 19
src/router/index.js

@@ -175,25 +175,6 @@ const routes = [
             }
         ]
     },
-    {
-        path: "/power",
-        name: "power",
-        component: DEFAULT_LAYOUT,
-        meta: {
-            title: '宿舍电费',
-            icon: 'icon-thunderbolt'
-        },
-        children: [
-            {
-                path: 'accountList',
-                name: 'power.accountList',
-                component: () => import('../pages/power/accountList.vue'),
-                meta: {
-                    title: '定制电费提醒'
-                }
-            }
-        ]
-    },
     {
         path: "/service",
         name: 'service',

+ 279 - 0
src/utils/lepaoRecord.js

@@ -0,0 +1,279 @@
+/**
+ * 乐跑记录展示:兼容旧版 result 与 JKES(useTime、distance、status 等)
+ */
+
+/** JKES 接口 distance 多为米;存库可能未换算,统一为千米用于展示 */
+export function normalizeJkesDistanceToKm(raw) {
+  const d = Number(raw)
+  if (!Number.isFinite(d) || d <= 0) return 0
+  if (d >= 50) return Math.round((d / 1000) * 1000) / 1000
+  return Math.round(d * 1000) / 1000
+}
+
+function normalizeResult(record) {
+  let r = record?.result
+  if (r != null && typeof r !== 'object') {
+    try {
+      r = JSON.parse(r)
+    } catch {
+      r = {}
+    }
+  }
+  return r && typeof r === 'object' && !Array.isArray(r) ? r : {}
+}
+
+function normalizeText(x) {
+  if (x == null) return ''
+  const s = String(x).trim()
+  return s
+}
+
+function translateRunStatus(raw) {
+  const s = normalizeText(raw)
+  if (!s) return '—'
+  const k = s.toLowerCase()
+
+  // 常见状态(尽量宽松匹配)
+  if (/(running|in[_\s-]?progress|processing|doing)/i.test(s)) return '进行中'
+  if (/(pending|to[_\s-]?sync|syncing|await)/i.test(s)) return '待同步'
+  // JKES 常见:END/ENDED(跑步成功结束)
+  if (/(^end$|ended|finish|finished|done)/i.test(s)) return '成功'
+  if (/(valid|success|passed|pass|complete|completed|ok|normal)/i.test(s)) return '成功'
+  if (/(invalid|fail|failed|error|abnormal|exception|reject)/i.test(s)) return '失败'
+
+  // 兜底:常见单词替换后展示
+  return s
+}
+
+function isCampusOk(raw) {
+  const s = normalizeText(raw)
+  if (!s) return true
+  const k = s.toLowerCase()
+  if (/(invalid|fail|failed|error|abnormal|exception|reject)/i.test(s)) return false
+  if (/(valid|success|passed|pass|ok|normal)/i.test(s)) return true
+  return true
+}
+
+/**
+ * 列表/表格用:从单行记录解析展示字段
+ */
+export function parseLepaoRecordRow(record) {
+  const jkes = record?.jkes_record
+  const r = normalizeResult(record)
+  const routeRunZoneName =
+    record?.path_run_zone_name ?? record?.run_zone_name ?? record?.path?.run_zone_name ?? null
+  const pathDistanceKm =
+    record?.path_distance != null ? normalizeJkesDistanceToKm(record.path_distance) : null
+  const plannedKm = r?.planned_km != null ? Number(r.planned_km) : null
+  const deductedKm = r?.deducted_km != null ? Number(r.deducted_km) : null
+  const officialKm = r?.official_km != null ? Number(r.official_km) : null
+  const refundedKm = r?.refunded_km != null ? Number(r.refunded_km) : null
+  const expectedKm =
+    plannedKm != null && Number.isFinite(plannedKm)
+      ? plannedKm
+      : pathDistanceKm != null && Number.isFinite(pathDistanceKm)
+        ? pathDistanceKm
+        : null
+
+  // 后端新增:0进行中 1待同步 2完成 3异常
+  const runState = Number(record?.state)
+  if (Number.isFinite(runState) && runState !== 2) {
+    const stateText =
+      runState === 0 ? '进行中' : runState === 1 ? '待同步' : runState === 3 ? '异常' : '未知状态'
+    return {
+      isJkes: true,
+      statusText: stateText,
+      passTit: '—',
+      campusStatusText: '—',
+      routeRunZoneName,
+      pathDistanceKm,
+      plannedKm,
+      deductedKm,
+      officialKm,
+      refundedKm,
+      expectedKm,
+      distance: 0,
+      timeSec: 0,
+      pointList: Array.isArray(r.point_list) ? r.point_list : [],
+      ok: false,
+      statusLevel: runState === 0 ? 'running' : runState === 1 ? 'sync' : 'fail'
+    }
+  }
+
+  const useTime = jkes?.use_time_sec ?? r.useTime ?? r.time
+  const distanceRaw = jkes?.distance_km ?? r.distance
+  const distance = normalizeJkesDistanceToKm(distanceRaw)
+
+  const looksJkes =
+    jkes != null ||
+    (r.useTime != null &&
+      r.record_failed_reason === undefined &&
+      r.pass_tit === undefined)
+
+  if (looksJkes && (useTime != null || distance != null)) {
+    const runStatus = jkes?.run_status ?? r.status?.value ?? r.status ?? ''
+    const campus = jkes?.campus_status ?? r.dataStatus?.value ?? r.dataStatus ?? ''
+    const runText = translateRunStatus(runStatus)
+    const campusOk = isCampusOk(campus)
+    const ok = runText === '成功' && campusOk
+
+    return {
+      isJkes: true,
+      statusText: runText,
+      passTit: campus ? String(campus) : '—', // 兼容旧页面:历史上 passTit 被当作“跑区/校区”
+      campusStatusText: campus ? String(campus) : '—',
+      routeRunZoneName,
+      pathDistanceKm,
+      plannedKm,
+      deductedKm,
+      officialKm,
+      refundedKm,
+      expectedKm,
+      distance: distance || 0,
+      timeSec: Number(useTime) || 0,
+      pointList: Array.isArray(r.point_list) ? r.point_list : [],
+      ok,
+      statusLevel: ok ? 'success' : 'fail'
+    }
+  }
+
+  const reason = r.record_failed_reason != null ? String(r.record_failed_reason) : '—'
+  const okText = reason === '自动确认有效'
+  return {
+    isJkes: false,
+    statusText: reason,
+    passTit: r.pass_tit != null ? String(r.pass_tit) : '—',
+    campusStatusText: r.pass_tit != null ? String(r.pass_tit) : '—',
+    routeRunZoneName,
+    pathDistanceKm,
+    plannedKm,
+    deductedKm,
+    officialKm,
+    refundedKm,
+    expectedKm,
+    distance: Number(r.distance) || 0,
+    timeSec: Number(r.time) || 0,
+    pointList: Array.isArray(r.point_list) ? r.point_list : [],
+    ok: okText,
+    statusLevel: okText ? 'success' : 'fail'
+  }
+}
+
+export function formatKm(n) {
+  const x = Number(n)
+  if (!Number.isFinite(x)) return '—'
+  return `${x.toFixed(2)} km`
+}
+
+export function formatPace(seconds, km) {
+  const s = Number(seconds)
+  const d = Number(km)
+  if (!Number.isFinite(s) || !Number.isFinite(d) || d <= 0) return '—'
+  const paceInSeconds = s / d
+  const minutes = Math.floor(paceInSeconds / 60)
+  const remainingSeconds = Math.round(paceInSeconds % 60)
+  return `${minutes}'${String(remainingSeconds).padStart(2, '0')}''`
+}
+
+export function formatSecondsToMinSec(totalSeconds) {
+  const t = Math.floor(Number(totalSeconds) || 0)
+  const minutes = Math.floor(t / 60)
+  const seconds = t % 60
+  return `${minutes}分${String(seconds).padStart(2, '0')}秒`
+}
+
+/** 详情页 descriptions + 地图 */
+export function buildRecordDetailInfo(data, options = {}) {
+  const { isAdmin = false } = options || {}
+  const row = parseLepaoRecordRow({
+    result: data?.result,
+    jkes_record: data?.jkes_record,
+    run_zone_name: data?.run_zone_name,
+    path_run_zone_name: data?.path_run_zone_name,
+    path_distance: data?.path_distance
+  })
+  const lines = [
+    { label: '账号名称', value: data?.name ?? '—' },
+    { label: '乐跑账号', value: data?.lepao_account ?? '—' },
+    { label: '路线跑区', value: row.routeRunZoneName ?? '—' },
+    { label: '记录时间', value: data?.time != null ? new Date(data.time).toLocaleString('zh-CN') : '—' }
+  ]
+
+  if (row.isJkes) {
+    const begin = data?.jkes_record?.begin_time ?? normalizeResult(data).beginTime
+    const end = data?.jkes_record?.end_time ?? normalizeResult(data).endTime
+    if (begin) {
+      lines.push({
+        label: '开始时间',
+        value: new Date(begin).toLocaleString('zh-CN')
+      })
+    }
+    if (end) {
+      lines.push({
+        label: '结束时间',
+        value: new Date(end).toLocaleString('zh-CN')
+      })
+    }
+    lines.push({ label: '跑步状态', value: row.statusText })
+    if (isAdmin) {
+      lines.push({
+        label: '打卡点数量',
+        value: String(row.pointList.length)
+      })
+    }
+  } else {
+    const r = normalizeResult(data)
+    if (r.start_time) {
+      lines.push({
+        label: '开始时间',
+        value: new Date(r.start_time * 1000).toLocaleString('zh-CN')
+      })
+    }
+    lines.push({ label: '乐跑状态', value: row.statusText })
+    if (isAdmin) {
+      lines.push({
+        label: '打卡点数量',
+        value: String(row.pointList.length)
+      })
+    }
+  }
+
+  lines.push({ label: '跑步距离', value: formatKm(row.distance) })
+  if (row.expectedKm != null) {
+    lines.push({ label: '预计距离', value: formatKm(row.expectedKm) })
+  }
+  if (row.deductedKm != null && Number.isFinite(row.deductedKm)) {
+    lines.push({ label: '扣费里程', value: formatKm(row.deductedKm) })
+  }
+  if (row.refundedKm != null && Number.isFinite(row.refundedKm) && row.refundedKm > 0) {
+    lines.push({ label: '退还里程', value: formatKm(row.refundedKm) })
+  }
+  if (row.officialKm != null && Number.isFinite(row.officialKm)) {
+    lines.push({ label: '实际扣费里程', value: formatKm(row.officialKm) })
+  }
+  lines.push({ label: '跑步时长', value: formatSecondsToMinSec(row.timeSec) })
+  lines.push({
+    label: '平均配速',
+    value: formatPace(row.timeSec, row.distance)
+  })
+
+  if (isAdmin && data?.path_id != null) {
+    lines.push({ label: '路线ID', value: String(data.path_id) })
+  }
+
+  return lines
+}
+
+export function normalizePointData(pd) {
+  if (!pd) return []
+  if (Array.isArray(pd)) return pd
+  if (typeof pd === 'string') {
+    try {
+      const p = JSON.parse(pd)
+      return Array.isArray(p) ? p : []
+    } catch {
+      return []
+    }
+  }
+  return []
+}