Browse Source

✨ feat: 优化抢课功能

Pchen. 1 month ago
parent
commit
80b5deca4b

+ 27 - 0
src/api/qk.js

@@ -5,11 +5,14 @@ const api = {
   TaskDetail: '/QK/Task/Detail',
   TaskDetail: '/QK/Task/Detail',
   AdminClientList: '/Admin/QK/Client/List',
   AdminClientList: '/Admin/QK/Client/List',
   AdminClientDelete: '/Admin/QK/Client/Delete',
   AdminClientDelete: '/Admin/QK/Client/Delete',
+  AdminClientEnable: '/Admin/QK/Client/Enable',
   AdminTaskList: '/Admin/QK/Task/List',
   AdminTaskList: '/Admin/QK/Task/List',
   AdminTaskDetail: '/Admin/QK/Task/Detail',
   AdminTaskDetail: '/Admin/QK/Task/Detail',
   AdminTaskUpdate: '/Admin/QK/Task/Update',
   AdminTaskUpdate: '/Admin/QK/Task/Update',
   AdminTaskCancel: '/Admin/QK/Task/Cancel',
   AdminTaskCancel: '/Admin/QK/Task/Cancel',
   AdminTaskRetry: '/Admin/QK/Task/Retry',
   AdminTaskRetry: '/Admin/QK/Task/Retry',
+  AdminTaskStart: '/Admin/QK/Task/Start',
+  AdminTaskPause: '/Admin/QK/Task/Pause',
   AdminReportList: '/Admin/QK/Report/List',
   AdminReportList: '/Admin/QK/Report/List',
   TaskStart: '/QK/Task/Start',
   TaskStart: '/QK/Task/Start',
   TaskPause: '/QK/Task/Pause',
   TaskPause: '/QK/Task/Pause',
@@ -106,6 +109,14 @@ export function adminQkClientDelete(parameter) {
   })
   })
 }
 }
 
 
