| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- <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>
|