Browse Source

✨ feat: 优化前端页面展示

Pchen. 1 month ago
parent
commit
06c6b052be

+ 2 - 0
src/components/index.js

@@ -18,6 +18,7 @@ import Breadcrumb from './Breadcrumb/index.vue'
 import EmailAutoComplete from './EmailAutoComplete/index.vue'
 import EmailAutoComplete from './EmailAutoComplete/index.vue'
 import AppPageShell from './layout/AppPageShell.vue'
 import AppPageShell from './layout/AppPageShell.vue'
 import AppQueryFilter from './layout/AppQueryFilter.vue'
 import AppQueryFilter from './layout/AppQueryFilter.vue'
+import AppTableEllipsis from './layout/AppTableEllipsis.vue'
 
 
 use([
 use([
   CanvasRenderer,
   CanvasRenderer,
@@ -45,5 +46,6 @@ export default {
     Vue.component('EmailAutoComplete', EmailAutoComplete)
     Vue.component('EmailAutoComplete', EmailAutoComplete)
     Vue.component('AppPageShell', AppPageShell)
     Vue.component('AppPageShell', AppPageShell)
     Vue.component('AppQueryFilter', AppQueryFilter)
     Vue.component('AppQueryFilter', AppQueryFilter)
+    Vue.component('AppTableEllipsis', AppTableEllipsis)
   },
   },
 }
 }

+ 33 - 0
src/components/layout/AppTableEllipsis.vue

@@ -0,0 +1,33 @@
+<template>
+  <a-typography-text
+    class="app-table-ellipsis"
+    :ellipsis="ellipsisConfig"
+  >
+    <slot>{{ content }}</slot>
+  </a-typography-text>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  content: { type: String, default: '' },
+  rows: { type: Number, default: 3 }
+})
+
+const ellipsisConfig = computed(() => ({
+  rows: props.rows,
+  showTooltip: true,
+  css: true
+}))
+</script>
+
+<style scoped lang="less">
+.app-table-ellipsis {
+  display: block;
+  margin: 0;
+  max-width: 100%;
+  word-break: break-word;
+  overflow-wrap: anywhere;
+}
+</style>

+ 326 - 10
src/pages/admin/qkClient/index.vue

@@ -2,6 +2,82 @@
   <div class="store-page">
   <div class="store-page">
     <Breadcrumb />
     <Breadcrumb />
     <a-card title="抢课客户端">
     <a-card title="抢课客户端">
