Browse Source

✨ feat: 更改代理请求方式、增加图书馆占座等页面

Pchen. 1 month ago
parent
commit
bc56c6e795

+ 77 - 0
src/api/ic.js

@@ -0,0 +1,77 @@
+import request from '../utils/request'
+
+const api = {
+  getSeatMenu: '/IC/GetSeatMenu',
+  getSeatInfo: '/IC/GetSeatInfo',
+  seat: '/IC/Seat',
+  endAhead: '/IC/Seat/EndAhead',
+  star: '/IC/Star',
+  starGroup: '/IC/StarGroup',
+  rule: '/IC/Rule',
+  myOrder: '/IC/Seat/MyOrder',
+  orderHistory: '/IC/OrderHistory'
+}
+
+export function getSeatMenu(parameter) {
+  return request({ url: api.getSeatMenu, method: 'get', params: parameter })
+}
+
+export function getSeatInfo(parameter) {
+  return request({ url: api.getSeatInfo, method: 'get', params: parameter })
+}
+
+export function orderSeat(parameter) {
+  return request({ url: api.seat, method: 'post', data: parameter })
+}
+
+export function myOrder(parameter) {
+  return request({ url: api.myOrder, method: 'post', data: parameter })
+}
+
+export function deleteSeat(parameter) {
+  return request({ url: api.seat, method: 'delete', data: parameter })
+}
+
+export function endAhead(parameter) {
+  return request({ url: api.endAhead, method: 'post', data: parameter })
+}
+
+export function getStar() {
+  return request({ url: api.star, method: 'get' })
+}
+
+export function saveStar(parameter) {
+  return request({ url: api.star, method: 'post', data: parameter })
+}
+
+export function deleteStar(parameter) {
+  return request({ url: api.star, method: 'delete', data: parameter })
+}
+
+export function getStarGroup() {
+  return request({ url: api.starGroup, method: 'get' })
+}
+
+export function saveStarGroup(parameter) {
+  return request({ url: api.starGroup, method: 'post', data: parameter })
+}
+
+export function deleteStarGroup(parameter) {
+  return request({ url: api.starGroup, method: 'delete', data: parameter })
+}
+
+export function getRule() {
+  return request({ url: api.rule, method: 'get' })
+}
+
+export function saveRule(parameter) {
+  return request({ url: api.rule, method: 'post', data: parameter })
+}
+
+export function deleteRule(parameter) {
+  return request({ url: api.rule, method: 'delete', data: parameter })
+}
+
+export function getOrderHistory() {
+  return request({ url: api.orderHistory, method: 'get' })
+}

+ 22 - 0
src/api/jwAccount.js

@@ -0,0 +1,22 @@
+import request from '../utils/request'
+
+const api = {
+  account: '/JW/Account',
+  active: '/JW/ActiveAccount'
+}
+
+export function getJwAccount() {
+  return request({ url: api.account, method: 'get' })
+}
+
+export function saveJwAccount(parameter) {
+  return request({ url: api.account, method: 'post', data: parameter })
+}
+
+export function deleteJwAccount(parameter) {
+  return request({ url: api.account, method: 'delete', data: parameter })
+}
+
+export function activeJwAccount(parameter) {
+  return request({ url: api.active, method: 'post', data: parameter })
+}

+ 1 - 46
src/api/lepao.js

@@ -18,12 +18,7 @@ const api = {
   BeginFaceReco: '/Face/BeginFaceReco',
   BindAuditList: '/Lepao/BindAudit/List',
   AdminBindAuditByAccount: '/Admin/Lepao/BindAudit/ByAccount',
-  AdminBindAuditList: '/Admin/Lepao/BindAudit/List',
-  AdminLepaoProxyStatus: '/Admin/Lepao/Proxy/Status',
-  AdminLepaoProxyConfig: '/Admin/Lepao/Proxy/Config',
-  AdminLepaoProxyLogs: '/Admin/Lepao/Proxy/Logs',
-  AdminLepaoProxyLogsDelete: '/Admin/Lepao/Proxy/Logs/Delete',
-  AdminLepaoProxyResources: '/Admin/Lepao/Proxy/Resources'
+  AdminBindAuditList: '/Admin/Lepao/BindAudit/List'
 }
 
 export function addAccount (parameter) {
@@ -177,43 +172,3 @@ export function getAdminBindAuditList (parameter) {
     params: parameter
   })
 }
-
-export function getAdminLepaoProxyStatus (parameter) {
-  return request({
-    url: api.AdminLepaoProxyStatus,
-    method: 'get',
-    params: parameter
-  })
-}
-
-export function postAdminLepaoProxyConfig (parameter) {
-  return request({
-    url: api.AdminLepaoProxyConfig,
-    method: 'post',
-    data: parameter
-  })
-}
-
-export function getAdminLepaoProxyLogs (parameter) {
-  return request({
-    url: api.AdminLepaoProxyLogs,
-    method: 'get',
-    params: parameter
-  })
-}
-
-export function postAdminLepaoProxyLogsDelete (parameter) {
-  return request({
-    url: api.AdminLepaoProxyLogsDelete,
-    method: 'post',
-    data: parameter
-  })
-}
-
-export function getAdminLepaoProxyResources (parameter) {
-  return request({
-    url: api.AdminLepaoProxyResources,
-    method: 'get',
-    params: parameter
-  })
-}

+ 10 - 1
src/api/qk.js

@@ -18,7 +18,8 @@ const api = {
   TaskPause: '/QK/Task/Pause',
   Batch: '/QK/Batch',
   AdminBatchList: '/Admin/QK/Batch/List',
-  AdminBatchSave: '/Admin/QK/Batch/Save'
+  AdminBatchSave: '/Admin/QK/Batch/Save',
+  AdminJwAccount: '/Admin/JW/Account'
 }
 
 export function getQkTask(parameter) {
@@ -180,3 +181,11 @@ export function adminQkReportList(parameter) {
     params: parameter
   })
 }
+
+export function adminGetJwAccount(parameter) {
+  return request({
+    url: api.AdminJwAccount,
+    method: 'get',
+    params: parameter
+  })
+}

+ 0 - 572
src/pages/admin/lepaoProxy/index.vue

