关于页运行状态、代理关联租户与步进框修复
CI / test (push) Successful in 25s
Release / release (push) Successful in 25s

This commit is contained in:
2026-07-13 12:21:28 +08:00
parent 2ea5b73de2
commit aea00c2382
9 changed files with 331 additions and 13 deletions
+28 -8
View File
@@ -22,6 +22,8 @@ import {
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'
@@ -120,6 +122,15 @@ async function remove(row: ProxyInfo) {
}
}
// ---- 关联租户管理 ----
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>())
@@ -207,13 +218,15 @@ const columns: DataTableColumns<ProxyInfo> = [
{
title: '认证',
key: 'username',
width: 110,
render: (row) =>
h(
'span',
{ class: 'text-[12.5px] text-ink-2' },
row.username ? `${row.username}${row.passwordSet ? ' / ●●●' : ''}` : row.passwordSet ? '●●●' : '无',
),
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: '关联租户',
@@ -225,7 +238,7 @@ const columns: DataTableColumns<ProxyInfo> = [
{
title: '操作',
key: 'actions',
width: 168,
width: 236,
render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [
h(
@@ -233,6 +246,7 @@ const columns: DataTableColumns<ProxyInfo> = [
{ 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,
@@ -261,6 +275,12 @@ const columns: DataTableColumns<ProxyInfo> = [
支持 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"
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { NButton, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listConfigs } from '@/api/configs'
import { setProxyTenants } from '@/api/proxies'
import FormField from '@/components/FormField.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { useAsync } from '@/composables/useAsync'
import type { ProxyInfo } from '@/types/api'
import { useToast } from '@/composables/useToast'
/** 代理侧关联租户管理:打开时回显当前关联名单,多选增删,保存即设置全集
* (选中即关联,含从其他代理改挂;取消选中即解除)。 */
const props = defineProps<{ show: boolean; proxy: ProxyInfo | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; saved: [] }>()
const message = useToast()
const configs = useAsync(listConfigs, false)
const selected = ref<number[]>([])
const saving = ref(false)
watch(
() => props.show,
async (v) => {
if (!v || !props.proxy) return
selected.value = []
await configs.run()
const pid = props.proxy.id
selected.value = (configs.data.value ?? []).filter((c) => c.proxyId === pid).map((c) => c.id)
},
)
const all = computed(() => configs.data.value ?? [])
/** 当前选中名单(打开时即已关联名单),chips 展示并支持点 × 移除 */
const picked = computed(() => all.value.filter((c) => selected.value.includes(c.id)))
/** 选中但已挂其他代理的租户:保存即改挂,给出警示 */
const rebinding = computed(() =>
picked.value.filter((c) => c.proxyId !== null && c.proxyId !== props.proxy?.id),
)
function removeOne(id: number) {
selected.value = selected.value.filter((v) => v !== id)
}
async function save() {
if (!props.proxy) return
saving.value = true
try {
const res = await setProxyTenants(props.proxy.id, selected.value)
message.success(`已更新关联,当前 ${res.usedBy} 个租户经此代理出站`)
emit('update:show', false)
emit('saved')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="`关联租户 · ${proxy?.name ?? ''}`"
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<FormField label="关联租户" hint="选中即关联,取消选中即解除;关联后租户全部 SDK 请求经此代理出站">
<TenantPicker
v-model:values="selected"
multiple
:configs="all"
:loading="configs.loading.value"
placeholder="选择要关联的租户"
/>
</FormField>
<div v-if="picked.length" class="mb-3 flex flex-wrap gap-1.5">
<span
v-for="c in picked"
:key="c.id"
class="inline-flex items-center gap-1 rounded-full border border-line bg-wash px-2.5 py-0.5 text-[11.5px] font-medium text-ink-2"
>
{{ c.alias }}
<button
type="button"
class="cursor-pointer text-ink-3 hover:text-err"
:aria-label="`移除 ${c.alias}`"
@click="removeOne(c.id)"
>
</button>
</span>
</div>
<div v-else class="mb-3 text-xs text-ink-3">暂无租户关联此代理</div>
<div
v-if="rebinding.length"
class="mb-3 flex items-start gap-2 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2.5 text-xs leading-relaxed"
>
<svg
class="mt-0.5 h-[14px] w-[14px] flex-none text-warn"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
<span>
<template v-for="(c, i) in rebinding" :key="c.id">
<template v-if="i > 0"></template>
<b>{{ c.alias }}</b>
<span v-if="c.proxyName" class="text-ink-3">现挂 {{ c.proxyName }}</span>
</template>
已关联其他代理,保存后将改挂到本代理
</span>
</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="emit('update:show', false)">取消</NButton>
</div>
</NModal>
</template>
+106 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { NButton, NSpin } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getAbout } from '@/api/settings'
import AppLogo from '@/components/AppLogo.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useParticles } from '@/composables/useParticles'
import { useAppStore } from '@/stores/app'
import type { AboutInfo } from '@/types/api'
@@ -20,15 +21,56 @@ const loading = ref(true)
const pcanvas = ref<HTMLCanvasElement | null>(null)
useParticles(pcanvas, { count: 26 })
// 运行时长随查看自增:记录拉取时刻,30s 粒度刷新本地流逝时间
const loadedAt = ref(0)
const nowTick = ref(0)
let upTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
try {
info.value = await getAbout()
loadedAt.value = nowTick.value = Date.now()
upTimer = setInterval(() => (nowTick.value = Date.now()), 30_000)
} catch {
/* 构建信息加载失败不阻塞页面,chips 显示占位 */
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (upTimer) clearInterval(upTimer)
})
/** 运行时长分段:天(为 0 时省略)/ 小时 / 分 */
const uptimeParts = computed(() => {
const base = info.value?.uptimeSeconds
if (base === undefined) return []
const sec = base + Math.max(0, (nowTick.value - loadedAt.value) / 1000)
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const parts: { v: number; u: string }[] = []
if (d > 0) parts.push({ v: d, u: '天' })
if (d > 0 || h > 0) parts.push({ v: h, u: '小时' })
parts.push({ v: Math.floor((sec % 3600) / 60), u: '分' })
return parts
})
const DB_LABEL: Record<string, string> = { sqlite: 'SQLite', mysql: 'MySQL', postgres: 'PostgreSQL' }
/** 四格资源指标:值与单位拆开渲染,sub 为口径说明 */
const statCells = computed(() => {
const r = info.value?.resources
if (!r) return []
const heap = fmtBytes(r.memHeapBytes).split(' ')
const store = r.dbBytes >= 0 ? fmtBytes(r.dbBytes).split(' ') : ['—', '']
const db = DB_LABEL[r.dbEngine] ?? r.dbEngine
return [
{ label: 'CPU 均值', value: String(r.cpuAvgPercent), unit: '%', sub: `${r.numCpu} 核 · 占单核百分比` },
{ label: '内存(堆)', value: heap[0], unit: heap[1], sub: `向系统申请 ${fmtBytes(r.memSysBytes)}` },
{ label: '存储占用', value: store[0], unit: store[1], sub: r.dbBytes >= 0 ? `${db} 含 WAL 日志` : `${db} 外部数据库,不可度量` },
{ label: 'Goroutines', value: String(r.goroutines), unit: '', sub: '运行时并发任务数' },
]
})
function fmtBuild(t: string) {
return t ? t.replace('T', ' ').replace('Z', ' UTC') : '—'
@@ -69,6 +111,40 @@ function openRepo() {
</div>
</div>
<!-- 运行状态:运行时长随查看自增,资源指标为进程自启动累计口径 -->
<div v-if="info?.resources" class="panel">
<div
class="flex items-center justify-between gap-3 border-b border-line-soft px-5 py-3.5 max-md:flex-col max-md:items-start max-md:gap-1"
>
<div class="flex items-center gap-2">
<span class="live-dot"></span>
<span class="text-[13px] font-semibold">运行状态</span>
</div>
<span class="text-xs text-ink-3">
{{ fmtTime(info.startedAt) }} 启动 · 指标为进程自启动累计口径
</span>
</div>
<div class="px-5 pt-[22px] pb-5">
<div class="text-xs font-medium text-ink-2">运行时间</div>
<div class="mt-1.5 text-[38px] leading-[1.1] font-semibold tracking-[-0.6px]">
<template v-for="p in uptimeParts" :key="p.u"
>{{ p.v }}<span class="uptime-unit">{{ p.u }}</span></template
>
</div>
</div>
<div class="mx-5 h-px bg-line-soft"></div>
<div class="grid grid-cols-4 max-md:grid-cols-2">
<div v-for="c in statCells" :key="c.label" class="statcell flex flex-col gap-[5px] px-5 pt-4 pb-[18px]">
<div class="text-xs font-medium text-ink-2">{{ c.label }}</div>
<div class="text-[26px] leading-[1.15] font-semibold tracking-[-0.3px]">
<span>{{ c.value }}</span
><span v-if="c.unit" class="ml-0.5 text-sm font-medium text-ink-2">{{ c.unit }}</span>
</div>
<div class="text-[11.5px] text-ink-3">{{ c.sub }}</div>
</div>
</div>
</div>
<!-- GitHub 仓库 -->
<div class="panel flex items-center gap-3.5 px-5 py-4">
<span class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
@@ -88,6 +164,35 @@ function openRepo() {
</template>
<style scoped>
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-ok);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ok) 18%, transparent);
}
.uptime-unit {
margin: 0 8px 0 3px;
font-size: 16px;
font-weight: 500;
letter-spacing: 0;
color: var(--color-ink-2);
}
.uptime-unit:last-of-type {
margin-right: 0;
}
/* 四格分栏:桌面竖线分隔;窄屏 2 列换行时第二行改上边框接续 */
.statcell + .statcell {
border-left: 1px solid var(--color-line-soft);
}
@media (width < 768px) {
.statcell:nth-child(3) {
border-left: none;
}
.statcell:nth-child(n + 3) {
border-top: 1px solid var(--color-line-soft);
}
}
.chip {
display: inline-flex;
align-items: center;