Browse Source

✨ feat: 增加代理模式

Pchen0 1 month ago
parent
commit
657ef0114d
3 changed files with 594 additions and 1 deletions
  1. 46 1
      src/api/lepao.js
  2. 539 0
      src/pages/admin/lepaoProxy/index.vue
  3. 9 0
      src/router/index.js

+ 46 - 1
src/api/lepao.js

@@ -16,7 +16,12 @@ const api = {
   BeginFaceReco: '/Face/BeginFaceReco',
   BeginFaceReco: '/Face/BeginFaceReco',
   BindAuditList: '/Lepao/BindAudit/List',
   BindAuditList: '/Lepao/BindAudit/List',
   AdminBindAuditByAccount: '/Admin/Lepao/BindAudit/ByAccount',
   AdminBindAuditByAccount: '/Admin/Lepao/BindAudit/ByAccount',
-  AdminBindAuditList: '/Admin/Lepao/BindAudit/List'
+  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'
 }
 }
 
 
 export function addAccount (parameter) {
 export function addAccount (parameter) {
@@ -153,4 +158,44 @@ export function getAdminBindAuditList (parameter) {
     method: 'get',
     method: 'get',
     params: 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
+  })
 }
 }

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

@@ -0,0 +1,539 @@
+<template>
+  <div class="lepao-proxy-page">
+    <Breadcrumb :items="['网站管理', '乐跑出口代理']" />
+
+    <a-spin :loading="pageLoading">
+      <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 class="hero-desc">
+            对学校 HTTPS 接口经 HTTP 代理出口;密钥在服务端 <code>qgChannelProxy</code>。
+            <a-link href="https://www.qg.net/doc/1850.html" target="_blank" rel="noopener">资源地区 API</a-link>
+            ·
+            <a-link href="https://www.qg.net/doc/1846.html" target="_blank" rel="noopener">提取 IP</a-link>
+          </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="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="16">
+          <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-col :xs="24" :lg="8">
+          <a-card title="说明" :bordered="false" class="panel side-tips">
+            <ul class="tips-list">
+              <li>资源列表来自青果「查询资源地区」接口,请以控制台实际可用为准。</li>
+              <li>日志表中「出口 IP」为青果 proxy_ip;「出口属地」仅据此 IP 解析(ip2region)。</li>
+              <li>单通道时请避免频繁作废 IP;后端已对 /get 与 POST 做多轮退让重试。</li>
+            </ul>
+          </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>
+        <a-table
+          v-model:selected-keys="selectedLogKeys"
+          row-key="id"
+          :row-selection="{ type: 'checkbox', showCheckedAll: true }"
+          :data="logData"
+          :loading="logLoading"
+          :bordered="false"
+          :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="160" ellipsis tooltip />
+            <a-table-column title="出口 IP" :width="130">
+              <template #cell="{ record }">
+                <span class="mono">{{ record.egress_ip ?? '—' }}</span>
+              </template>
+            </a-table-column>
+            <a-table-column title="出口属地" :width="200">
+              <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="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>
+      </a-card>
+    </a-spin>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, computed, watch, onMounted } from 'vue'
+import { Notification, Modal } from '@arco-design/web-vue'
+import {
+  getAdminLepaoProxyStatus,
+  postAdminLepaoProxyConfig,
+  getAdminLepaoProxyLogs,
+  postAdminLepaoProxyLogsDelete,
+  getAdminLepaoProxyResources
+} from '@/api/lepao'
+
+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;
+}
+
+.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;
+}
+
+</style>

+ 9 - 0
src/router/index.js

@@ -324,6 +324,15 @@ const routes = [
                     permission: ['admin', 'service']
                     permission: ['admin', 'service']
                 }
                 }
             },
             },
+            {
+                path: 'lepaoProxy',
+                name: 'admin.lepaoProxy',
+                component: () => import('../pages/admin/lepaoProxy/index.vue'),
+                meta: {
+                    title: '乐跑出口代理',
+                    permission: ['admin', 'service']
+                }
+            },
             {
             {
                 path: 'lepaoRecords/:id',
                 path: 'lepaoRecords/:id',
                 name: 'admin.lepaoRecords.detail',
                 name: 'admin.lepaoRecords.detail',