@@ -1,572 +0,0 @@
-<template>
-  <div class="lepao-proxy-page">
-    <Breadcrumb />
-
-    <a-skeleton animation :loading="pageLoading" class="lepao-proxy-skeleton">
-      <a-skeleton-line :rows="10" />
-      <template #content>
-      <a-card class="hero-card" :bordered="false">
-        <div class="hero-inner">
-          <div class="hero-title">
-            <span class="t1">乐跑出站代理</span>
-            <a-tag color="arcoblue" size="small">通道提取</a-tag>
-          </div>
-        </div>
-      </a-card>
-
-      <a-row :gutter="16" style="margin-top: 16px">
-        <a-col :span="24">
-          <a-card title="当前状态" :bordered="false" class="panel">
-            <a-descriptions :column="isMobile ? 1 : 2" bordered size="small">
-              <a-descriptions-item label="extractKey">
-                <a-tag :color="status?.extract_key_configured ? 'green' : 'orangered'">
-                  {{ status?.extract_key_configured ? '已配置' : '未配置' }}
-                </a-tag>
-              </a-descriptions-item>
-              <a-descriptions-item label="代理账密">
-                <a-tag :color="status?.proxy_auth_configured ? 'cyan' : 'gray'">
-                  {{ status?.proxy_auth_configured ? '已配置' : '可选(白名单可不填)' }}
-                </a-tag>
-              </a-descriptions-item>
-              <a-descriptions-item label="代理节点 server">
-                <span class="mono">{{ status?.current_proxy?.server ?? '—' }}</span>
-              </a-descriptions-item>
-              <a-descriptions-item label="节点 IP 属地">
-                <a-tag color="purple">{{ status?.current_proxy?.node_region ?? '—' }}</a-tag>
-              </a-descriptions-item>
-              <a-descriptions-item label="出口 IP proxy_ip">
-                <span class="mono">{{ status?.current_proxy?.proxy_ip ?? '—' }}</span>
-              </a-descriptions-item>
-              <a-descriptions-item label="出口 IP 属地">
-                <a-tag color="gold">{{ status?.current_proxy?.proxy_ip_region ?? '—' }}</a-tag>
-              </a-descriptions-item>
-              <a-descriptions-item label="deadline" :span="2">
-                {{ status?.current_proxy?.deadline ?? '—' }}
-                <a-tag
-                  v-if="status?.current_proxy"
-                  size="small"
-                  :color="status?.current_proxy?.stale ? 'orangered' : 'green'"
-                  style="margin-left: 8px"
-                >
-                  {{ status?.current_proxy?.stale ? '临近/已过期' : '有效期内' }}
-                </a-tag>
-              </a-descriptions-item>
-            </a-descriptions>
-          </a-card>
-        </a-col>
-      </a-row>
-
-      <a-row :gutter="16" style="margin-top: 16px">
-        <a-col :xs="24" :lg="24">
-          <a-card title="筛选与路由" :bordered="false" class="panel">
-            <a-form :model="form" layout="vertical">
-              <a-form-item label="启用青果出站">
-                <a-switch v-model="form.proxy_enabled" />
-              </a-form-item>
-
-              <a-form-item label="从可选资源勾选地区">
-                <a-space direction="vertical" fill style="width: 100%">
-                  <a-space wrap>
-                    <a-button size="mini" type="outline" @click="loadResources" :loading="resLoading">
-                      刷新资源列表
-                    </a-button>
-                    <a-checkbox v-model="onlyAvailableRes">仅显示「可提取」</a-checkbox>
-                  </a-space>
-                  <a-select
-                    v-model="selectedAreaCodes"
-                    multiple
-                    allow-search
-                    allow-clear
-                    :loading="resLoading"
-                    :options="filteredResourceOptions"
-                    :placeholder="resourcePlaceholder"
-                    :filter-option="filterResourceOption"
-                    style="width: 100%"
-                  />
-                  <div class="hint-muted">
-                    将写入后端 area 字段(逗号分隔)。也可在下方微调编码。
-                  </div>
-                  <a-input
-                    v-model="form.area"
-                    allow-clear
-                    placeholder="area 编码,逗号分隔(与上方选择同步,失焦后与多选对齐)"
-                    @blur="syncSelectedFromArea"
-                  />
-                </a-space>
-              </a-form-item>
-
-              <a-form-item label="排除地区 area_ex">
-                <a-input v-model="form.area_ex" allow-clear placeholder="可选,逗号分隔" />
-              </a-form-item>
-              <a-form-item label="运营商 isp">
-                <a-select v-model="form.isp" allow-clear placeholder="不筛选">
-                  <a-option :value="undefined">不筛选</a-option>
-                  <a-option :value="1">电信</a-option>
-                  <a-option :value="2">移动</a-option>
-                  <a-option :value="3">联通</a-option>
-                </a-select>
-              </a-form-item>
-              <a-form-item label="distinct 去重提取">
-                <a-switch v-model="form.distinct_extract" />
-              </a-form-item>
-              <a-form-item label="保存时清空 Redis 当前 IP">
-                <a-checkbox v-model="form.invalidate_cache">清空(下次强制重新提取)</a-checkbox>
-              </a-form-item>
-              <a-space>
-                <a-button type="primary" @click="saveConfig" :loading="saving">保存配置</a-button>
-                <a-button @click="loadStatus">刷新状态</a-button>
-              </a-space>
-            </a-form>
-          </a-card>
-        </a-col>
-      </a-row>
-
-      <a-card title="出站与切换摘要" class="panel log-card" style="margin-top: 16px" :bordered="false">
-        <template #extra>
-          <a-space>
-            <a-button
-              type="primary"
-              status="danger"
-              :disabled="!selectedLogKeys.length"
-              :loading="logDeleting"
-              @click="deleteSelectedLogs"
-            >
-              删除所选({{ selectedLogKeys.length }})
-            </a-button>
-            <a-button type="outline" status="danger" :loading="logDeleting" @click="confirmDeleteAllLogs">
-              清空全部
-            </a-button>
-          </a-space>
-        </template>
-        <div class="table-wrap">
-        <a-table
-          v-model:selected-keys="selectedLogKeys"
-          row-key="id"
-          class="table"
-          :row-selection="{ type: 'checkbox', showCheckedAll: true }"
-          :data="logData"
-          :loading="logLoading"
-          :bordered="false"
-          :scroll="{ x: TABLE_SCROLL_X }"
-          :pagination="{
-            showPageSize: true,
-            showJumper: true,
-            showTotal: true,
-            pageSize: pagination.pagesize,
-            current: pagination.current,
-            total: pagination.total
-          }"
-          @page-change="handlePageChange"
-          @page-size-change="handlePageSizeChange"
-        >
-          <template #columns>
-            <a-table-column title="时间" :width="178">
-              <template #cell="{ record }">{{ strTime(record.created_at) }}</template>
-            </a-table-column>
-            <a-table-column title="类型" :width="120">
-              <template #cell="{ record }">
-                <a-tag size="small" :color="record.event_color">{{ record.event_label }}</a-tag>
-              </template>
-            </a-table-column>
-            <a-table-column title="节点 server" data-index="server" :width="170" ellipsis tooltip />
-            <a-table-column title="出口 IP" :width="150">
-              <template #cell="{ record }">
-                <span class="mono">{{ record.egress_ip ?? '—' }}</span>
-              </template>
-            </a-table-column>
-            <a-table-column title="出口属地" :width="190">
-              <template #cell="{ record }">
-                <span v-if="!record.egress_ip" class="hint-muted">—</span>
-                <a-tag v-else color="cyan" size="small">{{ shortenRegion(record.egress_region || '未知') }}</a-tag>
-              </template>
-            </a-table-column>
-            <a-table-column title="摘要">
-              <template #cell="{ record }">
-                <div class="summary-text">{{ record.summary }}</div>
-              </template>
-            </a-table-column>
-            <a-table-column title="操作" :fixed="tableFixed('right')" :width="88">
-              <template #cell="{ record }">
-                <a-popconfirm content="删除该条日志?" @ok="deleteOneLog(record.id)">
-                  <a-button type="text" size="mini" status="danger">删除</a-button>
-                </a-popconfirm>
-              </template>
-            </a-table-column>
-          </template>
-        </a-table>
-        </div>
-      </a-card>
-      </template>
-    </a-skeleton>
-  </div>
-</template>
-
-<script setup>
-import { reactive, ref, computed, watch, onMounted } from 'vue'
-import { Notification, Modal } from '@arco-design/web-vue'
-import { useResponsiveTable } from '@/hooks/useResponsiveTable'
-import {
-  getAdminLepaoProxyStatus,
-  postAdminLepaoProxyConfig,
-  getAdminLepaoProxyLogs,
-  postAdminLepaoProxyLogsDelete,
-  getAdminLepaoProxyResources
-} from '@/api/lepao'
-
-const { isMobile, tableFixed } = useResponsiveTable()
-
-/** 列宽合计(含勾选列与摘要列最小宽度),保证小屏可横向滚动 */
-const TABLE_SCROLL_X = 1200
-
-const pageLoading = ref(false)
-const logLoading = ref(false)
-const logDeleting = ref(false)
-const selectedLogKeys = ref([])
-const saving = ref(false)
-const status = ref(null)
-const logData = ref([])
-const resourcesRaw = ref([])
-const resLoading = ref(false)
-const onlyAvailableRes = ref(true)
-const selectedAreaCodes = ref([])
-
-const pagination = reactive({
-  total: 0,
-  current: 1,
-  pagesize: 20
-})
-
-const form = reactive({
-  proxy_enabled: false,
-  area: '',
-  area_ex: '',
-  isp: undefined,
-  distinct_extract: true,
-  invalidate_cache: false
-})
-
-watch(
-  selectedAreaCodes,
-  (v) => {
-    form.area = Array.isArray(v) && v.length ? v.join(',') : ''
-  },
-  { deep: true }
-)
-
-const filteredResourceOptions = computed(() => {
-  const rows = resourcesRaw.value || []
-  let list = onlyAvailableRes.value ? rows.filter((r) => r.available === true) : [...rows]
-  return list.map((r) => {
-    const code = String(r.area_code ?? '')
-    const isp = r.isp ?? ''
-    const ok = r.available ? '可提取' : '暂不可用'
-    return {
-      label: `[${code}] ${r.area ?? ''} · ${isp} · ${ok}`,
-      value: code
-    }
-  })
-})
-
-const resourcePlaceholder = computed(() =>
-  resourcesRaw.value.length ? '搜索城市 / 运营商 / 编码…' : '请先点击「刷新资源列表」'
-)
-
-function filterResourceOption(input, option) {
-  return String(option?.label ?? '')
-    .toLowerCase()
-    .includes(String(input || '').toLowerCase())
-}
-
-function shortenRegion(r) {
-  if (!r || r === '未知') return '未知'
-  const parts = String(r).split(' · ')
-  return parts.slice(-2).join(' · ') || r
-}
-
-const strTime = (t) =>
-  t == null
-    ? '—'
-    : new Date(Number(t)).toLocaleString('zh-CN', {
-        year: 'numeric',
-        month: '2-digit',
-        day: '2-digit',
-        hour: '2-digit',
-        minute: '2-digit',
-        second: '2-digit'
-      })
-
-const syncSelectedFromArea = () => {
-  selectedAreaCodes.value = form.area
-    ? String(form.area)
-        .split(',')
-        .map((s) => s.trim())
-        .filter(Boolean)
-        .map(String)
-    : []
-}
-
-const applyStatus = (data) => {
-  status.value = data
-  form.proxy_enabled = !!data?.proxy_enabled
-  form.area = data?.area ?? ''
-  form.area_ex = data?.area_ex ?? ''
-  form.isp = data?.isp === null || data?.isp === '' ? undefined : Number(data.isp)
-  form.distinct_extract = data?.distinct_extract !== false
-  syncSelectedFromArea()
-}
-
-const loadStatus = async () => {
-  pageLoading.value = true
-  try {
-    const res = await getAdminLepaoProxyStatus({})
-    if (!res || res.code !== 0) {
-      Notification.error({ title: '读取状态失败', content: res?.msg ?? '请稍后再试' })
-      return
-    }
-    applyStatus(res.data)
-  } finally {
-    pageLoading.value = false
-  }
-}
-
-const loadResources = async () => {
-  resLoading.value = true
-  try {
-    const res = await getAdminLepaoProxyResources({})
-    if (!res || res.code !== 0) {
-      Notification.error({ title: '资源列表获取失败', content: res?.msg ?? '请检查 extractKey 与网络' })
-      return
-    }
-    resourcesRaw.value = res.data || []
-    Notification.success({ title: '资源列表已更新', content: `共 ${resourcesRaw.value.length} 条` })
-  } finally {
-    resLoading.value = false
-  }
-}
-
-const loadLogs = async () => {
-  logLoading.value = true
-  try {
-    const res = await getAdminLepaoProxyLogs({
-      current: pagination.current,
-      pagesize: pagination.pagesize
-    })
-    if (!res || res.code !== 0) {
-      Notification.error({ title: '读取日志失败', content: res?.msg ?? '请稍后再试' })
-      return
-    }
-    logData.value = res.data || []
-    pagination.total = res.pagination?.total || 0
-    selectedLogKeys.value = []
-  } finally {
-    logLoading.value = false
-  }
-}
-
-const deleteOneLog = async (id) => {
-  logDeleting.value = true
-  try {
-    const res = await postAdminLepaoProxyLogsDelete({ ids: [id] })
-    if (!res || res.code !== 0) {
-      Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
-      return
-    }
-    Notification.success({ title: '已删除', content: '' })
-    await loadLogs()
-  } finally {
-    logDeleting.value = false
-  }
-}
-
-const deleteSelectedLogs = async () => {
-  if (!selectedLogKeys.value.length) return
-  logDeleting.value = true
-  try {
-    const res = await postAdminLepaoProxyLogsDelete({ ids: selectedLogKeys.value.map(Number) })
-    if (!res || res.code !== 0) {
-      Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
-      return
-    }
-    Notification.success({ title: `已删除 ${res.data?.deleted ?? selectedLogKeys.value.length} 条`, content: '' })
-    pagination.current = 1
-    await loadLogs()
-  } finally {
-    logDeleting.value = false
-  }
-}
-
-const confirmDeleteAllLogs = () => {
-  Modal.confirm({
-    title: '清空全部代理日志',
-    content: '将删除表中全部 lepao_proxy_log 记录,不可恢复。',
-    okText: '确认清空',
-    modalStyle: { maxWidth: '420px' },
-    okButtonProps: { status: 'danger' },
-    onOk: async () => {
-      logDeleting.value = true
-      try {
-        const res = await postAdminLepaoProxyLogsDelete({ purge_all: 1 })
-        if (!res || res.code !== 0) {
-          Notification.error({ title: '清空失败', content: res?.msg ?? '请稍后再试' })
-          return
-        }
-        Notification.success({ title: '已清空日志', content: '' })
-        pagination.current = 1
-        await loadLogs()
-      } finally {
-        logDeleting.value = false
-      }
-    }
-  })
-}
-
-const saveConfig = async () => {
-  saving.value = true
-  try {
-    const res = await postAdminLepaoProxyConfig({
-      proxy_enabled: form.proxy_enabled ? 1 : 0,
-      area: form.area,
-      area_ex: form.area_ex,
-      isp: form.isp === undefined ? '' : form.isp,
-      distinct_extract: form.distinct_extract ? 1 : 0,
-      invalidate_cache: form.invalidate_cache ? 1 : 0
-    })
-    if (!res || res.code !== 0) {
-      Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
-      return
-    }
-    Notification.success({ title: '已保存', content: '' })
-    form.invalidate_cache = false
-    await loadStatus()
-    await loadLogs()
-  } finally {
-    saving.value = false
-  }
-}
-
-const handlePageChange = (page) => {
-  pagination.current = page
-  loadLogs()
-}
-
-const handlePageSizeChange = (size) => {
-  pagination.pagesize = size
-  pagination.current = 1
-  loadLogs()
-}
-
-onMounted(async () => {
-  await loadStatus()
-  await loadLogs()
-})
-</script>
-
-<style scoped lang="less">
-.lepao-proxy-page {
-  padding: 16px 20px 32px;
-  max-width: 1280px;
-  margin: 0 auto;
-}
-
-.lepao-proxy-skeleton {
-  width: 100%;
-}
-
-.hero-card {
-  background: linear-gradient(135deg, var(--color-bg-2), var(--color-fill-2));
-}
-
-.hero-inner {
-  padding: 4px 0;
-}
-
-.hero-title {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-
-  .t1 {
-    font-size: 20px;
-    font-weight: 600;
-    color: var(--color-text-1);
-  }
-}
-
-.hero-desc {
-  margin-top: 10px;
-  color: var(--color-text-2);
-  font-size: 13px;
-  line-height: 1.6;
-
-  code {
-    padding: 0 6px;
-    border-radius: 4px;
-    background: var(--color-fill-3);
-    font-size: 12px;
-  }
-}
-
-.panel {
-  border-radius: 12px;
-
-  &.log-card {
-    box-shadow: 0 1px 4px rgb(0 0 0 / 4%);
-  }
-}
-
-.mono {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
-  font-size: 13px;
-}
-
-.side-tips .tips-list {
-  margin: 0;
-  padding-left: 18px;
-  color: var(--color-text-2);
-  font-size: 13px;
-  line-height: 1.85;
-}
-
-.summary-text {
-  font-size: 13px;
-  line-height: 1.65;
-  color: var(--color-text-2);
-}
-
-.hint-muted {
-  color: var(--color-text-3);
-  font-size: 12px;
-}
-
-.table-wrap {
-  width: 100%;
-  overflow-x: auto;
-  -webkit-overflow-scrolling: touch;
-}
-
-.table {
-  min-width: 100%;
-}
-
-@media (max-width: 768px) {
-  .lepao-proxy-page {
-    padding: 12px 12px 24px;
-  }
-
-  .log-card :deep(.arco-card-header) {
-    flex-wrap: wrap;
-    gap: 8px;
-  }
-
-  .log-card :deep(.arco-card-header-extra) {
-    width: 100%;
-    margin-left: 0 !important;
-  }
-
-  .log-card :deep(.arco-card-header-extra .arco-space) {
-    flex-wrap: wrap;
-    width: 100%;
-  }
-}
-
-</style>

