Browse Source

✨ feat: 增强选课功能

Pchen. 1 month ago
parent
commit
d5ef2bb024

+ 19 - 1
src/api/power.js

@@ -3,7 +3,9 @@ import request from '../utils/request'
 const api = {
 const api = {
   Account: '/Power/Account',
   Account: '/Power/Account',
   GetChangeRecord: '/Power/GetChangeRecord',
   GetChangeRecord: '/Power/GetChangeRecord',
-  GetPowerData: '/Power/GetPowerData'
+  GetPowerData: '/Power/GetPowerData',
+  AdminTaskList: '/Admin/Power/Task/List',
+  AdminGetChangeRecord: '/Admin/Power/Task/GetChangeRecord'
 }
 }
 
 
 export function getPowerData(parameter) {
 export function getPowerData(parameter) {
@@ -45,3 +47,19 @@ export function getAccount(parameter) {
     params: parameter
     params: parameter
   })
   })
 }
 }
+
+export function adminPowerTaskList(parameter) {
+  return request({
+    url: api.AdminTaskList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminPowerGetChangeRecord(parameter) {
+  return request({
+    url: api.AdminGetChangeRecord,
+    method: 'get',
+    params: parameter
+  })
+}

+ 110 - 0
src/api/qk.js

@@ -0,0 +1,110 @@
+import request from '../utils/request'
+
+const api = {
+  Task: '/QK/Task',
+  TaskDetail: '/QK/Task/Detail',
+  AdminClientList: '/Admin/QK/Client/List',
+  AdminClientDelete: '/Admin/QK/Client/Delete',
+  AdminTaskList: '/Admin/QK/Task/List',
+  AdminTaskDetail: '/Admin/QK/Task/Detail',
+  AdminTaskUpdate: '/Admin/QK/Task/Update',
+  AdminTaskCancel: '/Admin/QK/Task/Cancel',
+  AdminTaskRetry: '/Admin/QK/Task/Retry',
+  AdminReportList: '/Admin/QK/Report/List'
+}
+
+export function getQkTask(parameter) {
+  return request({
+    url: api.Task,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getQkTaskDetail(parameter) {
+  return request({
+    url: api.TaskDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function saveQkTask(parameter) {
+  return request({
+    url: api.Task,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function deleteQkTask(parameter) {
+  return request({
+    url: api.Task,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function adminQkClientList(parameter) {
+  return request({
+    url: api.AdminClientList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkClientDelete(parameter) {
+  return request({
+    url: api.AdminClientDelete,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function adminQkTaskList(parameter) {
+  return request({
+    url: api.AdminTaskList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkTaskDetail(parameter) {
+  return request({
+    url: api.AdminTaskDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkTaskUpdate(parameter) {
+  return request({
+    url: api.AdminTaskUpdate,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkTaskCancel(parameter) {
+  return request({
+    url: api.AdminTaskCancel,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function adminQkTaskRetry(parameter) {
+  return request({
+    url: api.AdminTaskRetry,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkReportList(parameter) {
+  return request({
+    url: api.AdminReportList,
+    method: 'get',
+    params: parameter
+  })
+}

+ 19 - 1
src/api/qxs.js

@@ -2,7 +2,9 @@ import request from '../utils/request'
 
 
 const api = {
 const api = {
   getBookList: '/QXS/GetBookList',
   getBookList: '/QXS/GetBookList',
-  getBookImg: '/QXS/GetBookImg'
+  getBookImg: '/QXS/GetBookImg',
+  AdminAccountList: '/Admin/QXS/Account/List',
+  AdminAccountDetail: '/Admin/QXS/Account/Detail'
 }
 }
 
 
 export function GetBookList(parameter) {
 export function GetBookList(parameter) {
@@ -20,3 +22,19 @@ export function GetBookImg(parameter) {
     params: parameter
     params: parameter
   })
   })
 }
 }
+
+export function adminQxsAccountList(parameter) {
+  return request({
+    url: api.AdminAccountList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQxsAccountDetail(parameter) {
+  return request({
+    url: api.AdminAccountDetail,
+    method: 'get',
+    params: parameter
+  })
+}

+ 325 - 0
src/pages/admin/powerTask/index.vue

@@ -0,0 +1,325 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="电费查询任务">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <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-row :gutter="16">
+                <a-col :span="12">
+                  <a-form-item field="create_user" label="用户UUID">
+                    <a-input v-model="query.create_user" placeholder="创建人 UUID" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="query.username" placeholder="平台用户名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="email" label="通知邮箱">
+                    <a-input v-model="query.email" placeholder="任务通知邮箱" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="area" label="校区">
+                    <a-input v-model="query.area" placeholder="校区" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="building" label="楼栋">
+                    <a-input v-model="query.building" placeholder="楼栋" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="room" label="宿舍号">
+                    <a-input v-model="query.room" placeholder="宿舍号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="queryTime" label="更新时间">
+                    <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 84px" 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">搜索</a-button>
+              <a-button @click="reset">重置</a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        :bordered="false"
+        class="table"
+        :data="data"
+        :loading="loading"
+        :scroll="{ x: 1670 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="创建用户" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.avatar || defaultAvatar" />
+                </a-avatar>
+                {{ record.username || record.create_user || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="宿舍" :width="220">
+            <template #cell="{ record }">
+              {{ record.area }} · {{ record.building }} · {{ record.room }}
+            </template>
+          </a-table-column>
+          <a-table-column title="余额" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="isLowBalance(record) ? 'red' : 'green'">¥{{ record.balance }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="阈值" :width="90">
+            <template #cell="{ record }">¥{{ record.lowest }}</template>
+          </a-table-column>
+          <a-table-column title="通知邮箱" data-index="email" :width="180" ellipsis tooltip />
+          <a-table-column title="扣费时间" data-index="koufei_date" :width="170" ellipsis tooltip />
+          <a-table-column title="已提醒" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.is_notice) === 1 ? 'orange' : 'gray'">
+                {{ Number(record.is_notice) === 1 ? '是' : '否' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="备注" data-index="notes" :width="140" ellipsis tooltip />
+          <a-table-column title="添加时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="120" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="openChangeRecord(record)">账单记录</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="recordVisible"
+      title="电费变更记录"
+      width="auto"
+      hide-cancel
+      esc-to-close
+      draggable
+    >
+      <a-table
+        :bordered="false"
+        :data="changeList"
+        :loading="recordLoading"
+        :scroll="{ x: 720 }"
+        :pagination="false"
+        stripe
+      >
+        <template #columns>
+          <a-table-column title="记录时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.time) }}</template>
+          </a-table-column>
+          <a-table-column title="扣费时间" data-index="change_time" :width="170" ellipsis tooltip />
+          <a-table-column title="电费余额" :width="110">
+            <template #cell="{ record }">¥{{ record.balance }}</template>
+          </a-table-column>
+          <a-table-column title="原余额" :width="110">
+            <template #cell="{ record }">¥{{ record.old_balance }}</template>
+          </a-table-column>
+          <a-table-column title="变动金额" :width="110">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.balance) < Number(record.old_balance) ? 'red' : 'green'">
+                ¥{{ (Number(record.balance) - Number(record.old_balance)).toFixed(2) }}
+              </a-tag>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminPowerTaskList, adminPowerGetChangeRecord } from '@/api/power'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
+const loading = ref(false)
+const data = ref([])
+const query = reactive({
+  create_user: '',
+  username: '',
+  email: '',
+  area: '',
+  building: '',
+  room: '',
+  queryTime: []
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const recordVisible = ref(false)
+const recordLoading = ref(false)
+const changeList = ref([])
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const isLowBalance = (record) => Number(record.balance) <= Number(record.lowest)
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await adminPowerTaskList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取电费任务失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取电费任务失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const openChangeRecord = async (record) => {
+  try {
+    recordLoading.value = true
+    recordVisible.value = true
+    changeList.value = []
+    const res = await adminPowerGetChangeRecord({ id: record.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取账单记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    changeList.value = res.data || []
+  } catch (error) {
+    Notification.error({
+      title: '获取账单记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    recordLoading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.create_user = ''
+  query.username = ''
+  query.email = ''
+  query.area = ''
+  query.building = ''
+  query.room = ''
+  query.queryTime = []
+  pagination.current = 1
+  getList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 16px;
+}
+
+@media (max-width: 768px) {
+  .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>

+ 150 - 0
src/pages/admin/qkClient/index.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课客户端">
+      <a-alert type="info" class="info-alert">
+        客户端首次上线时会自动注册,无需在此手动创建。可在此查看在线状态,或对异常客户端执行禁用。
+      </a-alert>
+
+      <a-table
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :pagination="false"
+        :scroll="{ x: 1250 }"
+      >
+        <template #columns>
+          <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="在线" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.online) === 1 ? 'green' : 'gray'">
+                {{ Number(record.online) === 1 ? '在线' : '离线' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="槽位" :width="100">
+            <template #cell="{ record }">{{ record.current_slots }} / {{ record.max_slots }}</template>
+          </a-table-column>
+          <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="CPU" :width="220" ellipsis tooltip>
+            <template #cell="{ record }">{{ record.cpu_model || '-' }} ({{ record.cpu_threads || '-' }}线程)</template>
+          </a-table-column>
+          <a-table-column title="内存" :width="140">
+            <template #cell="{ record }">
+              {{ record.free_mem_mb || '-' }} / {{ record.total_mem_mb || '-' }} MB
+            </template>
+          </a-table-column>
+          <a-table-column title="系统" data-index="platform" :width="160" ellipsis tooltip />
+          <a-table-column title="完成/成功" :width="120">
+            <template #cell="{ record }">{{ record.total_completed }} / {{ record.total_success }}</template>
+          </a-table-column>
+          <a-table-column title="最后心跳" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.last_heartbeat_at) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-popconfirm content="确认禁用该客户端?" @ok="deleteClient(record)">
+                <a-button type="text" status="danger" size="small">禁用</a-button>
+              </a-popconfirm>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课客户端" />
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminQkClientDelete, adminQkClientList } from '@/api/qk'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+
+const data = ref([])
+const loading = ref(false)
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const getList = async (silent = false) => {
+  try {
+    if (!silent) loading.value = true
+    const res = await adminQkClientList()
+    if (!res || res.code !== 0) {
+      if (!silent) {
+        Notification.error({ title: '获取客户端失败', content: res?.msg || '请稍后再试' })
+      }
+      return
+    }
+    data.value = res.data || []
+  } catch (error) {
+    if (!silent) {
+      Notification.error({ title: '获取客户端失败', content: error.message || '请稍后再试' })
+    }
+  } finally {
+    if (!silent) loading.value = false
+  }
+}
+
+const deleteClient = async (record) => {
+  const res = await adminQkClientDelete({ client_id: record.client_id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '禁用客户端失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已禁用' })
+  getList()
+}
+
+let pollTimer = null
+
+const startPolling = () => {
+  if (!pollTimer) {
+    pollTimer = setInterval(() => getList(true), 5000)
+  }
+}
+
+const stopPolling = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+}
+
+onMounted(() => {
+  getList()
+  startPolling()
+})
+
+onUnmounted(() => {
+  stopPolling()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.info-alert {
+  margin-bottom: 16px;
+}
+
+.table {
+  margin-top: 4px;
+}
+</style>

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

@@ -0,0 +1,313 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课回报记录">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <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-row :gutter="16">
+                <a-col :span="12">
+                  <a-form-item field="name" label="姓名">
+                    <a-input v-model="query.name" placeholder="任务备注姓名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <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="12">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="query.username" placeholder="平台用户名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="client_label" label="客户端名称">
+                    <a-input v-model="query.client_label" placeholder="客户端备注名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="task_id" label="任务 ID">
+                    <a-input v-model="query.task_id" placeholder="任务 ID" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="client_id" label="客户端 ID">
+                    <a-input v-model="query.client_id" placeholder="client_id" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="event" label="回报类型">
+                    <a-select v-model="query.event" placeholder="全部" allow-clear>
+                      <a-option value="request_result">单次请求</a-option>
+                      <a-option value="progress_snapshot">定时进度</a-option>
+                      <a-option value="grab_success">抢课成功</a-option>
+                      <a-option value="grab_fail">抢课失败</a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="queryTime" label="回报时间">
+                    <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 120px" 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="reset">
+                <template #icon><icon-refresh /></template>
+                重置
+              </a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :scroll="{ x: 1500 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="任务" :width="240">
+            <template #cell="{ record }">
+              <div>#{{ record.task_id }} {{ record.task_name || '' }}</div>
+              <div class="sub-text">学号:{{ record.student_num || '-' }}</div>
+              <div v-if="record.username" class="sub-text">用户:{{ record.username }}</div>
+            </template>
+          </a-table-column>
+          <a-table-column title="客户端" :width="220">
+            <template #cell="{ record }">
+              <div>{{ record.client_label || record.client_id || '-' }}</div>
+              <div class="sub-text">{{ record.client_id || '-' }}</div>
+            </template>
+          </a-table-column>
+          <a-table-column title="类型" data-index="event" :width="120">
+            <template #cell="{ record }">
+              <a-tag :color="eventColor(record.event)">{{ eventLabel(record.event) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="回报信息" data-index="message" ellipsis tooltip />
+          <a-table-column title="任务状态" data-index="task_status" :width="100">
+            <template #cell="{ record }">
+              {{ taskStatusLabel(record.task_status) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="回报时间" :width="180">
+            <template #cell="{ record }">
+              {{ formatTime(record.create_time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="详情" :width="90" fixed="right">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无回报记录" />
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="detailVisible" title="回报详情" :footer="false" width="720px">
+      <a-descriptions :column="1" bordered size="small">
+        <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="类型">{{ eventLabel(detail.event) }}</a-descriptions-item>
+        <a-descriptions-item label="信息">{{ detail.message || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="时间">{{ formatTime(detail.create_time) }}</a-descriptions-item>
+        <a-descriptions-item label="原始数据">
+          <pre class="payload-pre">{{ formatPayload(detail.payload_json) }}</pre>
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
+import Breadcrumb from '@/components/Breadcrumb/index.vue'
+import { adminQkReportList } from '@/api/qk'
+
+const loading = ref(false)
+const data = ref([])
+const detailVisible = ref(false)
+const detail = ref({})
+const pagination = reactive({
+  current: 1,
+  pagesize: 20,
+  total: 0
+})
+const query = reactive({
+  name: '',
+  student_num: '',
+  username: '',
+  client_label: '',
+  task_id: '',
+  client_id: '',
+  event: '',
+  queryTime: []
+})
+
+const eventMap = {
+  request_result: '单次请求',
+  progress_snapshot: '定时进度',
+  grab_success: '抢课成功',
+  grab_fail: '抢课失败'
+}
+
+const taskStatusMap = {
+  pending: '待分配',
+  assigned: '已分配',
+  running: '抢课中',
+  success: '成功',
+  failed: '失败',
+  cancelled: '已取消'
+}
+
+function eventLabel(event) {
+  return eventMap[event] || event || '-'
+}
+
+function eventColor(event) {
+  if (event === 'grab_success') return 'green'
+  if (event === 'grab_fail') return 'red'
+  if (event === 'progress_snapshot') return 'orange'
+  return 'blue'
+}
+
+function taskStatusLabel(status) {
+  return taskStatusMap[status] || status || '-'
+}
+
+function formatTime(value) {
+  if (!value) return '-'
+  return new Date(Number(value)).toLocaleString()
+}
+
+function formatPayload(payload) {
+  if (!payload) return '-'
+  try {
+    return JSON.stringify(payload, null, 2)
+  } catch (_) {
+    return String(payload)
+  }
+}
+
+function buildParams() {
+  const params = {
+    current: pagination.current,
+    pagesize: pagination.pagesize
+  }
+  if (query.name) params.name = query.name
+  if (query.student_num) params.student_num = query.student_num
+  if (query.username) params.username = query.username
+  if (query.client_label) params.client_label = query.client_label
+  if (query.task_id) params.task_id = query.task_id
+  if (query.client_id) params.client_id = query.client_id
+  if (query.event) params.event = query.event
+  if (Array.isArray(query.queryTime) && query.queryTime.length === 2) {
+    params.start_time = query.queryTime[0]
+    params.end_time = query.queryTime[1]
+  }
+  return params
+}
+
+function fetchList() {
+  loading.value = true
+  adminQkReportList(buildParams())
+    .then((res) => {
+      if (res?.code === 0) {
+        data.value = res.data?.list || []
+        pagination.total = res.data?.total || 0
+        pagination.current = res.data?.current || pagination.current
+        pagination.pagesize = res.data?.pagesize || pagination.pagesize
+      } else {
+        Notification.error({ title: '获取回报记录失败', content: res?.msg || '请稍后再试' })
+      }
+    })
+    .catch((error) => {
+      Notification.error({ title: '获取回报记录失败', content: error.message || '请稍后再试' })
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+
+function search() {
+  pagination.current = 1
+  fetchList()
+}
+
+function reset() {
+  query.name = ''
+  query.student_num = ''
+  query.username = ''
+  query.client_label = ''
+  query.task_id = ''
+  query.client_id = ''
+  query.event = ''
+  query.queryTime = []
+  search()
+}
+
+function onPageChange(page) {
+  pagination.current = page
+  fetchList()
+}
+
+function onPageSizeChange(size) {
+  pagination.pagesize = size
+  pagination.current = 1
+  fetchList()
+}
+
+function openDetail(record) {
+  detail.value = record
+  detailVisible.value = true
+}
+
+onMounted(() => {
+  fetchList()
+})
+</script>
+
+<style scoped>
+.sub-text {
+  color: var(--color-text-3);
+  font-size: 12px;
+}
+
+.payload-pre {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-height: 320px;
+  overflow: auto;
+}
+</style>

+ 468 - 0
src/pages/admin/qkTask/index.vue

@@ -0,0 +1,468 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课任务监控">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <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-row :gutter="16">
+                <a-col :span="12">
+                  <a-form-item field="status" label="状态">
+                    <a-select v-model="query.status" placeholder="全部" allow-clear>
+                      <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="12">
+                  <a-form-item field="client_id" label="客户端">
+                    <a-input v-model="query.client_id" placeholder="client_id" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <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="12">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="query.username" placeholder="平台用户名" allow-clear />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 84px" 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="reset">
+                <template #icon><icon-refresh /></template>
+                重置
+              </a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :scroll="{ x: 1880 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="创建用户" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.avatar || defaultAvatar" alt="" />
+                </a-avatar>
+                {{ record.username || record.create_user || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <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="password" :width="140" ellipsis tooltip />
+          <a-table-column title="客户端" data-index="assigned_client_id" :width="180" ellipsis tooltip />
+          <a-table-column title="状态" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="课程" :width="260">
+            <template #cell="{ record }">{{ [...(record.courses || []), ...(record.course_groups || [])].join('、') || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="错误" data-index="error_msg" :width="220" ellipsis tooltip />
+          <a-table-column title="创建时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column v-if="canManage()" title="操作" :width="220" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-space>
+                <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-popconfirm>
+                <a-popconfirm :content="cancelConfirmText(record)" @ok="cancelTask(record)">
+                  <a-button type="text" status="danger" size="small" :disabled="!canCancel(record)">取消</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课任务" />
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      title="编辑抢课任务"
+      :width="isMobile ? '100%' : '720px'"
+      draggable
+      esc-to-close
+      :ok-loading="saving"
+      @ok="submitTask"
+    >
+      <a-alert v-if="editingAssigned" type="warning" class="edit-alert">
+        该任务正在客户端执行,保存后将收回并重新进入待分配队列。
+      </a-alert>
+      <a-form :model="form" layout="vertical" class="task-form">
+        <a-form-item label="备注名" required>
+          <a-input v-model="form.name" placeholder="例如:张三选修课" allow-clear />
+        </a-form-item>
+        <a-form-item label="选课批次 ID" required>
+          <a-input v-model="form.jx0502zbid" placeholder="jx0502zbid" allow-clear />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="学号" required>
+              <a-input v-model="form.student_num" allow-clear />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="教务密码">
+              <a-input v-model="form.password" placeholder="明文显示,不填则不修改" allow-clear />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-form-item label="课程名称(每行一个,精确匹配)">
+          <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
+        </a-form-item>
+        <a-form-item label="课程分组(每行一个,例如羽毛球)">
+          <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="抢课间隔(ms)">
+              <a-input-number v-model="form.interval_ms" :min="200" :max="10000" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="启用公选课接口">
+              <a-switch v-model="form.enable_ggxxk" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted, onUnmounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import {
+  adminQkTaskList,
+  adminQkTaskDetail,
+  adminQkTaskUpdate,
+  adminQkTaskCancel,
+  adminQkTaskRetry
+} from '@/api/qk'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+import { hasPermission } from '@/utils/permission'
+
+const { tableFixed, isMobile } = useResponsiveTable()
+const canManage = () => hasPermission('action.qk.admin.taskManage')
+const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
+
+const loading = ref(false)
+const saving = ref(false)
+const visible = ref(false)
+const editingAssigned = ref(false)
+const data = ref([])
+const query = reactive({
+  status: '',
+  client_id: '',
+  student_num: '',
+  username: ''
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+const form = reactive({
+  id: '',
+  name: '',
+  jx0502zbid: '',
+  student_num: '',
+  password: '',
+  coursesText: '',
+  courseGroupsText: '',
+  enable_ggxxk: true,
+  interval_ms: 500
+})
+
+const splitLines = (value) => String(value || '').split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const statusText = (status) => ({
+  pending: '待分配',
+  assigned: '已分配',
+  running: '抢课中',
+  success: '成功',
+  failed: '失败',
+  cancelled: '已取消'
+}[status] || status)
+
+const statusColor = (status) => ({
+  pending: 'blue',
+  assigned: 'arcoblue',
+  running: 'orange',
+  success: 'green',
+  failed: 'red',
+  cancelled: 'gray'
+}[status] || 'gray')
+
+const canEdit = (record) => record.status !== 'success'
+const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
+const canCancel = (record) => ['pending', 'assigned', 'running', 'failed'].includes(record.status)
+
+const cancelConfirmText = (record) => {
+  if (['assigned', 'running'].includes(record.status)) {
+    return '该任务正在执行,确认取消并收回?'
+  }
+  return '确认取消该抢课任务?'
+}
+
+const resetForm = () => {
+  Object.assign(form, {
+    id: '',
+    name: '',
+    jx0502zbid: '',
+    student_num: '',
+    password: '',
+    coursesText: '',
+    courseGroupsText: '',
+    enable_ggxxk: true,
+    interval_ms: 500
+  })
+  editingAssigned.value = false
+}
+
+const openModal = async (record) => {
+  resetForm()
+  try {
+    const res = await adminQkTaskDetail({ id: record.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '获取任务详情失败', content: res?.msg || '请稍后再试' })
+    }
+    const task = res.data || record
+    editingAssigned.value = ['assigned', 'running'].includes(task.status)
+    Object.assign(form, {
+      id: task.id,
+      name: task.name,
+      jx0502zbid: task.jx0502zbid,
+      student_num: task.student_num,
+      password: task.password || '',
+      coursesText: (task.courses || []).join('\n'),
+      courseGroupsText: (task.course_groups || []).join('\n'),
+      enable_ggxxk: task.enable_ggxxk,
+      interval_ms: task.interval_ms
+    })
+    visible.value = true
+  } catch (error) {
+    Notification.error({ title: '获取任务详情失败', content: error.message || '请稍后再试' })
+  }
+}
+
+const getList = async (silent = false) => {
+  try {
+    if (!silent) loading.value = true
+    const res = await adminQkTaskList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      if (!silent) {
+        Notification.error({ title: '获取抢课任务失败', content: res?.msg || '请稍后再试' })
+      }
+      return
+    }
+    data.value = res.data?.list || []
+    pagination.total = res.data?.total || 0
+  } catch (error) {
+    if (!silent) {
+      Notification.error({ title: '获取抢课任务失败', content: error.message || '请稍后再试' })
+    }
+  } finally {
+    if (!silent) loading.value = false
+  }
+}
+
+const submitTask = async () => {
+  try {
+    saving.value = true
+    const res = await adminQkTaskUpdate({
+      id: form.id,
+      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
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '保存失败', content: res?.msg || '请稍后再试' })
+    }
+    Notification.success({ title: '保存成功' })
+    visible.value = false
+    getList()
+  } catch (error) {
+    Notification.error({ title: '保存失败', content: error.message || '请稍后再试' })
+  } finally {
+    saving.value = false
+  }
+}
+
+const cancelTask = async (record) => {
+  const res = await adminQkTaskCancel({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '取消失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已取消' })
+  getList()
+}
+
+const retryTask = async (record) => {
+  const res = await adminQkTaskRetry({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '重试失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已重新加入待分配队列' })
+  getList()
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.status = ''
+  query.client_id = ''
+  query.student_num = ''
+  query.username = ''
+  search()
+}
+
+const onPageChange = (current) => {
+  pagination.current = current
+  getList()
+}
+
+const onPageSizeChange = (pageSize) => {
+  pagination.pagesize = pageSize
+  pagination.current = 1
+  getList()
+}
+
+let pollTimer = null
+
+const startPolling = () => {
+  if (!pollTimer) {
+    pollTimer = setInterval(() => getList(true), 5000)
+  }
+}
+
+const stopPolling = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+}
+
+onMounted(() => {
+  getList()
+  startPolling()
+})
+
+onUnmounted(() => {
+  stopPolling()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 16px;
+}
+
+.edit-alert {
+  margin-bottom: 16px;
+}
+
+@media (max-width: 768px) {
+  .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>

+ 312 - 0
src/pages/admin/qxsAccount/index.vue

@@ -0,0 +1,312 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="趣选书查询管理">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <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-row :gutter="16">
+                <a-col :span="12">
+                  <a-form-item field="username" label="趣选书账号">
+                    <a-input v-model="query.username" placeholder="登录账号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="realname" label="姓名">
+                    <a-input v-model="query.realname" placeholder="真实姓名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="email" label="邮箱">
+                    <a-input v-model="query.email" placeholder="邮箱" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="mobile" label="手机号">
+                    <a-input v-model="query.mobile" placeholder="手机号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="className" label="班级">
+                    <a-input v-model="query.className" placeholder="班级名称" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="termName" label="学期">
+                    <a-input v-model="query.termName" placeholder="学期名称" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="queryTime" label="查询时间">
+                    <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 84px" 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">搜索</a-button>
+              <a-button @click="reset">重置</a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        :bordered="false"
+        class="table"
+        :data="data"
+        :loading="loading"
+        :scroll="{ x: 1320 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="趣选书账号" data-index="username" :width="160" ellipsis tooltip />
+          <a-table-column title="姓名" data-index="realname" :width="120" ellipsis tooltip />
+          <a-table-column title="班级" data-index="className" :width="160" ellipsis tooltip />
+          <a-table-column title="邮箱" data-index="email" :width="180" ellipsis tooltip />
+          <a-table-column title="手机号" data-index="mobile" :width="130" ellipsis tooltip />
+          <a-table-column title="学期" data-index="termName" :width="180" ellipsis tooltip />
+          <a-table-column title="书籍数量" data-index="book_count" :width="100" />
+          <a-table-column title="查询时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="openDetail(record)">查看书单</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="detailVisible"
+      :title="detailTitle"
+      width="960px"
+      hide-cancel
+      esc-to-close
+      draggable
+    >
+      <a-descriptions v-if="detail" :column="2" bordered size="medium" style="margin-bottom: 16px">
+        <a-descriptions-item label="账号">{{ detail.username }}</a-descriptions-item>
+        <a-descriptions-item label="姓名">{{ detail.realname || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="班级">{{ detail.className || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="邮箱">{{ detail.email || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="手机号">{{ detail.mobile || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="学期" :span="2">{{ detail.termName || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="查询时间" :span="2">{{ formatTime(detail.create_time) }}</a-descriptions-item>
+      </a-descriptions>
+
+      <a-table
+        :bordered="false"
+        :data="detail?.bookList || []"
+        :loading="detailLoading"
+        :pagination="false"
+        :scroll="{ x: 900 }"
+        stripe
+      >
+        <template #columns>
+          <a-table-column title="封面" :width="80">
+            <template #cell="{ record }">
+              <a-image v-if="record.imgUrl" width="50" height="50" fit="contain" :src="record.imgUrl" />
+              <a-avatar v-else shape="square" :size="50">
+                <icon-book />
+              </a-avatar>
+            </template>
+          </a-table-column>
+          <a-table-column title="书名" data-index="title" :width="220" ellipsis tooltip />
+          <a-table-column title="ISBN" data-index="isbn" :width="150" ellipsis tooltip />
+          <a-table-column title="课程" :width="180" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.crouseName || record.courseName || '-' }}
+              <span v-if="record.courseType">({{ record.courseType }})</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="价格" :width="120">
+            <template #cell="{ record }">
+              ¥{{ record.textbookDiscountedPrice ?? record.totalDiscountedPrice ?? record.textbookPrice ?? '-' }}
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, computed, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminQxsAccountList, adminQxsAccountDetail } from '@/api/qxs'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+const loading = ref(false)
+const data = ref([])
+const query = reactive({
+  username: '',
+  realname: '',
+  email: '',
+  mobile: '',
+  className: '',
+  termName: '',
+  queryTime: []
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detail = ref(null)
+
+const detailTitle = computed(() => {
+  if (!detail.value) return '书单详情'
+  return `书单详情 · ${detail.value.realname || detail.value.username || ''}`
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await adminQxsAccountList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取查询记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取查询记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const openDetail = async (record) => {
+  try {
+    detailLoading.value = true
+    detailVisible.value = true
+    detail.value = null
+    const res = await adminQxsAccountDetail({ id: record.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取书单详情失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    detail.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取书单详情失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.username = ''
+  query.realname = ''
+  query.email = ''
+  query.mobile = ''
+  query.className = ''
+  query.termName = ''
+  query.queryTime = []
+  pagination.current = 1
+  getList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 16px;
+}
+
+@media (max-width: 768px) {
+  .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>

+ 1 - 1
src/pages/doc/index.vue

@@ -40,7 +40,7 @@
               <h3 class="doc-card__title">{{ item.title }}</h3>
               <h3 class="doc-card__title">{{ item.title }}</h3>
               <p v-if="item.describe" class="doc-card__desc">{{ item.describe }}</p>
               <p v-if="item.describe" class="doc-card__desc">{{ item.describe }}</p>
               <div class="doc-card__meta">
               <div class="doc-card__meta">
-                <span>{{ item.author || 'RunForge' }}</span>
+                <!-- <span>{{ item.author || 'RunForge' }}</span> -->
                 <span>{{ formatTime(item.time) }}</span>
                 <span>{{ formatTime(item.time) }}</span>
                 <!-- <span>{{ item.views ?? 0 }} 次阅读</span> -->
                 <!-- <span>{{ item.views ?? 0 }} 次阅读</span> -->
               </div>
               </div>

+ 332 - 0
src/pages/qk/taskList.vue

@@ -0,0 +1,332 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="抢课任务">
+      <div class="buttonGroup buttonGroup-desktop">
+        <a-button type="primary" size="large" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          提交任务
+        </a-button>
+        <a-button size="large" @click="refreshList()">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <div class="buttonGroup buttonGroup-mobile">
+        <a-button type="primary" size="large" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          提交任务
+        </a-button>
+        <a-button size="large" @click="refreshList()">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-table
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :scroll="{ x: 1200 }"
+        :pagination="false"
+      >
+        <template #columns>
+          <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="student_num" :width="130" />
+          <a-table-column title="选课批次" data-index="jx0502zbid" :width="220" ellipsis tooltip />
+          <a-table-column title="目标课程" :width="260">
+            <template #cell="{ record }">
+              <a-space wrap>
+                <a-tag v-for="item in record.courses || []" :key="item">{{ item }}</a-tag>
+                <a-tag v-for="item in record.course_groups || []" :key="item" color="purple">{{ item }}</a-tag>
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" :width="110">
+            <template #cell="{ record }">
+              <a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="结果" :width="220" ellipsis tooltip>
+            <template #cell="{ record }">{{ resultText(record) }}</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="150" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button type="text" size="small" @click="openModal(record)">编辑</a-button>
+                <a-popconfirm content="确认取消该抢课任务?" @ok="cancelTask(record)">
+                  <a-button type="text" status="danger" size="small" :disabled="!canCancel(record)">取消</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课任务">
+            <a-button type="primary" @click="openModal()">提交第一个任务</a-button>
+          </a-empty>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="form.id ? '编辑抢课任务' : '提交抢课任务'"
+      :width="isMobile ? '100%' : '720px'"
+      draggable
+      esc-to-close
+      @ok="submitTask"
+    >
+      <a-form :model="form" layout="vertical" class="task-form">
+        <a-form-item label="备注名" required>
+          <a-input v-model="form.name" placeholder="例如:张三选修课" allow-clear />
+        </a-form-item>
+        <a-form-item label="选课批次 ID" required>
+          <a-input v-model="form.jx0502zbid" placeholder="jx0502zbid" allow-clear />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="学号" required>
+              <a-input v-model="form.student_num" allow-clear />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="教务密码" :required="!form.id">
+              <a-input-password v-model="form.password" :placeholder="form.id ? '不填则不修改' : '请输入教务密码'" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-form-item label="课程名称(每行一个,精确匹配)">
+          <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
+        </a-form-item>
+        <a-form-item label="课程分组(每行一个,例如羽毛球)">
+          <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="抢课间隔(ms)">
+              <a-input-number v-model="form.interval_ms" :min="200" :max="10000" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="启用公选课接口">
+              <a-switch v-model="form.enable_ggxxk" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted, onUnmounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getQkTask, saveQkTask, deleteQkTask } from '@/api/qk'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed, isMobile } = useResponsiveTable()
+
+const loading = ref(false)
+const visible = ref(false)
+const data = ref([])
+const form = reactive({
+  id: '',
+  name: '',
+  jx0502zbid: '',
+  student_num: '',
+  password: '',
+  coursesText: '',
+  courseGroupsText: '',
+  enable_ggxxk: true,
+  interval_ms: 500
+})
+
+const splitLines = (value) => String(value || '').split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const statusText = (status) => ({
+  pending: '待分配',
+  assigned: '已分配',
+  running: '抢课中',
+  success: '成功',
+  failed: '失败',
+  cancelled: '已取消'
+}[status] || status)
+
+const statusColor = (status) => ({
+  pending: 'blue',
+  assigned: 'arcoblue',
+  running: 'orange',
+  success: 'green',
+  failed: 'red',
+  cancelled: 'gray'
+}[status] || 'gray')
+
+const canCancel = (record) => ['pending', 'failed'].includes(record.status)
+const resultText = (record) => record.error_msg || record.result_json?.message || record.result_json?.course || '-'
+
+const resetForm = () => {
+  Object.assign(form, {
+    id: '',
+    name: '',
+    jx0502zbid: '',
+    student_num: '',
+    password: '',
+    coursesText: '',
+    courseGroupsText: '',
+    enable_ggxxk: true,
+    interval_ms: 500
+  })
+}
+
+const openModal = (record) => {
+  resetForm()
+  if (record) {
+    Object.assign(form, {
+      id: record.id,
+      name: record.name,
+      jx0502zbid: record.jx0502zbid,
+      student_num: record.student_num,
+      coursesText: (record.courses || []).join('\n'),
+      courseGroupsText: (record.course_groups || []).join('\n'),
+      enable_ggxxk: record.enable_ggxxk,
+      interval_ms: record.interval_ms
+    })
+  }
+  visible.value = true
+}
+
+const getList = async (silent = false) => {
+  try {
+    if (!silent) loading.value = true
+    const res = await getQkTask()
+    if (!res || res.code !== 0) {
+      if (!silent) {
+        Notification.error({ title: '获取抢课任务失败', content: res?.msg || '请稍后再试' })
+      }
+      return
+    }
+    data.value = res.data || []
+  } catch (error) {
+    if (!silent) {
+      Notification.error({ title: '获取抢课任务失败', content: error.message || '请稍后再试' })
+    }
+  } finally {
+    if (!silent) loading.value = false
+  }
+}
+
+const refreshList = () => getList()
+
+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 res = await saveQkTask(payload)
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '保存抢课任务失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '保存成功' })
+  visible.value = false
+  getList()
+}
+
+const cancelTask = async (record) => {
+  const res = await deleteQkTask({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '取消失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已取消' })
+  getList()
+}
+
+let pollTimer = null
+
+const startPolling = () => {
+  if (!pollTimer) {
+    pollTimer = setInterval(() => getList(true), 5000)
+  }
+}
+
+const stopPolling = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+}
+
+onMounted(() => {
+  getList()
+  startPolling()
+})
+
+onUnmounted(() => {
+  stopPolling()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.buttonGroup {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.buttonGroup-mobile {
+  display: none;
+}
+
+.table {
+  margin-top: 4px;
+}
+
+.task-form {
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+@media (max-width: 768px) {
+  .buttonGroup-desktop {
+    display: none;
+  }
+
+  .buttonGroup-mobile {
+    display: flex;
+    align-items: center;
+  }
+
+  .buttonGroup-mobile :deep(.arco-btn) {
+    flex: 1;
+  }
+}
+</style>

+ 89 - 0
src/router/index.js

@@ -421,9 +421,98 @@ const routes = [
                     title: '请求日志',
                     title: '请求日志',
                     permission: ['admin']
                     permission: ['admin']
                 }
                 }
+            },
+            {
+                path: 'powerTask',
+                name: 'admin.powerTask',
+                component: () => import('../pages/admin/powerTask/index.vue'),
+                meta: {
+                    title: '电费任务',
+                    permission: ['admin', 'service']
+                }
+            },
+            {
+                path: 'qxsAccount',
+                name: 'admin.qxsAccount',
+                component: () => import('../pages/admin/qxsAccount/index.vue'),
+                meta: {
+                    title: '趣选书管理',
+                    permission: ['admin', 'service']
+                }
+            }
+        ]
+    },
+    {
+        path: '/qkManage',
+        name: 'qkManage',
+        redirect: '/qkManage/task',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '抢课管理',
+            icon: 'icon-schedule',
+            permission: ['admin', 'service'],
+            permissionCodes: [
+                'page.qk.taskList',
+                'page.admin.qk.client',
+                'page.admin.qk.task',
+                'page.admin.qk.report'
+            ]
+        },
+        children: [
+            {
+                path: 'assistant',
+                name: 'qkManage.assistant',
+                component: () => import('../pages/qk/taskList.vue'),
+                meta: {
+                    title: '抢课助手',
+                    permission: ['admin'],
+                    permissionCode: 'page.qk.taskList'
+                }
+            },
+            {
+                path: 'client',
+                name: 'qkManage.client',
+                component: () => import('../pages/admin/qkClient/index.vue'),
+                meta: {
+                    title: '抢课客户端',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.qk.client'
+                }
+            },
+            {
+                path: 'task',
+                name: 'qkManage.task',
+                component: () => import('../pages/admin/qkTask/index.vue'),
+                meta: {
+                    title: '抢课任务',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.qk.task'
+                }
+            },
+            {
+                path: 'report',
+                name: 'qkManage.report',
+                component: () => import('../pages/admin/qkReport/index.vue'),
+                meta: {
+                    title: '回报记录',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.qk.report'
+                }
             }
             }
         ]
         ]
     },
     },
+    {
+        path: '/admin/qkAssistant',
+        redirect: '/qkManage/assistant'
+    },
+    {
+        path: '/admin/qkClient',
+        redirect: '/qkManage/client'
+    },
+    {
+        path: '/admin/qkTask',
+        redirect: '/qkManage/task'
+    },
     {
     {
         path: '/noticeManage',
         path: '/noticeManage',
         name: 'noticeManage',
         name: 'noticeManage',