+      <a-row :gutter="[16, 16]" class="stats-row" align="stretch">
+        <a-col :xs="24" :md="10" :lg="8" class="stats-col">
+          <div class="stats-panel stats-highlight">
+            <a-progress
+              type="circle"
+              :percent="clientStats.occupancyRate / 100"
+              :stroke-width="8"
+              :width="isMobile ? 96 : 108"
+              :color="occupancyColor"
+            >
+              <template #text>
+                <div class="progress-text">
+                  <div class="progress-value">{{ clientStats.occupancyRate }}%</div>
+                  <div class="progress-label">槽位占用</div>
+                </div>
+              </template>
+            </a-progress>
+            <div class="stats-highlight-meta">
+              <div class="highlight-title">在线客户端容量</div>
+              <div class="highlight-desc">
+                已占用 <strong>{{ clientStats.usedSlots }}</strong> / 总容量 <strong>{{ clientStats.totalSlots }}</strong>
+              </div>
+              <div class="highlight-desc">
+                剩余可用槽位 <strong class="highlight-free">{{ clientStats.freeSlots }}</strong>
+              </div>
+            </div>
+          </div>
+        </a-col>
+        <a-col :xs="24" :md="14" :lg="16" class="stats-col">
+          <div class="stats-panel stats-grid-panel">
+            <a-row :gutter="[12, 12]" class="stats-grid">
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="任务总数" :value="taskStats.total" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="抢课中" :value="taskStats.active" value-style="color: rgb(var(--orange-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="待分配" :value="taskStats.pending" value-style="color: rgb(var(--arcoblue-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="客户端总数" :value="clientStats.total" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="在线客户端" :value="clientStats.online" value-style="color: rgb(var(--green-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="已禁用" :value="clientStats.disabled" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="累计完成" :value="clientStats.totalCompleted" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="累计成功" :value="clientStats.totalSuccess" value-style="color: rgb(var(--green-6))" />
+              </div>
+            </a-col>
+            </a-row>
+          </div>
+        </a-col>
+      </a-row>
+
       <a-alert type="info" class="info-alert">
       <a-alert type="info" class="info-alert">
         客户端首次上线时会自动注册,无需在此手动创建。可在此查看在线状态,对异常客户端执行禁用,或对已禁用客户端执行解禁。
         客户端首次上线时会自动注册,无需在此手动创建。可在此查看在线状态,对异常客户端执行禁用,或对已禁用客户端执行解禁。
       </a-alert>
       </a-alert>
@@ -10,10 +86,10 @@
         :data="data"
         :data="data"
         :bordered="false"
         :bordered="false"
         hoverable
         hoverable
-        class="table"
+        class="table app-data-table"
         :loading="loading"
         :loading="loading"
         :pagination="false"
         :pagination="false"
-        :scroll="{ x: 1250 }"
+        :scroll="{ x: isMobile ? 960 : 1250 }"
       >
       >
         <template #columns>
         <template #columns>
           <a-table-column title="客户端 ID" data-index="client_id" :width="180" ellipsis tooltip />
           <a-table-column title="客户端 ID" data-index="client_id" :width="180" ellipsis tooltip />
@@ -32,13 +108,28 @@
               </a-tag>
               </a-tag>
             </template>
             </template>
           </a-table-column>
           </a-table-column>
-          <a-table-column title="槽位" :width="100">
-            <template #cell="{ record }">{{ record.current_slots }} / {{ record.max_slots }}</template>
+          <a-table-column title="槽位" :width="120">
+            <template #cell="{ record }">
+              <div class="slot-cell">
+                <div class="slot-cell__text">{{ record.current_slots }} / {{ record.max_slots }}</div>
+                <div class="slot-bar">
+                  <div
+                    class="slot-bar__inner"
+                    :style="{
+                      width: `${slotPercent(record)}%`,
+                      backgroundColor: slotBarColor(record)
+                    }"
+                  />
+                </div>
+              </div>
+            </template>
           </a-table-column>
           </a-table-column>
           <a-table-column title="机器名" data-index="hostname" :width="140" ellipsis tooltip />
           <a-table-column title="机器名" data-index="hostname" :width="140" ellipsis tooltip />
           <a-table-column title="登录用户" data-index="os_username" :width="120" ellipsis tooltip />
           <a-table-column title="登录用户" data-index="os_username" :width="120" ellipsis tooltip />
-          <a-table-column title="CPU" :width="220" ellipsis tooltip>
-            <template #cell="{ record }">{{ record.cpu_model || '-' }} ({{ record.cpu_threads || '-' }}线程)</template>
+          <a-table-column title="CPU" :width="220">
+            <template #cell="{ record }">
+              <AppTableEllipsis>{{ record.cpu_model || '-' }} ({{ record.cpu_threads || '-' }}线程)</AppTableEllipsis>
+            </template>
           </a-table-column>
           </a-table-column>
           <a-table-column title="内存" :width="140">
           <a-table-column title="内存" :width="140">
             <template #cell="{ record }">
             <template #cell="{ record }">
@@ -80,15 +171,62 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, onMounted, onUnmounted } from 'vue'
+import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
-import { adminQkClientDelete, adminQkClientEnable, adminQkClientList } from '@/api/qk'
+import { adminQkClientDelete, adminQkClientEnable, adminQkClientList, adminQkTaskList } from '@/api/qk'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 
 
-const { tableFixed } = useResponsiveTable()
+const { tableFixed, isMobile } = useResponsiveTable()
 
 
 const data = ref([])
 const data = ref([])
 const loading = ref(false)
 const loading = ref(false)
+const taskStats = reactive({
+  total: 0,
+  active: 0,
+  pending: 0
+})
+
+const clientStats = computed(() => {
+  const list = data.value || []
+  const enabled = list.filter(item => Number(item.enabled) === 1)
+  const online = enabled.filter(item => Number(item.online) === 1)
+  const totalSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.max_slots) || 0), 0)
+  const usedSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.current_slots) || 0), 0)
+  const freeSlots = Math.max(0, totalSlots - usedSlots)
+  const occupancyRate = totalSlots > 0 ? Math.min(100, Math.round((usedSlots / totalSlots) * 100)) : 0
+  return {
+    total: list.length,
+    online: online.length,
+    disabled: list.length - enabled.length,
+    totalSlots,
+    usedSlots,
+    freeSlots,
+    occupancyRate,
+    totalCompleted: list.reduce((sum, item) => sum + (Number(item.total_completed) || 0), 0),
+    totalSuccess: list.reduce((sum, item) => sum + (Number(item.total_success) || 0), 0)
+  }
+})
+
+const occupancyColor = computed(() => {
+  const rate = clientStats.value.occupancyRate
+  if (rate >= 90) return '#F53F3F'
+  if (rate >= 70) return '#FF7D00'
+  return '#165DFF'
+})
+
+const slotPercent = (record) => {
+  const max = Math.max(0, Number(record.max_slots) || 0)
+  const current = Math.max(0, Number(record.current_slots) || 0)
+  if (!max) return 0
+  return Math.min(100, Math.round((current / max) * 100))
+}
+
+const slotBarColor = (record) => {
+  const percent = slotPercent(record)
+  if (percent >= 90) return '#F53F3F'
+  if (Number(record.online) !== 1 || Number(record.enabled) !== 1) return '#C9CDD4'
+  return '#165DFF'
+}
 
 
 const formatTime = (time) => {
 const formatTime = (time) => {
   if (!time) return '-'
   if (!time) return '-'
@@ -101,6 +239,22 @@ const formatTime = (time) => {
   })
   })
 }
 }
 
 
