初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+386
View File
@@ -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&#10;https://198.51.100.2:8443&#10;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>