Browse Source

✨ feat: 增加代理功能

Pchen0 1 month ago
parent
commit
ad29510cb3

+ 65 - 0
src/api/adminProxy.js

@@ -0,0 +1,65 @@
+import request from '../utils/request'
+
+/** 乐跑代理池(管理端),与 ic-ctbu-backend /Admin/Lepao/Proxy/* 对应 */
+export function adminProxyGlobalGet (parameter) {
+  return request({
+    url: '/Admin/Lepao/Proxy/Global',
+    method: 'get',
+    params: parameter,
+    timeout: 30000
+  })
+}
+
+export function adminProxySetGlobal (data) {
+  return request({
+    url: '/Admin/Lepao/Proxy/SetGlobal',
+    method: 'post',
+    data,
+    timeout: 30000
+  })
+}
+
+export function adminProxyList (parameter) {
+  return request({
+    url: '/Admin/Lepao/Proxy/List',
+    method: 'get',
+    params: parameter,
+    timeout: 30000
+  })
+}
+
+export function adminProxyImportText (data) {
+  return request({
+    url: '/Admin/Lepao/Proxy/ImportText',
+    method: 'post',
+    data,
+    timeout: 120000
+  })
+}
+
+export function adminProxyImportUrl (data) {
+  return request({
+    url: '/Admin/Lepao/Proxy/ImportUrl',
+    method: 'post',
+    data,
+    timeout: 120000
+  })
+}
+
+export function adminProxyBatchCheck (data) {
+  return request({
+    url: '/Admin/Lepao/Proxy/BatchCheck',
+    method: 'post',
+    data,
+    timeout: 600000
+  })
+}
+
+export function adminProxyDelete (data) {
+  return request({
+    url: '/Admin/Lepao/Proxy/Delete',
+    method: 'delete',
+    data,
+    timeout: 30000
+  })
+}

+ 138 - 17
src/pages/admin/lepaoAccount/accountList.vue

@@ -149,10 +149,28 @@
                     <div v-else-if="record.state === 1" class="state">
                     <div v-else-if="record.state === 1" class="state">
                         <div class="circle one"></div>正常
                         <div class="circle one"></div>正常
                     </div>
                     </div>
+                    <div v-else-if="record.state === 3" class="state">
+                        <div class="circle three"></div>统一认证失败
+                    </div>
                     <div v-else class="state">
                     <div v-else class="state">
                         <div class="circle else"></div>状态异常
                         <div class="circle else"></div>状态异常
                     </div>
                     </div>
                 </template>
                 </template>
+                <template #jw_bind="{ record }">
+                    <a-space size="mini" wrap>
+                        <a-tag :color="jwTagColor(record)">{{ jwBindLabel(record) }}</a-tag>
+                    </a-space>
+                </template>
+                <template #jw_password="{ record }">
+                    <a-space v-if="record.jw_password" size="mini">
+                        <span class="jw-pw-mask" :title="record.jw_password">{{ maskJwPassword(record.jw_password)
+                        }}</span>
+                        <a-button type="text" size="mini" @click.stop="copyJwPassword(record.jw_password)">
+                            复制
+                        </a-button>
+                    </a-space>
+                    <span v-else class="muted">-</span>
+                </template>
                 <template #face_state="{ record }">
                 <template #face_state="{ record }">
                     <div v-if="record.face_state === 0" class="state">
                     <div v-if="record.face_state === 0" class="state">
                         <div class="circle zero"></div>未采集
                         <div class="circle zero"></div>未采集
@@ -200,11 +218,29 @@
             <a-form-item field="student_num" label="学号">
             <a-form-item field="student_num" label="学号">
                 <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" />
                 <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" />
             </a-form-item>
             </a-form-item>
