Browse Source

✨ feat: 优化抢课功能

Pchen. 1 month ago
parent
commit
c8ed8a21ac

+ 46 - 1
src/api/qk.js

@@ -10,7 +10,12 @@ const api = {
   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',
-  AdminReportList: '/Admin/QK/Report/List'
+  AdminReportList: '/Admin/QK/Report/List',
+  TaskStart: '/QK/Task/Start',
+  TaskPause: '/QK/Task/Pause',
+  Batch: '/QK/Batch',
+  AdminBatchList: '/Admin/QK/Batch/List',
+  AdminBatchSave: '/Admin/QK/Batch/Save'
 }
 }
 
 
 export function getQkTask(parameter) {
 export function getQkTask(parameter) {
@@ -45,6 +50,46 @@ export function deleteQkTask(parameter) {
   })
   })
 }
 }
 
 
+export function startQkTask(parameter) {
+  return request({
+    url: api.TaskStart,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function pauseQkTask(parameter) {
+  return request({
+    url: api.TaskPause,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function getQkBatchList(parameter) {
+  return request({
+    url: api.Batch,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkBatchList(parameter) {
+  return request({
+    url: api.AdminBatchList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkBatchSave(parameter) {
+  return request({
+    url: api.AdminBatchSave,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function adminQkClientList(parameter) {
 export function adminQkClientList(parameter) {
   return request({
   return request({
     url: api.AdminClientList,
     url: api.AdminClientList,

+ 1 - 1
src/components/Menu/index.vue

@@ -6,7 +6,7 @@
 
 
         <template v-for="menu in menuData" :key="menu.key">
         <template v-for="menu in menuData" :key="menu.key">
 
 
-            <a-sub-menu v-if="menu.children" :key="menu.key">
+            <a-sub-menu v-if="menu.children && menu.children.length" :key="menu.key">
 
 
                 <template #icon>
                 <template #icon>
 
 

+ 175 - 0
src/pages/admin/qkBatch/index.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课批次管理">
+      <div class="toolbar">
+        <a-button v-if="canManage" type="primary" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          新建批次
+        </a-button>
+        <a-button @click="getList()">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-alert type="info" class="info-alert">
+        停用批次会自动收回该批次下所有进行中的任务;修改批次 ID 后会自动将已分配任务重新放回待分配队列。
+      </a-alert>
+
+      <a-table
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :pagination="false"
+        :scroll="{ x: 980 }"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="名称" data-index="name" :width="180" ellipsis tooltip />
+          <a-table-column title="批次 ID" data-index="jx0502zbid" :width="260" ellipsis tooltip />
+          <a-table-column title="状态" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="record.enabled ? 'green' : 'gray'">{{ record.enabled ? '启用' : '停用' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button v-if="canManage" type="text" size="small" @click="openModal(record)">编辑</a-button>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课批次" />
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="form.id ? '编辑批次' : '新建批次'"
+      :width="560"
+      @ok="submitBatch"
+    >
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="批次名称" required>
+          <a-input v-model="form.name" placeholder="例如:2025春公选课" allow-clear />
+        </a-form-item>
+        <a-form-item label="批次 ID(jx0502zbid)" required>
+          <a-input v-model="form.jx0502zbid" placeholder="教务系统选课批次 ID" allow-clear />
+        </a-form-item>
+        <a-form-item label="是否启用">
+          <a-switch v-model="form.enabled" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon'
+import { adminQkBatchList, adminQkBatchSave } from '@/api/qk'
+import { hasPermission } from '@/utils/permission'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+const canManage = computed(() => hasPermission('action.qk.admin.batchManage'))
+
+const data = ref([])
+const loading = ref(false)
+const visible = ref(false)
+const form = reactive({
+  id: '',
+  name: '',
+  jx0502zbid: '',
+  enabled: true
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN')
+}
+
+const resetForm = () => {
+  Object.assign(form, {
+    id: '',
+    name: '',
+    jx0502zbid: '',
+    enabled: true
+  })
+}
+
+const openModal = (record) => {
+  resetForm()
+  if (record) {
+    Object.assign(form, {
+      id: record.id,
+      name: record.name,
+      jx0502zbid: record.jx0502zbid,
+      enabled: !!record.enabled
+    })
+  }
+  visible.value = true
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await adminQkBatchList()
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取批次失败', content: res?.msg || '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } catch (error) {
+    Notification.error({ title: '获取批次失败', content: error.message || '请稍后再试' })
+  } finally {
+    loading.value = false
+  }
+}
+
+const submitBatch = async () => {
+  const res = await adminQkBatchSave({
+    id: form.id || undefined,
+    name: form.name,
+    jx0502zbid: form.jx0502zbid,
+    enabled: form.enabled
+  })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg || '请稍后再试' })
+    return false
+  }
+  Notification.success({ title: '保存成功' })
+  visible.value = false
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.toolbar {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.info-alert {
+  margin-bottom: 16px;
+}
+
+.table {
+  margin-top: 4px;
+}
+</style>

+ 1 - 0
src/pages/admin/qkReport/index.vue

@@ -182,6 +182,7 @@ const eventMap = {
 }
 }
 
 
 const taskStatusMap = {
 const taskStatusMap = {
+  paused: '未开始',
   pending: '待分配',
   pending: '待分配',
   assigned: '已分配',
   assigned: '已分配',
   running: '抢课中',
   running: '抢课中',

+ 51 - 14
src/pages/admin/qkTask/index.vue

@@ -10,6 +10,7 @@
                 <a-col :span="12">
                 <a-col :span="12">
                   <a-form-item field="status" label="状态">
                   <a-form-item field="status" label="状态">
                     <a-select v-model="query.status" placeholder="全部" allow-clear>
                     <a-select v-model="query.status" placeholder="全部" allow-clear>
+                      <a-option value="paused">未开始</a-option>
                       <a-option value="pending">待分配</a-option>
                       <a-option value="pending">待分配</a-option>
                       <a-option value="assigned">已分配</a-option>
                       <a-option value="assigned">已分配</a-option>
                       <a-option value="running">抢课中</a-option>
                       <a-option value="running">抢课中</a-option>
@@ -132,14 +133,18 @@
       @ok="submitTask"
       @ok="submitTask"
     >
     >
       <a-alert v-if="editingAssigned" type="warning" class="edit-alert">
       <a-alert v-if="editingAssigned" type="warning" class="edit-alert">
-        该任务正在客户端执行,保存后将收回并重新进入待分配队列
+        该任务正在客户端执行,保存后将收回并变为未开始状态
       </a-alert>
       </a-alert>
       <a-form :model="form" layout="vertical" class="task-form">
       <a-form :model="form" layout="vertical" class="task-form">
         <a-form-item label="备注名" required>
         <a-form-item label="备注名" required>
           <a-input v-model="form.name" placeholder="例如:张三选修课" allow-clear />
           <a-input v-model="form.name" placeholder="例如:张三选修课" allow-clear />
         </a-form-item>
         </a-form-item>
-        <a-form-item label="选课批次 ID" required>
-          <a-input v-model="form.jx0502zbid" placeholder="jx0502zbid" allow-clear />
+        <a-form-item label="选课批次" required>
+          <a-select v-model="form.batch_id" placeholder="请选择抢课批次" allow-clear allow-search>
+            <a-option v-for="item in batchOptions" :key="item.id" :value="item.id">
+              {{ item.name }}({{ item.jx0502zbid }}){{ item.enabled ? '' : ' [已停用]' }}
+            </a-option>
+          </a-select>
         </a-form-item>
         </a-form-item>
         <a-row :gutter="16">
         <a-row :gutter="16">
           <a-col :xs="24" :sm="12">
           <a-col :xs="24" :sm="12">
@@ -155,6 +160,7 @@
         </a-row>
         </a-row>
         <a-form-item label="课程名称(每行一个,精确匹配)">
         <a-form-item label="课程名称(每行一个,精确匹配)">
           <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
           <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
+          <template #extra>{{ targetHint }}</template>
         </a-form-item>
         </a-form-item>
         <a-form-item label="课程分组(每行一个,例如羽毛球)">
         <a-form-item label="课程分组(每行一个,例如羽毛球)">
           <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
           <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
@@ -162,7 +168,8 @@
         <a-row :gutter="16">
         <a-row :gutter="16">
           <a-col :xs="24" :sm="12">
           <a-col :xs="24" :sm="12">
             <a-form-item label="抢课间隔(ms)">
             <a-form-item label="抢课间隔(ms)">
-              <a-input-number v-model="form.interval_ms" :min="200" :max="10000" style="width: 100%" />
+              <a-input-number v-model="form.interval_ms" :min="minInterval" :max="10000" style="width: 100%" />
+              <template #extra>当前最低 {{ minInterval }}ms</template>
             </a-form-item>
             </a-form-item>
           </a-col>
           </a-col>
           <a-col :xs="24" :sm="12">
           <a-col :xs="24" :sm="12">
@@ -177,17 +184,24 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { reactive, ref, onMounted, onUnmounted } 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 {
   adminQkTaskList,
   adminQkTaskList,
   adminQkTaskDetail,
   adminQkTaskDetail,
   adminQkTaskUpdate,
   adminQkTaskUpdate,
   adminQkTaskCancel,
   adminQkTaskCancel,
-  adminQkTaskRetry
+  adminQkTaskRetry,
+  adminQkBatchList
 } from '@/api/qk'
 } from '@/api/qk'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { hasPermission } from '@/utils/permission'
 import { hasPermission } from '@/utils/permission'
+import {
+  getMinQkInterval,
+  getQkTargetHint,
+  splitQkLines,
+  validateQkTaskTargets
+} from '@/utils/qkTaskRules'
 
 
 const { tableFixed, isMobile } = useResponsiveTable()
 const { tableFixed, isMobile } = useResponsiveTable()
 const canManage = () => hasPermission('action.qk.admin.taskManage')
 const canManage = () => hasPermission('action.qk.admin.taskManage')
@@ -198,6 +212,7 @@ const saving = ref(false)
 const visible = ref(false)
 const visible = ref(false)
 const editingAssigned = ref(false)
 const editingAssigned = ref(false)
 const data = ref([])
 const data = ref([])
+const batchOptions = ref([])
 const query = reactive({
 const query = reactive({
   status: '',
   status: '',
   client_id: '',
   client_id: '',
@@ -212,7 +227,7 @@ const pagination = reactive({
 const form = reactive({
 const form = reactive({
   id: '',
   id: '',
   name: '',
   name: '',
-  jx0502zbid: '',
+  batch_id: '',
   student_num: '',
   student_num: '',
   password: '',
   password: '',
   coursesText: '',
   coursesText: '',
@@ -221,7 +236,13 @@ const form = reactive({
   interval_ms: 500
   interval_ms: 500
 })
 })
 
 
-const splitLines = (value) => String(value || '').split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
+const targetCount = computed(() => {
+  return splitQkLines(form.coursesText).length + splitQkLines(form.courseGroupsText).length
+})
+const minInterval = computed(() => getMinQkInterval(targetCount.value))
+const targetHint = computed(() => getQkTargetHint(form.coursesText, form.courseGroupsText))
+
+const splitLines = splitQkLines
 
 
 const formatTime = (time) => {
 const formatTime = (time) => {
   if (!time) return '-'
   if (!time) return '-'
@@ -235,6 +256,7 @@ const formatTime = (time) => {
 }
 }
 
 
 const statusText = (status) => ({
 const statusText = (status) => ({
+  paused: '未开始',
   pending: '待分配',
   pending: '待分配',
   assigned: '已分配',
   assigned: '已分配',
   running: '抢课中',
   running: '抢课中',
@@ -244,6 +266,7 @@ const statusText = (status) => ({
 }[status] || status)
 }[status] || status)
 
 
 const statusColor = (status) => ({
 const statusColor = (status) => ({
+  paused: 'gray',
   pending: 'blue',
   pending: 'blue',
   assigned: 'arcoblue',
   assigned: 'arcoblue',
   running: 'orange',
   running: 'orange',
@@ -254,7 +277,7 @@ const statusColor = (status) => ({
 
 
 const canEdit = (record) => record.status !== 'success'
 const canEdit = (record) => record.status !== 'success'
 const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
 const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
-const canCancel = (record) => ['pending', 'assigned', 'running', 'failed'].includes(record.status)
+const canCancel = (record) => ['paused', 'pending', 'assigned', 'running', 'failed'].includes(record.status)
 
 
 const cancelConfirmText = (record) => {
 const cancelConfirmText = (record) => {
   if (['assigned', 'running'].includes(record.status)) {
   if (['assigned', 'running'].includes(record.status)) {
@@ -267,7 +290,7 @@ const resetForm = () => {
   Object.assign(form, {
   Object.assign(form, {
     id: '',
     id: '',
     name: '',
     name: '',
-    jx0502zbid: '',
+    batch_id: '',
     student_num: '',
     student_num: '',
     password: '',
     password: '',
     coursesText: '',
     coursesText: '',
@@ -290,7 +313,7 @@ const openModal = async (record) => {
     Object.assign(form, {
     Object.assign(form, {
       id: task.id,
       id: task.id,
       name: task.name,
       name: task.name,
-      jx0502zbid: task.jx0502zbid,
+      batch_id: task.batch_id || '',
       student_num: task.student_num,
       student_num: task.student_num,
       password: task.password || '',
       password: task.password || '',
       coursesText: (task.courses || []).join('\n'),
       coursesText: (task.courses || []).join('\n'),
@@ -330,16 +353,20 @@ const getList = async (silent = false) => {
 }
 }
 
 
 const submitTask = async () => {
 const submitTask = async () => {
+  const validated = validateQkTaskTargets(form.coursesText, form.courseGroupsText, form.interval_ms)
+  if (!validated.ok) {
+    return Notification.error({ title: '保存失败', content: validated.message })
+  }
   try {
   try {
     saving.value = true
     saving.value = true
     const res = await adminQkTaskUpdate({
     const res = await adminQkTaskUpdate({
       id: form.id,
       id: form.id,
       name: form.name,
       name: form.name,
-      jx0502zbid: form.jx0502zbid,
+      batch_id: form.batch_id,
       student_num: form.student_num,
       student_num: form.student_num,
       password: form.password,
       password: form.password,
-      courses: splitLines(form.coursesText),
-      course_groups: splitLines(form.courseGroupsText),
+      courses: validated.courses,
+      course_groups: validated.courseGroups,
       enable_ggxxk: form.enable_ggxxk,
       enable_ggxxk: form.enable_ggxxk,
       interval_ms: form.interval_ms
       interval_ms: form.interval_ms
     })
     })
@@ -413,7 +440,17 @@ const stopPolling = () => {
   }
   }
 }
 }
 
 
+const loadBatches = async () => {
+  try {
+    const res = await adminQkBatchList()
+    if (res?.code === 0) {
+      batchOptions.value = res.data || []
+    }
+  } catch (_) {}
+}
+
 onMounted(() => {
 onMounted(() => {
+  loadBatches()
   getList()
   getList()
   startPolling()
   startPolling()
 })
 })

+ 153 - 36
src/pages/qk/taskList.vue

@@ -4,7 +4,7 @@
 
 
     <a-card title="抢课任务">
     <a-card title="抢课任务">
       <div class="buttonGroup buttonGroup-desktop">
       <div class="buttonGroup buttonGroup-desktop">
-        <a-button type="primary" size="large" @click="openModal()">
+        <a-button v-if="canSubmit" type="primary" size="large" @click="openModal()">
           <template #icon><icon-plus /></template>
           <template #icon><icon-plus /></template>
           提交任务
           提交任务
         </a-button>
         </a-button>
@@ -15,7 +15,7 @@
       </div>
       </div>
 
 
       <div class="buttonGroup buttonGroup-mobile">
       <div class="buttonGroup buttonGroup-mobile">
-        <a-button type="primary" size="large" @click="openModal()">
+        <a-button v-if="canSubmit" type="primary" size="large" @click="openModal()">
           <template #icon><icon-plus /></template>
           <template #icon><icon-plus /></template>
           提交任务
           提交任务
         </a-button>
         </a-button>
@@ -38,7 +38,11 @@
           <a-table-column title="ID" data-index="id" :width="80" />
           <a-table-column title="ID" data-index="id" :width="80" />
           <a-table-column title="备注" data-index="name" :width="140" ellipsis tooltip />
           <a-table-column title="备注" data-index="name" :width="140" ellipsis tooltip />
           <a-table-column title="学号" data-index="student_num" :width="130" />
           <a-table-column title="学号" data-index="student_num" :width="130" />
-          <a-table-column title="选课批次" data-index="jx0502zbid" :width="220" ellipsis tooltip />
+          <a-table-column title="选课批次" :width="220" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.batch_name || record.jx0502zbid || '-' }}
+            </template>
+          </a-table-column>
           <a-table-column title="目标课程" :width="260">
           <a-table-column title="目标课程" :width="260">
             <template #cell="{ record }">
             <template #cell="{ record }">
               <a-space wrap>
               <a-space wrap>
@@ -58,11 +62,29 @@
           <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 title="操作" :width="150" :fixed="tableFixed('right')">
+          <a-table-column title="操作" :width="220" :fixed="tableFixed('right')">
             <template #cell="{ record }">
             <template #cell="{ record }">
-              <a-space>
-                <a-button type="text" size="small" @click="openModal(record)">编辑</a-button>
-                <a-popconfirm content="确认取消该抢课任务?" @ok="cancelTask(record)">
+              <a-space wrap>
+                <a-button
+                  v-if="canControl && canStart(record)"
+                  type="text"
+                  size="small"
+                  status="success"
+                  @click="startTask(record)"
+                >
+                  开启
+                </a-button>
+                <a-button
+                  v-if="canControl && canPause(record)"
+                  type="text"
+                  size="small"
+                  status="warning"
+                  @click="pauseTask(record)"
+                >
+                  暂停
+                </a-button>
+                <a-button v-if="canSubmit" type="text" size="small" :disabled="!canEdit(record)" @click="openModal(record)">编辑</a-button>
+                <a-popconfirm v-if="canSubmit" content="确认取消该抢课任务?" @ok="cancelTask(record)">
                   <a-button type="text" status="danger" size="small" :disabled="!canCancel(record)">取消</a-button>
                   <a-button type="text" status="danger" size="small" :disabled="!canCancel(record)">取消</a-button>
                 </a-popconfirm>
                 </a-popconfirm>
               </a-space>
               </a-space>
@@ -71,7 +93,7 @@
         </template>
         </template>
         <template #empty>
         <template #empty>
           <a-empty description="暂无抢课任务">
           <a-empty description="暂无抢课任务">
-            <a-button type="primary" @click="openModal()">提交第一个任务</a-button>
+            <a-button v-if="canSubmit" type="primary" @click="openModal()">提交第一个任务</a-button>
           </a-empty>
           </a-empty>
         </template>
         </template>
       </a-table>
       </a-table>
@@ -83,14 +105,19 @@
       :width="isMobile ? '100%' : '720px'"
       :width="isMobile ? '100%' : '720px'"
       draggable
       draggable
       esc-to-close
       esc-to-close
-      @ok="submitTask"
+      :ok-loading="saving"
+      :on-before-ok="submitTask"
     >
     >
       <a-form :model="form" layout="vertical" class="task-form">
       <a-form :model="form" layout="vertical" class="task-form">
         <a-form-item label="备注名" required>
         <a-form-item label="备注名" required>
           <a-input v-model="form.name" placeholder="例如:张三选修课" allow-clear />
           <a-input v-model="form.name" placeholder="例如:张三选修课" allow-clear />
         </a-form-item>
         </a-form-item>
-        <a-form-item label="选课批次 ID" required>
-          <a-input v-model="form.jx0502zbid" placeholder="jx0502zbid" allow-clear />
+        <a-form-item label="选课批次" required>
+          <a-select v-model="form.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-form-item>
         <a-row :gutter="16">
         <a-row :gutter="16">
           <a-col :xs="24" :sm="12">
           <a-col :xs="24" :sm="12">
@@ -106,6 +133,7 @@
         </a-row>
         </a-row>
         <a-form-item label="课程名称(每行一个,精确匹配)">
         <a-form-item label="课程名称(每行一个,精确匹配)">
           <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
           <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
+          <template #extra>{{ targetHint }}</template>
         </a-form-item>
         </a-form-item>
         <a-form-item label="课程分组(每行一个,例如羽毛球)">
         <a-form-item label="课程分组(每行一个,例如羽毛球)">
           <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
           <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
@@ -113,7 +141,8 @@
         <a-row :gutter="16">
         <a-row :gutter="16">
           <a-col :xs="24" :sm="12">
           <a-col :xs="24" :sm="12">
             <a-form-item label="抢课间隔(ms)">
             <a-form-item label="抢课间隔(ms)">
-              <a-input-number v-model="form.interval_ms" :min="200" :max="10000" style="width: 100%" />
+              <a-input-number v-model="form.interval_ms" :min="minInterval" :max="10000" style="width: 100%" />
+              <template #extra>当前最低 {{ minInterval }}ms</template>
             </a-form-item>
             </a-form-item>
           </a-col>
           </a-col>
           <a-col :xs="24" :sm="12">
           <a-col :xs="24" :sm="12">
@@ -128,20 +157,31 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { reactive, ref, onMounted, onUnmounted } from 'vue'
-import { Notification } from '@arco-design/web-vue'
-import { getQkTask, saveQkTask, deleteQkTask } from '@/api/qk'
+import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
+import { Notification, Modal } from '@arco-design/web-vue'
+import { getQkTask, saveQkTask, deleteQkTask, startQkTask, pauseQkTask, getQkBatchList } from '@/api/qk'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+import { hasAnyPermission, QK_ASSISTANT_CONTROL_CODES, QK_ASSISTANT_SUBMIT_CODES } from '@/utils/permission'
+import {
+  getMinQkInterval,
+  getQkTargetHint,
+  splitQkLines,
+  validateQkTaskForm
+} from '@/utils/qkTaskRules'
 
 
 const { tableFixed, isMobile } = useResponsiveTable()
 const { tableFixed, isMobile } = useResponsiveTable()
+const canSubmit = computed(() => hasAnyPermission(QK_ASSISTANT_SUBMIT_CODES))
+const canControl = computed(() => hasAnyPermission(QK_ASSISTANT_CONTROL_CODES))
 
 
 const loading = ref(false)
 const loading = ref(false)
+const saving = ref(false)
 const visible = ref(false)
 const visible = ref(false)
 const data = ref([])
 const data = ref([])
+const batchOptions = ref([])
 const form = reactive({
 const form = reactive({
   id: '',
   id: '',
   name: '',
   name: '',
-  jx0502zbid: '',
+  batch_id: '',
   student_num: '',
   student_num: '',
   password: '',
   password: '',
   coursesText: '',
   coursesText: '',
@@ -150,7 +190,13 @@ const form = reactive({
   interval_ms: 500
   interval_ms: 500
 })
 })
 
 
-const splitLines = (value) => String(value || '').split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
+const targetCount = computed(() => {
+  return splitQkLines(form.coursesText).length + splitQkLines(form.courseGroupsText).length
+})
+const minInterval = computed(() => getMinQkInterval(targetCount.value))
+const targetHint = computed(() => getQkTargetHint(form.coursesText, form.courseGroupsText))
+
+const splitLines = splitQkLines
 
 
 const formatTime = (time) => {
 const formatTime = (time) => {
   if (!time) return '-'
   if (!time) return '-'
@@ -164,6 +210,7 @@ const formatTime = (time) => {
 }
 }
 
 
 const statusText = (status) => ({
 const statusText = (status) => ({
+  paused: '未开始',
   pending: '待分配',
   pending: '待分配',
   assigned: '已分配',
   assigned: '已分配',
   running: '抢课中',
   running: '抢课中',
@@ -173,6 +220,7 @@ const statusText = (status) => ({
 }[status] || status)
 }[status] || status)
 
 
 const statusColor = (status) => ({
 const statusColor = (status) => ({
+  paused: 'gray',
   pending: 'blue',
   pending: 'blue',
   assigned: 'arcoblue',
   assigned: 'arcoblue',
   running: 'orange',
   running: 'orange',
@@ -181,14 +229,17 @@ const statusColor = (status) => ({
   cancelled: 'gray'
   cancelled: 'gray'
 }[status] || 'gray')
 }[status] || 'gray')
 
 
-const canCancel = (record) => ['pending', 'failed'].includes(record.status)
+const canStart = (record) => ['paused', 'failed'].includes(record.status)
+const canPause = (record) => ['pending', 'assigned', 'running'].includes(record.status)
+const canEdit = (record) => ['paused', 'failed', 'cancelled'].includes(record.status)
+const canCancel = (record) => ['paused', 'pending', 'failed'].includes(record.status)
 const resultText = (record) => record.error_msg || record.result_json?.message || record.result_json?.course || '-'
 const resultText = (record) => record.error_msg || record.result_json?.message || record.result_json?.course || '-'
 
 
 const resetForm = () => {
 const resetForm = () => {
   Object.assign(form, {
   Object.assign(form, {
     id: '',
     id: '',
     name: '',
     name: '',
-    jx0502zbid: '',
+    batch_id: '',
     student_num: '',
     student_num: '',
     password: '',
     password: '',
     coursesText: '',
     coursesText: '',
@@ -204,7 +255,7 @@ const openModal = (record) => {
     Object.assign(form, {
     Object.assign(form, {
       id: record.id,
       id: record.id,
       name: record.name,
       name: record.name,
-      jx0502zbid: record.jx0502zbid,
+      batch_id: record.batch_id || '',
       student_num: record.student_num,
       student_num: record.student_num,
       coursesText: (record.courses || []).join('\n'),
       coursesText: (record.courses || []).join('\n'),
       courseGroupsText: (record.course_groups || []).join('\n'),
       courseGroupsText: (record.course_groups || []).join('\n'),
@@ -215,6 +266,32 @@ const openModal = (record) => {
   visible.value = true
   visible.value = true
 }
 }
 
 
+const buildCopySnapshot = (validated) => ({
+  name: validated.name,
+  batch_id: validated.batch_id,
+  student_num: validated.student_num,
+  password: validated.password || '',
+  coursesText: validated.courses.join('\n'),
+  courseGroupsText: validated.courseGroups.join('\n'),
+  enable_ggxxk: form.enable_ggxxk,
+  interval_ms: validated.intervalMs
+})
+
+const openModalFromSnapshot = (snapshot) => {
+  resetForm()
+  Object.assign(form, snapshot)
+  visible.value = true
+}
+
+const loadBatches = async () => {
+  try {
+    const res = await getQkBatchList()
+    if (res?.code === 0) {
+      batchOptions.value = res.data || []
+    }
+  } catch (_) {}
+}
+
 const getList = async (silent = false) => {
 const getList = async (silent = false) => {
   try {
   try {
     if (!silent) loading.value = true
     if (!silent) loading.value = true
@@ -238,24 +315,45 @@ const getList = async (silent = false) => {
 const refreshList = () => getList()
 const refreshList = () => getList()
 
 
 const submitTask = async () => {
 const submitTask = async () => {
-  const payload = {
-    id: form.id || undefined,
-    name: form.name,
-    jx0502zbid: form.jx0502zbid,
-    student_num: form.student_num,
-    password: form.password,
-    courses: splitLines(form.coursesText),
-    course_groups: splitLines(form.courseGroupsText),
-    enable_ggxxk: form.enable_ggxxk,
-    interval_ms: form.interval_ms
+  const validated = validateQkTaskForm(form)
+  if (!validated.ok) {
+    Notification.error({ title: '保存抢课任务失败', content: validated.message })
+    return false
   }
   }
-  const res = await saveQkTask(payload)
-  if (!res || res.code !== 0) {
-    return Notification.error({ title: '保存抢课任务失败', content: res?.msg || '请稍后再试' })
+  try {
+    saving.value = true
+    const payload = {
+      id: form.id || undefined,
+      name: validated.name,
+      batch_id: validated.batch_id,
+      student_num: validated.student_num,
+      password: validated.password,
+      courses: validated.courses,
+      course_groups: validated.courseGroups,
+      enable_ggxxk: form.enable_ggxxk,
+      interval_ms: validated.intervalMs
+    }
+    const res = await saveQkTask(payload)
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '保存抢课任务失败', content: res?.msg || '请稍后再试' })
+      return false
+    }
+    const snapshot = buildCopySnapshot(validated)
+    await getList()
+    Modal.confirm({
+      title: '保存成功',
+      content: '是否复制此任务并继续提交新任务?',
+      okText: '复制任务',
+      cancelText: '关闭',
+      onOk: () => openModalFromSnapshot(snapshot)
+    })
+    return true
+  } catch (error) {
+    Notification.error({ title: '保存抢课任务失败', content: error.message || '请稍后再试' })
+    return false
+  } finally {
+    saving.value = false
   }
   }
-  Notification.success({ title: '保存成功' })
-  visible.value = false
-  getList()
 }
 }
 
 
 const cancelTask = async (record) => {
 const cancelTask = async (record) => {
@@ -267,6 +365,24 @@ const cancelTask = async (record) => {
   getList()
   getList()
 }
 }
 
 
+const startTask = async (record) => {
+  const res = await startQkTask({ 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 pauseQkTask({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '暂停失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已暂停' })
+  getList()
+}
+
 let pollTimer = null
 let pollTimer = null
 
 
 const startPolling = () => {
 const startPolling = () => {
@@ -283,6 +399,7 @@ const stopPolling = () => {
 }
 }
 
 
 onMounted(() => {
 onMounted(() => {
+  loadBatches()
   getList()
   getList()
   startPolling()
   startPolling()
 })
 })

+ 46 - 12
src/router/index.js

@@ -1,7 +1,7 @@
 import * as VueRouter from 'vue-router'
 import * as VueRouter from 'vue-router'
 import { useUserStore } from '@/store'
 import { useUserStore } from '@/store'
 import { isElectron } from '../utils/electron'
 import { isElectron } from '../utils/electron'
-import { canAccessRoute } from '@/utils/permission'
+import { canAccessRoute, QK_ASSISTANT_ACCESS_CODES } from '@/utils/permission'
 const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue')
 const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue')
 const HTML_VIEW = () => import('@/layout/html-view.vue')
 const HTML_VIEW = () => import('@/layout/html-view.vue')
 // import { Message } from '@arco-design/web-vue'
 // import { Message } from '@arco-design/web-vue'
@@ -183,6 +183,28 @@ const routes = [
             }
             }
         ]
         ]
     },
     },
+    {
+        path: '/qk',
+        name: 'qk',
+        redirect: '/qk/taskList',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '抢课助手',
+            icon: 'icon-schedule',
+            permissionCodes: QK_ASSISTANT_ACCESS_CODES
+        },
+        children: [
+            {
+                path: 'taskList',
+                name: 'qk.taskList',
+                component: () => import('../pages/qk/taskList.vue'),
+                meta: {
+                    title: '抢课任务',
+                    permissionCodes: QK_ASSISTANT_ACCESS_CODES
+                }
+            }
+        ]
+    },
     {
     {
         path: "/qxs",
         path: "/qxs",
         name: "qxs",
         name: "qxs",
@@ -450,23 +472,22 @@ const routes = [
         meta: {
         meta: {
             title: '抢课管理',
             title: '抢课管理',
             icon: 'icon-schedule',
             icon: 'icon-schedule',
-            permission: ['admin', 'service'],
             permissionCodes: [
             permissionCodes: [
-                'page.qk.taskList',
                 'page.admin.qk.client',
                 'page.admin.qk.client',
+                'page.admin.qk.batch',
                 'page.admin.qk.task',
                 'page.admin.qk.task',
                 'page.admin.qk.report'
                 'page.admin.qk.report'
             ]
             ]
         },
         },
         children: [
         children: [
             {
             {
-                path: 'assistant',
-                name: 'qkManage.assistant',
-                component: () => import('../pages/qk/taskList.vue'),
+                path: 'batch',
+                name: 'qkManage.batch',
+                component: () => import('../pages/admin/qkBatch/index.vue'),
                 meta: {
                 meta: {
-                    title: '抢课助手',
-                    permission: ['admin'],
-                    permissionCode: 'page.qk.taskList'
+                    title: '批次管理',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.qk.batch'
                 }
                 }
             },
             },
             {
             {
@@ -501,17 +522,30 @@ const routes = [
             }
             }
         ]
         ]
     },
     },
+    {
+        path: '/lepao/qkAssistant',
+        redirect: '/qk/taskList',
+        meta: { hideInMenu: true }
+    },
     {
     {
         path: '/admin/qkAssistant',
         path: '/admin/qkAssistant',
-        redirect: '/qkManage/assistant'
+        redirect: '/qk/taskList',
+        meta: { hideInMenu: true }
+    },
+    {
+        path: '/qkManage/assistant',
+        redirect: '/qk/taskList',
+        meta: { hideInMenu: true }
     },
     },
     {
     {
         path: '/admin/qkClient',
         path: '/admin/qkClient',
-        redirect: '/qkManage/client'
+        redirect: '/qkManage/client',
+        meta: { hideInMenu: true }
     },
     },
     {
     {
         path: '/admin/qkTask',
         path: '/admin/qkTask',
-        redirect: '/qkManage/task'
+        redirect: '/qkManage/task',
+        meta: { hideInMenu: true }
     },
     },
     {
     {
         path: '/noticeManage',
         path: '/noticeManage',

+ 28 - 0
src/utils/permission.js

@@ -15,6 +15,25 @@ export const BASIC_PERMISSION_CODES = [
 
 
 export const isBasicPermission = (code) => BASIC_PERMISSION_CODES.includes(code)
 export const isBasicPermission = (code) => BASIC_PERMISSION_CODES.includes(code)
 
 
+/** 抢课助手页面访问权限,与后端 QK_ASSISTANT_ACCESS_CODES 保持一致 */
+export const QK_ASSISTANT_ACCESS_CODES = [
+  'page.qk.taskList',
+  'page.admin.qk.client',
+  'page.admin.qk.batch',
+  'page.admin.qk.task',
+  'page.admin.qk.report'
+]
+
+export const QK_ASSISTANT_SUBMIT_CODES = [
+  'action.qk.task.submit',
+  'action.qk.admin.taskManage'
+]
+
+export const QK_ASSISTANT_CONTROL_CODES = [
+  'action.qk.task.control',
+  'action.qk.admin.taskManage'
+]
+
 /** 根据额外授权与已关闭的基础权限,生成弹窗勾选状态 */
 /** 根据额外授权与已关闭的基础权限,生成弹窗勾选状态 */
 export const buildSelectedPermissionCodes = ({
 export const buildSelectedPermissionCodes = ({
   directPermissionCodes = [],
   directPermissionCodes = [],
@@ -73,9 +92,18 @@ export const hasPublicMenuDescendant = (route) => {
   return route.children.some((child) => hasPublicMenuDescendant(child))
   return route.children.some((child) => hasPublicMenuDescendant(child))
 }
 }
 
 
+export const hasVisibleMenuChildren = (route, user) => {
+  if (!route?.children?.length) return true
+  return route.children
+    .filter((child) => !child.meta?.hideInMenu)
+    .some((child) => canShowInMenu(child, user))
+}
+
 export const canShowInMenu = (route, user) => {
 export const canShowInMenu = (route, user) => {
   if (!route || route.meta?.hideInMenu) return false
   if (!route || route.meta?.hideInMenu) return false
+  if (route.redirect && !route.component && !route.meta?.title) return false
   if (!canAccessRoute(route, user)) return false
   if (!canAccessRoute(route, user)) return false
+  if (route.children?.length && !hasVisibleMenuChildren(route, user)) return false
   if (isLoggedIn(user)) return true
   if (isLoggedIn(user)) return true
   if (route.meta?.public) return true
   if (route.meta?.public) return true
   return hasPublicMenuDescendant(route)
   return hasPublicMenuDescendant(route)

+ 86 - 0
src/utils/qkTaskRules.js

@@ -0,0 +1,86 @@
+export const MAX_QK_TARGETS = 10
+
+export function splitQkLines(value) {
+  return String(value || '').split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
+}
+
+export function countQkTargets(courses = [], courseGroups = []) {
+  return courses.length + courseGroups.length
+}
+
+export function getMinQkInterval(totalCount) {
+  return totalCount >= 5 ? 500 : 200
+}
+
+export function validateQkTaskTargets(coursesText, courseGroupsText, intervalMs) {
+  const courses = splitQkLines(coursesText)
+  const courseGroups = splitQkLines(courseGroupsText)
+  const total = countQkTargets(courses, courseGroups)
+  if (total <= 0) {
+    return { ok: false, message: '至少需要填写一门课程或一个课程分组' }
+  }
+  if (total > MAX_QK_TARGETS) {
+    return { ok: false, message: `每个任务最多选择 ${MAX_QK_TARGETS} 个课程或分组` }
+  }
+  const minInterval = getMinQkInterval(total)
+  const safeInterval = Number(intervalMs)
+  if (!Number.isFinite(safeInterval) || safeInterval < minInterval || safeInterval > 10000) {
+    return {
+      ok: false,
+      message: `当前共 ${total} 个抢课目标,间隔需在 ${minInterval}-10000ms 之间`
+    }
+  }
+  return {
+    ok: true,
+    courses,
+    courseGroups,
+    total,
+    minInterval
+  }
+}
+
+export function validateQkTaskForm(form) {
+  const name = String(form?.name || '').trim()
+  if (!name) {
+    return { ok: false, message: '请填写备注名' }
+  }
+  if (!form?.batch_id) {
+    return { ok: false, message: '请选择选课批次' }
+  }
+  const studentNum = String(form?.student_num || '').trim()
+  if (!studentNum) {
+    return { ok: false, message: '请填写学号' }
+  }
+  if (!form?.id && !String(form?.password || '').trim()) {
+    return { ok: false, message: '新建任务需要填写教务密码' }
+  }
+  const targetValidation = validateQkTaskTargets(
+    form.coursesText,
+    form.courseGroupsText,
+    form.interval_ms
+  )
+  if (!targetValidation.ok) {
+    return targetValidation
+  }
+  return {
+    ok: true,
+    name,
+    batch_id: form.batch_id,
+    student_num: studentNum,
+    password: form.password,
+    courses: targetValidation.courses,
+    courseGroups: targetValidation.courseGroups,
+    intervalMs: Number(form.interval_ms),
+    total: targetValidation.total,
+    minInterval: targetValidation.minInterval
+  }
+}
+
+export function getQkTargetHint(coursesText, courseGroupsText) {
+  const total = countQkTargets(splitQkLines(coursesText), splitQkLines(courseGroupsText))
+  if (total <= 0) {
+    return `最多 ${MAX_QK_TARGETS} 个目标;少于 5 个最低 200ms,5 个及以上最低 500ms`
+  }
+  const minInterval = getMinQkInterval(total)
+  return `已选 ${total}/${MAX_QK_TARGETS} 个目标,当前最低间隔 ${minInterval}ms`
+}