415 lines
14 KiB
Vue
415 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
NButton,
|
|
NDataTable,
|
|
NInput,
|
|
NModal,
|
|
NPopconfirm,
|
|
NRadioButton,
|
|
NRadioGroup,
|
|
type DataTableColumns,
|
|
} from 'naive-ui'
|
|
import { computed, h, onUnmounted, ref } from 'vue'
|
|
|
|
import {
|
|
createProxy,
|
|
deleteProxy,
|
|
importProxies,
|
|
listProxies,
|
|
probeProxy,
|
|
updateProxy,
|
|
} from '@/api/proxies'
|
|
import AppInputNumber from '@/components/AppInputNumber.vue'
|
|
import FootNote from '@/components/FootNote.vue'
|
|
import FormField from '@/components/FormField.vue'
|
|
import ProxyTenantsModal from '@/components/proxy/ProxyTenantsModal.vue'
|
|
import StatusBadge from '@/components/StatusBadge.vue'
|
|
import { useAsync } from '@/composables/useAsync'
|
|
import type { ProxyImportResult, ProxyInfo } from '@/types/api'
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
const message = useToast()
|
|
|
|
const proxies = useAsync(listProxies)
|
|
const rows = computed(() => proxies.data.value?.items ?? [])
|
|
|
|
// 创建 / 导入后服务端异步探测出口地区,延迟静默刷新把结果带回来
|
|
let geoTimer: ReturnType<typeof setTimeout> | null = null
|
|
function scheduleGeoRefresh() {
|
|
if (geoTimer) clearTimeout(geoTimer)
|
|
geoTimer = setTimeout(() => void proxies.run({ silent: true }), 3000)
|
|
}
|
|
onUnmounted(() => {
|
|
if (geoTimer) clearTimeout(geoTimer)
|
|
})
|
|
|
|
// ---- 新增 / 编辑弹窗 ----
|
|
const showForm = ref(false)
|
|
const editing = ref<ProxyInfo | null>(null)
|
|
const saving = ref(false)
|
|
const form = ref({ name: '', type: 'socks5', host: '', port: 1080 as number | null, username: '', password: '' })
|
|
|
|
const typeOptions = [
|
|
{ label: 'SOCKS5', value: 'socks5' },
|
|
{ label: 'HTTP', value: 'http' },
|
|
{ label: 'HTTPS', value: 'https' },
|
|
]
|
|
|
|
/** 名称留空时的自动命名预览,与后端 autoProxyName 规则一致 */
|
|
const autoNamePreview = computed(() => {
|
|
const f = form.value
|
|
if (f.name.trim() || !f.host.trim() || !f.port) return ''
|
|
return `${f.type}-${f.host.trim()}:${f.port}`
|
|
})
|
|
|
|
function openCreate() {
|
|
editing.value = null
|
|
form.value = { name: '', type: 'socks5', host: '', port: 1080, username: '', password: '' }
|
|
showForm.value = true
|
|
}
|
|
|
|
function openEdit(row: ProxyInfo) {
|
|
editing.value = row
|
|
form.value = { name: row.name, type: row.type, host: row.host, port: row.port, username: row.username, password: '' }
|
|
showForm.value = true
|
|
}
|
|
|
|
const passwordPlaceholder = computed(() =>
|
|
editing.value?.passwordSet ? '已配置,留空沿用;输入新值覆盖' : '无认证可留空;服务端加密存储',
|
|
)
|
|
|
|
async function save() {
|
|
const f = form.value
|
|
if (!f.host.trim() || !f.port) {
|
|
message.error('请填写主机与端口')
|
|
return
|
|
}
|
|
saving.value = true
|
|
try {
|
|
const body = {
|
|
name: f.name.trim(),
|
|
type: f.type,
|
|
host: f.host.trim(),
|
|
port: f.port,
|
|
username: f.username.trim(),
|
|
// 编辑时留空表示沿用已存密码;新建时留空即无密码
|
|
...(f.password || !editing.value ? { password: f.password } : {}),
|
|
}
|
|
if (editing.value) {
|
|
await updateProxy(editing.value.id, body)
|
|
message.success('代理已更新,关联租户的后续请求即用新配置')
|
|
} else {
|
|
await createProxy(body)
|
|
message.success('代理已创建,正在检测出口地区')
|
|
}
|
|
showForm.value = false
|
|
void proxies.run({ silent: true })
|
|
scheduleGeoRefresh()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '保存失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(row: ProxyInfo) {
|
|
try {
|
|
await deleteProxy(row.id)
|
|
message.success('代理已删除')
|
|
void proxies.run({ silent: true })
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '删除失败')
|
|
}
|
|
}
|
|
|
|
// ---- 关联租户管理 ----
|
|
const showTenants = ref(false)
|
|
const tenantsFor = ref<ProxyInfo | null>(null)
|
|
|
|
function openTenants(row: ProxyInfo) {
|
|
tenantsFor.value = row
|
|
showTenants.value = true
|
|
}
|
|
|
|
// ---- 手动重测出口地区 ----
|
|
const probingIds = ref(new Set<number>())
|
|
|
|
async function probe(row: ProxyInfo) {
|
|
probingIds.value.add(row.id)
|
|
try {
|
|
const view = await probeProxy(row.id)
|
|
message.success(view.country ? `出口地区:${view.country}${view.city ? '·' + view.city : ''}` : '探测失败,出口地区未知')
|
|
void proxies.run({ silent: true })
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '重测失败')
|
|
} finally {
|
|
probingIds.value.delete(row.id)
|
|
}
|
|
}
|
|
|
|
// ---- 批量导入 ----
|
|
const showImport = ref(false)
|
|
const importing = ref(false)
|
|
const importText = ref('')
|
|
const importResult = ref<ProxyImportResult | null>(null)
|
|
|
|
function openImport() {
|
|
importText.value = ''
|
|
importResult.value = null
|
|
showImport.value = true
|
|
}
|
|
|
|
async function doImport() {
|
|
if (!importText.value.trim()) {
|
|
message.error('请粘贴代理列表')
|
|
return
|
|
}
|
|
importing.value = true
|
|
try {
|
|
const res = await importProxies(importText.value)
|
|
importResult.value = res
|
|
void proxies.run({ silent: true })
|
|
scheduleGeoRefresh()
|
|
if (!res.failed.length) {
|
|
message.success(`已导入 ${res.created.length} 条,正在检测出口地区`)
|
|
showImport.value = false
|
|
} else {
|
|
message.warning(`成功 ${res.created.length} 条,失败 ${res.failed.length} 条,详见下方`)
|
|
}
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '导入失败')
|
|
} finally {
|
|
importing.value = false
|
|
}
|
|
}
|
|
|
|
/** 地区单元格:未探测显示「检测中」,探测失败显示「未知」 */
|
|
function renderGeo(row: ProxyInfo) {
|
|
if (!row.geoAt) return h('span', { class: 'text-[12.5px] text-ink-3' }, '检测中…')
|
|
if (!row.country) return h('span', { class: 'text-[12.5px] text-ink-3' }, '未知')
|
|
return h('span', { class: 'text-[13px]' }, row.city ? `${row.country}·${row.city}` : row.country)
|
|
}
|
|
|
|
/** 状态单元格:与地区探测同源 —— 探测经该代理链路发起,失败即代理不可用 */
|
|
function renderStatus(row: ProxyInfo) {
|
|
if (!row.geoAt) return h(StatusBadge, { kind: 'check', label: '检测中' })
|
|
const ok = !!row.country
|
|
return h(StatusBadge, { kind: ok ? 'run' : 'term', label: ok ? '正常' : '异常' })
|
|
}
|
|
|
|
// 新增 / 批量导入入口由页面标题行触发(ProxyListView),这里暴露打开方法
|
|
defineExpose({ openCreate, openImport })
|
|
|
|
const columns: DataTableColumns<ProxyInfo> = [
|
|
{
|
|
title: '名称',
|
|
key: 'name',
|
|
minWidth: 140,
|
|
ellipsis: { tooltip: true },
|
|
render: (row) => h('span', { class: 'text-[13px] font-medium' }, row.name),
|
|
},
|
|
{
|
|
title: '类型',
|
|
key: 'type',
|
|
width: 84,
|
|
render: (row) => h('span', { class: 'mono text-[12.5px] uppercase' }, row.type),
|
|
},
|
|
{
|
|
title: '地址',
|
|
key: 'host',
|
|
minWidth: 180,
|
|
ellipsis: { tooltip: true },
|
|
render: (row) => h('span', { class: 'mono text-[12.5px] text-ink-2' }, `${row.host}:${row.port}`),
|
|
},
|
|
{ title: '地区', key: 'geo', minWidth: 120, render: renderGeo },
|
|
{ title: '状态', key: 'status', width: 88, render: renderStatus },
|
|
{
|
|
title: '认证',
|
|
key: 'username',
|
|
width: 76,
|
|
render: (row) => {
|
|
const has = !!row.username || row.passwordSet
|
|
// 悬停「是」可见用户名(无用户名则提示仅密码)
|
|
const title = has ? (row.username ? `用户名 ${row.username}` : '仅密码认证') : undefined
|
|
return h('span', { title }, [
|
|
h(StatusBadge, { kind: has ? 'run' : 'check', label: has ? '是' : '否', dot: false }),
|
|
])
|
|
},
|
|
},
|
|
{
|
|
title: '关联租户',
|
|
key: 'usedBy',
|
|
width: 88,
|
|
render: (row) =>
|
|
h('span', { class: 'text-[13px] tabular-nums' }, row.usedBy ? `${row.usedBy} 个` : '—'),
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'actions',
|
|
width: 236,
|
|
render: (row) =>
|
|
h('div', { class: 'flex items-center gap-1' }, [
|
|
h(
|
|
NButton,
|
|
{ size: 'tiny', quaternary: true, loading: probingIds.value.has(row.id), onClick: () => probe(row) },
|
|
() => '重测',
|
|
),
|
|
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openTenants(row) }, () => '关联租户'),
|
|
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
|
|
h(
|
|
NPopconfirm,
|
|
{ onPositiveClick: () => remove(row) },
|
|
{
|
|
trigger: () =>
|
|
h(NButton, { size: 'tiny', type: 'error', quaternary: true, disabled: row.usedBy > 0 }, () => '删除'),
|
|
default: () => '删除后不可恢复,确认?',
|
|
},
|
|
),
|
|
]),
|
|
},
|
|
]
|
|
</script>
|
|
|
|
<template>
|
|
<div class="panel">
|
|
<NDataTable
|
|
:columns="columns"
|
|
:data="rows"
|
|
:loading="proxies.loading.value"
|
|
:scroll-x="900"
|
|
:row-key="(r: ProxyInfo) => r.id"
|
|
/>
|
|
<FootNote>
|
|
支持 SOCKS5 / HTTP / HTTPS;密码 AES 加密存储、不回显明文;地区与状态为经代理实测的出口探测结果(ip-api.com),创建后自动检测,探测失败即视为异常,可点「重测」复查;被租户关联的代理不可删除,请先解除关联
|
|
</FootNote>
|
|
|
|
<ProxyTenantsModal
|
|
v-model:show="showTenants"
|
|
:proxy="tenantsFor"
|
|
@saved="void proxies.run({ silent: true })"
|
|
/>
|
|
|
|
<NModal
|
|
v-model:show="showForm"
|
|
preset="card"
|
|
:title="editing ? '编辑代理' : '新增代理'"
|
|
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
|
|
>
|
|
<FormField label="类型">
|
|
<NRadioGroup v-model:value="form.type" class="w-full" size="small">
|
|
<NRadioButton
|
|
v-for="t in typeOptions"
|
|
:key="t.value"
|
|
:value="t.value"
|
|
class="w-1/3 text-center"
|
|
>
|
|
{{ t.label }}
|
|
</NRadioButton>
|
|
</NRadioGroup>
|
|
</FormField>
|
|
<div class="grid grid-cols-3 gap-x-3">
|
|
<FormField label="主机" required class="col-span-2">
|
|
<NInput v-model:value="form.host" placeholder="203.0.113.50" />
|
|
</FormField>
|
|
<FormField label="端口" required>
|
|
<AppInputNumber v-model:value="form.port" :min="1" :max="65535" class="!w-full" />
|
|
</FormField>
|
|
</div>
|
|
<FormField
|
|
label="名称"
|
|
hint="选填;留空按「类型-主机:端口」自动生成,重名自动追加序号"
|
|
:feedback="autoNamePreview ? `将生成 ${autoNamePreview}` : undefined"
|
|
>
|
|
<NInput v-model:value="form.name" placeholder="留空自动生成" />
|
|
</FormField>
|
|
<div class="grid grid-cols-2 gap-x-3">
|
|
<FormField label="用户名">
|
|
<NInput v-model:value="form.username" placeholder="无认证可留空" />
|
|
</FormField>
|
|
<FormField label="密码" :hint="passwordPlaceholder">
|
|
<NInput
|
|
v-model:value="form.password"
|
|
type="password"
|
|
show-password-on="click"
|
|
placeholder="可选"
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
<div class="mb-3.5 flex items-start gap-2.5 rounded-lg border border-ok/30 bg-ok/10 px-3 py-2.5">
|
|
<svg
|
|
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 0 20 15.3 15.3 0 0 1 0-20" />
|
|
</svg>
|
|
<div class="min-w-0 text-xs leading-relaxed">
|
|
<div class="font-medium">保存后自动检测出口地区</div>
|
|
<div class="mt-0.5 text-ink-3">
|
|
经该代理请求 ip-api.com 实测出口位置(非主机字段),结果显示在「地区」列,可随时重测
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
|
<NButton size="small" quaternary @click="showForm = false">取消</NButton>
|
|
</div>
|
|
</NModal>
|
|
|
|
<NModal
|
|
v-model:show="showImport"
|
|
preset="card"
|
|
title="批量导入代理"
|
|
:style="{ width: '560px', maxWidth: 'calc(100vw - 24px)' }"
|
|
>
|
|
<FormField label="代理列表">
|
|
<NInput
|
|
v-model:value="importText"
|
|
type="textarea"
|
|
:autosize="{ minRows: 6, maxRows: 12 }"
|
|
placeholder="socks5://user:pass@203.0.113.50:1080 https://198.51.100.2:8443 203.0.113.9:1080:user:pass"
|
|
class="mono"
|
|
/>
|
|
</FormField>
|
|
<div class="mb-3 flex flex-col gap-1.5 rounded-lg border border-line-soft bg-wash px-3 py-2.5 text-xs text-ink-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<code class="mono rounded border border-line bg-white px-1.5 py-px text-[11px]">scheme://[user:pass@]host:port</code>
|
|
<span>scheme 为 socks5 / http / https</span>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<code class="mono rounded border border-line bg-white px-1.5 py-px text-[11px]">host:port[:user:pass]</code>
|
|
<span>无协议头,缺省按 SOCKS5;# 开头为注释</span>
|
|
</div>
|
|
<div class="text-ink-3">失败行回显已脱敏(凭据段替换为 ***),不阻断其余行;名称自动生成</div>
|
|
</div>
|
|
<div v-if="importResult" class="mb-3 overflow-hidden rounded-lg border border-line">
|
|
<div class="flex items-center gap-4 border-b border-line-soft bg-white px-3 py-2 text-[12.5px] font-semibold">
|
|
<span><span class="text-ok">{{ importResult.created.length }}</span> 条导入成功</span>
|
|
<span v-if="importResult.failed.length">
|
|
<span class="text-err">{{ importResult.failed.length }}</span> 条失败
|
|
</span>
|
|
<span v-if="importResult.created.length" class="ml-auto text-[11px] font-medium text-ink-3">
|
|
正在检测出口地区…
|
|
</span>
|
|
</div>
|
|
<div
|
|
v-for="f in importResult.failed"
|
|
:key="f.lineNo"
|
|
class="mono flex items-center gap-2.5 bg-err/8 px-3 py-1.5 text-[12px] leading-relaxed"
|
|
>
|
|
<span class="flex-none font-semibold text-err">第 {{ f.lineNo }} 行</span>
|
|
<span class="truncate text-ink">{{ f.line }}</span>
|
|
<span class="ml-auto flex-none text-[11.5px] text-ink-2">{{ f.reason }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<NButton size="small" type="primary" :loading="importing" @click="doImport">导入</NButton>
|
|
<NButton size="small" quaternary @click="showImport = false">关闭</NButton>
|
|
</div>
|
|
</NModal>
|
|
</div>
|
|
</template>
|