+const fetchTaskStats = async () => {
+  try {
+    const [allRes, runningRes, assignedRes, pendingRes] = await Promise.all([
+      adminQkTaskList({ current: 1, pagesize: 1 }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'running' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'assigned' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'pending' })
+    ])
+    if (allRes?.code === 0) taskStats.total = allRes.data?.total || 0
+    const running = runningRes?.code === 0 ? (runningRes.data?.total || 0) : 0
+    const assigned = assignedRes?.code === 0 ? (assignedRes.data?.total || 0) : 0
+    taskStats.active = running + assigned
+    if (pendingRes?.code === 0) taskStats.pending = pendingRes.data?.total || 0
+  } catch (_) {}
+}
+
 const getList = async (silent = false) => {
 const getList = async (silent = false) => {
   try {
   try {
     if (!silent) loading.value = true
     if (!silent) loading.value = true
@@ -128,6 +282,7 @@ const deleteClient = async (record) => {
   }
   }
   Notification.success({ title: '已禁用' })
   Notification.success({ title: '已禁用' })
   getList()
   getList()
+  fetchTaskStats()
 }
 }
 
 
 const enableClient = async (record) => {
 const enableClient = async (record) => {
@@ -137,14 +292,19 @@ const enableClient = async (record) => {
   }
   }
   Notification.success({ title: '已解禁', content: '客户端下次上线时可正常注册' })
   Notification.success({ title: '已解禁', content: '客户端下次上线时可正常注册' })
   getList()
   getList()
+  fetchTaskStats()
 }
 }
 
 
 let pollTimer = null
 let pollTimer = null
+let statsTimer = null
 
 
 const startPolling = () => {
 const startPolling = () => {
   if (!pollTimer) {
   if (!pollTimer) {
     pollTimer = setInterval(() => getList(true), 5000)
     pollTimer = setInterval(() => getList(true), 5000)
   }
   }
+  if (!statsTimer) {
+    statsTimer = setInterval(fetchTaskStats, 30000)
+  }
 }
 }
 
 
 const stopPolling = () => {
 const stopPolling = () => {
@@ -152,10 +312,15 @@ const stopPolling = () => {
     clearInterval(pollTimer)
     clearInterval(pollTimer)
     pollTimer = null
     pollTimer = null
   }
   }
+  if (statsTimer) {
+    clearInterval(statsTimer)
+    statsTimer = null
+  }
 }
 }
 
 
 onMounted(() => {
 onMounted(() => {
   getList()
   getList()
+  fetchTaskStats()
   startPolling()
   startPolling()
 })
 })
 
 