+export function adminQkClientEnable(parameter) {
+  return request({
+    url: api.AdminClientEnable,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function adminQkTaskList(parameter) {
 export function adminQkTaskList(parameter) {
   return request({
   return request({
     url: api.AdminTaskList,
     url: api.AdminTaskList,
@@ -146,6 +157,22 @@ export function adminQkTaskRetry(parameter) {
   })
   })
 }
 }
 
 
+export function adminQkTaskStart(parameter) {
+  return request({
+    url: api.AdminTaskStart,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkTaskPause(parameter) {
+  return request({
+    url: api.AdminTaskPause,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function adminQkReportList(parameter) {
 export function adminQkReportList(parameter) {
   return request({
   return request({
     url: api.AdminReportList,
     url: api.AdminReportList,

+ 31 - 4
src/pages/admin/qkClient/index.vue

@@ -3,7 +3,7 @@
     <Breadcrumb />
     <Breadcrumb />
     <a-card title="抢课客户端">
     <a-card title="抢课客户端">
       <a-alert type="info" class="info-alert">
       <a-alert type="info" class="info-alert">
-        客户端首次上线时会自动注册,无需在此手动创建。可在此查看在线状态,对异常客户端执行禁用。
+        客户端首次上线时会自动注册,无需在此手动创建。可在此查看在线状态,对异常客户端执行禁用,或对已禁用客户端执行解禁
       </a-alert>
       </a-alert>
 
 
       <a-table
       <a-table
@@ -18,6 +18,13 @@
         <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 />
           <a-table-column title="备注" data-index="label" :width="140" ellipsis tooltip />
           <a-table-column title="备注" data-index="label" :width="140" ellipsis tooltip />
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.enabled) === 1 ? 'arcoblue' : 'red'">
+                {{ Number(record.enabled) === 1 ? '启用' : '已禁用' }}
+              </a-tag>
+            </template>
+          </a-table-column>
           <a-table-column title="在线" :width="90">
           <a-table-column title="在线" :width="90">
             <template #cell="{ record }">
             <template #cell="{ record }">
               <a-tag :color="Number(record.online) === 1 ? 'green' : 'gray'">
               <a-tag :color="Number(record.online) === 1 ? 'green' : 'gray'">
@@ -45,11 +52,22 @@
           <a-table-column title="最后心跳" :width="180">
           <a-table-column title="最后心跳" :width="180">
             <template #cell="{ record }">{{ formatTime(record.last_heartbeat_at) }}</template>
             <template #cell="{ record }">{{ formatTime(record.last_heartbeat_at) }}</template>
           </a-table-column>
           </a-table-column>
-          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+          <a-table-column title="操作" :width="120" :fixed="tableFixed('right')">
             <template #cell="{ record }">
             <template #cell="{ record }">
-              <a-popconfirm content="确认禁用该客户端?" @ok="deleteClient(record)">
+              <a-popconfirm
+                v-if="Number(record.enabled) === 1"
+                content="确认禁用该客户端?"
+                @ok="deleteClient(record)"
+              >
                 <a-button type="text" status="danger" size="small">禁用</a-button>
                 <a-button type="text" status="danger" size="small">禁用</a-button>
               </a-popconfirm>
               </a-popconfirm>
+              <a-popconfirm
+                v-else
+                content="确认解禁该客户端?解禁后客户端可重新注册上线。"
+                @ok="enableClient(record)"
+              >
+                <a-button type="text" status="success" size="small">解禁</a-button>
+              </a-popconfirm>
             </template>
             </template>
           </a-table-column>
           </a-table-column>
         </template>
         </template>
@@ -64,7 +82,7 @@
 <script setup>
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
 import { ref, onMounted, onUnmounted } from 'vue'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
-import { adminQkClientDelete, adminQkClientList } from '@/api/qk'
+import { adminQkClientDelete, adminQkClientEnable, adminQkClientList } from '@/api/qk'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 
 
 const { tableFixed } = useResponsiveTable()
 const { tableFixed } = useResponsiveTable()
@@ -112,6 +130,15 @@ const deleteClient = async (record) => {
   getList()
   getList()
 }
 }
 
 
+const enableClient = async (record) => {
+  const res = await adminQkClientEnable({ client_id: record.client_id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '解禁客户端失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已解禁', content: '客户端下次上线时可正常注册' })
+  getList()
+}
+
 let pollTimer = null
 let pollTimer = null
 
 
 const startPolling = () => {
 const startPolling = () => {

+ 8 - 2
src/pages/admin/qkReport/index.vue

@@ -109,7 +109,12 @@
               <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="message" ellipsis tooltip />
+          <a-table-column title="课程" data-index="course_name" :width="180" ellipsis tooltip />
+          <a-table-column title="回报信息" :width="320" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.display_message || record.message || '-' }}
+            </template>
+          </a-table-column>
           <a-table-column title="任务状态" data-index="task_status" :width="100">
           <a-table-column title="任务状态" data-index="task_status" :width="100">
             <template #cell="{ record }">
             <template #cell="{ record }">
               {{ taskStatusLabel(record.task_status) }}
               {{ taskStatusLabel(record.task_status) }}
@@ -137,7 +142,8 @@
         <a-descriptions-item label="任务 ID">{{ detail.task_id }}</a-descriptions-item>
         <a-descriptions-item label="任务 ID">{{ detail.task_id }}</a-descriptions-item>
         <a-descriptions-item label="客户端">{{ detail.client_id }}</a-descriptions-item>
         <a-descriptions-item label="客户端">{{ detail.client_id }}</a-descriptions-item>
         <a-descriptions-item label="类型">{{ eventLabel(detail.event) }}</a-descriptions-item>
         <a-descriptions-item label="类型">{{ eventLabel(detail.event) }}</a-descriptions-item>
-        <a-descriptions-item label="信息">{{ detail.message || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="课程">{{ detail.course_name || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="信息">{{ detail.display_message || detail.message || '-' }}</a-descriptions-item>
         <a-descriptions-item label="时间">{{ formatTime(detail.create_time) }}</a-descriptions-item>
         <a-descriptions-item label="时间">{{ formatTime(detail.create_time) }}</a-descriptions-item>
         <a-descriptions-item label="原始数据">
         <a-descriptions-item label="原始数据">
           <pre class="payload-pre">{{ formatPayload(detail.payload_json) }}</pre>
           <pre class="payload-pre">{{ formatPayload(detail.payload_json) }}</pre>

+ 52 - 2
src/pages/admin/qkTask/index.vue

@@ -105,9 +105,27 @@
           <a-table-column title="更新时间" :width="180">
           <a-table-column title="更新时间" :width="180">
             <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
             <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
           </a-table-column>
           </a-table-column>
-          <a-table-column v-if="canManage()" title="操作" :width="220" :fixed="tableFixed('right')">
+          <a-table-column v-if="canManage()" title="操作" :width="300" :fixed="tableFixed('right')">
             <template #cell="{ record }">
             <template #cell="{ record }">
-              <a-space>
+              <a-space wrap>
+                <a-button
+                  v-if="canStart(record)"
+                  type="text"
+                  size="small"
+                  status="success"
+                  @click="startTask(record)"
+                >
+                  开启
+                </a-button>
+                <a-button
+                  v-if="canPause(record)"
+                  type="text"
+                  size="small"
+                  status="warning"
+                  @click="pauseTask(record)"
+                >
+                  暂停
+                </a-button>
                 <a-button type="text" size="small" :disabled="!canEdit(record)" @click="openModal(record)">编辑</a-button>
                 <a-button type="text" size="small" :disabled="!canEdit(record)" @click="openModal(record)">编辑</a-button>
                 <a-popconfirm content="确认重试该任务?将重新进入待分配队列。" @ok="retryTask(record)">
                 <a-popconfirm content="确认重试该任务?将重新进入待分配队列。" @ok="retryTask(record)">
                   <a-button type="text" size="small" :disabled="!canRetry(record)">重试</a-button>
                   <a-button type="text" size="small" :disabled="!canRetry(record)">重试</a-button>
@@ -187,6 +205,12 @@
               <a-switch v-model="form.enable_ggxxk" />
               <a-switch v-model="form.enable_ggxxk" />
             </a-form-item>
             </a-form-item>
           </a-col>
           </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="登录失效后自动重新登录">
+              <a-switch v-model="form.auto_relogin" />
+              <template #extra>开启后抢课过程中会话失效将自动重新登录并继续</template>
+            </a-form-item>
+          </a-col>
         </a-row>
         </a-row>
       </a-form>
       </a-form>
     </a-modal>
     </a-modal>
@@ -202,6 +226,8 @@ import {
   adminQkTaskUpdate,
   adminQkTaskUpdate,
   adminQkTaskCancel,
   adminQkTaskCancel,
   adminQkTaskRetry,
   adminQkTaskRetry,
+  adminQkTaskStart,
+  adminQkTaskPause,
   adminQkBatchList
   adminQkBatchList
 } from '@/api/qk'
 } from '@/api/qk'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
@@ -243,6 +269,7 @@ const form = reactive({
   coursesText: '',
   coursesText: '',
   courseGroupsText: '',
   courseGroupsText: '',
   enable_ggxxk: true,
   enable_ggxxk: true,
+  auto_relogin: false,
   interval_ms: 500
   interval_ms: 500
 })
 })
 
 
@@ -286,6 +313,8 @@ const statusColor = (status) => ({
 }[status] || 'gray')
 }[status] || 'gray')
 
 
 const canEdit = (record) => record.status !== 'success'
 const canEdit = (record) => record.status !== 'success'
+const canStart = (record) => ['paused', 'failed'].includes(record.status)
+const canPause = (record) => ['pending', 'assigned', 'running'].includes(record.status)
 const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
 const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
 const canCancel = (record) => ['paused', 'pending', 'assigned', 'running', 'failed'].includes(record.status)
 const canCancel = (record) => ['paused', 'pending', 'assigned', 'running', 'failed'].includes(record.status)
 
 
@@ -306,6 +335,7 @@ const resetForm = () => {
     coursesText: '',
     coursesText: '',
     courseGroupsText: '',
     courseGroupsText: '',
     enable_ggxxk: true,
     enable_ggxxk: true,
+    auto_relogin: false,
     interval_ms: 500
     interval_ms: 500
   })
   })
   editingAssigned.value = false
   editingAssigned.value = false
@@ -329,6 +359,7 @@ const openModal = async (record) => {
       coursesText: (task.courses || []).join('\n'),
       coursesText: (task.courses || []).join('\n'),
       courseGroupsText: (task.course_groups || []).join('\n'),
       courseGroupsText: (task.course_groups || []).join('\n'),
       enable_ggxxk: task.enable_ggxxk,
       enable_ggxxk: task.enable_ggxxk,
+      auto_relogin: !!task.auto_relogin,
       interval_ms: task.interval_ms
       interval_ms: task.interval_ms
     })
     })
     visible.value = true
     visible.value = true
@@ -378,6 +409,7 @@ const submitTask = async () => {
       courses: validated.courses,
       courses: validated.courses,
       course_groups: validated.courseGroups,
       course_groups: validated.courseGroups,
       enable_ggxxk: form.enable_ggxxk,
       enable_ggxxk: form.enable_ggxxk,
+      auto_relogin: form.auto_relogin,
       interval_ms: form.interval_ms
       interval_ms: form.interval_ms
     })
     })
     if (!res || res.code !== 0) {
     if (!res || res.code !== 0) {
@@ -402,6 +434,24 @@ const cancelTask = async (record) => {
   getList()
   getList()
 }
 }
 
 
+const startTask = async (record) => {
+  const res = await adminQkTaskStart({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '开启失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已开启', content: '任务已进入待分配队列' })
+  getList()
+}
+
+const pauseTask = async (record) => {
+  const res = await adminQkTaskPause({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '暂停失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已暂停' })
+  getList()
+}
+
 const retryTask = async (record) => {
 const retryTask = async (record) => {
   const res = await adminQkTaskRetry({ id: record.id })
   const res = await adminQkTaskRetry({ id: record.id })
   if (!res || res.code !== 0) {
   if (!res || res.code !== 0) {

+ 124 - 1
src/pages/qk/taskList.vue

@@ -25,6 +25,62 @@
         </a-button>
         </a-button>
       </div>
       </div>
 
 
+      <AppQueryFilter>
+      <a-row class="queryForm app-query-form query-row">
+        <a-col :flex="'1000px'" class="query-main">
+          <a-form :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+            <a-row :gutter="16">
+              <a-col :span="6">
+                <a-form-item field="status" label="状态">
+                  <a-select v-model="query.status" placeholder="全部" allow-clear>
+                    <a-option value="paused">未开始</a-option>
+                    <a-option value="pending">待分配</a-option>
+                    <a-option value="assigned">已分配</a-option>
+                    <a-option value="running">抢课中</a-option>
+                    <a-option value="success">成功</a-option>
+                    <a-option value="failed">失败</a-option>
+                    <a-option value="cancelled">已取消</a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="student_num" label="学号">
+                  <a-input v-model="query.student_num" placeholder="学号" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="name" label="备注">
+                  <a-input v-model="query.name" placeholder="备注关键词" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="batch_id" label="选课批次">
+                  <a-select v-model="query.batch_id" placeholder="全部" allow-clear allow-search>
+                    <a-option v-for="item in batchOptions" :key="item.id" :value="item.id">
+                      {{ item.name }}({{ item.jx0502zbid }})
+                    </a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+            </a-row>
+          </a-form>
+        </a-col>
+        <a-divider class="query-divider" style="height: 42px" direction="vertical" />
+        <a-col :flex="1" class="app-query-actions">
+          <a-space class="query-actions-space" direction="vertical" :size="18">
+            <a-button type="primary" @click="search">
+              <template #icon><icon-search /></template>
+              搜索
+            </a-button>
+            <a-button @click="resetQuery">
+              <template #icon><icon-refresh /></template>
+              重置
+            </a-button>
+          </a-space>
+        </a-col>
+      </a-row>
+      </AppQueryFilter>
+
       <a-table
       <a-table
         :data="data"
         :data="data"
         :bordered="false"
         :bordered="false"
@@ -162,6 +218,12 @@
               <a-switch v-model="form.enable_ggxxk" />
               <a-switch v-model="form.enable_ggxxk" />
             </a-form-item>
             </a-form-item>
           </a-col>
           </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="登录失效后自动重新登录">
+              <a-switch v-model="form.auto_relogin" />
+              <template #extra>开启后抢课过程中会话失效将自动重新登录并继续</template>
+            </a-form-item>
+          </a-col>
         </a-row>
         </a-row>
       </a-form>
       </a-form>
       </a-spin>
       </a-spin>
@@ -193,6 +255,12 @@ const visible = ref(false)
 const isCopy = ref(false)
 const isCopy = ref(false)
 const data = ref([])
 const data = ref([])
 const batchOptions = ref([])
 const batchOptions = ref([])
+const query = reactive({
+  status: '',
+  student_num: '',
+  name: '',
+  batch_id: ''
+})
 const form = reactive({
 const form = reactive({
   id: '',
   id: '',
   name: '',
   name: '',
@@ -202,6 +270,7 @@ const form = reactive({
   coursesText: '',
   coursesText: '',
   courseGroupsText: '',
   courseGroupsText: '',
   enable_ggxxk: true,
   enable_ggxxk: true,
+  auto_relogin: false,
   interval_ms: 500
   interval_ms: 500
 })
 })
 
 
@@ -271,6 +340,7 @@ const resetForm = () => {
     coursesText: '',
     coursesText: '',
     courseGroupsText: '',
     courseGroupsText: '',
     enable_ggxxk: true,
     enable_ggxxk: true,
+    auto_relogin: false,
     interval_ms: 500
     interval_ms: 500
   })
   })
 }
 }
@@ -285,6 +355,7 @@ const fillFormFromTask = (task, { copy = false } = {}) => {
     coursesText: (task.courses || []).join('\n'),
     coursesText: (task.courses || []).join('\n'),
     courseGroupsText: (task.course_groups || []).join('\n'),
     courseGroupsText: (task.course_groups || []).join('\n'),
     enable_ggxxk: task.enable_ggxxk,
     enable_ggxxk: task.enable_ggxxk,
+    auto_relogin: !!task.auto_relogin,
     interval_ms: task.interval_ms
     interval_ms: task.interval_ms
   })
   })
 }
 }
@@ -346,7 +417,12 @@ const loadBatches = async () => {
 const getList = async (silent = false) => {
 const getList = async (silent = false) => {
   try {
   try {
     if (!silent) loading.value = true
     if (!silent) loading.value = true
-    const res = await getQkTask()
+    const params = {}
+    if (query.status) params.status = query.status
+    if (query.student_num) params.student_num = query.student_num
+    if (query.name) params.name = query.name
+    if (query.batch_id) params.batch_id = query.batch_id
+    const res = await getQkTask(params)
     if (!res || res.code !== 0) {
     if (!res || res.code !== 0) {
       if (!silent) {
       if (!silent) {
         Notification.error({ title: '获取抢课任务失败', content: res?.msg || '请稍后再试' })
         Notification.error({ title: '获取抢课任务失败', content: res?.msg || '请稍后再试' })
@@ -363,6 +439,16 @@ const getList = async (silent = false) => {
   }
   }
 }
 }
 
 
+const search = () => getList()
+
+const resetQuery = () => {
+  query.status = ''
+  query.student_num = ''
+  query.name = ''
+  query.batch_id = ''
+  getList()
+}
+
 const refreshList = () => getList()
 const refreshList = () => getList()
 
 
 const submitTask = async () => {
 const submitTask = async () => {
@@ -382,6 +468,7 @@ const submitTask = async () => {
       courses: validated.courses,
       courses: validated.courses,
       course_groups: validated.courseGroups,
       course_groups: validated.courseGroups,
       enable_ggxxk: form.enable_ggxxk,
       enable_ggxxk: form.enable_ggxxk,
+      auto_relogin: form.auto_relogin,
       interval_ms: validated.intervalMs
       interval_ms: validated.intervalMs
     }
     }
     const res = await saveQkTask(payload)
     const res = await saveQkTask(payload)
@@ -467,6 +554,24 @@ onUnmounted(() => {
   display: none;
   display: none;
 }
 }
 
 
+.queryForm {
+  padding: 0;
+  width: 100%;
+  box-sizing: border-box;
+
+  @media (min-width: (@app-breakpoint-md + 1px)) {
+    margin-top: 20px;
+  }
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
 .table {
 .table {
   margin-top: 4px;
   margin-top: 4px;
 }
 }
@@ -489,5 +594,23 @@ onUnmounted(() => {
   .buttonGroup-mobile :deep(.arco-btn) {
   .buttonGroup-mobile :deep(.arco-btn) {
     flex: 1;
     flex: 1;
   }
   }
+
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+
+  .query-actions-space :deep(.arco-btn) {
+    flex: 1;
+  }
 }
 }
 </style>
 </style>