-            <a-form-item field="jw_password" label="教务密码" :required="!form.id">
-                <a-input-password v-model="form.jw_password" placeholder="统一身份认证密码(与学号对应)" allow-clear />
+            <template v-if="editSourceRecord && form.id">
+                <a-form-item label="统一认证状态">
+                    <a-space size="mini" wrap align="center">
+                        <a-tag :color="jwTagColor(editSourceRecord)">{{ jwBindLabel(editSourceRecord) }}</a-tag>
+                    </a-space>
+                </a-form-item>
+                <a-form-item label="统一认证密码">
+                    <a-space direction="vertical" fill>
+                        <template v-if="editSourceRecord.jw_password">
+                            <a-space size="mini">
+                                <span class="jw-pw-mask">{{ maskJwPassword(editSourceRecord.jw_password) }}</span>
+                                <a-button size="mini" type="outline" @click="copyJwPassword(editSourceRecord.jw_password)">
+                                    复制明文</a-button>
+                            </a-space>
+                        </template>
+                        <span v-else class="muted">暂无(用户未绑定或暂未同步到数据库)</span>
+                    </a-space>
+                </a-form-item>
+            </template>
+            <a-form-item field="jw_password" label="修改统一认证密码">
+                <a-input-password v-model="form.jw_password" placeholder="如需更新请输入新密码;留空不修改" allow-clear />
                 <template #extra>
                 <template #extra>
-                    <span v-if="!form.id">新绑定必填,将写入教务账号并校验 WebVPN。</span>
-                    <span v-else>留空不修改密码;填写则更新并重新校验。</span>
+                    <span>保存后将经 WebVPN 校验并写回服务端。</span>
                 </template>
                 </template>
             </a-form-item>
             </a-form-item>
             <a-form-item field="notice_type" label="通知方式">
             <a-form-item field="notice_type" label="通知方式">
@@ -308,6 +344,9 @@ import { getSemesterTimestamps } from '@/utils/util'
 
 
 const faceRecoRef = ref(null)
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)
 const bindBotRef = ref(null)
+/** 编辑弹窗:用于展示服务端返回的教务状态与密码快照(列表项) */
+const editSourceRecord = ref(null)
+
 const bindAuditVisible = ref(false)
 const bindAuditVisible = ref(false)
 const bindAuditLoading = ref(false)
 const bindAuditLoading = ref(false)
 const bindAuditData = ref([])
 const bindAuditData = ref([])
@@ -336,7 +375,6 @@ const ok_loading = ref(false)
 const form = reactive({
 const form = reactive({
     id: null,
     id: null,
     student_num: '',
     student_num: '',
-    jw_password: '',
     email: '',
     email: '',
     area: '',
     area: '',
     notice_type: 'email',
     notice_type: 'email',
@@ -344,7 +382,8 @@ const form = reactive({
     auto_time: -1,
     auto_time: -1,
     target_count: 30,
     target_count: 30,
     auto_day: [0, 1, 2, 3, 4, 5, 6],
     auto_day: [0, 1, 2, 3, 4, 5, 6],
-    notes: ''
+    notes: '',
+    jw_password: ''
 })
 })
 
 
 const loading = ref(false)
 const loading = ref(false)
@@ -361,7 +400,11 @@ const auto_day = [
 ]
 ]
 
 
 const state = [
 const state = [
-    { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
+    { label: '全部', value: -1 },
+    { label: '需登录', value: 0 },
+    { label: '正常', value: 1 },
+    { label: '状态异常', value: 2 },
+    { label: '统一认证失败', value: 3 }
 ]
 ]
 const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
 const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
 
 
@@ -402,7 +445,17 @@ const columns = [
     }, {
     }, {
         title: '帐号状态',
         title: '帐号状态',
         slotName: 'state',
         slotName: 'state',
+        width: 120
+    }, {
+        title: '统一认证',
+        slotName: 'jw_bind',
         width: 100
         width: 100
+    }, {
+        title: '统一认证密码',
+        slotName: 'jw_password',
+        width: 140,
+        ellipsis: true,
+        tooltip: true
     }, {
     }, {
         title: '乐跑跑区',
         title: '乐跑跑区',
         dataIndex: 'area',
         dataIndex: 'area',
@@ -513,11 +566,47 @@ const getAccounts = async () => {
     }
     }
 }
 }
 
 
