index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. <template>
  2. <div class="lepao-proxy-page">
  3. <Breadcrumb :items="['网站管理', '乐跑出口代理']" />
  4. <a-spin :loading="pageLoading">
  5. <a-card class="hero-card" :bordered="false">
  6. <div class="hero-inner">
  7. <div class="hero-title">
  8. <span class="t1">乐跑出站代理</span>
  9. <a-tag color="arcoblue" size="small">青果 · 通道提取</a-tag>
  10. </div>
  11. <div class="hero-desc">
  12. 对学校 HTTPS 接口经 HTTP 代理出口;密钥在服务端 <code>qgChannelProxy</code>。
  13. <a-link href="https://www.qg.net/doc/1850.html" target="_blank" rel="noopener">资源地区 API</a-link>
  14. ·
  15. <a-link href="https://www.qg.net/doc/1846.html" target="_blank" rel="noopener">提取 IP</a-link>
  16. </div>
  17. </div>
  18. </a-card>
  19. <a-row :gutter="16" style="margin-top: 16px">
  20. <a-col :span="24">
  21. <a-card title="当前状态" :bordered="false" class="panel">
  22. <a-descriptions :column="2" bordered size="small">
  23. <a-descriptions-item label="extractKey">
  24. <a-tag :color="status?.extract_key_configured ? 'green' : 'orangered'">
  25. {{ status?.extract_key_configured ? '已配置' : '未配置' }}
  26. </a-tag>
  27. </a-descriptions-item>
  28. <a-descriptions-item label="代理账密">
  29. <a-tag :color="status?.proxy_auth_configured ? 'cyan' : 'gray'">
  30. {{ status?.proxy_auth_configured ? '已配置' : '可选(白名单可不填)' }}
  31. </a-tag>
  32. </a-descriptions-item>
  33. <a-descriptions-item label="代理节点 server">
  34. <span class="mono">{{ status?.current_proxy?.server ?? '—' }}</span>
  35. </a-descriptions-item>
  36. <a-descriptions-item label="节点 IP 属地">
  37. <a-tag color="purple">{{ status?.current_proxy?.node_region ?? '—' }}</a-tag>
  38. </a-descriptions-item>
  39. <a-descriptions-item label="出口 IP proxy_ip">
  40. <span class="mono">{{ status?.current_proxy?.proxy_ip ?? '—' }}</span>
  41. </a-descriptions-item>
  42. <a-descriptions-item label="出口 IP 属地">
  43. <a-tag color="gold">{{ status?.current_proxy?.proxy_ip_region ?? '—' }}</a-tag>
  44. </a-descriptions-item>
  45. <a-descriptions-item label="deadline" :span="2">
  46. {{ status?.current_proxy?.deadline ?? '—' }}
  47. <a-tag
  48. v-if="status?.current_proxy"
  49. size="small"
  50. :color="status?.current_proxy?.stale ? 'orangered' : 'green'"
  51. style="margin-left: 8px"
  52. >
  53. {{ status?.current_proxy?.stale ? '临近/已过期' : '有效期内' }}
  54. </a-tag>
  55. </a-descriptions-item>
  56. </a-descriptions>
  57. </a-card>
  58. </a-col>
  59. </a-row>
  60. <a-row :gutter="16" style="margin-top: 16px">
  61. <a-col :xs="24" :lg="16">
  62. <a-card title="筛选与路由" :bordered="false" class="panel">
  63. <a-form :model="form" layout="vertical">
  64. <a-form-item label="启用青果出站">
  65. <a-switch v-model="form.proxy_enabled" />
  66. </a-form-item>
  67. <a-form-item label="从青果可选资源勾选地区(支持搜索)">
  68. <a-space direction="vertical" fill style="width: 100%">
  69. <a-space wrap>
  70. <a-button size="mini" type="outline" @click="loadResources" :loading="resLoading">
  71. 刷新资源列表
  72. </a-button>
  73. <a-checkbox v-model="onlyAvailableRes">仅显示「可提取」</a-checkbox>
  74. </a-space>
  75. <a-select
  76. v-model="selectedAreaCodes"
  77. multiple
  78. allow-search
  79. allow-clear
  80. :loading="resLoading"
  81. :options="filteredResourceOptions"
  82. :placeholder="resourcePlaceholder"
  83. :filter-option="filterResourceOption"
  84. style="width: 100%"
  85. />
  86. <div class="hint-muted">
  87. 将写入后端 area 字段(逗号分隔,与青果调试工具一致)。也可在下方微调编码。
  88. </div>
  89. <a-input
  90. v-model="form.area"
  91. allow-clear
  92. placeholder="area 编码,逗号分隔(与上方选择同步,失焦后与多选对齐)"
  93. @blur="syncSelectedFromArea"
  94. />
  95. </a-space>
  96. </a-form-item>
  97. <a-form-item label="排除地区 area_ex">
  98. <a-input v-model="form.area_ex" allow-clear placeholder="可选,逗号分隔" />
  99. </a-form-item>
  100. <a-form-item label="运营商 isp">
  101. <a-select v-model="form.isp" allow-clear placeholder="不筛选">
  102. <a-option :value="undefined">不筛选</a-option>
  103. <a-option :value="1">电信</a-option>
  104. <a-option :value="2">移动</a-option>
  105. <a-option :value="3">联通</a-option>
  106. </a-select>
  107. </a-form-item>
  108. <a-form-item label="distinct 去重提取">
  109. <a-switch v-model="form.distinct_extract" />
  110. </a-form-item>
  111. <a-form-item label="保存时清空 Redis 当前 IP">
  112. <a-checkbox v-model="form.invalidate_cache">清空(下次强制重新提取)</a-checkbox>
  113. </a-form-item>
  114. <a-space>
  115. <a-button type="primary" @click="saveConfig" :loading="saving">保存配置</a-button>
  116. <a-button @click="loadStatus">刷新状态</a-button>
  117. </a-space>
  118. </a-form>
  119. </a-card>
  120. </a-col>
  121. <a-col :xs="24" :lg="8">
  122. <a-card title="说明" :bordered="false" class="panel side-tips">
  123. <ul class="tips-list">
  124. <li>资源列表来自青果「查询资源地区」接口,请以控制台实际可用为准。</li>
  125. <li>日志表中「出口 IP」为青果 proxy_ip;「出口属地」仅据此 IP 解析(ip2region)。</li>
  126. <li>单通道时请避免频繁作废 IP;后端已对 /get 与 POST 做多轮退让重试。</li>
  127. </ul>
  128. </a-card>
  129. </a-col>
  130. </a-row>
  131. <a-card title="出站与切换摘要" class="panel log-card" style="margin-top: 16px" :bordered="false">
  132. <template #extra>
  133. <a-space>
  134. <a-button
  135. type="primary"
  136. status="danger"
  137. :disabled="!selectedLogKeys.length"
  138. :loading="logDeleting"
  139. @click="deleteSelectedLogs"
  140. >
  141. 删除所选({{ selectedLogKeys.length }})
  142. </a-button>
  143. <a-button type="outline" status="danger" :loading="logDeleting" @click="confirmDeleteAllLogs">
  144. 清空全部
  145. </a-button>
  146. </a-space>
  147. </template>
  148. <a-table
  149. v-model:selected-keys="selectedLogKeys"
  150. row-key="id"
  151. :row-selection="{ type: 'checkbox', showCheckedAll: true }"
  152. :data="logData"
  153. :loading="logLoading"
  154. :bordered="false"
  155. :pagination="{
  156. showPageSize: true,
  157. showJumper: true,
  158. showTotal: true,
  159. pageSize: pagination.pagesize,
  160. current: pagination.current,
  161. total: pagination.total
  162. }"
  163. @page-change="handlePageChange"
  164. @page-size-change="handlePageSizeChange"
  165. >
  166. <template #columns>
  167. <a-table-column title="时间" :width="178">
  168. <template #cell="{ record }">{{ strTime(record.created_at) }}</template>
  169. </a-table-column>
  170. <a-table-column title="类型" :width="120">
  171. <template #cell="{ record }">
  172. <a-tag size="small" :color="record.event_color">{{ record.event_label }}</a-tag>
  173. </template>
  174. </a-table-column>
  175. <a-table-column title="节点 server" data-index="server" :width="160" ellipsis tooltip />
  176. <a-table-column title="出口 IP" :width="130">
  177. <template #cell="{ record }">
  178. <span class="mono">{{ record.egress_ip ?? '—' }}</span>
  179. </template>
  180. </a-table-column>
  181. <a-table-column title="出口属地" :width="200">
  182. <template #cell="{ record }">
  183. <span v-if="!record.egress_ip" class="hint-muted">—</span>
  184. <a-tag v-else color="cyan" size="small">{{ shortenRegion(record.egress_region || '未知') }}</a-tag>
  185. </template>
  186. </a-table-column>
  187. <a-table-column title="摘要">
  188. <template #cell="{ record }">
  189. <div class="summary-text">{{ record.summary }}</div>
  190. </template>
  191. </a-table-column>
  192. <a-table-column title="操作" fixed="right" :width="88">
  193. <template #cell="{ record }">
  194. <a-popconfirm content="删除该条日志?" @ok="deleteOneLog(record.id)">
  195. <a-button type="text" size="mini" status="danger">删除</a-button>
  196. </a-popconfirm>
  197. </template>
  198. </a-table-column>
  199. </template>
  200. </a-table>
  201. </a-card>
  202. </a-spin>
  203. </div>
  204. </template>
  205. <script setup>
  206. import { reactive, ref, computed, watch, onMounted } from 'vue'
  207. import { Notification, Modal } from '@arco-design/web-vue'
  208. import {
  209. getAdminLepaoProxyStatus,
  210. postAdminLepaoProxyConfig,
  211. getAdminLepaoProxyLogs,
  212. postAdminLepaoProxyLogsDelete,
  213. getAdminLepaoProxyResources
  214. } from '@/api/lepao'
  215. const pageLoading = ref(false)
  216. const logLoading = ref(false)
  217. const logDeleting = ref(false)
  218. const selectedLogKeys = ref([])
  219. const saving = ref(false)
  220. const status = ref(null)
  221. const logData = ref([])
  222. const resourcesRaw = ref([])
  223. const resLoading = ref(false)
  224. const onlyAvailableRes = ref(true)
  225. const selectedAreaCodes = ref([])
  226. const pagination = reactive({
  227. total: 0,
  228. current: 1,
  229. pagesize: 20
  230. })
  231. const form = reactive({
  232. proxy_enabled: false,
  233. area: '',
  234. area_ex: '',
  235. isp: undefined,
  236. distinct_extract: true,
  237. invalidate_cache: false
  238. })
  239. watch(
  240. selectedAreaCodes,
  241. (v) => {
  242. form.area = Array.isArray(v) && v.length ? v.join(',') : ''
  243. },
  244. { deep: true }
  245. )
  246. const filteredResourceOptions = computed(() => {
  247. const rows = resourcesRaw.value || []
  248. let list = onlyAvailableRes.value ? rows.filter((r) => r.available === true) : [...rows]
  249. return list.map((r) => {
  250. const code = String(r.area_code ?? '')
  251. const isp = r.isp ?? ''
  252. const ok = r.available ? '可提取' : '暂不可用'
  253. return {
  254. label: `[${code}] ${r.area ?? ''} · ${isp} · ${ok}`,
  255. value: code
  256. }
  257. })
  258. })
  259. const resourcePlaceholder = computed(() =>
  260. resourcesRaw.value.length ? '搜索城市 / 运营商 / 编码…' : '请先点击「刷新资源列表」'
  261. )
  262. function filterResourceOption(input, option) {
  263. return String(option?.label ?? '')
  264. .toLowerCase()
  265. .includes(String(input || '').toLowerCase())
  266. }
  267. function shortenRegion(r) {
  268. if (!r || r === '未知') return '未知'
  269. const parts = String(r).split(' · ')
  270. return parts.slice(-2).join(' · ') || r
  271. }
  272. const strTime = (t) =>
  273. t == null
  274. ? '—'
  275. : new Date(Number(t)).toLocaleString('zh-CN', {
  276. year: 'numeric',
  277. month: '2-digit',
  278. day: '2-digit',
  279. hour: '2-digit',
  280. minute: '2-digit',
  281. second: '2-digit'
  282. })
  283. const syncSelectedFromArea = () => {
  284. selectedAreaCodes.value = form.area
  285. ? String(form.area)
  286. .split(',')
  287. .map((s) => s.trim())
  288. .filter(Boolean)
  289. .map(String)
  290. : []
  291. }
  292. const applyStatus = (data) => {
  293. status.value = data
  294. form.proxy_enabled = !!data?.proxy_enabled
  295. form.area = data?.area ?? ''
  296. form.area_ex = data?.area_ex ?? ''
  297. form.isp = data?.isp === null || data?.isp === '' ? undefined : Number(data.isp)
  298. form.distinct_extract = data?.distinct_extract !== false
  299. syncSelectedFromArea()
  300. }
  301. const loadStatus = async () => {
  302. pageLoading.value = true
  303. try {
  304. const res = await getAdminLepaoProxyStatus({})
  305. if (!res || res.code !== 0) {
  306. Notification.error({ title: '读取状态失败', content: res?.msg ?? '请稍后再试' })
  307. return
  308. }
  309. applyStatus(res.data)
  310. } finally {
  311. pageLoading.value = false
  312. }
  313. }
  314. const loadResources = async () => {
  315. resLoading.value = true
  316. try {
  317. const res = await getAdminLepaoProxyResources({})
  318. if (!res || res.code !== 0) {
  319. Notification.error({ title: '资源列表获取失败', content: res?.msg ?? '请检查 extractKey 与网络' })
  320. return
  321. }
  322. resourcesRaw.value = res.data || []
  323. Notification.success({ title: '资源列表已更新', content: `共 ${resourcesRaw.value.length} 条` })
  324. } finally {
  325. resLoading.value = false
  326. }
  327. }
  328. const loadLogs = async () => {
  329. logLoading.value = true
  330. try {
  331. const res = await getAdminLepaoProxyLogs({
  332. current: pagination.current,
  333. pagesize: pagination.pagesize
  334. })
  335. if (!res || res.code !== 0) {
  336. Notification.error({ title: '读取日志失败', content: res?.msg ?? '请稍后再试' })
  337. return
  338. }
  339. logData.value = res.data || []
  340. pagination.total = res.pagination?.total || 0
  341. selectedLogKeys.value = []
  342. } finally {
  343. logLoading.value = false
  344. }
  345. }
  346. const deleteOneLog = async (id) => {
  347. logDeleting.value = true
  348. try {
  349. const res = await postAdminLepaoProxyLogsDelete({ ids: [id] })
  350. if (!res || res.code !== 0) {
  351. Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
  352. return
  353. }
  354. Notification.success({ title: '已删除', content: '' })
  355. await loadLogs()
  356. } finally {
  357. logDeleting.value = false
  358. }
  359. }
  360. const deleteSelectedLogs = async () => {
  361. if (!selectedLogKeys.value.length) return
  362. logDeleting.value = true
  363. try {
  364. const res = await postAdminLepaoProxyLogsDelete({ ids: selectedLogKeys.value.map(Number) })
  365. if (!res || res.code !== 0) {
  366. Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
  367. return
  368. }
  369. Notification.success({ title: `已删除 ${res.data?.deleted ?? selectedLogKeys.value.length} 条`, content: '' })
  370. pagination.current = 1
  371. await loadLogs()
  372. } finally {
  373. logDeleting.value = false
  374. }
  375. }
  376. const confirmDeleteAllLogs = () => {
  377. Modal.confirm({
  378. title: '清空全部代理日志',
  379. content: '将删除表中全部 lepao_proxy_log 记录,不可恢复。',
  380. okText: '确认清空',
  381. modalStyle: { maxWidth: '420px' },
  382. okButtonProps: { status: 'danger' },
  383. onOk: async () => {
  384. logDeleting.value = true
  385. try {
  386. const res = await postAdminLepaoProxyLogsDelete({ purge_all: 1 })
  387. if (!res || res.code !== 0) {
  388. Notification.error({ title: '清空失败', content: res?.msg ?? '请稍后再试' })
  389. return
  390. }
  391. Notification.success({ title: '已清空日志', content: '' })
  392. pagination.current = 1
  393. await loadLogs()
  394. } finally {
  395. logDeleting.value = false
  396. }
  397. }
  398. })
  399. }
  400. const saveConfig = async () => {
  401. saving.value = true
  402. try {
  403. const res = await postAdminLepaoProxyConfig({
  404. proxy_enabled: form.proxy_enabled ? 1 : 0,
  405. area: form.area,
  406. area_ex: form.area_ex,
  407. isp: form.isp === undefined ? '' : form.isp,
  408. distinct_extract: form.distinct_extract ? 1 : 0,
  409. invalidate_cache: form.invalidate_cache ? 1 : 0
  410. })
  411. if (!res || res.code !== 0) {
  412. Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
  413. return
  414. }
  415. Notification.success({ title: '已保存', content: '' })
  416. form.invalidate_cache = false
  417. await loadStatus()
  418. await loadLogs()
  419. } finally {
  420. saving.value = false
  421. }
  422. }
  423. const handlePageChange = (page) => {
  424. pagination.current = page
  425. loadLogs()
  426. }
  427. const handlePageSizeChange = (size) => {
  428. pagination.pagesize = size
  429. pagination.current = 1
  430. loadLogs()
  431. }
  432. onMounted(async () => {
  433. await loadStatus()
  434. await loadLogs()
  435. })
  436. </script>
  437. <style scoped lang="less">
  438. .lepao-proxy-page {
  439. padding: 16px 20px 32px;
  440. max-width: 1280px;
  441. margin: 0 auto;
  442. }
  443. .hero-card {
  444. background: linear-gradient(135deg, var(--color-bg-2), var(--color-fill-2));
  445. }
  446. .hero-inner {
  447. padding: 4px 0;
  448. }
  449. .hero-title {
  450. display: flex;
  451. align-items: center;
  452. gap: 12px;
  453. .t1 {
  454. font-size: 20px;
  455. font-weight: 600;
  456. color: var(--color-text-1);
  457. }
  458. }
  459. .hero-desc {
  460. margin-top: 10px;
  461. color: var(--color-text-2);
  462. font-size: 13px;
  463. line-height: 1.6;
  464. code {
  465. padding: 0 6px;
  466. border-radius: 4px;
  467. background: var(--color-fill-3);
  468. font-size: 12px;
  469. }
  470. }
  471. .panel {
  472. border-radius: 12px;
  473. &.log-card {
  474. box-shadow: 0 1px 4px rgb(0 0 0 / 4%);
  475. }
  476. }
  477. .mono {
  478. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
  479. font-size: 13px;
  480. }
  481. .side-tips .tips-list {
  482. margin: 0;
  483. padding-left: 18px;
  484. color: var(--color-text-2);
  485. font-size: 13px;
  486. line-height: 1.85;
  487. }
  488. .summary-text {
  489. font-size: 13px;
  490. line-height: 1.65;
  491. color: var(--color-text-2);
  492. }
  493. .hint-muted {
  494. color: var(--color-text-3);
  495. font-size: 12px;
  496. }
  497. </style>