初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NRadioButton,
|
||||
NRadioGroup,
|
||||
useMessage,
|
||||
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 { useAsync } from '@/composables/useAsync'
|
||||
import type { ProxyImportResult, ProxyInfo } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
// 新增 / 批量导入入口由页面标题行触发(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: 'username',
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'text-[12.5px] text-ink-2' },
|
||||
row.username ? `${row.username}${row.passwordSet ? ' / ●●●' : ''}` : row.passwordSet ? '●●●' : '无',
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '关联租户',
|
||||
key: 'usedBy',
|
||||
width: 88,
|
||||
render: (row) =>
|
||||
h('span', { class: 'text-[13px] tabular-nums' }, row.usedBy ? `${row.usedBy} 个` : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 168,
|
||||
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: () => 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>
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user