+const jwBindLabel = (record) => {
+    const st = record?.jw_state
+    if (st === null || st === undefined) return '未绑定'
+    if (Number(st) === 1) return '已激活'
+    if (Number(st) === 0) return '未激活'
+    if (Number(st) === 2) return '已失效'
+    return '未知'
+}
+
+const jwTagColor = (record) => {
+    const st = record?.jw_state
+    if (st === null || st === undefined) return 'gray'
+    if (Number(st) === 1) return 'green'
+    if (Number(st) === 0) return 'orangered'
+    if (Number(st) === 2) return 'red'
+    return 'gray'
+}
+
+const maskJwPassword = (pwd) => {
+    const s = String(pwd || '')
+    if (!s) return '-'
+    if (s.length <= 2) return '•'.repeat(s.length)
+    return `${s.slice(0, 2)}${'•'.repeat(Math.min(8, s.length - 2))}${s.length > 4 ? s.slice(-1) : ''}`
+}
+
+const copyJwPassword = async (pwd) => {
+    const t = String(pwd || '')
+    if (!t) return
+    try {
+        await navigator.clipboard.writeText(t)
+        Message.success('密码已复制到剪贴板')
+    } catch {
+        Message.error('复制失败,请手动选择复制')
+    }
+}
+
 const editAccount = (item) => {
 const editAccount = (item) => {
     if (item) {
     if (item) {
+        editSourceRecord.value = { ...item }
         form.id = item.id
         form.id = item.id
         form.student_num = item.student_num
         form.student_num = item.student_num
-        form.jw_password = ''
         form.email = item.email
         form.email = item.email
         form.area = item.area
         form.area = item.area
         form.auto_run = item.auto_run
         form.auto_run = item.auto_run
@@ -526,16 +615,18 @@ const editAccount = (item) => {
         form.notice_type = item.notice_type || 'email'
         form.notice_type = item.notice_type || 'email'
         form.target_count = item.target_count
         form.target_count = item.target_count
         form.notes = item.notes
         form.notes = item.notes
+        form.jw_password = ''
     } else {
     } else {
+        editSourceRecord.value = null
         form.id = null
         form.id = null
         form.student_num = ''
         form.student_num = ''
-        form.jw_password = ''
         form.auto_run = 1
         form.auto_run = 1
         form.auto_time = 7
         form.auto_time = 7
         form.target_count = 30
         form.target_count = 30
         form.auto_day = [0, 1, 2, 3, 4, 5, 6]
         form.auto_day = [0, 1, 2, 3, 4, 5, 6]
         form.email = ''
         form.email = ''
         form.notes = ''
         form.notes = ''
+        form.jw_password = ''
     }
     }
     visible.value = true
     visible.value = true
 }
 }
@@ -555,23 +646,23 @@ const handleBeforeOk = async (done) => {
             return false
             return false
         }
         }
 
 
-        const jwPlain = (form.jw_password || '').trim()
-        if (!form.id && !jwPlain) {
-            Message.error('请填写教务系统密码(统一身份认证密码)')
-            return false
-        }
-
         const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
         const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
         if (form.notice_type === 'email' && !emailRegex.test(email)) {
         if (form.notice_type === 'email' && !emailRegex.test(email)) {
             Message.error('请检查邮箱格式是否正确')
             Message.error('请检查邮箱格式是否正确')
             return false
             return false
         }
         }
 
 
+        const jwPlain = (form.jw_password || '').trim()
+        if (!form.id && !jwPlain) {
+            Message.error('新绑定时请填写统一认证密码')
+            return false
+        }
         const data = { ...form }
         const data = { ...form }
         delete data.jw_password
         delete data.jw_password
         if (jwPlain) {
         if (jwPlain) {
             data.jw_password = btoa(jwPlain)
             data.jw_password = btoa(jwPlain)
         }
         }
