|
|
@@ -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="示例: 1.2.3.4:8080 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>
|