@@ -167,11 +332,162 @@ onUnmounted(() => {
 <style scoped lang="less">
 <style scoped lang="less">
 @import '@/styles/store-theme.less';
 @import '@/styles/store-theme.less';
 
 
+.stats-row {
+  margin-bottom: 24px;
+}
+
+.stats-col {
+  display: flex;
+}
+
+.stats-panel {
+  width: 100%;
+  min-height: 148px;
+  box-sizing: border-box;
+}
+
+.stats-highlight {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  height: 100%;
+  padding: 16px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: linear-gradient(135deg, rgba(var(--primary-1), 0.35), rgba(var(--primary-1), 0.08));
+}
+
+.stats-grid-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: var(--color-bg-1);
+}
+
+.stats-grid {
+  flex: 1;
+  align-content: stretch;
+}
+
+.stat-card {
+  height: 100%;
+  min-height: 72px;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 10px;
+  background: var(--color-bg-2);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+:deep(.stat-card .arco-statistic-title) {
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+:deep(.stat-card .arco-statistic-value) {
+  font-size: 22px;
+  line-height: 1.2;
+}
+
+.slot-cell {
+  width: 96px;
+  max-width: 100%;
+}
+
+.slot-cell__text {
+  font-size: 13px;
+  line-height: 1.4;
+  white-space: nowrap;
+}
+
+.slot-bar {
+  height: 6px;
+  margin-top: 6px;
+  border-radius: 999px;
+  background: var(--color-fill-3);
+  overflow: hidden;
+}
+
+.slot-bar__inner {
+  height: 100%;
+  border-radius: 999px;
+  min-width: 0;
+  max-width: 100%;
+  transition: width 0.2s ease;
+}
+
+.progress-text {
+  text-align: center;
+}
+
+.progress-value {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.progress-label {
+  margin-top: 2px;
+  font-size: 12px;
+  color: var(--color-text-3);
+}
+
+.stats-highlight-meta {
+  flex: 1;
+  min-width: 0;
+}
+
+.highlight-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.highlight-desc {
+  color: var(--color-text-2);
+  font-size: 13px;
+  line-height: 1.6;
+
+  strong {
+    color: var(--color-text-1);
+  }
+}
+
+.highlight-free {
+  color: rgb(var(--green-6));
+}
+
 .info-alert {
 .info-alert {
-  margin-bottom: 16px;
+  margin-bottom: 24px;
 }
 }
 
 
 .table {
 .table {
   margin-top: 4px;
   margin-top: 4px;
 }
 }
+
+@media (max-width: 768px) {
+  .stats-panel {
+    min-height: auto;
+  }
+
+  .stats-highlight {
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+  }
+
+  .stats-highlight-meta {
+    width: 100%;
+  }
+
+  .stat-card {
+    min-height: 64px;
+  }
+}
 </style>
 </style>

+ 7 - 3
src/pages/admin/qkReport/index.vue

@@ -109,10 +109,14 @@
               <a-tag :color="eventColor(record.event)">{{ eventLabel(record.event) }}</a-tag>
               <a-tag :color="eventColor(record.event)">{{ eventLabel(record.event) }}</a-tag>
             </template>
             </template>
           </a-table-column>
           </a-table-column>
-          <a-table-column title="课程" data-index="course_name" :width="180" ellipsis tooltip />
-          <a-table-column title="回报信息" :width="320" ellipsis tooltip>
+          <a-table-column title="课程" :width="180">
             <template #cell="{ record }">
             <template #cell="{ record }">
-              {{ record.display_message || record.message || '-' }}
+              <AppTableEllipsis>{{ record.course_name || '-' }}</AppTableEllipsis>
+            </template>
+          </a-table-column>
+          <a-table-column title="回报信息" :width="320">
+            <template #cell="{ record }">
+              <AppTableEllipsis>{{ record.display_message || record.message || '-' }}</AppTableEllipsis>
             </template>
             </template>
           </a-table-column>
           </a-table-column>
           <a-table-column title="任务状态" data-index="task_status" :width="100">
           <a-table-column title="任务状态" data-index="task_status" :width="100">

+ 353 - 11
src/pages/admin/qkTask/index.vue

@@ -2,7 +2,86 @@
   <div class="store-page">
   <div class="store-page">
     <Breadcrumb />
     <Breadcrumb />
     <a-card title="抢课任务监控">
     <a-card title="抢课任务监控">
-      <AppQueryFilter>
+      <div class="stats-section">
+      <a-row :gutter="[16, 16]" class="stats-row" align="stretch">
+        <a-col :xs="24" :md="10" :lg="8" class="stats-col">
+          <div class="stats-panel stats-highlight">
+            <a-progress
+              type="circle"
+              :percent="summary.activeRate / 100"
+              :stroke-width="8"
+              :width="isMobile ? 96 : 108"
+              :color="activeRateColor"
+            >
+              <template #text>
+                <div class="progress-text">
+                  <div class="progress-value">{{ summary.activeRate }}%</div>
+                  <div class="progress-label">活跃占比</div>
+                </div>
+              </template>
+            </a-progress>
+            <div class="stats-highlight-meta">
+              <div class="highlight-title">任务执行概况</div>
+              <div class="highlight-desc">
+                抢课中 <strong>{{ summary.grabbing }}</strong> · 待分配 <strong>{{ summary.pending }}</strong>
+              </div>
+              <div class="highlight-desc">
+                任务总数 <strong>{{ summary.total }}</strong> · 未开始 <strong>{{ summary.paused }}</strong>
+              </div>
+            </div>
+          </div>
+        </a-col>
+        <a-col :xs="24" :md="14" :lg="16" class="stats-col">
+          <div class="stats-panel stats-grid-panel">
+            <a-row :gutter="[12, 12]" class="stats-grid">
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="任务总数" :value="summary.total" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="抢课中" :value="summary.grabbing" value-style="color: rgb(var(--orange-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="待分配" :value="summary.pending" value-style="color: rgb(var(--arcoblue-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="未开始" :value="summary.paused" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="成功" :value="summary.success" value-style="color: rgb(var(--green-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="失败" :value="summary.failed" value-style="color: rgb(var(--red-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="已取消" :value="summary.cancelled" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="剩余槽位" :value="summary.freeSlots" value-style="color: rgb(var(--green-6))" />
+                <div class="stat-sub">占用 {{ summary.usedSlots }} / {{ summary.totalSlots }}</div>
+              </div>
+            </a-col>
+            </a-row>
+          </div>
+        </a-col>
+      </a-row>
+      </div>
+
+      <AppQueryFilter class="query-filter">
         <a-row class="query-row">
         <a-row class="query-row">
           <a-col :flex="'1000px'" class="query-main">
           <a-col :flex="'1000px'" class="query-main">
             <a-form class="queryForm app-query-form" :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
             <a-form class="queryForm app-query-form" :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
@@ -55,12 +134,13 @@
       </AppQueryFilter>
       </AppQueryFilter>
 
 
       <a-table
       <a-table
+        row-key="id"
         :data="data"
         :data="data"
         :bordered="false"
         :bordered="false"
         hoverable
         hoverable
         class="table"
         class="table"
         :loading="loading"
         :loading="loading"
-        :scroll="{ x: 1880 }"
+        :scroll="{ x: isMobile ? 1200 : 1880 }"
         :pagination="{
         :pagination="{
           showPageSize: true,
           showPageSize: true,
           showJumper: true,
           showJumper: true,
@@ -93,9 +173,15 @@
             </template>
             </template>
           </a-table-column>
           </a-table-column>
           <a-table-column title="课程" :width="260">
           <a-table-column title="课程" :width="260">
-            <template #cell="{ record }">{{ [...(record.courses || []), ...(record.course_groups || [])].join('、') || '-' }}</template>
+            <template #cell="{ record }">
+              <AppTableEllipsis>{{ formatCourseList(record) }}</AppTableEllipsis>
+            </template>
+          </a-table-column>
+          <a-table-column title="错误" :width="220">
+            <template #cell="{ record }">
+              <AppTableEllipsis>{{ record.error_msg || '-' }}</AppTableEllipsis>
+            </template>
           </a-table-column>
           </a-table-column>
-          <a-table-column title="错误" data-index="error_msg" :width="220" ellipsis tooltip />
           <a-table-column title="备注" :width="140" ellipsis tooltip>
           <a-table-column title="备注" :width="140" ellipsis tooltip>
             <template #cell="{ record }">{{ record.name || '-' }}</template>
             <template #cell="{ record }">{{ record.name || '-' }}</template>
           </a-table-column>
           </a-table-column>
@@ -110,6 +196,7 @@
               <a-space wrap>
               <a-space wrap>
                 <a-button
                 <a-button
                   v-if="canStart(record)"
                   v-if="canStart(record)"
+                  :key="`${record.id}-start`"
                   type="text"
                   type="text"
                   size="small"
                   size="small"
                   status="success"
                   status="success"
@@ -119,6 +206,7 @@
                 </a-button>
                 </a-button>
                 <a-button
                 <a-button
                   v-if="canPause(record)"
                   v-if="canPause(record)"
+                  :key="`${record.id}-pause`"
                   type="text"
                   type="text"
                   size="small"
                   size="small"
                   status="warning"
                   status="warning"
@@ -126,12 +214,30 @@
                 >
                 >
                   暂停
                   暂停
                 </a-button>
                 </a-button>
-                <a-button type="text" size="small" :disabled="!canEdit(record)" @click="openModal(record)">编辑</a-button>
-                <a-popconfirm content="确认重试该任务?将重新进入待分配队列。" @ok="retryTask(record)">
-                  <a-button type="text" size="small" :disabled="!canRetry(record)">重试</a-button>
+                <a-button
+                  v-if="canEdit(record)"
+                  :key="`${record.id}-edit`"
+                  type="text"
+                  size="small"
+                  @click="openModal(record)"
+                >
+                  编辑
+                </a-button>
+                <a-popconfirm
+                  v-if="canRetry(record)"
+                  :key="`${record.id}-retry`"
+                  content="确认重试该任务?将重新进入待分配队列。"
+                  @ok="retryTask(record)"
+                >
+                  <a-button type="text" size="small">重试</a-button>
                 </a-popconfirm>
                 </a-popconfirm>
-                <a-popconfirm :content="cancelConfirmText(record)" @ok="cancelTask(record)">
-                  <a-button type="text" status="danger" size="small" :disabled="!canCancel(record)">取消</a-button>
+                <a-popconfirm
+                  v-if="canCancel(record)"
+                  :key="`${record.id}-cancel`"
+                  :content="cancelConfirmText(record)"
+                  @ok="cancelTask(record)"
+                >
+                  <a-button type="text" status="danger" size="small">取消</a-button>
                 </a-popconfirm>
                 </a-popconfirm>
               </a-space>
               </a-space>
             </template>
             </template>
@@ -221,6 +327,7 @@
 import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
 import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
 import {
 import {
+  adminQkClientList,
   adminQkTaskList,
   adminQkTaskList,
   adminQkTaskDetail,
   adminQkTaskDetail,
   adminQkTaskUpdate,
   adminQkTaskUpdate,
@@ -260,6 +367,47 @@ const pagination = reactive({
   current: 1,
   current: 1,
   pagesize: 20
   pagesize: 20
 })
 })
+const statusStats = reactive({
+  total: 0,
+  paused: 0,
+  pending: 0,
+  assigned: 0,
+  running: 0,
+  success: 0,
+  failed: 0,
+  cancelled: 0,
+  usedSlots: 0,
+  totalSlots: 0,
+  freeSlots: 0
+})
+
+const summary = computed(() => {
+  const grabbing = statusStats.running + statusStats.assigned
+  const active = grabbing + statusStats.pending
+  const total = statusStats.total || 0
+  const activeRate = total > 0 ? Math.min(100, Math.round((active / total) * 100)) : 0
+  return {
+    total,
+    paused: statusStats.paused,
+    pending: statusStats.pending,
+    grabbing,
+    success: statusStats.success,
+    failed: statusStats.failed,
+    cancelled: statusStats.cancelled,
+    activeRate,
+    usedSlots: statusStats.usedSlots,
+    totalSlots: statusStats.totalSlots,
+    freeSlots: statusStats.freeSlots
+  }
+})
+
+const activeRateColor = computed(() => {
+  const rate = summary.value.activeRate
+  if (rate >= 80) return '#F53F3F'
+  if (rate >= 50) return '#FF7D00'
+  return '#165DFF'
+})
+
 const form = reactive({
 const form = reactive({
   id: '',
   id: '',
   name: '',
   name: '',
@@ -281,6 +429,11 @@ const targetHint = computed(() => getQkTargetHint(form.coursesText, form.courseG
 
 
 const splitLines = splitQkLines
 const splitLines = splitQkLines
 
 
+const formatCourseList = (record) => {
+  const list = [...(record.courses || []), ...(record.course_groups || [])]
+  return list.length ? list.join('、') : '-'
+}
+
 const formatTime = (time) => {
 const formatTime = (time) => {
   if (!time) return '-'
   if (!time) return '-'
   return new Date(Number(time)).toLocaleString('zh-CN', {
   return new Date(Number(time)).toLocaleString('zh-CN', {
@@ -393,6 +546,48 @@ const getList = async (silent = false) => {
   }
   }
 }
 }
 
 
+const fetchStats = async () => {
+  try {
+    const [
+      allRes,
+      pausedRes,
+      pendingRes,
+      assignedRes,
+      runningRes,
+      successRes,
+      failedRes,
+      cancelledRes,
+      clientRes
+    ] = await Promise.all([
+      adminQkTaskList({ current: 1, pagesize: 1 }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'paused' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'pending' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'assigned' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'running' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'success' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'failed' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'cancelled' }),
+      adminQkClientList()
+    ])
+    if (allRes?.code === 0) statusStats.total = allRes.data?.total || 0
+    if (pausedRes?.code === 0) statusStats.paused = pausedRes.data?.total || 0
+    if (pendingRes?.code === 0) statusStats.pending = pendingRes.data?.total || 0
+    if (assignedRes?.code === 0) statusStats.assigned = assignedRes.data?.total || 0
+    if (runningRes?.code === 0) statusStats.running = runningRes.data?.total || 0
+    if (successRes?.code === 0) statusStats.success = successRes.data?.total || 0
+    if (failedRes?.code === 0) statusStats.failed = failedRes.data?.total || 0
+    if (cancelledRes?.code === 0) statusStats.cancelled = cancelledRes.data?.total || 0
+
+    const clients = clientRes?.code === 0 ? (clientRes.data || []) : []
+    const online = clients.filter(item => Number(item.enabled) === 1 && Number(item.online) === 1)
+    const totalSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.max_slots) || 0), 0)
+    const usedSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.current_slots) || 0), 0)
+    statusStats.totalSlots = totalSlots
+    statusStats.usedSlots = usedSlots
+    statusStats.freeSlots = Math.max(0, totalSlots - usedSlots)
+  } catch (_) {}
+}
+
 const submitTask = async () => {
 const submitTask = async () => {
   const validated = validateQkTaskTargets(form.coursesText, form.courseGroupsText, form.interval_ms)
   const validated = validateQkTaskTargets(form.coursesText, form.courseGroupsText, form.interval_ms)
   if (!validated.ok) {
   if (!validated.ok) {
@@ -418,6 +613,7 @@ const submitTask = async () => {
     Notification.success({ title: '保存成功' })
     Notification.success({ title: '保存成功' })
     visible.value = false
     visible.value = false
     getList()
     getList()
+    fetchStats()
   } catch (error) {
   } catch (error) {
     Notification.error({ title: '保存失败', content: error.message || '请稍后再试' })
     Notification.error({ title: '保存失败', content: error.message || '请稍后再试' })
   } finally {
   } finally {
@@ -432,6 +628,7 @@ const cancelTask = async (record) => {
   }
   }
   Notification.success({ title: '已取消' })
   Notification.success({ title: '已取消' })
   getList()
   getList()
+  fetchStats()
 }
 }
 
 
 const startTask = async (record) => {
 const startTask = async (record) => {
@@ -441,6 +638,7 @@ const startTask = async (record) => {
   }
   }
   Notification.success({ title: '已开启', content: '任务已进入待分配队列' })
   Notification.success({ title: '已开启', content: '任务已进入待分配队列' })
   getList()
   getList()
+  fetchStats()
 }
 }
 
 
 const pauseTask = async (record) => {
 const pauseTask = async (record) => {
@@ -450,6 +648,7 @@ const pauseTask = async (record) => {
   }
   }
   Notification.success({ title: '已暂停' })
   Notification.success({ title: '已暂停' })
   getList()
   getList()
+  fetchStats()
 }
 }
 
 
 const retryTask = async (record) => {
 const retryTask = async (record) => {
@@ -459,6 +658,7 @@ const retryTask = async (record) => {
   }
   }
   Notification.success({ title: '已重新加入待分配队列' })
   Notification.success({ title: '已重新加入待分配队列' })
   getList()
   getList()
+  fetchStats()
 }
 }
 
 
 const search = () => {
 const search = () => {
@@ -486,11 +686,15 @@ const onPageSizeChange = (pageSize) => {
 }
 }
 
 
 let pollTimer = null
 let pollTimer = null
+let statsTimer = null
 
 
 const startPolling = () => {
 const startPolling = () => {
   if (!pollTimer) {
   if (!pollTimer) {
     pollTimer = setInterval(() => getList(true), 5000)
     pollTimer = setInterval(() => getList(true), 5000)
   }
   }
+  if (!statsTimer) {
+    statsTimer = setInterval(fetchStats, 30000)
+  }
 }
 }
 
 
 const stopPolling = () => {
 const stopPolling = () => {
@@ -498,6 +702,10 @@ const stopPolling = () => {
     clearInterval(pollTimer)
     clearInterval(pollTimer)
     pollTimer = null
     pollTimer = null
   }
   }
+  if (statsTimer) {
+    clearInterval(statsTimer)
+    statsTimer = null
+  }
 }
 }
 
 
 const loadBatches = async () => {
 const loadBatches = async () => {
@@ -512,6 +720,7 @@ const loadBatches = async () => {
 onMounted(() => {
 onMounted(() => {
   loadBatches()
   loadBatches()
   getList()
   getList()
+  fetchStats()
   startPolling()
   startPolling()
 })
 })
 
 
@@ -523,8 +732,123 @@ onUnmounted(() => {
 <style scoped lang="less">
 <style scoped lang="less">
 @import '@/styles/store-theme.less';
 @import '@/styles/store-theme.less';
 
 
+.stats-section {
+  padding-bottom: 40px;
+}
+
+.stats-row {
+  margin-bottom: 0;
+}
+
+:deep(.query-filter.app-query-filter) {
+  margin-top: 0;
+  margin-bottom: 24px;
+}
+
+.stats-col {
+  display: flex;
+}
+
+.stats-panel {
+  width: 100%;
+  min-height: 148px;
+  box-sizing: border-box;
+}
+
+.stats-highlight {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  height: 100%;
+  padding: 16px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: linear-gradient(135deg, rgba(var(--primary-1), 0.35), rgba(var(--primary-1), 0.08));
+}
+
+.stats-grid-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: var(--color-bg-1);
+}
+
+.stats-grid {
+  flex: 1;
+  align-content: stretch;
+}
+
+.progress-text {
+  text-align: center;
+}
+
+.progress-value {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.progress-label {
+  margin-top: 2px;
+  font-size: 12px;
+  color: var(--color-text-3);
+}
+
+.stats-highlight-meta {
+  flex: 1;
+  min-width: 0;
+}
+
+.highlight-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.highlight-desc {
+  color: var(--color-text-2);
+  font-size: 13px;
+  line-height: 1.6;
+
+  strong {
+    color: var(--color-text-1);
+  }
+}
+
+.stat-card {
+  height: 100%;
+  min-height: 72px;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 10px;
+  background: var(--color-bg-2);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+:deep(.stat-card .arco-statistic-title) {
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+:deep(.stat-card .arco-statistic-value) {
+  font-size: 22px;
+  line-height: 1.2;
+}
+
+.stat-sub {
+  margin-top: 4px;
+  font-size: 12px;
+  color: var(--color-text-3);
+}
+
 .query-row {
 .query-row {
-  margin-bottom: 10px;
+  margin-bottom: 0;
 }
 }
 
 
 .query-main {
 .query-main {
@@ -536,7 +860,7 @@ onUnmounted(() => {
 }
 }
 
 
 .table {
 .table {
-  margin-top: 16px;
+  margin-top: 4px;
 }
 }
 
 
 .edit-alert {
 .edit-alert {
@@ -544,6 +868,24 @@ onUnmounted(() => {
 }
 }
 
 
 @media (max-width: 768px) {
 @media (max-width: 768px) {
+  .stats-panel {
+    min-height: auto;
+  }
+
+  .stats-highlight {
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+  }
+
+  .stats-highlight-meta {
+    width: 100%;
+  }
+
+  .stat-card {
+    min-height: 64px;
+  }
+
   .query-row {
   .query-row {
     display: block;
     display: block;
   }
   }

+ 4 - 2
src/pages/qk/taskList.vue

@@ -111,8 +111,10 @@
               <a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
               <a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
             </template>
             </template>
           </a-table-column>
           </a-table-column>
-          <a-table-column title="结果" :width="220" ellipsis tooltip>
-            <template #cell="{ record }">{{ resultText(record) }}</template>
+          <a-table-column title="结果" :width="220">
+            <template #cell="{ record }">
+              <AppTableEllipsis>{{ resultText(record) }}</AppTableEllipsis>
+            </template>
           </a-table-column>
           </a-table-column>
           <a-table-column title="备注" :width="140" ellipsis tooltip>
           <a-table-column title="备注" :width="140" ellipsis tooltip>
             <template #cell="{ record }">{{ record.name || '-' }}</template>
             <template #cell="{ record }">{{ record.name || '-' }}</template>

+ 1 - 1
src/router/index.js

@@ -190,7 +190,7 @@ const routes = [
         component: DEFAULT_LAYOUT,
         component: DEFAULT_LAYOUT,
         meta: {
         meta: {
             title: '抢课助手',
             title: '抢课助手',
-            icon: 'icon-schedule',
+            icon: 'icon-calendar-clock',
             permissionCodes: QK_ASSISTANT_ACCESS_CODES
             permissionCodes: QK_ASSISTANT_ACCESS_CODES
         },
         },
         children: [
         children: [

+ 12 - 1
src/style.css

@@ -211,8 +211,19 @@ h1 {
     width: 100%;
     width: 100%;
   }
   }
 
 
-  .store-page .arco-card .arco-table-th,
+  .store-page .arco-card .arco-table-th {
+    white-space: nowrap;
+  }
+
   .store-page .arco-card .arco-table-td {
   .store-page .arco-card .arco-table-td {
+    vertical-align: middle;
+    word-break: break-word;
+    overflow-wrap: anywhere;
+  }
+
+  /* Arco 单行省略 + AutoTooltip 需保持 nowrap,悬停才显示气泡 */
+  .store-page .arco-card .arco-table-td .arco-auto-tooltip,
+  .store-page .arco-card .arco-table-td .arco-table-text-ellipsis {
     white-space: nowrap;
     white-space: nowrap;
   }
   }
 
 

+ 15 - 0
src/styles/store-theme.less

@@ -188,6 +188,21 @@
   }
   }
 }
 }
 
 
+// 表格单元格垂直居中(避免全局改造后文字贴顶)
+.store-page .arco-card {
+  .arco-table-td {
+    vertical-align: middle;
+  }
+
+  .arco-table-cell {
+    align-items: center;
+  }
+}
+
+.app-table-ellipsis {
+  line-height: 1.45;
+}
+
 // —— 筛选表单响应式 ——
 // —— 筛选表单响应式 ——
 .app-query-form {
 .app-query-form {
   .arco-form-item {
   .arco-form-item {