+
         const res = await addAccount(data)
         const res = await addAccount(data)
         if (!res || res.code !== 0) {
         if (!res || res.code !== 0) {
             Notification.error({
             Notification.error({
@@ -599,7 +690,8 @@ const handleBeforeOk = async (done) => {
 }
 }
 
 
 const handleCancel = () => {
 const handleCancel = () => {
-    visible.value = false;
+    visible.value = false
+    editSourceRecord.value = null
 }
 }
 
 
 // 分页 - 页码变化
 // 分页 - 页码变化
@@ -656,10 +748,16 @@ const ChangeAutoRun = async (record) => {
     }
     }
 }
 }
 
 
+const singleRunBlockTitle = (state) => {
+    if (state === 3) return '统一认证失败,请核对统一认证密码后重新尝试'
+    if (state === 2) return '账号状态异常,请更新账号信息后再试'
+    return '当前乐跑账号需登录,请登录后再试'
+}
+
 const SingleRun = async (item) => {
 const SingleRun = async (item) => {
     if (item.state !== 1)
     if (item.state !== 1)
         return Notification.warning({
         return Notification.warning({
-            title: '当前乐跑账号需登录,请登录后再试',
+            title: singleRunBlockTitle(item.state),
             content: '如有疑问请联系客服'
             content: '如有疑问请联系客服'
         })
         })
     Modal.confirm({
     Modal.confirm({
@@ -853,6 +951,29 @@ const autoTimeLabel = (record) => {
         .else {
         .else {
             background-color: rgb(var(--red-6));
             background-color: rgb(var(--red-6));
         }
         }
+
+        .three {
+            background-color: rgb(var(--magenta-6));
+        }
     }
     }
 }
 }
+
+.jw-realname {
+    color: rgb(var(--gray-6));
+    font-size: 12px;
+}
+
+.jw-pw-mask {
+    font-family: ui-monospace, monospace;
+    font-size: 12px;
+    max-width: 160px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.muted {
+    color: rgb(var(--gray-6));
+    font-size: 12px;
+}
 </style>
 </style>

+ 402 - 0
src/pages/admin/proxyPool/index.vue

@@ -0,0 +1,402 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['网站管理', '乐跑代理池']" />
+
+    <a-card title="全局策略" class="card-block">
+      <a-spin :loading="globalLoading">
+        <a-row :gutter="16">
+          <a-col :span="24">
+            <a-space wrap>
+              <span>为乐跑绑定账号粘性分配出站代理(网络失败轮换,全失败直连)</span>
+              <a-switch v-model="global.random_proxy_enabled" :checked-value="1" :unchecked-value="0" />
+              <span class="muted">{{ global.random_proxy_enabled === 1 ? '已开启' : '已关闭' }}</span>
+              <a-button type="primary" :loading="savingGlobal" @click="saveGlobal">保存全局配置</a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+        <a-form :model="global" layout="vertical" class="global-form">
+          <a-row :gutter="16">
+            <a-col :span="24">
+              <a-form-item label="代理列表拉取 URL(Corn / 一键导入会使用;留空则用库内默认值)">
+                <a-input v-model="global.import_url" placeholder="http://..." allow-clear />
+              </a-form-item>
+            </a-col>
+            <a-col :span="24">
+              <a-form-item label="可用性探测目标 URL">
+                <a-input v-model="global.probe_target_url" placeholder="https://..." allow-clear />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="12">
+              <a-form-item label="探测超时 (ms)">
+                <a-input-number v-model="global.check_timeout_ms" :min="500" :max="120000" mode="button" />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="12">
+              <a-form-item label="探测并发上限">
+                <a-input-number v-model="global.check_concurrency" :min="1" :max="50" mode="button" />
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </a-form>
+      </a-spin>
+    </a-card>
+
+    <a-card title="代理地址" class="card-block">
+      <template #extra>
+        <a-space wrap>
+          <a-select v-model="filters.is_active" :style="{ width: '170px' }" @change="onFilterChange">
+            <a-option value="">可用性:全部</a-option>
+            <a-option value="1">仅可用</a-option>
+            <a-option value="0">仅不可用</a-option>
+          </a-select>
+          <a-button :loading="listLoading" @click="loadList">
+            <template #icon><icon-refresh /></template>
+            刷新列表
+          </a-button>
+          <a-button type="outline" @click="openImportModal">粘贴导入</a-button>
+          <a-button type="outline" @click="doImportUrl">从 URL 导入</a-button>
+          <a-button type="outline" status="danger" @click="checkSelected">
+            <template #icon><icon-thunderbolt /></template>
+            批量检测所选
+          </a-button>
+          <a-button type="primary" :loading="checkAllLoading" @click="checkAll">
+            检测全部代理
+          </a-button>
+        </a-space>
+      </template>
+
+      <a-table
+        row-key="id"
+        :data="rows"
+        :loading="listLoading"
+        :pagination="{
+          showPageSize: true,
+          showTotal: true,
+          pageSizeOptions: pagination.pageSizeOptions,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        :row-selection="{
+          type: 'checkbox',
+          showCheckedAll: true,
+          selectedRowKeys,
+          onChange: onSelectChange
+        }"
+        class="table"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="代理" :width="260">
+            <template #cell="{ record }">{{ record.scheme }}://{{ record.host }}:{{ record.port }}</template>
+          </a-table-column>
+          <a-table-column title="IP属地" data-index="ip_location" :width="180" ellipsis tooltip>
+            <template #cell="{ record }">{{ record.ip_location || '未知' }}</template>
+          </a-table-column>
+          <a-table-column title="状态" data-index="is_active" :width="100">
+            <template #cell="{ record }">
+              <a-tag v-if="Number(record.is_active) === 1" color="green">可用</a-tag>
+              <a-tag v-else color="red">不可用</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="延迟(ms)" data-index="latency_ms" :width="100">
+            <template #cell="{ record }">{{ record.latency_ms != null ? record.latency_ms : '—' }}</template>
+          </a-table-column>
+          <a-table-column title="最近检测" :width="180">
+            <template #cell="{ record }">{{ fmtTime(record.last_check_at) }}</template>
+          </a-table-column>
+          <a-table-column title="绑定的乐跑账号数" data-index="account_count" :width="140" />
+          <a-table-column title="来源" data-index="source" :width="100" ellipsis tooltip />
+          <a-table-column title="操作" :width="110" fixed="right">
+            <template #cell="{ record }">
+              <a-popconfirm content="删除该代理?(已绑定账号的 assigned_proxy_id 将清空)" @ok="delOne(record.id)">
+                <a-button type="text" status="danger" size="small">删除</a-button>
+              </a-popconfirm>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="importModalVisible" title="粘贴导入(每行 ip:port 或 http(s)://ip:port)" :footer="false">
+      <a-textarea v-model="importText" :auto-size="{ minRows: 8, maxRows: 22 }" placeholder="示例:&#10;1.2.3.4:8080&#10;http://5.6.7.8:8123" />
+      <div style="margin-top:12px;text-align:right">
+        <a-button style="margin-right:8px" @click="importModalVisible=false">取消</a-button>
+        <a-button type="primary" :loading="importLoading" @click="submitImport">导入</a-button>
+      </div>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import {
+  adminProxyBatchCheck,
+  adminProxyDelete,
+  adminProxyGlobalGet,
+  adminProxyImportText,
+  adminProxyImportUrl,
+  adminProxyList,
+  adminProxySetGlobal
+} from '@/api/adminProxy'
+import { Notification } from '@arco-design/web-vue'
+
+const globalLoading = ref(false)
+const savingGlobal = ref(false)
+const listLoading = ref(false)
+const checkAllLoading = ref(false)
+const rows = ref([])
+
+const global = reactive({
+  random_proxy_enabled: 0,
+  import_url: '',
+  probe_target_url: 'https://www.baidu.com',
+  check_timeout_ms: 8000,
+  check_concurrency: 10
+})
+
+const pagination = reactive({
+  current: 1,
+  pagesize: 20,
+  total: 0,
+  showTotal: true,
+  showPageSize: true,
+  pageSizeOptions: [10, 20, 50, 100]
+})
+
+const selectedKeys = ref([])
+const importLoading = ref(false)
+const filters = reactive({
+  is_active: ''
+})
+
+function onSelectChange (keys) {
+  selectedKeys.value = keys
+}
+
+const importModalVisible = ref(false)
+const importText = ref('')
+
+const fmtTime = (ts) => {
+  if (ts == null || ts === '') return '—'
+  const n = Number(ts)
+  if (!Number.isFinite(n)) return '—'
+  return new Date(n).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit'
+  })
+}
+
+async function loadGlobal () {
+  globalLoading.value = true
+  try {
+    const res = await adminProxyGlobalGet({})
+    if (!res || res.code !== 0 || !res.data) {
+      Notification.error({ title: '加载全局配置失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Object.assign(global, res.data)
+  } catch (e) {
+    Notification.error({ title: '加载全局配置失败', content: e.message })
+  } finally {
+    globalLoading.value = false
+  }
+}
+
+async function saveGlobal () {
+  savingGlobal.value = true
+  try {
+    const res = await adminProxySetGlobal({
+      random_proxy_enabled: Number(global.random_proxy_enabled) === 1 ? 1 : 0,
+      import_url: global.import_url,
+      probe_target_url: global.probe_target_url,
+      check_timeout_ms: global.check_timeout_ms,
+      check_concurrency: global.check_concurrency
+    })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '保存失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Notification.success({ title: '保存成功', content: '全局配置已更新' })
+    await loadGlobal()
+  } catch (e) {
+    Notification.error({ title: '保存失败', content: e.message })
+  } finally {
+    savingGlobal.value = false
+  }
+}
+
+async function loadList () {
+  listLoading.value = true
+  try {
+    const res = await adminProxyList({
+      pagesize: pagination.pagesize,
+      current: pagination.current,
+      is_active: filters.is_active
+    })
+    if (!res || res.code !== 0 || !Array.isArray(res.data)) {
+      Notification.error({ title: '加载代理列表失败', content: res?.msg || '未知错误' })
+      return
+    }
+    rows.value = res.data.map((row) => ({ ...row, account_count: row.account_count ?? 0 }))
+    pagination.total = res.pagination?.total ?? rows.value.length
+  } catch (e) {
+    Notification.error({ title: '加载代理列表失败', content: e.message })
+  } finally {
+    listLoading.value = false
+  }
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  loadList()
+}
+
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  loadList()
+}
+
+const onFilterChange = () => {
+  pagination.current = 1
+  loadList()
+}
+
+const openImportModal = () => {
+  importModalVisible.value = true
+}
+
+const submitImport = async () => {
+  importLoading.value = true
+  try {
+    const res = await adminProxyImportText({ text: importText.value })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '导入失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Notification.success({
+      title: '导入完成',
+      content: res.data?.message || `条目 ${res.data?.imported ?? 0}`
+    })
+    importModalVisible.value = false
+    importText.value = ''
+    pagination.current = 1
+    await loadList()
+  } catch (e) {
+    Notification.error({ title: '导入失败', content: e.message })
+  } finally {
+    importLoading.value = false
+  }
+}
+
+const doImportUrl = async () => {
+  try {
+    const payload = {}
+    if (global.import_url && String(global.import_url).trim()) {
+      payload.url = String(global.import_url).trim()
+    }
+    const res = await adminProxyImportUrl(payload)
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '从 URL 导入失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Notification.success({ title: '导入完成', content: `条数 ${res.data?.imported ?? 0}` })
+    pagination.current = 1
+    await loadList()
+  } catch (e) {
+    Notification.error({ title: '从 URL 导入失败', content: e.message })
+  }
+}
+
+const checkSelected = async () => {
+  if (!selectedKeys.value.length) {
+    Notification.warning({ title: '未选择', content: '勾选中至少一条代理或为全部检测按钮' })
+    return
+  }
+  try {
+    const res = await adminProxyBatchCheck({ ids: selectedKeys.value.slice() })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '检测失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Notification.success({
+      title: '检测完成',
+      content: `共 ${res.data?.total ?? 0},成功 ${res.data?.ok ?? 0},失败 ${res.data?.fail ?? 0}`
+    })
+    await loadList()
+  } catch (e) {
+    Notification.error({ title: '检测失败', content: e.message })
+  }
+}
+
+const checkAll = async () => {
+  checkAllLoading.value = true
+  try {
+    const res = await adminProxyBatchCheck({})
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '检测失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Notification.success({
+      title: '检测完成',
+      content: `共 ${res.data?.total ?? 0},成功 ${res.data?.ok ?? 0},失败 ${res.data?.fail ?? 0}`
+    })
+    await loadList()
+  } catch (e) {
+    Notification.error({ title: '检测失败', content: e.message })
+  } finally {
+    checkAllLoading.value = false
+  }
+}
+
+const delOne = async (id) => {
+  try {
+    const res = await adminProxyDelete({ id })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '删除失败', content: res?.msg || '未知错误' })
+      return
+    }
+    Notification.success({ title: '已删除' })
+    selectedKeys.value = (selectedKeys.value || []).filter((k) => k !== id)
+    await loadList()
+  } catch (e) {
+    Notification.error({ title: '删除失败', content: e.message })
+  }
+}
+
+onMounted(async () => {
+  await loadGlobal()
+  await loadList()
+})
+</script>
+
+<style scoped>
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.card-block {
+  margin-bottom: 16px;
+}
+
+.global-form {
+  margin-top: 12px;
+  max-width: 960px;
+}
+
+.table {
+  margin-top: 8px;
+}
+
+.muted {
+  color: var(--color-text-3);
+  font-size: 13px;
+}
+</style>

