|
|
@@ -1,937 +0,0 @@
|
|
|
-<template>
|
|
|
- <div class="store-page">
|
|
|
- <Breadcrumb />
|
|
|
- <a-card title="抢课任务监控">
|
|
|
- <div class="stats-section">
|
|
|
- <a-row :gutter="[16, 16]" class="stats-row" align="stretch">
|
|
|
- <a-col :xs="24" :md="10" :lg="8" class="stats-col">
|
|
|
- <div class="stats-panel stats-highlight">
|
|
|
- <a-progress
|
|
|
- type="circle"
|
|
|
- :percent="summary.activeRate / 100"
|
|
|
- :stroke-width="8"
|
|
|
- :width="isMobile ? 96 : 108"
|
|
|
- :color="activeRateColor"
|
|
|
- >
|
|
|
- <template #text>
|
|
|
- <div class="progress-text">
|
|
|
- <div class="progress-value">{{ summary.activeRate }}%</div>
|
|
|
- <div class="progress-label">活跃占比</div>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- </a-progress>
|
|
|
- <div class="stats-highlight-meta">
|
|
|
- <div class="highlight-title">任务执行概况</div>
|
|
|
- <div class="highlight-desc">
|
|
|
- 抢课中 <strong>{{ summary.grabbing }}</strong> · 待分配 <strong>{{ summary.pending }}</strong>
|
|
|
- </div>
|
|
|
- <div class="highlight-desc">
|
|
|
- 任务总数 <strong>{{ summary.total }}</strong> · 未开始 <strong>{{ summary.paused }}</strong>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="24" :md="14" :lg="16" class="stats-col">
|
|
|
- <div class="stats-panel stats-grid-panel">
|
|
|
- <a-row :gutter="[12, 12]" class="stats-grid">
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="任务总数" :value="summary.total" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="抢课中" :value="summary.grabbing" value-style="color: rgb(var(--orange-6))" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="待分配" :value="summary.pending" value-style="color: rgb(var(--arcoblue-6))" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="未开始" :value="summary.paused" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="成功" :value="summary.success" value-style="color: rgb(var(--green-6))" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="失败" :value="summary.failed" value-style="color: rgb(var(--red-6))" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="已取消" :value="summary.cancelled" />
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- <a-col :xs="12" :sm="8" :md="6">
|
|
|
- <div class="stat-card">
|
|
|
- <a-statistic title="剩余槽位" :value="summary.freeSlots" value-style="color: rgb(var(--green-6))" />
|
|
|
- <div class="stat-sub">占用 {{ summary.usedSlots }} / {{ summary.totalSlots }}</div>
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- </a-row>
|
|
|
- </div>
|
|
|
- </a-col>
|
|
|
- </a-row>
|
|
|
- </div>
|
|
|
-
|
|
|
- <AppQueryFilter class="query-filter">
|
|
|
- <a-row class="query-row">
|
|
|
- <a-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="paused">未开始</a-option>
|
|
|
- <a-option value="pending">待分配</a-option>
|
|
|
- <a-option value="assigned">已分配</a-option>
|
|
|
- <a-option value="running">抢课中</a-option>
|
|
|
- <a-option value="success">成功</a-option>
|
|
|
- <a-option value="failed">失败</a-option>
|
|
|
- <a-option value="cancelled">已取消</a-option>
|
|
|
- </a-select>
|
|
|
- </a-form-item>
|
|
|
- </a-col>
|
|
|
- <a-col :span="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-col :span="12">
|
|
|
- <a-form-item field="course_name" label="课程名称">
|
|
|
- <a-input v-model="query.course_name" placeholder="模糊匹配课程或分组" allow-clear />
|
|
|
- </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
|
|
|
- row-key="id"
|
|
|
- :data="data"
|
|
|
- :bordered="false"
|
|
|
- hoverable
|
|
|
- class="table"
|
|
|
- :loading="loading"
|
|
|
- :scroll="{ x: isMobile ? 1200 : 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="student_num" :width="130" />
|
|
|
- <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 }">
|
|
|
- <AppTableEllipsis :row-key="`${record.id}-course`" :content="formatCourseList(record)" />
|
|
|
- </template>
|
|
|
- </a-table-column>
|
|
|
- <a-table-column title="错误" :width="220">
|
|
|
- <template #cell="{ record }">
|
|
|
- <AppTableEllipsis :row-key="`${record.id}-error`" :content="record.error_msg || '-'" />
|
|
|
- </template>
|
|
|
- </a-table-column>
|
|
|
- <a-table-column title="备注" :width="140" ellipsis tooltip>
|
|
|
- <template #cell="{ record }">{{ record.name || '-' }}</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="180">
|
|
|
- <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
|
|
|
- </a-table-column>
|
|
|
- <a-table-column v-if="canManage()" title="操作" :width="300" :fixed="tableFixed('right')">
|
|
|
- <template #cell="{ record }">
|
|
|
- <a-space wrap>
|
|
|
- <a-button
|
|
|
- v-if="canStart(record)"
|
|
|
- :key="`${record.id}-start`"
|
|
|
- type="text"
|
|
|
- size="small"
|
|
|
- status="success"
|
|
|
- @click="startTask(record)"
|
|
|
- >
|
|
|
- 开启
|
|
|
- </a-button>
|
|
|
- <a-button
|
|
|
- v-if="canPause(record)"
|
|
|
- :key="`${record.id}-pause`"
|
|
|
- type="text"
|
|
|
- size="small"
|
|
|
- status="warning"
|
|
|
- @click="pauseTask(record)"
|
|
|
- >
|
|
|
- 暂停
|
|
|
- </a-button>
|
|
|
- <a-button
|
|
|
- v-if="canEdit(record)"
|
|
|
- :key="`${record.id}-edit`"
|
|
|
- type="text"
|
|
|
- size="small"
|
|
|
- @click="openModal(record)"
|
|
|
- >
|
|
|
- 编辑
|
|
|
- </a-button>
|
|
|
- <a-popconfirm
|
|
|
- v-if="canRetry(record)"
|
|
|
- :key="`${record.id}-retry`"
|
|
|
- content="确认重试该任务?将重新进入待分配队列。"
|
|
|
- @ok="retryTask(record)"
|
|
|
- >
|
|
|
- <a-button type="text" size="small">重试</a-button>
|
|
|
- </a-popconfirm>
|
|
|
- <a-popconfirm
|
|
|
- v-if="canCancel(record)"
|
|
|
- :key="`${record.id}-cancel`"
|
|
|
- :content="cancelConfirmText(record)"
|
|
|
- @ok="cancelTask(record)"
|
|
|
- >
|
|
|
- <a-button type="text" status="danger" size="small">取消</a-button>
|
|
|
- </a-popconfirm>
|
|
|
- </a-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="备注">
|
|
|
- <a-textarea
|
|
|
- v-model="form.name"
|
|
|
- no-trim
|
|
|
- placeholder="添加对任务的备注(非必填)"
|
|
|
- :max-length="{ length: 50 }"
|
|
|
- allow-clear
|
|
|
- show-word-limit
|
|
|
- :auto-size="{ minRows: 2, maxRows: 4 }"
|
|
|
- />
|
|
|
- </a-form-item>
|
|
|
- <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 label="统一身份认证账号" required>
|
|
|
- <a-select
|
|
|
- v-model="form.jw_account_id"
|
|
|
- placeholder="请选择该用户已验证的统一身份认证账号"
|
|
|
- allow-search
|
|
|
- allow-clear
|
|
|
- :loading="jwAccountLoading"
|
|
|
- >
|
|
|
- <a-option v-for="item in jwAccounts" :key="item.id" :value="item.id">
|
|
|
- {{ item.username }}({{ item.realname || '未知' }})
|
|
|
- </a-option>
|
|
|
- </a-select>
|
|
|
- </a-form-item>
|
|
|
- <a-form-item label="课程名称(每行一个,精确匹配)">
|
|
|
- <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
|
|
|
- <template #extra>{{ targetHint }}</template>
|
|
|
- </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="minInterval" :max="10000" style="width: 100%" />
|
|
|
- <template #extra>当前最低 {{ minInterval }}ms</template>
|
|
|
- </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-col :xs="24" :sm="12">
|
|
|
- <a-form-item label="登录失效后自动重新登录">
|
|
|
- <a-switch v-model="form.auto_relogin" />
|
|
|
- <template #extra>开启后抢课过程中会话失效将自动重新登录并继续</template>
|
|
|
- </a-form-item>
|
|
|
- </a-col>
|
|
|
- </a-row>
|
|
|
- </a-form>
|
|
|
- </a-modal>
|
|
|
- </div>
|
|
|
-</template>
|
|
|
-
|
|
|
-<script setup>
|
|
|
-import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
|
|
|
-import { Notification } from '@arco-design/web-vue'
|
|
|
-import {
|
|
|
- adminQkClientList,
|
|
|
- adminQkTaskList,
|
|
|
- adminQkTaskDetail,
|
|
|
- adminQkTaskUpdate,
|
|
|
- adminQkTaskCancel,
|
|
|
- adminQkTaskRetry,
|
|
|
- adminQkTaskStart,
|
|
|
- adminQkTaskPause,
|
|
|
- adminQkBatchList,
|
|
|
- adminGetJwAccount
|
|
|
-} from '@/api/qk'
|
|
|
-import { useResponsiveTable } from '@/hooks/useResponsiveTable'
|
|
|
-import { hasPermission } from '@/utils/permission'
|
|
|
-import {
|
|
|
- getMinQkInterval,
|
|
|
- getQkTargetHint,
|
|
|
- splitQkLines,
|
|
|
- validateQkTaskTargets
|
|
|
-} from '@/utils/qkTaskRules'
|
|
|
-
|
|
|
-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 batchOptions = ref([])
|
|
|
-const jwAccounts = ref([])
|
|
|
-const jwAccountLoading = ref(false)
|
|
|
-const query = reactive({
|
|
|
- status: '',
|
|
|
- client_id: '',
|
|
|
- student_num: '',
|
|
|
- username: '',
|
|
|
- course_name: ''
|
|
|
-})
|
|
|
-const pagination = reactive({
|
|
|
- total: 0,
|
|
|
- current: 1,
|
|
|
- pagesize: 20
|
|
|
-})
|
|
|
-const statusStats = reactive({
|
|
|
- total: 0,
|
|
|
- paused: 0,
|
|
|
- pending: 0,
|
|
|
- assigned: 0,
|
|
|
- running: 0,
|
|
|
- success: 0,
|
|
|
- failed: 0,
|
|
|
- cancelled: 0,
|
|
|
- usedSlots: 0,
|
|
|
- totalSlots: 0,
|
|
|
- freeSlots: 0
|
|
|
-})
|
|
|
-
|
|
|
-const summary = computed(() => {
|
|
|
- const grabbing = statusStats.running + statusStats.assigned
|
|
|
- const active = grabbing + statusStats.pending
|
|
|
- const total = statusStats.total || 0
|
|
|
- const activeRate = total > 0 ? Math.min(100, Math.round((active / total) * 100)) : 0
|
|
|
- return {
|
|
|
- total,
|
|
|
- paused: statusStats.paused,
|
|
|
- pending: statusStats.pending,
|
|
|
- grabbing,
|
|
|
- success: statusStats.success,
|
|
|
- failed: statusStats.failed,
|
|
|
- cancelled: statusStats.cancelled,
|
|
|
- activeRate,
|
|
|
- usedSlots: statusStats.usedSlots,
|
|
|
- totalSlots: statusStats.totalSlots,
|
|
|
- freeSlots: statusStats.freeSlots
|
|
|
- }
|
|
|
-})
|
|
|
-
|
|
|
-const activeRateColor = computed(() => {
|
|
|
- const rate = summary.value.activeRate
|
|
|
- if (rate >= 80) return '#F53F3F'
|
|
|
- if (rate >= 50) return '#FF7D00'
|
|
|
- return '#165DFF'
|
|
|
-})
|
|
|
-
|
|
|
-const form = reactive({
|
|
|
- id: '',
|
|
|
- name: '',
|
|
|
- batch_id: '',
|
|
|
- jw_account_id: undefined,
|
|
|
- create_user: '',
|
|
|
- coursesText: '',
|
|
|
- courseGroupsText: '',
|
|
|
- enable_ggxxk: true,
|
|
|
- auto_relogin: false,
|
|
|
- interval_ms: 500
|
|
|
-})
|
|
|
-
|
|
|
-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 formatCourseList = (record) => {
|
|
|
- const list = [...(record.courses || []), ...(record.course_groups || [])]
|
|
|
- return list.length ? list.join('、') : '-'
|
|
|
-}
|
|
|
-
|
|
|
-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) => ({
|
|
|
- paused: '未开始',
|
|
|
- pending: '待分配',
|
|
|
- assigned: '已分配',
|
|
|
- running: '抢课中',
|
|
|
- success: '成功',
|
|
|
- failed: '失败',
|
|
|
- cancelled: '已取消'
|
|
|
-}[status] || status)
|
|
|
-
|
|
|
-const statusColor = (status) => ({
|
|
|
- paused: 'gray',
|
|
|
- pending: 'blue',
|
|
|
- assigned: 'arcoblue',
|
|
|
- running: 'orange',
|
|
|
- success: 'green',
|
|
|
- failed: 'red',
|
|
|
- cancelled: 'gray'
|
|
|
-}[status] || 'gray')
|
|
|
-
|
|
|
-const canEdit = (record) => record.status !== 'success'
|
|
|
-const canStart = (record) => ['paused', 'failed'].includes(record.status)
|
|
|
-const canPause = (record) => ['pending', 'assigned', 'running'].includes(record.status)
|
|
|
-const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
|
|
|
-const canCancel = (record) => ['paused', '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: '',
|
|
|
- batch_id: '',
|
|
|
- jw_account_id: undefined,
|
|
|
- create_user: '',
|
|
|
- coursesText: '',
|
|
|
- courseGroupsText: '',
|
|
|
- enable_ggxxk: true,
|
|
|
- auto_relogin: false,
|
|
|
- interval_ms: 500
|
|
|
- })
|
|
|
- jwAccounts.value = []
|
|
|
- editingAssigned.value = false
|
|
|
-}
|
|
|
-
|
|
|
-const loadJwAccounts = async (createUser) => {
|
|
|
- if (!createUser) {
|
|
|
- jwAccounts.value = []
|
|
|
- return
|
|
|
- }
|
|
|
- jwAccountLoading.value = true
|
|
|
- try {
|
|
|
- const res = await adminGetJwAccount({ create_user: createUser })
|
|
|
- if (res?.code === 0) {
|
|
|
- jwAccounts.value = res.data || []
|
|
|
- }
|
|
|
- } finally {
|
|
|
- jwAccountLoading.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)
|
|
|
- await loadJwAccounts(task.create_user)
|
|
|
- Object.assign(form, {
|
|
|
- id: task.id,
|
|
|
- name: task.name,
|
|
|
- batch_id: task.batch_id || '',
|
|
|
- jw_account_id: task.jw_account_id || jwAccounts.value.find(item => item.username === task.student_num)?.id,
|
|
|
- create_user: task.create_user,
|
|
|
- coursesText: (task.courses || []).join('\n'),
|
|
|
- courseGroupsText: (task.course_groups || []).join('\n'),
|
|
|
- enable_ggxxk: task.enable_ggxxk,
|
|
|
- auto_relogin: !!task.auto_relogin,
|
|
|
- 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 fetchStats = async () => {
|
|
|
- try {
|
|
|
- const [
|
|
|
- allRes,
|
|
|
- pausedRes,
|
|
|
- pendingRes,
|
|
|
- assignedRes,
|
|
|
- runningRes,
|
|
|
- successRes,
|
|
|
- failedRes,
|
|
|
- cancelledRes,
|
|
|
- clientRes
|
|
|
- ] = await Promise.all([
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1 }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'paused' }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'pending' }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'assigned' }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'running' }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'success' }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'failed' }),
|
|
|
- adminQkTaskList({ current: 1, pagesize: 1, status: 'cancelled' }),
|
|
|
- adminQkClientList()
|
|
|
- ])
|
|
|
- if (allRes?.code === 0) statusStats.total = allRes.data?.total || 0
|
|
|
- if (pausedRes?.code === 0) statusStats.paused = pausedRes.data?.total || 0
|
|
|
- if (pendingRes?.code === 0) statusStats.pending = pendingRes.data?.total || 0
|
|
|
- if (assignedRes?.code === 0) statusStats.assigned = assignedRes.data?.total || 0
|
|
|
- if (runningRes?.code === 0) statusStats.running = runningRes.data?.total || 0
|
|
|
- if (successRes?.code === 0) statusStats.success = successRes.data?.total || 0
|
|
|
- if (failedRes?.code === 0) statusStats.failed = failedRes.data?.total || 0
|
|
|
- if (cancelledRes?.code === 0) statusStats.cancelled = cancelledRes.data?.total || 0
|
|
|
-
|
|
|
- const clients = clientRes?.code === 0 ? (clientRes.data || []) : []
|
|
|
- const online = clients.filter(item => Number(item.enabled) === 1 && Number(item.online) === 1)
|
|
|
- const totalSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.max_slots) || 0), 0)
|
|
|
- const usedSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.current_slots) || 0), 0)
|
|
|
- statusStats.totalSlots = totalSlots
|
|
|
- statusStats.usedSlots = usedSlots
|
|
|
- statusStats.freeSlots = Math.max(0, totalSlots - usedSlots)
|
|
|
- } catch (_) {}
|
|
|
-}
|
|
|
-
|
|
|
-const submitTask = async () => {
|
|
|
- const validated = validateQkTaskTargets(form.coursesText, form.courseGroupsText, form.interval_ms)
|
|
|
- if (!validated.ok) {
|
|
|
- return Notification.error({ title: '保存失败', content: validated.message })
|
|
|
- }
|
|
|
- if (!form.jw_account_id) {
|
|
|
- return Notification.error({ title: '保存失败', content: '请选择统一身份认证账号' })
|
|
|
- }
|
|
|
- try {
|
|
|
- saving.value = true
|
|
|
- const res = await adminQkTaskUpdate({
|
|
|
- id: form.id,
|
|
|
- name: form.name,
|
|
|
- batch_id: form.batch_id,
|
|
|
- jw_account_id: form.jw_account_id,
|
|
|
- courses: validated.courses,
|
|
|
- course_groups: validated.courseGroups,
|
|
|
- enable_ggxxk: form.enable_ggxxk,
|
|
|
- auto_relogin: form.auto_relogin,
|
|
|
- interval_ms: form.interval_ms
|
|
|
- })
|
|
|
- if (!res || res.code !== 0) {
|
|
|
- return Notification.error({ title: '保存失败', content: res?.msg || '请稍后再试' })
|
|
|
- }
|
|
|
- Notification.success({ title: '保存成功' })
|
|
|
- visible.value = false
|
|
|
- getList()
|
|
|
- fetchStats()
|
|
|
- } 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()
|
|
|
- fetchStats()
|
|
|
-}
|
|
|
-
|
|
|
-const startTask = async (record) => {
|
|
|
- const res = await adminQkTaskStart({ id: record.id })
|
|
|
- if (!res || res.code !== 0) {
|
|
|
- return Notification.error({ title: '开启失败', content: res?.msg || '请稍后再试' })
|
|
|
- }
|
|
|
- Notification.success({ title: '已开启', content: '任务已进入待分配队列' })
|
|
|
- getList()
|
|
|
- fetchStats()
|
|
|
-}
|
|
|
-
|
|
|
-const pauseTask = async (record) => {
|
|
|
- const res = await adminQkTaskPause({ id: record.id })
|
|
|
- if (!res || res.code !== 0) {
|
|
|
- return Notification.error({ title: '暂停失败', content: res?.msg || '请稍后再试' })
|
|
|
- }
|
|
|
- Notification.success({ title: '已暂停' })
|
|
|
- getList()
|
|
|
- fetchStats()
|
|
|
-}
|
|
|
-
|
|
|
-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()
|
|
|
- fetchStats()
|
|
|
-}
|
|
|
-
|
|
|
-const search = () => {
|
|
|
- pagination.current = 1
|
|
|
- getList()
|
|
|
-}
|
|
|
-
|
|
|
-const reset = () => {
|
|
|
- query.status = ''
|
|
|
- query.client_id = ''
|
|
|
- query.student_num = ''
|
|
|
- query.username = ''
|
|
|
- query.course_name = ''
|
|
|
- search()
|
|
|
-}
|
|
|
-
|
|
|
-const onPageChange = (current) => {
|
|
|
- pagination.current = current
|
|
|
- getList()
|
|
|
-}
|
|
|
-
|
|
|
-const onPageSizeChange = (pageSize) => {
|
|
|
- pagination.pagesize = pageSize
|
|
|
- pagination.current = 1
|
|
|
- getList()
|
|
|
-}
|
|
|
-
|
|
|
-let pollTimer = null
|
|
|
-let statsTimer = null
|
|
|
-
|
|
|
-const startPolling = () => {
|
|
|
- if (!pollTimer) {
|
|
|
- pollTimer = setInterval(() => getList(true), 5000)
|
|
|
- }
|
|
|
- if (!statsTimer) {
|
|
|
- statsTimer = setInterval(fetchStats, 30000)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const stopPolling = () => {
|
|
|
- if (pollTimer) {
|
|
|
- clearInterval(pollTimer)
|
|
|
- pollTimer = null
|
|
|
- }
|
|
|
- if (statsTimer) {
|
|
|
- clearInterval(statsTimer)
|
|
|
- statsTimer = null
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const loadBatches = async () => {
|
|
|
- try {
|
|
|
- const res = await adminQkBatchList()
|
|
|
- if (res?.code === 0) {
|
|
|
- batchOptions.value = res.data || []
|
|
|
- }
|
|
|
- } catch (_) {}
|
|
|
-}
|
|
|
-
|
|
|
-onMounted(() => {
|
|
|
- loadBatches()
|
|
|
- getList()
|
|
|
- fetchStats()
|
|
|
- startPolling()
|
|
|
-})
|
|
|
-
|
|
|
-onUnmounted(() => {
|
|
|
- stopPolling()
|
|
|
-})
|
|
|
-</script>
|
|
|
-
|
|
|
-<style scoped lang="less">
|
|
|
-@import '@/styles/store-theme.less';
|
|
|
-
|
|
|
-.stats-section {
|
|
|
- padding-bottom: 40px;
|
|
|
-}
|
|
|
-
|
|
|
-.stats-row {
|
|
|
- margin-bottom: 0;
|
|
|
-}
|
|
|
-
|
|
|
-:deep(.query-filter.app-query-filter) {
|
|
|
- margin-top: 0;
|
|
|
- margin-bottom: 24px;
|
|
|
-}
|
|
|
-
|
|
|
-.stats-col {
|
|
|
- display: flex;
|
|
|
-}
|
|
|
-
|
|
|
-.stats-panel {
|
|
|
- width: 100%;
|
|
|
- min-height: 148px;
|
|
|
- box-sizing: border-box;
|
|
|
-}
|
|
|
-
|
|
|
-.stats-highlight {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 16px;
|
|
|
- height: 100%;
|
|
|
- padding: 16px;
|
|
|
- border: 1px solid var(--color-border-2);
|
|
|
- border-radius: 12px;
|
|
|
- background: linear-gradient(135deg, rgba(var(--primary-1), 0.35), rgba(var(--primary-1), 0.08));
|
|
|
-}
|
|
|
-
|
|
|
-.stats-grid-panel {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- height: 100%;
|
|
|
- padding: 12px;
|
|
|
- border: 1px solid var(--color-border-2);
|
|
|
- border-radius: 12px;
|
|
|
- background: var(--color-bg-1);
|
|
|
-}
|
|
|
-
|
|
|
-.stats-grid {
|
|
|
- flex: 1;
|
|
|
- align-content: stretch;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-text {
|
|
|
- text-align: center;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-value {
|
|
|
- font-size: 18px;
|
|
|
- font-weight: 600;
|
|
|
- line-height: 1.2;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-label {
|
|
|
- margin-top: 2px;
|
|
|
- font-size: 12px;
|
|
|
- color: var(--color-text-3);
|
|
|
-}
|
|
|
-
|
|
|
-.stats-highlight-meta {
|
|
|
- flex: 1;
|
|
|
- min-width: 0;
|
|
|
-}
|
|
|
-
|
|
|
-.highlight-title {
|
|
|
- font-size: 15px;
|
|
|
- font-weight: 600;
|
|
|
- margin-bottom: 8px;
|
|
|
-}
|
|
|
-
|
|
|
-.highlight-desc {
|
|
|
- color: var(--color-text-2);
|
|
|
- font-size: 13px;
|
|
|
- line-height: 1.6;
|
|
|
-
|
|
|
- strong {
|
|
|
- color: var(--color-text-1);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.stat-card {
|
|
|
- height: 100%;
|
|
|
- min-height: 72px;
|
|
|
- padding: 12px;
|
|
|
- border: 1px solid var(--color-border-2);
|
|
|
- border-radius: 10px;
|
|
|
- background: var(--color-bg-2);
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- justify-content: center;
|
|
|
- box-sizing: border-box;
|
|
|
-}
|
|
|
-
|
|
|
-:deep(.stat-card .arco-statistic-title) {
|
|
|
- margin-bottom: 4px;
|
|
|
- font-size: 13px;
|
|
|
-}
|
|
|
-
|
|
|
-:deep(.stat-card .arco-statistic-value) {
|
|
|
- font-size: 22px;
|
|
|
- line-height: 1.2;
|
|
|
-}
|
|
|
-
|
|
|
-.stat-sub {
|
|
|
- margin-top: 4px;
|
|
|
- font-size: 12px;
|
|
|
- color: var(--color-text-3);
|
|
|
-}
|
|
|
-
|
|
|
-.query-row {
|
|
|
- margin-bottom: 0;
|
|
|
-}
|
|
|
-
|
|
|
-.query-main {
|
|
|
- min-width: 0;
|
|
|
-}
|
|
|
-
|
|
|
-.query-actions-space {
|
|
|
- width: 100%;
|
|
|
-}
|
|
|
-
|
|
|
-.table {
|
|
|
- margin-top: 4px;
|
|
|
-}
|
|
|
-
|
|
|
-.edit-alert {
|
|
|
- margin-bottom: 16px;
|
|
|
-}
|
|
|
-
|
|
|
-@media (max-width: 768px) {
|
|
|
- .stats-panel {
|
|
|
- min-height: auto;
|
|
|
- }
|
|
|
-
|
|
|
- .stats-highlight {
|
|
|
- flex-direction: column;
|
|
|
- align-items: center;
|
|
|
- text-align: center;
|
|
|
- }
|
|
|
-
|
|
|
- .stats-highlight-meta {
|
|
|
- width: 100%;
|
|
|
- }
|
|
|
-
|
|
|
- .stat-card {
|
|
|
- min-height: 64px;
|
|
|
- }
|
|
|
-
|
|
|
- .query-row {
|
|
|
- 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>
|