+ 55 - 25
src/pages/admin/qkTask/index.vue

@@ -114,10 +114,15 @@
                     <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: 84px" direction="vertical" />
+          <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">
@@ -165,7 +170,6 @@
             </template>
           </a-table-column>
           <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 }">
@@ -280,23 +284,24 @@
             </a-option>
           </a-select>
         </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="统一身份认证账号" 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-form-item label="课程分组(体育课请在此填写项目名称)">
           <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
         </a-form-item>
         <a-row :gutter="16">
@@ -335,7 +340,8 @@ import {
   adminQkTaskRetry,
   adminQkTaskStart,
   adminQkTaskPause,
-  adminQkBatchList
+  adminQkBatchList,
+  adminGetJwAccount
 } from '@/api/qk'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { hasPermission } from '@/utils/permission'
@@ -356,11 +362,14 @@ 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: ''
+  username: '',
+  course_name: ''
 })
 const pagination = reactive({
   total: 0,
@@ -412,8 +421,8 @@ const form = reactive({
   id: '',
   name: '',
   batch_id: '',
-  student_num: '',
-  password: '',
+  jw_account_id: undefined,
+  create_user: '',
   coursesText: '',
   courseGroupsText: '',
   enable_ggxxk: true,
@@ -483,17 +492,34 @@ const resetForm = () => {
     id: '',
     name: '',
     batch_id: '',
-    student_num: '',
-    password: '',
+    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 {
@@ -503,12 +529,13 @@ const openModal = async (record) => {
     }
     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 || '',
-      student_num: task.student_num,
-      password: task.password || '',
+      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,
@@ -593,14 +620,16 @@ const submitTask = async () => {
   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,
-      student_num: form.student_num,
-      password: form.password,
+      jw_account_id: form.jw_account_id,
       courses: validated.courses,
       course_groups: validated.courseGroups,
       enable_ggxxk: form.enable_ggxxk,
@@ -671,6 +700,7 @@ const reset = () => {
   query.client_id = ''
   query.student_num = ''
   query.username = ''
+  query.course_name = ''
   search()
 }
 

+ 255 - 0
src/pages/ic/freeSeats.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="座位查询">
+      <AppQueryFilter>
+        <a-row class="queryForm app-query-form query-row">
+          <a-col :flex="'900px'" class="query-main">
+            <a-form :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+              <a-row :gutter="16">
+                <a-col :span="8">
+                  <a-form-item label="查询账号">
+                    <a-select v-model="query.username" placeholder="请选择账号" allow-search>
+                      <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+                        {{ item.username }}({{ item.realname || '未知' }})
+                      </a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item label="座位区域">
+                    <a-cascader
+                      v-model="query.roomId"
+                      :options="menu"
+                      placeholder="请选择座位区域"
+                      :field-names="{ label: 'name', value: 'id', children: 'children' }"
+                      check-strictly
+                      allow-clear
+                    />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item label="查询日期">
+                    <a-date-picker v-model="query.date" placeholder="请选择日期" style="width: 100%" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 42px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-button type="primary" @click="searchSeats">
+              <template #icon><icon-search /></template>
+              查询
+            </a-button>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+    </a-card>
+
+    <a-card style="margin-top: 16px">
+      <a-result v-if="noAccount" status="warning" title="您还没有可使用的统一身份认证账号">
+        <template #extra>
+          <a-button type="primary" @click="$router.push({ name: 'jwAccount.accountList' })">去添加</a-button>
+        </template>
+      </a-result>
+
+      <a-spin v-else :loading="loading" style="width: 100%">
+        <a-empty v-if="!loading && seats.length === 0" description="请选择条件后查询座位" />
+        <a-row v-else :gutter="16">
+          <a-col v-for="item in seats" :key="item.devId" :xs="24" :sm="12" :md="8" :lg="6" style="margin-bottom: 16px">
+            <a-card hoverable>
+              <template #title>
+                <a-space>
+                  <a-badge :status="item.resvInfo?.length ? 'warning' : 'success'" />
+                  {{ item.devName }}
+                </a-space>
+              </template>
+              <p><b>座位区域:</b>{{ item.labName }} - {{ item.roomName }}</p>
+              <p>
+                <b>占用时段:</b>
+                <template v-if="item.resvInfo?.length">
+                  <span v-for="(record, index) in item.resvInfo" :key="index">
+                    <span v-if="index">、</span>
+                    {{ formatTimestamp(record.startTime) }}~{{ formatTimeOnly(record.endTime) }}
+                  </span>
+                </template>
+                <span v-else>空闲</span>
+              </p>
+              <p><b>预约时长:</b>{{ item.resvRule?.minResvTime }}~{{ item.resvRule?.maxResvTime }}分</p>
+              <p><b>提前预约:</b>{{ item.resvRule?.deadlineTime }}~{{ item.resvRule?.rangeNum }}天</p>
+              <template #actions>
+                <span @click="openOrderModal(item)"><icon-rocket /> 立即预约</span>
+                <span @click="handleAddStar(item)"><icon-plus-circle /> 加入志愿</span>
+              </template>
+            </a-card>
+          </a-col>
+        </a-row>
+      </a-spin>
+    </a-card>
+
+    <a-modal
+      v-model:visible="orderVisible"
+      title="预定座位"
+      @before-ok="submitOrder"
+      :ok-loading="orderSaving"
+      draggable
+      esc-to-close
+    >
+      <a-form :model="orderForm" layout="vertical">
+        <a-form-item label="申请人" required>
+          <a-select v-model="orderForm.username" placeholder="请选择申请账号">
+            <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+              {{ item.username }}({{ item.realname || '未知' }})
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="申请时间" required>
+          <a-range-picker
+            v-model="orderForm.dateRange"
+            show-time
+            format="YYYY-MM-DD HH:mm"
+            style="width: 100%"
+          />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getJwAccount } from '@/api/jwAccount'
+import { getSeatMenu, getSeatInfo, orderSeat, saveStar } from '@/api/ic'
+import { formatTimestamp, formatTimeOnly, formatDateCompact } from '@/utils/icTime'
+
+const loading = ref(false)
+const noAccount = ref(false)
+const orderVisible = ref(false)
+const orderSaving = ref(false)
+const accounts = ref([])
+const menu = ref([])
+const seats = ref([])
+const currentSeat = ref(null)
+
+const query = reactive({
+  username: '',
+  roomId: [],
+  date: new Date()
+})
+
+const orderForm = reactive({
+  username: '',
+  dateRange: []
+})
+
+const loadAccounts = async () => {
+  const res = await getJwAccount()
+  if (res?.code !== 0) {
+    Notification.error({ title: '获取账号失败', content: res?.msg })
+    return
+  }
+  const verified = (res.data || []).filter((item) => item.state === 1)
+  accounts.value = verified
+  noAccount.value = verified.length === 0
+  if (verified.length) {
+    query.username = verified[0].username
+    orderForm.username = verified[0].username
+    await loadMenu()
+  }
+}
+
+const loadMenu = async () => {
+  if (!query.username) return
+  const res = await getSeatMenu({ username: query.username })
+  if (res?.code !== 0) {
+    Notification.error({ title: '获取位置目录失败', content: res?.msg })
+    return
+  }
+  menu.value = res.data || []
+}
+
+const searchSeats = async () => {
+  if (!query.username || !query.roomId?.length || !query.date) {
+    Notification.warning({ title: '请完整填写查询条件' })
+    return
+  }
+  loading.value = true
+  try {
+    const roomId = query.roomId[query.roomId.length - 1]
+    const date = formatDateCompact(query.date instanceof Date ? query.date : new Date(query.date))
+    const res = await getSeatInfo({ username: query.username, roomId, date })
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取座位信息失败', content: res?.msg })
+      return
+    }
+    seats.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openOrderModal = (item) => {
+  currentSeat.value = item
+  const baseDate = query.date instanceof Date ? query.date : new Date(query.date)
+  orderForm.dateRange = [baseDate, baseDate]
+  orderVisible.value = true
+}
+
+const formatDateTime = (date) => {
+  const d = date instanceof Date ? date : new Date(date)
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  const h = String(d.getHours()).padStart(2, '0')
+  const min = String(d.getMinutes()).padStart(2, '0')
+  return `${y}-${m}-${day} ${h}:${min}:00`
+}
+
+const submitOrder = async () => {
+  if (!orderForm.username || orderForm.dateRange?.length !== 2) {
+    Notification.error({ title: '请完整填写预约信息' })
+    return false
+  }
+  orderSaving.value = true
+  try {
+    const res = await orderSeat({
+      resvDev: [currentSeat.value.devId],
+      resvBeginTime: formatDateTime(orderForm.dateRange[0]),
+      resvEndTime: formatDateTime(orderForm.dateRange[1]),
+      username: orderForm.username
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '预约失败', content: res?.msg })
+      return false
+    }
+    Notification.success({ title: '预约成功', content: '请按时前往预定位置,避免违约' })
+    return true
+  } catch (error) {
+    Notification.error({ title: '预约失败', content: error.message })
+    return false
+  } finally {
+    orderSaving.value = false
+  }
+}
+
+const handleAddStar = async (item) => {
+  const res = await saveStar({ devId: item.devId, name: item.devName })
+  if (res?.code !== 0) {
+    Notification.error({ title: '添加志愿失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '添加成功', content: '您可在志愿列表中查看' })
+}
+
+onMounted(loadAccounts)
+</script>
+
+<style scoped>
+p {
+  margin: 4px 0;
+  font-size: 13px;
+}
+</style>

+ 183 - 0
src/pages/ic/myOrder.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="我的预约">
+      <AppQueryFilter>
+        <a-row class="queryForm app-query-form query-row">
+          <a-col :flex="'700px'" class="query-main">
+            <a-form :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+              <a-row :gutter="16">
+                <a-col :span="12">
+                  <a-form-item label="查询账号">
+                    <a-select v-model="query.username" placeholder="请选择账号">
+                      <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+                        {{ item.username }}({{ item.realname || '未知' }})
+                      </a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="查询日期">
+                    <a-range-picker v-model="query.dateRange" style="width: 100%" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 42px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-button type="primary" @click="refreshTable">
+              <template #icon><icon-search /></template>
+              查询
+            </a-button>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-result v-if="noAccount" status="warning" title="您还没有可使用的统一身份认证账号">
+        <template #extra>
+          <a-button type="primary" @click="$router.push({ name: 'jwAccount.accountList' })">去添加</a-button>
+        </template>
+      </a-result>
+
+      <a-table
+        v-else
+        row-key="uuid"
+        :data="data"
+        :loading="loading"
+        :pagination="pagination"
+        @page-change="onPageChange"
+        :scroll="{ x: 1100 }"
+      >
+        <template #columns>
+          <a-table-column title="预约日期" data-index="resvDate" :width="120" />
+          <a-table-column title="开始时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvBeginTime) }}</template>
+          </a-table-column>
+          <a-table-column title="结束时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvEndTime) }}</template>
+          </a-table-column>
+          <a-table-column title="预定时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvCheckTime) }}</template>
+          </a-table-column>
+          <a-table-column title="实际结束" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvEndRealTime) }}</template>
+          </a-table-column>
+          <a-table-column title="座位信息" :width="180">
+            <template #cell="{ record }">
+              {{ record.resvDevInfoList?.[0]?.roomName }}-{{ record.resvDevInfoList?.[0]?.devName }}
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="180" fixed="right">
+            <template #cell="{ record }">
+              <a-space>
+                <a-popconfirm content="该操作不可逆,确认取消预约?" @ok="handleDelete(record)">
+                  <a-button type="text" status="danger" size="small">取消预约</a-button>
+                </a-popconfirm>
+                <a-popconfirm content="确认提前结束本次预约?" @ok="handleEndAhead(record)">
+                  <a-button type="text" size="small">提前结束</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getJwAccount } from '@/api/jwAccount'
+import { myOrder, deleteSeat, endAhead } from '@/api/ic'
+import { formatTimestamp, formatDateStr } from '@/utils/icTime'
+
+const loading = ref(false)
+const noAccount = ref(false)
+const accounts = ref([])
+const data = ref([])
+
+const query = reactive({
+  username: '',
+  dateRange: [new Date(), new Date()]
+})
+
+const pagination = reactive({
+  current: 1,
+  pageSize: 10,
+  total: 0
+})
+
+const loadAccounts = async () => {
+  const res = await getJwAccount()
+  if (res?.code !== 0) {
+    Notification.error({ title: '获取账号失败', content: res?.msg })
+    return
+  }
+  const verified = (res.data || []).filter((item) => item.state === 1)
+  accounts.value = verified
+  noAccount.value = verified.length === 0
+  if (verified.length) {
+    query.username = verified[0].username
+    await loadList()
+  }
+}
+
+const loadList = async () => {
+  if (!accounts.value.length) return
+  loading.value = true
+  try {
+    const beginDate = formatDateStr(query.dateRange[0] instanceof Date ? query.dateRange[0] : new Date(query.dateRange[0]))
+    const endDate = formatDateStr(query.dateRange[1] instanceof Date ? query.dateRange[1] : new Date(query.dateRange[1]))
+    const res = await myOrder({
+      username: query.username,
+      beginDate,
+      endDate,
+      page: pagination.current,
+      pageNum: pagination.pageSize
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取预约信息失败', content: res?.msg })
+      return
+    }
+    data.value = Array.isArray(res.data) ? res.data : res.data?.list || []
+    pagination.total = res.data?.count ?? data.value.length
+  } finally {
+    loading.value = false
+  }
+}
+
+const refreshTable = () => {
+  pagination.current = 1
+  loadList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  loadList()
+}
+
+const handleDelete = async (record) => {
+  const res = await deleteSeat({ username: record.logonName, seatuuid: record.uuid })
+  if (res?.code !== 0) {
+    Notification.error({ title: '取消预约失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '取消预约成功' })
+  loadList()
+}
+
+const handleEndAhead = async (record) => {
+  const res = await endAhead({ username: record.logonName, seatuuid: record.uuid })
+  if (res?.code !== 0) {
+    Notification.error({ title: '提前结束失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '提前结束成功' })
+  loadList()
+}
+
+onMounted(loadAccounts)
+</script>

+ 302 - 0
src/pages/ic/rule.vue

@@ -0,0 +1,302 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="占座规则">
+      <div class="buttonGroup">
+        <a-button type="primary" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          添加占座规则
+        </a-button>
+        <a-button @click="loadList">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-alert closable style="margin-bottom: 16px">
+        占座将在每晚 22:30 开始,请在每天 22:20 前修改占座规则及志愿,否则修改可能无效。
+      </a-alert>
+
+      <a-table row-key="id" :data="data" :loading="loading" :pagination="false" :scroll="{ x: 900 }">
+        <template #columns>
+          <a-table-column title="规则名" data-index="name" :width="160" />
+          <a-table-column title="占座模式" :width="140">
+            <template #cell="{ record }">{{ RULE_MODE_MAP[record.mode] || record.mode }}</template>
+          </a-table-column>
+          <a-table-column title="执行次数" :width="120">
+            <template #cell="{ record }">{{ record.loop === 0 ? '单次' : '每周循环' }}</template>
+          </a-table-column>
+          <a-table-column title="执行星期" :width="180">
+            <template #cell="{ record }">
+              <span v-for="(day, index) in record.day_of_week || []" :key="day">
+                <span v-if="index">、</span>{{ getWeekdayLabel(day) }}
+              </span>
+            </template>
+          </a-table-column>
+          <a-table-column title="预约时间" :width="140">
+            <template #cell="{ record }">{{ record.beginTime }}~{{ record.endTime }}</template>
+          </a-table-column>
+          <a-table-column title="参与账号" :width="180" ellipsis tooltip>
+            <template #cell="{ record }">{{ (record.users || []).join('、') }}</template>
+          </a-table-column>
+          <a-table-column title="是否启用" :width="100">
+            <template #cell="{ record }">
+              <a-switch
+                :model-value="record.state === 1"
+                :loading="toggleLoading === record.id"
+                @change="() => toggleRule(record)"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="140" fixed="right">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button type="text" size="small" @click="openModal(record)">编辑</a-button>
+                <a-popconfirm content="删除后无法恢复,确认删除?" @ok="handleDelete(record)">
+                  <a-button type="text" status="danger" size="small">删除</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="form.id ? '编辑占座规则' : '添加占座规则'"
+      width="640px"
+      @before-ok="handleSave"
+      :ok-loading="saving"
+      draggable
+      esc-to-close
+    >
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="规则名称" required>
+          <a-input v-model="form.name" placeholder="仅供识别使用" />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="开始时间" required>
+              <a-time-picker v-model="form.beginTime" format="HH:mm" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="结束时间" required>
+              <a-time-picker v-model="form.endTime" format="HH:mm" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-form-item label="执行次数" required>
+          <a-radio-group v-model="form.loop">
+            <a-radio :value="0">单次执行</a-radio>
+            <a-radio :value="1">每周循环</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item v-if="form.loop === 1" label="执行星期" required>
+          <a-checkbox-group v-model="form.day_of_week">
+            <a-checkbox v-for="item in WEEKDAY_OPTIONS" :key="item.value" :value="item.value">
+              {{ item.label }}
+            </a-checkbox>
+          </a-checkbox-group>
+        </a-form-item>
+        <a-form-item label="执行模式" required>
+          <a-select v-model="form.mode" placeholder="请选择执行模式">
+            <a-option v-for="item in RULE_MODE_OPTIONS" :key="item.value" :value="item.value">
+              {{ item.label }}
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="参与账号" required>
+          <a-select v-model="form.users" multiple placeholder="请选择参与账号">
+            <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+              {{ item.username }}({{ item.realname || '未知' }})
+            </a-option>
+          </a-select>
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getJwAccount } from '@/api/jwAccount'
+import { getRule, saveRule, deleteRule } from '@/api/ic'
+import { RULE_MODE_MAP, RULE_MODE_OPTIONS, WEEKDAY_OPTIONS, getWeekdayLabel } from '@/utils/icTime'
+
+const loading = ref(false)
+const saving = ref(false)
+const visible = ref(false)
+const toggleLoading = ref(null)
+const data = ref([])
+const accounts = ref([])
+
+const defaultForm = () => ({
+  id: undefined,
+  name: '',
+  beginTime: undefined,
+  endTime: undefined,
+  loop: 0,
+  day_of_week: [],
+  mode: 0,
+  users: [],
+  state: 0
+})
+
+const form = reactive(defaultForm())
+
+const parseTime = (timeStr) => {
+  if (!timeStr) return undefined
+  const [h, m, s = '0'] = timeStr.split(':')
+  const d = new Date()
+  d.setHours(+h, +m, +s, 0)
+  return d
+}
+
+const formatTime = (date) => {
+  if (!date) return ''
+  const d = date instanceof Date ? date : new Date(date)
+  const h = String(d.getHours()).padStart(2, '0')
+  const m = String(d.getMinutes()).padStart(2, '0')
+  return `${h}:${m}:00`
+}
+
+const validateTime = (beginTime, endTime) => {
+  const begin = new Date(`1970-01-01T${beginTime}`)
+  const end = new Date(`1970-01-01T${endTime}`)
+  if (begin >= end) throw new Error('开始时间不得晚于结束时间')
+  const diffMin = (end - begin) / 60000
+  if (diffMin < 60 || diffMin > 880) throw new Error('预约时长应在 60~880 分钟内')
+  if (begin.getHours() < 8 || begin.getHours() > 22 || end.getHours() < 8 || end.getHours() > 22) {
+    throw new Error('预约时间必须在 8:00~22:00 之间')
+  }
+}
+
+const loadAccounts = async () => {
+  const res = await getJwAccount()
+  if (res?.code === 0) {
+    accounts.value = (res.data || []).filter((item) => item.state === 1)
+  }
+}
+
+const loadList = async () => {
+  loading.value = true
+  try {
+    const res = await getRule()
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取占座规则失败', content: res?.msg })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openModal = (record) => {
+  Object.assign(form, defaultForm())
+  if (record) {
+    Object.assign(form, {
+      id: record.id,
+      name: record.name,
+      beginTime: parseTime(record.beginTime),
+      endTime: parseTime(record.endTime),
+      loop: record.loop,
+      day_of_week: [...(record.day_of_week || [])],
+      mode: record.mode,
+      users: [...(record.users || [])],
+      state: record.state
+    })
+  }
+  visible.value = true
+}
+
+const handleSave = async () => {
+  if (!form.name?.trim()) {
+    Notification.error({ title: '请填写规则名称' })
+    return false
+  }
+  if (!form.beginTime || !form.endTime) {
+    Notification.error({ title: '请选择预约时间' })
+    return false
+  }
+  if (!form.users?.length) {
+    Notification.error({ title: '请选择参与账号' })
+    return false
+  }
+  saving.value = true
+  try {
+    const beginTime = formatTime(form.beginTime)
+    const endTime = formatTime(form.endTime)
+    validateTime(beginTime, endTime)
+    const res = await saveRule({
+      id: form.id,
+      name: form.name.trim(),
+      beginTime,
+      endTime,
+      loop: form.loop,
+      mode: form.mode,
+      users: form.users,
+      state: form.state,
+      day_of_week: form.loop === 1 ? form.day_of_week : []
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '保存失败', content: res?.msg })
+      return false
+    }
+    Notification.success({ title: '保存成功' })
+    await loadList()
+    return true
+  } catch (error) {
+    Notification.error({ title: '保存失败', content: error.message })
+    return false
+  } finally {
+    saving.value = false
+  }
+}
+
+const toggleRule = async (record) => {
+  toggleLoading.value = record.id
+  try {
+    const payload = { ...record, state: record.state === 0 ? 1 : 0 }
+    const res = await saveRule(payload)
+    if (res?.code !== 0) {
+      Notification.error({ title: '更改失败', content: res?.msg })
+      return
+    }
+    Notification.success({
+      title: `${payload.state === 1 ? '启用' : '禁用'}成功`,
+      content: payload.state === 1 ? '该规则将在预定时间执行' : '当前规则将不被执行'
+    })
+    loadList()
+  } finally {
+    toggleLoading.value = null
+  }
+}
+
+const handleDelete = async (record) => {
+  const res = await deleteRule({ id: record.id })
+  if (res?.code !== 0) {
+    Notification.error({ title: '删除失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '删除成功' })
+  loadList()
+}
+
+onMounted(async () => {
+  await loadAccounts()
+  await loadList()
+})
+</script>
+
+<style scoped>
+.buttonGroup {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+</style>

+ 171 - 0
src/pages/ic/star.vue

@@ -0,0 +1,171 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="座位志愿">
+      <div class="buttonGroup">
+        <a-button type="primary" @click="$router.push({ name: 'ic.freeSeats' })">
+          <template #icon><icon-plus /></template>
+          添加志愿
+        </a-button>
+        <a-button @click="loadList">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-table row-key="id" :data="data" :loading="loading" :pagination="false" :scroll="{ x: 700 }">
+        <template #columns>
+          <a-table-column title="座位名称" data-index="name" :width="200" />
+          <a-table-column title="志愿顺序" data-index="index" :width="120" sortable />
+          <a-table-column title="创建时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="状态" :width="100">
+            <template #cell="{ record }">
+              <a-switch
+                :model-value="record.state === 1"
+                :loading="toggleLoading === record.id"
+                @change="() => toggleStar(record)"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="140" fixed="right">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button type="text" size="small" @click="openModal(record)">编辑</a-button>
+                <a-popconfirm content="删除后无法恢复,确认删除?" @ok="handleDelete(record)">
+                  <a-button type="text" status="danger" size="small">删除</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      title="编辑志愿信息"
+      @before-ok="handleSave"
+      :ok-loading="saving"
+      draggable
+      esc-to-close
+    >
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="座位名称" required>
+          <a-input v-model="form.name" placeholder="仅供备注使用" />
+        </a-form-item>
+        <a-form-item label="志愿顺序" required>
+          <a-input-number v-model="form.index" placeholder="数字越小优先级越高" :min="0" style="width: 100%" />
+        </a-form-item>
+        <a-form-item label="状态" required>
+          <a-select v-model="form.state" placeholder="请选择启用状态">
+            <a-option :value="0">禁用</a-option>
+            <a-option :value="1">启用</a-option>
+          </a-select>
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getStar, saveStar, deleteStar } from '@/api/ic'
+import { formatTimestamp } from '@/utils/icTime'
+
+const loading = ref(false)
+const saving = ref(false)
+const visible = ref(false)
+const toggleLoading = ref(null)
+const data = ref([])
+
+const form = reactive({
+  id: undefined,
+  devId: undefined,
+  name: '',
+  index: 0,
+  state: 1
+})
+
+const loadList = async () => {
+  loading.value = true
+  try {
+    const res = await getStar()
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取志愿失败', content: res?.msg })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openModal = (record) => {
+  form.id = record.id
+  form.devId = record.devId
+  form.name = record.name
+  form.index = record.index
+  form.state = record.state
+  visible.value = true
+}
+
+const handleSave = async () => {
+  if (!form.name?.trim()) {
+    Notification.error({ title: '请填写座位名称' })
+    return false
+  }
+  saving.value = true
+  try {
+    const res = await saveStar({ ...form })
+    if (res?.code !== 0) {
+      Notification.error({ title: '保存失败', content: res?.msg })
+      return false
+    }
+    Notification.success({ title: '保存成功' })
+    await loadList()
+    return true
+  } finally {
+    saving.value = false
+  }
+}
+
+const toggleStar = async (record) => {
+  toggleLoading.value = record.id
+  try {
+    const payload = { ...record, state: record.state === 0 ? 1 : 0 }
+    const res = await saveStar(payload)
+    if (res?.code !== 0) {
+      Notification.error({ title: '更改失败', content: res?.msg })
+      return
+    }
+    Notification.success({ title: `${payload.state === 1 ? '启用' : '禁用'}成功` })
+    loadList()
+  } finally {
+    toggleLoading.value = null
+  }
+}
+
+const handleDelete = async (record) => {
+  const res = await deleteStar({ id: record.id })
+  if (res?.code !== 0) {
+    Notification.error({ title: '删除失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '删除成功' })
+  loadList()
+}
+
+onMounted(loadList)
+</script>
+
+<style scoped>
+.buttonGroup {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+</style>

+ 178 - 0
src/pages/jwAccount/accountList.vue

@@ -0,0 +1,178 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="账号管理">
+      <div class="buttonGroup">
+        <a-button type="primary" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          添加账号
+        </a-button>
+        <a-button @click="loadList">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-table row-key="id" :data="data" :loading="loading" :pagination="false" :scroll="{ x: 900 }">
+        <template #columns>
+          <a-table-column title="登录名" data-index="username" :width="140" />
+          <a-table-column title="姓名" :width="100">
+            <template #cell="{ record }">{{ record.realname || '未知' }}</template>
+          </a-table-column>
+          <a-table-column title="学院" :width="140">
+            <template #cell="{ record }">{{ record.deptName || '未知' }}</template>
+          </a-table-column>
+          <a-table-column title="班级" :width="140">
+            <template #cell="{ record }">{{ record.className || '未知' }}</template>
+          </a-table-column>
+          <a-table-column title="创建时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column title="状态" :width="100">
+            <template #cell="{ record }">
+              <a-tag v-if="record.state === 0" color="orangered">待验证</a-tag>
+              <a-tag v-else-if="record.state === 1" color="green">已验证</a-tag>
+              <a-tag v-else-if="record.state === 2" color="red">已失效</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="200" fixed="right">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button type="text" size="small" @click="handleActive(record)">验证</a-button>
+                <a-button type="text" size="small" @click="openModal(record)">编辑</a-button>
+                <a-popconfirm content="删除后无法恢复,确认删除?" @ok="handleDelete(record)">
+                  <a-button type="text" status="danger" size="small">删除</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="form.id ? '编辑账号' : '添加账号'"
+      @before-ok="handleSave"
+      :ok-loading="saving"
+      draggable
+      esc-to-close
+    >
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="用户名" required>
+          <a-input v-model="form.username" placeholder="统一身份认证用户名" allow-clear />
+        </a-form-item>
+        <a-form-item label="密码" required>
+          <a-input-password v-model="form.password" placeholder="统一身份认证密码" allow-clear />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification, Modal } from '@arco-design/web-vue'
+import { getJwAccount, saveJwAccount, deleteJwAccount, activeJwAccount } from '@/api/jwAccount'
+import { formatTimestamp } from '@/utils/icTime'
+
+const loading = ref(false)
+const saving = ref(false)
+const visible = ref(false)
+const data = ref([])
+const form = reactive({ id: undefined, username: '', password: '' })
+
+const loadList = async () => {
+  loading.value = true
+  try {
+    const res = await getJwAccount()
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取账号列表失败', content: res?.msg || '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } catch (error) {
+    Notification.error({ title: '请求失败', content: error.message || '网络错误' })
+  } finally {
+    loading.value = false
+  }
+}
+
+const openModal = (record) => {
+  form.id = record?.id
+  form.username = record?.username || ''
+  form.password = ''
+  visible.value = true
+}
+
+const handleSave = async () => {
+  if (!form.username?.trim()) {
+    Notification.error({ title: '请填写用户名' })
+    return false
+  }
+  if (!form.password?.trim()) {
+    Notification.error({ title: '请填写密码' })
+    return false
+  }
+  saving.value = true
+  try {
+    const res = await saveJwAccount({
+      id: form.id,
+      username: form.username.trim(),
+      password: btoa(form.password)
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '保存失败', content: res?.msg || '请稍后再试' })
+      return false
+    }
+    Notification.success({ title: '保存成功', content: '请及时验证账号' })
+    await loadList()
+    return true
+  } catch (error) {
+    Notification.error({ title: '保存失败', content: error.message || '请稍后再试' })
+    return false
+  } finally {
+    saving.value = false
+  }
+}
+
+const handleActive = (record) => {
+  Modal.confirm({
+    title: '验证账号',
+    content: `您正在验证统一身份认证账号 ${record.username},请确保用户名和密码无误。`,
+    onOk: async () => {
+      const res = await activeJwAccount({ username: record.username })
+      if (res?.code !== 0) {
+        Notification.error({ title: '验证失败', content: res?.msg || '请稍后再试' })
+        return
+      }
+      Notification.success({ title: '验证成功', content: '该账号现在已可正常使用' })
+      loadList()
+    }
+  })
+}
+
+const handleDelete = async (record) => {
+  const res = await deleteJwAccount({ id: record.id })
+  if (res?.code !== 0) {
+    Notification.error({ title: '删除失败', content: res?.msg || '请稍后再试' })
+    return
+  }
+  Notification.success({ title: '删除成功' })
+  loadList()
+}
+
+onMounted(loadList)
+</script>
+
+<style scoped>
+.buttonGroup {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+</style>

+ 49 - 29
src/pages/qk/taskList.vue

@@ -62,10 +62,15 @@
                   </a-select>
                 </a-form-item>
               </a-col>
+              <a-col :span="6">
+                <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: 42px" direction="vertical" />
+        <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">
@@ -92,7 +97,6 @@
         :pagination="false"
       >
         <template #columns>
-          <a-table-column title="ID" data-index="id" :width="80" />
           <a-table-column title="学号" data-index="student_num" :width="130" />
           <a-table-column title="选课批次" :width="220" ellipsis tooltip>
             <template #cell="{ record }">
@@ -190,23 +194,30 @@
             </a-option>
           </a-select>
         </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="passwordPlaceholder" />
-            </a-form-item>
-          </a-col>
-        </a-row>
+        <a-form-item label="统一身份认证账号" required>
+          <a-select
+            v-model="form.jw_account_id"
+            placeholder="请选择已验证的统一身份认证账号"
+            allow-search
+            allow-clear
+          >
+            <a-option v-for="item in jwAccounts" :key="item.id" :value="item.id">
+              {{ item.username }}({{ item.realname || '未知' }})
+            </a-option>
+          </a-select>
+          <template #extra>
+            <span v-if="jwAccounts.length === 0">
+              暂无可用账号,请先到
+              <a-link @click="$router.push({ name: 'jwAccount.accountList' })">统一身份认证</a-link>
+              添加并验证
+            </span>
+          </template>
+        </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-form-item label="课程分组(体育课请在此填写项目名称)">
           <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
         </a-form-item>
         <a-row :gutter="16">
@@ -238,6 +249,7 @@
 import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
 import { Notification } from '@arco-design/web-vue'
 import { getQkTask, getQkTaskDetail, saveQkTask, deleteQkTask, startQkTask, pauseQkTask, getQkBatchList } from '@/api/qk'
+import { getJwAccount } from '@/api/jwAccount'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { hasAnyPermission, QK_ASSISTANT_CONTROL_CODES, QK_ASSISTANT_SUBMIT_CODES } from '@/utils/permission'
 import {
@@ -258,18 +270,19 @@ const visible = ref(false)
 const isCopy = ref(false)
 const data = ref([])
 const batchOptions = ref([])
+const jwAccounts = ref([])
 const query = reactive({
   status: '',
   student_num: '',
   name: '',
-  batch_id: ''
+  batch_id: '',
+  course_name: ''
 })
 const form = reactive({
   id: '',
   name: '',
   batch_id: '',
-  student_num: '',
-  password: '',
+  jw_account_id: undefined,
   coursesText: '',
   courseGroupsText: '',
   enable_ggxxk: true,
@@ -287,11 +300,6 @@ const modalTitle = computed(() => {
   if (isCopy.value) return '复制抢课任务'
   return '提交抢课任务'
 })
-const passwordPlaceholder = computed(() => {
-  if (form.id) return '已自动填充,修改后保存将更新密码'
-  if (isCopy.value) return '已自动填充'
-  return '请输入教务密码'
-})
 
 const splitLines = splitQkLines
 
@@ -338,8 +346,7 @@ const resetForm = () => {
     id: '',
     name: '',
     batch_id: '',
-    student_num: '',
-    password: '',
+    jw_account_id: jwAccounts.value[0]?.id,
     coursesText: '',
     courseGroupsText: '',
     enable_ggxxk: true,
@@ -353,8 +360,7 @@ const fillFormFromTask = (task, { copy = false } = {}) => {
     id: copy ? '' : task.id,
     name: task.name,
     batch_id: task.batch_id || '',
-    student_num: task.student_num,
-    password: task.password || '',
+    jw_account_id: task.jw_account_id || jwAccounts.value.find(item => item.username === task.student_num)?.id,
     coursesText: (task.courses || []).join('\n'),
     courseGroupsText: (task.course_groups || []).join('\n'),
     enable_ggxxk: task.enable_ggxxk,
@@ -408,6 +414,18 @@ const copyTask = async (record) => {
   }
 }
 
+const loadJwAccounts = async () => {
+  try {
+    const res = await getJwAccount()
+    if (res?.code === 0) {
+      jwAccounts.value = (res.data || []).filter(item => item.state === 1)
+      if (!form.jw_account_id && jwAccounts.value.length) {
+        form.jw_account_id = jwAccounts.value[0].id
+      }
+    }
+  } catch (_) {}
+}
+
 const loadBatches = async () => {
   try {
     const res = await getQkBatchList()
@@ -425,6 +443,7 @@ const getList = async (silent = false) => {
     if (query.student_num) params.student_num = query.student_num
     if (query.name) params.name = query.name
     if (query.batch_id) params.batch_id = query.batch_id
+    if (query.course_name) params.course_name = query.course_name
     const res = await getQkTask(params)
     if (!res || res.code !== 0) {
       if (!silent) {
@@ -449,6 +468,7 @@ const resetQuery = () => {
   query.student_num = ''
   query.name = ''
   query.batch_id = ''
+  query.course_name = ''
   getList()
 }
 
@@ -466,8 +486,7 @@ const submitTask = async () => {
       id: form.id || undefined,
       name: validated.name,
       batch_id: validated.batch_id,
-      student_num: validated.student_num,
-      password: validated.password,
+      jw_account_id: validated.jw_account_id,
       courses: validated.courses,
       course_groups: validated.courseGroups,
       enable_ggxxk: form.enable_ggxxk,
@@ -533,6 +552,7 @@ const stopPolling = () => {
 }
 
 onMounted(() => {
+  loadJwAccounts()
   loadBatches()
   getList()
   startPolling()

+ 64 - 9
src/router/index.js

@@ -244,6 +244,70 @@ const routes = [
             }
         ]
     },
+    // {
+    //     path: '/jwAccount',
+    //     name: 'jwAccount',
+    //     redirect: '/jwAccount/accountList',
+    //     component: DEFAULT_LAYOUT,
+    //     meta: {
+    //         title: '统一身份认证',
+    //         icon: 'icon-idcard'
+    //     },
+    //     children: [
+    //         {
+    //             path: 'accountList',
+    //             name: 'jwAccount.accountList',
+    //             component: () => import('../pages/jwAccount/accountList.vue'),
+    //             meta: {
+    //                 title: '账号管理'
+    //             }
+    //         }
+    //     ]
+    // },
+    // {
+    //     path: '/ic',
+    //     name: 'ic',
+    //     redirect: '/ic/freeSeats',
+    //     component: DEFAULT_LAYOUT,
+    //     meta: {
+    //         title: '图书馆占座',
+    //         icon: 'icon-bookmark'
+    //     },
+    //     children: [
+    //         {
+    //             path: 'freeSeats',
+    //             name: 'ic.freeSeats',
+    //             component: () => import('../pages/ic/freeSeats.vue'),
+    //             meta: {
+    //                 title: '座位查询'
+    //             }
+    //         },
+    //         {
+    //             path: 'myOrder',
+    //             name: 'ic.myOrder',
+    //             component: () => import('../pages/ic/myOrder.vue'),
+    //             meta: {
+    //                 title: '我的预约'
+    //             }
+    //         },
+    //         {
+    //             path: 'star',
+    //             name: 'ic.star',
+    //             component: () => import('../pages/ic/star.vue'),
+    //             meta: {
+    //                 title: '座位志愿'
+    //             }
+    //         },
+    //         {
+    //             path: 'rule',
+    //             name: 'ic.rule',
+    //             component: () => import('../pages/ic/rule.vue'),
+    //             meta: {
+    //                 title: '占座规则'
+    //             }
+    //         }
+    //     ]
+    // },
     {
         path: "/service",
         name: 'service',
@@ -367,15 +431,6 @@ const routes = [
                     permission: ['admin', 'service']
                 }
             },
-            {
-                path: 'lepaoProxy',
-                name: 'admin.lepaoProxy',
-                component: () => import('../pages/admin/lepaoProxy/index.vue'),
-                meta: {
-                    title: '网络代理',
-                    permission: ['admin', 'service']
-                }
-            },
             {
                 path: 'lepaoRecords/:id',
                 name: 'admin.lepaoRecords.detail',

+ 58 - 0
src/utils/icTime.js

@@ -0,0 +1,58 @@
+export function formatTimestamp(time, options = {}) {
+  if (!time || time < 10) return options.fallback ?? '-'
+  return new Date(+time).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: options.withSeconds ? '2-digit' : undefined
+  })
+}
+
+export function formatTimeOnly(time) {
+  if (!time || time < 10) return '-'
+  return new Date(+time).toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' })
+}
+
+export function formatDateStr(date) {
+  const y = date.getFullYear()
+  const m = String(date.getMonth() + 1).padStart(2, '0')
+  const d = String(date.getDate()).padStart(2, '0')
+  return `${y}-${m}-${d}`
+}
+
+export function formatDateCompact(date) {
+  const y = date.getFullYear()
+  const m = String(date.getMonth() + 1).padStart(2, '0')
+  const d = String(date.getDate()).padStart(2, '0')
+  return `${y}${m}${d}`
+}
+
+export const WEEKDAY_OPTIONS = [
+  { label: '星期日', value: 0 },
+  { label: '星期一', value: 1 },
+  { label: '星期二', value: 2 },
+  { label: '星期三', value: 3 },
+  { label: '星期四', value: 4 },
+  { label: '星期五', value: 5 },
+  { label: '星期六', value: 6 }
+]
+
+export function getWeekdayLabel(day) {
+  return WEEKDAY_OPTIONS.find((item) => item.value === day)?.label || '未知'
+}
+
+export const RULE_MODE_MAP = {
+  0: '随机占座',
+  1: '志愿顺序占座',
+  2: '志愿组顺序占座',
+  3: '志愿优先',
+  4: '志愿组优先'
+}
+
+export const RULE_MODE_OPTIONS = [
+  { label: '随机占座', value: 0 },
+  { label: '志愿顺序占座', value: 1 },
+  { label: '志愿优先', value: 3 }
+]

+ 3 - 8
src/utils/qkTaskRules.js

@@ -48,12 +48,8 @@ export function validateQkTaskForm(form) {
   if (!form?.batch_id) {
     return { ok: false, message: '请选择选课批次' }
   }
-  const studentNum = String(form?.student_num || '').trim()
-  if (!studentNum) {
-    return { ok: false, message: '请填写学号' }
-  }
-  if (!form?.id && !String(form?.password || '').trim()) {
-    return { ok: false, message: '新建任务需要填写教务密码' }
+  if (!form?.jw_account_id) {
+    return { ok: false, message: '请选择统一身份认证账号' }
   }
   const targetValidation = validateQkTaskTargets(
     form.coursesText,
@@ -67,8 +63,7 @@ export function validateQkTaskForm(form) {
     ok: true,
     name,
     batch_id: form.batch_id,
-    student_num: studentNum,
-    password: form.password,
+    jw_account_id: form.jw_account_id,
     courses: targetValidation.courses,
     courseGroups: targetValidation.courseGroups,
     intervalMs: Number(form.interval_ms),