+ 24 - 7
src/pages/lepao/accountList/index.vue

@@ -189,6 +189,9 @@
               <div v-else-if="record.state === 1" class="state">
               <div v-else-if="record.state === 1" class="state">
                 <div class="circle one"></div>正常
                 <div class="circle one"></div>正常
               </div>
               </div>
+              <div v-else-if="record.state === 3" class="state">
+                <div class="circle three"></div>统一认证失败
+              </div>
               <div v-else class="state">
               <div v-else class="state">
                 <div class="circle else"></div>状态异常
                 <div class="circle else"></div>状态异常
               </div>
               </div>
@@ -302,11 +305,11 @@
       <a-form-item field="student_num" label="学号">
       <a-form-item field="student_num" label="学号">
         <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" allow-clear />
         <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" allow-clear />
       </a-form-item>
       </a-form-item>
-      <a-form-item field="jw_password" label="教务密码" :required="!form.id">
-        <a-input-password v-model="form.jw_password" placeholder="统一身份认证密码(登录名与上方学号相同)" allow-clear />
+      <a-form-item field="jw_password" label="统一认证密码">
+        <a-input-password v-model="form.jw_password" placeholder="统一身份认证密码" allow-clear />
         <template #extra>
         <template #extra>
-          <span v-if="!form.id">新绑定必填;服务端将校验并通过 WebVPN 访问乐跑接口。</span>
-          <span v-else>留空则不修改已保存的教务密码;填写则更新并重新校验。</span>
+          <span v-if="!form.id">统一身份认证平台密码,平台地址:cas.ctbu.edu.cn。</span>
+          <span v-else>留空则不修改已保存的统一认证密码;填写则更新并重新校验。</span>
         </template>
         </template>
       </a-form-item>
       </a-form-item>
       <a-form-item field="notice_type" label="通知方式">
       <a-form-item field="notice_type" label="通知方式">
@@ -430,7 +433,11 @@ const handleSearch = (value) => {
 
 
 const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
 const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
 const state = [
 const state = [
-  { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
+  { label: '全部', value: -1 },
+  { label: '需登录', value: 0 },
+  { label: '正常', value: 1 },
+  { label: '状态异常', value: 2 },
+  { label: '统一认证失败', value: 3 }
 ]
 ]
 
 
 const search = () => {
 const search = () => {
@@ -655,7 +662,7 @@ const handleBeforeOk = async (done) => {
 
 
     const jwPlain = (form.jw_password || '').trim()
     const jwPlain = (form.jw_password || '').trim()
     if (!form.id && !jwPlain) {
     if (!form.id && !jwPlain) {
-      Message.error('请填写教务系统密码(统一身份认证密码)')
+      Message.error('请填写统一身份认证密码,平台地址:cas.ctbu.edu.cn。')
       return false
       return false
     }
     }
 
 
@@ -737,10 +744,16 @@ const GetNotice = async () => {
   notice.value = res
   notice.value = res
 }
 }
 
 
+const singleRunBlockTitle = (state) => {
+  if (state === 3) return '统一身份认证失败,请核对认证密码后重新尝试'
+  if (state === 2) return '账号状态异常,请更新账号信息后再试'
+  return '当前乐跑账号需登录,请登录后再试'
+}
+
 const SingleRun = async (item) => {
 const SingleRun = async (item) => {
   if (item.state !== 1)
   if (item.state !== 1)
     return Notification.warning({
     return Notification.warning({
-      title: '当前乐跑账号需登录,请登录后再试',
+      title: singleRunBlockTitle(item.state),
       content: '如有疑问请联系RunForge客服'
       content: '如有疑问请联系RunForge客服'
     })
     })
   Modal.confirm({
   Modal.confirm({
@@ -895,6 +908,10 @@ onUnmounted(() => {
     .else {
     .else {
       background-color: rgb(var(--red-6));
       background-color: rgb(var(--red-6));
     }
     }
+
+    .three {
+      background-color: rgb(var(--magenta-6));
+    }
   }
   }
 }
 }
 
 

+ 9 - 0
src/router/index.js

@@ -370,6 +370,15 @@ const routes = [
                     permission: ['admin', 'service']
                     permission: ['admin', 'service']
                 }
                 }
             },
             },
+            {
+                path: 'lepaoProxyPool',
+                name: 'admin.lepaoProxyPool',
+                component: () => import('../pages/admin/proxyPool/index.vue'),
+                meta: {
+                    title: '乐跑代理池',
+                    permission: ['admin', 'service']
+                }
+            },
             {
             {
                 path: 'goods/sendCountRequestList',
                 path: 'goods/sendCountRequestList',
                 name: 'admin.goods.sendCountRequestList',
                 name: 'admin.goods.sendCountRequestList',