refactor(frontend): modularize i18n architecture

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent bf29129a4b
commit 9f138d09e6
118 changed files with 11940 additions and 5368 deletions
@@ -4,8 +4,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Cpu } from 'lucide-vue-next'
import { computed } from 'vue'
import { formatCompactNumber } from '@/utils/format'
import { useI18n } from '@/i18n'
const props = defineProps<{ node: ProxyNode }>()
const { legacyT } = useI18n()
const hardwareInfo = computed<Record<string, unknown> | null>(() => {
return normalizeHardwareInfo(props.node.hardware_info)
@@ -89,7 +91,7 @@ const hardwareRows = computed(() => {
const nativeTooltipText = computed(() =>
hardwareRows.value.length > 0
? hardwareRows.value.map((row) => `${row.label}: ${row.value}`).join('\n')
: '暂无硬件信息上报'
: legacyT('暂无硬件信息上报')
)
const showHardwareInfo = computed(
@@ -171,7 +173,7 @@ function pickString(...values: unknown[]): string {
<TooltipTrigger as-child>
<button
type="button"
aria-label="硬件信息"
:aria-label="legacyT('硬件信息')"
:title="nativeTooltipText"
class="inline-flex items-center justify-center rounded-sm p-0.5 hover:bg-muted/60 transition-colors cursor-pointer"
>
@@ -187,7 +189,7 @@ function pickString(...values: unknown[]): string {
v-if="hardwareRows.length === 0"
class="text-muted-foreground"
>
暂无硬件信息上报
{{ legacyT('暂无硬件信息上报') }}
</div>
<template v-else>
<div
@@ -0,0 +1,61 @@
<template>
<Dialog
:model-value="open"
:title="legacyT('批量升级')"
:description="legacyT('给所有 tunnel 节点写入升级目标,节点会在下次心跳自动领取')"
:icon="Settings"
size="sm"
@update:model-value="$emit('update:open', $event)"
>
<form
class="space-y-4"
@submit.prevent="$emit('submit')"
>
<div class="space-y-1.5">
<Label>{{ legacyT('目标版本') }}</Label>
<Input
:model-value="version"
:placeholder="legacyT('例如 0.2.3')"
@update:model-value="$emit('update:version', String($event))"
/>
<p class="text-xs text-muted-foreground">
{{ legacyT('gateway 只会写入 `upgrade_to` 目标版本,不再维护分波 rollout 或确认状态。') }}
</p>
</div>
</form>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
{{ legacyT('取消') }}
</Button>
<Button
:disabled="upgrading || !version.trim()"
@click="$emit('submit')"
>
{{ upgrading ? legacyT('下发中...') : legacyT('确认下发') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Settings } from 'lucide-vue-next'
import { Button, Dialog, Input, Label } from '@/components/ui'
import { useI18n } from '@/i18n'
defineProps<{
open: boolean
version: string
upgrading: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
'update:version': [value: string]
submit: []
}>()
const { legacyT } = useI18n()
</script>
@@ -4,13 +4,13 @@
<div>
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold">
节点数据
{{ legacyT('节点数据') }}
</h4>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0"
>
最近 24 小时
{{ legacyT('最近 24 小时') }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
@@ -28,7 +28,7 @@
class="h-3.5 w-3.5 mr-1"
:class="state?.loading ? 'animate-spin' : ''"
/>
刷新
{{ legacyT('刷新') }}
</Button>
</div>
@@ -37,7 +37,7 @@
class="py-8 flex items-center justify-center gap-2 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
加载节点数据...
{{ legacyT('加载节点数据...') }}
</div>
<div
@@ -59,7 +59,7 @@
class="rounded-lg border border-border/50 bg-background/70 px-3 py-2 min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
{{ legacyT(item.label) }}
</div>
<div
class="text-sm font-semibold tabular-nums truncate mt-0.5"
@@ -71,7 +71,7 @@
v-if="item.hint"
class="text-[10px] text-muted-foreground truncate mt-0.5"
>
{{ item.hint }}
{{ legacyT(item.hint) }}
</div>
</div>
</div>
@@ -81,13 +81,13 @@
<div class="flex items-center justify-between gap-3 mb-3">
<div>
<h5 class="text-xs font-semibold">
在线率采样
{{ legacyT('在线率采样') }}
</h5>
<p class="text-[11px] text-muted-foreground mt-0.5">
1h 桶,颜色随在线率和错误数变化
{{ legacyT('1h 桶,颜色随在线率和错误数变化') }}
</p>
</div>
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ formatNumber(bucketItems.length) }} 点</span>
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ legacyT(`${formatNumber(bucketItems.length)} 点`) }}</span>
</div>
<div
v-if="bucketItems.length > 0"
@@ -105,14 +105,14 @@
v-else
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
>
暂无采样数据
{{ legacyT('暂无采样数据') }}
</div>
</section>
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<div class="flex items-center justify-between gap-2 mb-3">
<h5 class="text-xs font-semibold">
最近事件
{{ legacyT('最近事件') }}
</h5>
<span class="text-[11px] text-muted-foreground tabular-nums">{{ recentEvents.length }}/8</span>
</div>
@@ -120,7 +120,7 @@
v-if="recentEvents.length === 0"
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
>
暂无关键事件
{{ legacyT('暂无关键事件') }}
</div>
<div
v-else
@@ -135,12 +135,12 @@
:variant="eventTypeVariant(event.event_type)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ eventTypeLabel(event.event_type) }}
{{ legacyT(eventTypeLabel(event.event_type)) }}
</Badge>
<span
class="text-muted-foreground truncate flex-1"
:title="eventTooltip(event)"
>{{ eventDetail(event) }}</span>
:title="legacyT(eventTooltip(event))"
>{{ legacyT(eventDetail(event)) }}</span>
<span class="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at || null) }}</span>
</div>
</div>
@@ -150,7 +150,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<h5 class="text-xs font-semibold mb-3">
硬件与资源
{{ legacyT('硬件与资源') }}
</h5>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
<div
@@ -159,19 +159,19 @@
class="min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
{{ legacyT(item.label) }}
</div>
<div
class="text-xs font-medium tabular-nums truncate mt-0.5"
:class="item.tone"
>
{{ item.value }}
{{ legacyT(item.value) }}
</div>
<div
v-if="item.hint"
class="text-[10px] text-muted-foreground truncate mt-0.5"
>
{{ item.hint }}
{{ legacyT(item.hint) }}
</div>
</div>
</div>
@@ -179,7 +179,7 @@
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<h5 class="text-xs font-semibold mb-3">
实时快照
{{ legacyT('实时快照') }}
</h5>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
<div
@@ -188,10 +188,10 @@
class="min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
{{ legacyT(item.label) }}
</div>
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
{{ item.value }}
{{ legacyT(item.value) }}
</div>
</div>
</div>
@@ -199,7 +199,7 @@
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<h5 class="text-xs font-semibold mb-3">
隧道计数器
{{ legacyT('隧道计数器') }}
</h5>
<div
v-if="tunnelMetrics"
@@ -211,10 +211,10 @@
class="min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
{{ legacyT(item.label) }}
</div>
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
{{ item.value }}
{{ legacyT(item.value) }}
</div>
</div>
</div>
@@ -222,7 +222,7 @@
v-else
class="rounded-md bg-muted/30 py-4 text-center text-xs text-muted-foreground"
>
暂无隧道计数器
{{ legacyT('暂无隧道计数器') }}
</div>
</section>
</div>
@@ -234,6 +234,7 @@
import { computed } from 'vue'
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-vue-next'
import { Badge, Button } from '@/components/ui'
import { useI18n } from '@/i18n'
import { formatCompactNumber } from '@/utils/format'
import type {
ProxyNode,
@@ -260,6 +261,8 @@ defineEmits<{
refresh: []
}>()
const { legacyT, locale } = useI18n()
const detailNode = computed(() => props.state?.node ?? props.node)
const metrics = computed(() => props.state?.metrics ?? null)
const metricsSummary = computed(() => metrics.value?.summary ?? null)
@@ -271,9 +274,11 @@ const resourceUsage = computed(() => asRecord(metadata.value?.resource_usage))
const tunnelMetrics = computed(() => asRecord(metadata.value?.tunnel_metrics))
const loadedText = computed(() => {
if (props.state?.loading) return '正在刷新采样数据'
if (!props.state?.loadedAt) return '展开后自动读取 24 小时指标和最近关键事件'
return `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
if (props.state?.loading) return legacyT('正在刷新采样数据')
if (!props.state?.loadedAt) return legacyT('展开后自动读取 24 小时指标和最近关键事件')
return locale.value === 'en-US'
? `Data refreshed at ${formatLoadedAt(props.state.loadedAt)}`
: `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
})
const summaryStats = computed(() => {
@@ -299,7 +304,7 @@ const summaryStats = computed(() => {
tone: '',
},
{
label: '断开',
label: '断开次数',
value: formatNumber(summary?.disconnects_delta ?? 0),
hint: '24h delta',
tone: (summary?.disconnects_delta ?? 0) > 0 ? 'text-yellow-600 dark:text-yellow-400' : '',
@@ -560,14 +565,20 @@ function formatTime(iso: string | null) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '-'
const diff = (Date.now() - date.getTime()) / 1000
if (locale.value === 'en-US') {
if (diff >= 0 && diff < 60) return 'Just now'
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return date.toLocaleDateString(locale.value, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
if (diff >= 0 && diff < 60) return '刚刚'
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}小时前`
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return date.toLocaleDateString(locale.value, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function formatLoadedAt(value: number) {
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
return new Date(value).toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function bucketBarHeight(bucket: ProxyNodeMetricsBucket) {
@@ -591,20 +602,21 @@ function bucketBarColor(bucket: ProxyNodeMetricsBucket) {
}
function bucketTitle(bucket: ProxyNodeMetricsBucket) {
return [
const parts = [
formatBucketTime(bucket.bucket_start),
`在线率 ${formatPercent(bucket.uptime_ratio)}`,
`${legacyT('在线率')} ${formatPercent(bucket.uptime_ratio)}`,
`RTT ${formatMs(bucket.heartbeat_rtt_ms_avg)}`,
`断开 ${formatNumber(bucket.disconnects_delta)}`,
`错误 ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
].join(',')
`${legacyT('断开')} ${formatNumber(bucket.disconnects_delta)}`,
`${legacyT('错误')} ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
]
return parts.join(locale.value === 'en-US' ? ', ' : ',')
}
function formatBucketTime(value: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit' })
return date.toLocaleString(locale.value, { month: '2-digit', day: '2-digit', hour: '2-digit' })
}
function uptimeTone(value: number | null) {
@@ -0,0 +1,103 @@
<template>
<Dialog
:open="open"
:title="legacyT('连接事件')"
:description="description"
size="lg"
@update:open="$emit('update:open', $event)"
>
<div class="space-y-3">
<div
v-if="node"
class="grid grid-cols-3 gap-3 text-sm"
>
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
<span class="block text-foreground/60 text-xs">{{ legacyT('失败请求') }}</span>
<span class="tabular-nums font-medium">{{ formatProxyNodeNumber(node.failed_requests || 0) }}</span>
</div>
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
<span class="block text-foreground/60 text-xs">{{ legacyT('DNS 失败') }}</span>
<span class="tabular-nums font-medium">{{ formatProxyNodeNumber(node.dns_failures || 0) }}</span>
</div>
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
<span class="block text-foreground/60 text-xs">{{ legacyT('流错误') }}</span>
<span class="tabular-nums font-medium">{{ formatProxyNodeNumber(node.stream_errors || 0) }}</span>
</div>
</div>
<div
v-if="loading"
class="py-8 text-center text-muted-foreground text-sm"
>
{{ legacyT('加载中...') }}
</div>
<div
v-else-if="events.length === 0"
class="py-8 text-center text-muted-foreground text-sm"
>
{{ legacyT('暂无连接事件记录') }}
</div>
<div
v-else
class="max-h-80 overflow-y-auto space-y-1.5"
>
<div
v-for="event in events"
:key="event.id"
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 text-sm"
>
<Badge
:variant="proxyNodeEventTypeVariant(event.event_type)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ legacyT(proxyNodeEventTypeLabel(event.event_type)) }}
</Badge>
<span class="text-muted-foreground truncate flex-1">{{ proxyNodeEventDetail(event) }}</span>
<span class="text-xs text-muted-foreground/70 tabular-nums shrink-0">{{ formatProxyNodeTime(event.created_at, locale) }}</span>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
{{ legacyT('关闭') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Badge, Button, Dialog } from '@/components/ui'
import type { ProxyNode, ProxyNodeEvent } from '@/api/proxy-nodes'
import { useI18n } from '@/i18n'
import {
formatProxyNodeNumber,
formatProxyNodeTime,
proxyNodeEventDetail,
proxyNodeEventTypeLabel,
proxyNodeEventTypeVariant,
} from './proxy-node-display'
const props = defineProps<{
open: boolean
node: ProxyNode | null
events: ProxyNodeEvent[]
loading: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
const { legacyT, locale } = useI18n()
const description = computed(() => {
if (!props.node) return ''
return locale.value === 'en-US'
? `${props.node.name} connection history`
: `${props.node.name} 的连接历史`
})
</script>
@@ -0,0 +1,382 @@
<template>
<Dialog
:model-value="open"
:title="editingNode ? legacyT('编辑代理节点') : legacyT('添加代理节点')"
:description="editingNode ? legacyT('修改手动代理节点的配置') : legacyT('推荐使用一键脚本部署 aether-tunnel,也可手动或批量添加已有 HTTP/SOCKS 代理')"
:icon="editingNode ? SquarePen : Plus"
size="lg"
@update:model-value="$emit('update:open', $event)"
>
<div
v-if="!editingNode"
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
>
<Button
type="button"
:variant="addMode === 'script' ? 'default' : 'ghost'"
class="h-9"
@click="$emit('update:addMode', 'script')"
>
<Terminal class="w-3.5 h-3.5 mr-1.5" />
{{ legacyT('脚本自动添加') }}
</Button>
<Button
type="button"
:variant="addMode === 'manual' ? 'default' : 'ghost'"
class="h-9"
@click="$emit('update:addMode', 'manual')"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
{{ legacyT('手动添加') }}
</Button>
<Button
type="button"
:variant="addMode === 'batch' ? 'default' : 'ghost'"
class="h-9"
@click="$emit('update:addMode', 'batch')"
>
<ListPlus class="w-3.5 h-3.5 mr-1.5" />
{{ legacyT('批量添加') }}
</Button>
</div>
<div
v-if="!editingNode && addMode === 'script'"
class="space-y-4"
>
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
{{ legacyT('输入节点名称后生成一次性安装命令,有效期 15 分钟。复制的命令不含敏感授权信息,只能在目标机器使用一次,避免 Token 暴露在页面或聊天记录中。') }}
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('节点名称 *') }}</Label>
<Input
v-model="installNodeName"
:placeholder="legacyT('例如: jp-proxy-01')"
@keyup.enter="$emit('refresh-install-command')"
/>
</div>
<div class="space-y-2">
<Label class="text-sm font-semibold">{{ legacyT('目标系统') }}</Label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button
type="button"
:variant="installSystem === 'unix' ? 'default' : 'outline'"
class="justify-start h-auto py-3"
@click="$emit('update:installSystem', 'unix')"
>
macOS / Linux
</Button>
<Button
type="button"
:variant="installSystem === 'windows' ? 'default' : 'outline'"
class="justify-start h-auto py-3"
@click="$emit('update:installSystem', 'windows')"
>
Windows PowerShell
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-sm font-semibold">{{ legacyT('复制到代理节点机器执行') }}</Label>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
class="gap-1.5"
:disabled="installLoading || !proxyInstallCommand"
@click="$emit('copy-install-command')"
>
<CheckCircle
v-if="installCopied"
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
/>
<Copy
v-else
class="h-3.5 w-3.5"
/>
{{ installCopied ? legacyT('已复制') : legacyT('一键复制') }}
</Button>
<Button
variant="ghost"
size="sm"
:disabled="installLoading || !installForm.node_name.trim()"
@click="$emit('refresh-install-command')"
>
{{ legacyT(installLoading ? '生成中...' : proxyInstallCommand ? '重新生成' : '生成命令') }}
</Button>
</div>
</div>
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ proxyInstallCommand || legacyT('输入节点名称后点击“生成命令”') }}</pre>
</div>
<p class="text-xs text-muted-foreground">
{{ legacyT(proxyInstallHint) }}
</p>
</div>
</div>
<div
v-else-if="!editingNode && addMode === 'batch'"
class="space-y-4"
>
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
{{ legacyT('支持一行一个,或使用英文逗号分隔。URL 中的用户名和密码会自动拆分到手动添加接口,节点名称自动使用主机和端口。') }}
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('代理地址 *') }}</Label>
<Textarea
v-model="batchContent"
class="min-h-[180px] font-mono text-xs break-all !rounded-xl"
placeholder="socks5://username:[email protected]:1080&#10;http://username:[email protected]:8080"
/>
</div>
<div
v-if="batchParseResult.errors.length"
class="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
>
<div class="font-medium">
{{ legacyT(`有 ${batchParseResult.errors.length} 条格式错误`) }}
</div>
<ul class="mt-1 space-y-1">
<li
v-for="message in batchParseResult.errors.slice(0, 3)"
:key="message"
>
{{ legacyT(message) }}
</li>
</ul>
<div
v-if="batchParseResult.errors.length > 3"
class="mt-1 text-destructive/80"
>
{{ legacyT(`还有 ${batchParseResult.errors.length - 3} 条错误未显示`) }}
</div>
</div>
<p
v-else-if="batchForm.content.trim()"
class="text-xs text-muted-foreground"
>
{{ legacyT(`已识别 ${batchParseResult.nodes.length} 个代理节点。`) }}
</p>
</div>
<form
v-else
class="space-y-4"
@submit.prevent="$emit('submit-manual')"
>
<div class="space-y-1.5">
<Label>{{ legacyT('名称 *') }}</Label>
<Input
v-model="manualName"
:placeholder="legacyT('例如: 美西 VPN 代理')"
/>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('代理地址 *') }}</Label>
<Input
v-model="manualProxyUrl"
:placeholder="legacyT('http://proxy:port 或 socks5://proxy:port')"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label>{{ legacyT('用户名') }}</Label>
<Input
v-model="manualUsername"
:placeholder="legacyT('可选')"
autocomplete="off"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('密码') }}</Label>
<Input
v-model="manualPassword"
type="text"
masked
:placeholder="legacyT('可选')"
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('区域') }}</Label>
<Input
v-model="manualRegion"
:placeholder="legacyT('可选,例如: US-West')"
/>
</div>
</form>
<template #footer>
<div
v-if="!editingNode && addMode === 'script'"
class="flex items-center justify-end gap-2 w-full"
>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
{{ legacyT('关闭') }}
</Button>
<Button
:disabled="installLoading || !proxyInstallCommand"
@click="$emit('copy-install-command')"
>
{{ installCopied ? legacyT('已复制') : legacyT('复制命令') }}
</Button>
</div>
<div
v-else-if="!editingNode && addMode === 'batch'"
class="flex items-center justify-between gap-3 w-full"
>
<span class="text-xs text-muted-foreground">
{{ batchForm.content.trim() ? legacyT(`待添加 ${batchParseResult.nodes.length} 个`) : legacyT('等待输入代理地址') }}
</span>
<div class="flex items-center gap-2">
<Button
variant="outline"
@click="$emit('update:open', false)"
>
{{ legacyT('取消') }}
</Button>
<Button
:disabled="addingNode || !batchForm.content.trim() || batchParseResult.errors.length > 0 || batchParseResult.nodes.length === 0"
@click="$emit('submit-batch')"
>
{{ addingNode ? legacyT('添加中...') : legacyT('批量添加') }}
</Button>
</div>
</div>
<div
v-else
class="flex items-center justify-between w-full"
>
<Button
variant="outline"
:disabled="testingUrl || !addForm.proxy_url"
@click="$emit('test-url')"
>
{{ testingUrl ? legacyT('测试中...') : legacyT('测试') }}
</Button>
<div class="flex items-center gap-2">
<Button
variant="outline"
@click="$emit('update:open', false)"
>
{{ legacyT('取消') }}
</Button>
<Button
:disabled="addingNode || !addForm.name || !addForm.proxy_url"
@click="$emit('submit-manual')"
>
{{ submitLabel }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle, Copy, ListPlus, Plus, SquarePen, Terminal } from 'lucide-vue-next'
import { Button, Dialog, Input, Label, Textarea } from '@/components/ui'
import type { ProxyNode } from '@/api/proxy-nodes'
import { useI18n } from '@/i18n'
import type { BatchProxyNodeParseResult } from '../proxy-node-batch'
import type {
ProxyNodeAddMode,
ProxyNodeBatchForm,
ProxyNodeInstallForm,
ProxyNodeInstallSystem,
ProxyNodeManualForm,
} from './proxy-node-types'
const props = defineProps<{
open: boolean
editingNode: ProxyNode | null
addMode: ProxyNodeAddMode
addForm: ProxyNodeManualForm
batchForm: ProxyNodeBatchForm
installForm: ProxyNodeInstallForm
installSystem: ProxyNodeInstallSystem
installLoading: boolean
installCopied: boolean
proxyInstallCommand: string
proxyInstallHint: string
batchParseResult: BatchProxyNodeParseResult
addingNode: boolean
testingUrl: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'update:addMode': [value: ProxyNodeAddMode]
'update:addForm': [value: ProxyNodeManualForm]
'update:batchForm': [value: ProxyNodeBatchForm]
'update:installForm': [value: ProxyNodeInstallForm]
'update:installSystem': [value: ProxyNodeInstallSystem]
'refresh-install-command': []
'copy-install-command': []
'submit-manual': []
'submit-batch': []
'test-url': []
}>()
const { legacyT } = useI18n()
const installNodeName = computed({
get: () => props.installForm.node_name,
set: (value: string) => emit('update:installForm', { ...props.installForm, node_name: value }),
})
const batchContent = computed({
get: () => props.batchForm.content,
set: (value: string) => emit('update:batchForm', { ...props.batchForm, content: value }),
})
const manualName = computed({
get: () => props.addForm.name,
set: (value: string) => emit('update:addForm', { ...props.addForm, name: value }),
})
const manualProxyUrl = computed({
get: () => props.addForm.proxy_url,
set: (value: string) => emit('update:addForm', { ...props.addForm, proxy_url: value }),
})
const manualUsername = computed({
get: () => props.addForm.username,
set: (value: string) => emit('update:addForm', { ...props.addForm, username: value }),
})
const manualPassword = computed({
get: () => props.addForm.password,
set: (value: string) => emit('update:addForm', { ...props.addForm, password: value }),
})
const manualRegion = computed({
get: () => props.addForm.region,
set: (value: string) => emit('update:addForm', { ...props.addForm, region: value }),
})
const submitLabel = computed(() => {
if (props.addingNode) {
return legacyT(props.editingNode ? '保存中...' : '添加中...')
}
return legacyT(props.editingNode ? '保存' : '添加')
})
</script>
@@ -0,0 +1,169 @@
<template>
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 sm:hidden">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
{{ legacyT('代理节点') }}
</h3>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="h-7 text-xs"
@click="$emit('open-distribution')"
>
{{ legacyT('均分') }}
</Button>
<Button
size="sm"
variant="outline"
class="h-7 text-xs"
@click="$emit('open-batch-upgrade')"
>
{{ legacyT('升级') }}
</Button>
<Button
size="sm"
class="h-7 text-xs"
@click="$emit('open-add')"
>
<Plus class="w-3 h-3 mr-1" />
{{ legacyT('添加') }}
</Button>
<RefreshButton
:loading="loading"
@click="$emit('refresh')"
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-0 basis-full">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchValue"
type="text"
:placeholder="legacyT('搜索...')"
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<div class="min-w-0 flex-1">
<Select v-model="statusValue">
<SelectTrigger class="w-full h-8 text-xs border-border/60">
<SelectValue :placeholder="legacyT('状态')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in statusOptions"
:key="option.value"
:value="option.value"
>
{{ legacyT(option.label) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="hidden sm:flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">
{{ legacyT('代理节点') }}
</h3>
<div class="flex items-center gap-2">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchValue"
type="text"
:placeholder="legacyT('搜索...')"
class="w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<div class="h-4 w-px bg-border" />
<div class="xl:hidden">
<Select v-model="statusValue">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue :placeholder="legacyT('全部状态')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in statusOptions"
:key="option.value"
:value="option.value"
>
{{ legacyT(option.label) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="h-4 w-px bg-border" />
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="$emit('open-distribution')"
>
<Shuffle class="w-3.5 h-3.5 mr-1.5" />
{{ legacyT('号池均分') }}
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="$emit('open-batch-upgrade')"
>
{{ legacyT('批量升级') }}
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('手动添加')"
@click="$emit('open-add')"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="loading"
@click="$emit('refresh')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Plus, Search, Shuffle } from 'lucide-vue-next'
import { Button, Input, RefreshButton, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { useI18n } from '@/i18n'
import type { ProxyNodeStatusFilterOption } from './proxy-node-types'
const props = defineProps<{
loading: boolean
searchQuery: string
filterStatus: string
statusOptions: ProxyNodeStatusFilterOption[]
}>()
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:filterStatus': [value: string]
'open-distribution': []
'open-batch-upgrade': []
'open-add': []
refresh: []
}>()
const { legacyT } = useI18n()
const searchValue = computed({
get: () => props.searchQuery,
set: (value: string) => emit('update:searchQuery', value),
})
const statusValue = computed({
get: () => props.filterStatus,
set: (value: string) => emit('update:filterStatus', value),
})
</script>
@@ -0,0 +1,151 @@
<template>
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[28px] min-w-[28px] max-w-[28px] h-12 p-0 pl-2" />
<TableHead class="w-[160px] h-12 font-semibold">
{{ legacyT('名称') }}
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
{{ legacyT('地址') }}
</TableHead>
<TableHead class="w-[100px] h-12 font-semibold">
{{ legacyT('区域') }}
</TableHead>
<SortableTableHead
class="w-[90px] h-12 font-semibold text-center"
column-key="status"
:sortable="false"
align="center"
:filter-active="filterStatus !== 'all'"
:filter-title="legacyT('筛选状态')"
filter-content-class="w-36 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
{{ legacyT('状态') }}
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterStatus"
:options="statusOptions.map((option) => ({ ...option, label: legacyT(option.label) }))"
@update:model-value="(value) => $emit('update:filterStatus', value)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
{{ legacyT('总请求') }}
</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
{{ legacyT('失败率') }}
</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
{{ legacyT('延迟') }}
</TableHead>
<TableHead class="w-[120px] h-12 font-semibold text-center">
{{ legacyT('版本') }}
</TableHead>
<TableHead class="w-[160px] h-12 font-semibold">
{{ legacyT('最后心跳') }}
</TableHead>
<TableHead class="w-[140px] h-12 font-semibold text-center">
{{ legacyT('操作') }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<ProxyNodeTableRow
v-for="node in nodes"
:key="node.id"
:node="node"
:expanded="expandedNodeIds.has(node.id)"
:testing="testingNodeIds.has(node.id)"
:detail-state="nodeDetails[node.id]"
@toggle-details="$emit('toggle-details', node)"
@refresh-details="$emit('refresh-details', node)"
@test="$emit('test', node)"
@edit="$emit('edit', node)"
@config="$emit('config', node)"
@view-events="$emit('view-events', node)"
@delete="$emit('delete', node)"
/>
<TableRow v-if="nodes.length === 0">
<TableCell
colspan="11"
class="py-12 text-center text-muted-foreground text-sm"
>
{{ loading ? legacyT('加载中...') : legacyT('暂无代理节点') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="xl:hidden divide-y divide-border/40">
<ProxyNodeMobileCard
v-for="node in nodes"
:key="node.id"
:node="node"
:expanded="expandedNodeIds.has(node.id)"
:testing="testingNodeIds.has(node.id)"
:detail-state="nodeDetails[node.id]"
@toggle-details="$emit('toggle-details', node)"
@refresh-details="$emit('refresh-details', node)"
@test="$emit('test', node)"
@edit="$emit('edit', node)"
@config="$emit('config', node)"
@delete="$emit('delete', node)"
/>
<div
v-if="nodes.length === 0"
class="p-8 text-center text-muted-foreground text-sm"
>
{{ loading ? legacyT('加载中...') : legacyT('暂无代理节点') }}
</div>
</div>
<Pagination
:current="currentPage"
:total="total"
:page-size="pageSize"
cache-key="proxy-nodes-page-size"
@update:current="$emit('update:currentPage', $event)"
@update:page-size="$emit('update:pageSize', $event)"
/>
</template>
<script setup lang="ts">
import { Pagination, SortableTableHead, Table, TableBody, TableCell, TableFilterMenu, TableHead, TableHeader, TableRow } from '@/components/ui'
import type { ProxyNode } from '@/api/proxy-nodes'
import { useI18n } from '@/i18n'
import ProxyNodeMobileCard from './ProxyNodeMobileCard.vue'
import ProxyNodeTableRow from './ProxyNodeTableRow.vue'
import type { ProxyNodeDetailState, ProxyNodeStatusFilterOption } from './proxy-node-types'
defineProps<{
nodes: ProxyNode[]
total: number
loading: boolean
filterStatus: string
statusOptions: ProxyNodeStatusFilterOption[]
currentPage: number
pageSize: number
expandedNodeIds: Set<string>
testingNodeIds: Set<string>
nodeDetails: Record<string, ProxyNodeDetailState>
}>()
defineEmits<{
'update:filterStatus': [value: string]
'update:currentPage': [value: number]
'update:pageSize': [value: number]
'toggle-details': [node: ProxyNode]
'refresh-details': [node: ProxyNode]
test: [node: ProxyNode]
edit: [node: ProxyNode]
config: [node: ProxyNode]
'view-events': [node: ProxyNode]
delete: [node: ProxyNode]
}>()
const { legacyT } = useI18n()
</script>
@@ -0,0 +1,190 @@
<template>
<div class="p-4 sm:p-5">
<div class="flex items-start justify-between mb-2">
<div>
<div class="flex items-center gap-1.5">
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 shrink-0"
:title="expanded ? legacyT('收起数据') : legacyT('展开数据')"
@click="$emit('toggle-details')"
>
<ChevronDown
v-if="expanded"
class="h-3.5 w-3.5"
/>
<ChevronRight
v-else
class="h-3.5 w-3.5"
/>
</button>
<span class="font-semibold text-sm">{{ node.name }}</span>
<Badge
v-if="node.is_manual"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
{{ legacyT('手动') }}
</Badge>
<Badge
v-if="node.tunnel_mode"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
Tunnel
</Badge>
<Badge
v-if="schedulingBadge"
:variant="schedulingBadge.variant"
class="text-[10px] px-1.5 py-0"
>
{{ legacyT(schedulingBadge.label) }}
</Badge>
<HardwareTooltip :node="node" />
</div>
<code class="text-xs text-muted-foreground">{{ proxyNodeAddress(node) }}</code>
<div
v-if="!node.is_manual"
class="text-[11px] text-muted-foreground mt-1"
>
{{ legacyT('版本:') }} {{ proxyNodeVersion(node) }}
</div>
</div>
<Badge
:variant="proxyNodeStatusVariant(node.status)"
:title="legacyT(proxyNodeStatusTitle(node))"
class="text-xs"
>
{{ legacyT(proxyNodeStatusLabel(node)) }}
</Badge>
</div>
<div class="grid grid-cols-4 gap-2 text-xs text-muted-foreground mb-3">
<div>
<span class="block text-foreground/60">{{ legacyT('区域') }}</span>
<span>{{ legacyT(formatProxyNodeRegion(node.region)) }}</span>
</div>
<div>
<span class="block text-foreground/60">{{ legacyT('总请求') }}</span>
<span class="tabular-nums">{{ formatProxyNodeNumber(node.total_requests) }}</span>
</div>
<div>
<span class="block text-foreground/60">{{ legacyT('失败率') }}</span>
<span
class="tabular-nums"
:class="proxyNodeFailureRate(node) > 5 ? 'text-destructive font-medium' : ''"
>{{ formatProxyNodeFailureRate(node) }}</span>
</div>
<div>
<span class="block text-foreground/60">{{ legacyT('延迟') }}</span>
<span class="tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ formatProxyNodeTime(node.last_heartbeat_at, locale) }}</span>
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="testing"
@click="$emit('test')"
>
<Loader2
v-if="testing"
class="h-3 w-3 mr-1 animate-spin"
/>
<Activity
v-else
class="h-3 w-3 mr-1"
/>
{{ testing ? legacyT('测试中') : legacyT('测试') }}
</Button>
<Button
v-if="node.is_manual"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="$emit('edit')"
>
<SquarePen class="h-3 w-3 mr-1" />
{{ legacyT('编辑') }}
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="$emit('config')"
>
<Settings class="h-3 w-3 mr-1" />
{{ legacyT('配置') }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="$emit('delete')"
>
<Trash2 class="h-3 w-3 mr-1" />
{{ legacyT('删除') }}
</Button>
</div>
</div>
<div
v-if="expanded"
class="mt-4 -mx-4 sm:-mx-5"
>
<ProxyNodeDataPanel
:node="node"
:state="detailState"
@refresh="$emit('refresh-details')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Activity, ChevronDown, ChevronRight, Loader2, Settings, SquarePen, Trash2 } from 'lucide-vue-next'
import { Badge, Button } from '@/components/ui'
import type { ProxyNode } from '@/api/proxy-nodes'
import { useI18n } from '@/i18n'
import HardwareTooltip from './HardwareTooltip.vue'
import ProxyNodeDataPanel from './ProxyNodeDataPanel.vue'
import {
formatProxyNodeFailureRate,
formatProxyNodeNumber,
formatProxyNodeRegion,
formatProxyNodeTime,
proxyNodeAddress,
proxyNodeFailureRate,
proxyNodeSchedulingBadge,
proxyNodeStatusLabel,
proxyNodeStatusTitle,
proxyNodeStatusVariant,
proxyNodeVersion,
} from './proxy-node-display'
import type { ProxyNodeDetailState } from './proxy-node-types'
const props = defineProps<{
node: ProxyNode
expanded: boolean
testing: boolean
detailState?: ProxyNodeDetailState | null
}>()
defineEmits<{
'toggle-details': []
'refresh-details': []
test: []
edit: []
config: []
delete: []
}>()
const { legacyT, locale } = useI18n()
const schedulingBadge = computed(() => proxyNodeSchedulingBadge(props.node))
</script>
@@ -0,0 +1,143 @@
<template>
<Dialog
:model-value="open"
:title="legacyT('远程配置')"
:description="legacyT('修改后将在下次心跳时自动下发到 aether-tunnel 节点')"
:icon="Settings"
size="md"
@update:model-value="$emit('update:open', $event)"
>
<form
class="space-y-4"
@submit.prevent
>
<div class="space-y-1.5">
<Label>{{ legacyT('允许的端口') }}</Label>
<Input
v-model="allowedPorts"
placeholder="80, 443, 8080, 8443"
/>
<p class="text-xs text-muted-foreground">
{{ legacyT('逗号分隔的目标端口白名单') }}
</p>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('日志级别') }}</Label>
<Select v-model="logLevel">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="trace">trace</SelectItem>
<SelectItem value="debug">debug</SelectItem>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warn">warn</SelectItem>
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>{{ legacyT('心跳间隔 (秒)') }}</Label>
<Input
v-model="heartbeatInterval"
type="number"
min="5"
max="600"
/>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('接单状态') }}</Label>
<Select v-model="schedulingState">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">active</SelectItem>
<SelectItem value="draining">draining</SelectItem>
<SelectItem value="cordoned">cordoned</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ legacyT('draining/cordoned 都会停止新隧道请求;draining 用于排空,cordoned 用于人工隔离。') }}
</p>
</div>
</div>
<div class="space-y-1.5">
<Label>{{ legacyT('升级到版本') }}</Label>
<Input
v-model="upgradeTo"
:placeholder="legacyT('例如 0.2.3')"
/>
<p class="text-xs text-muted-foreground">
{{ legacyT('留空可清除已有升级指令') }}
</p>
</div>
<div
v-if="node"
class="text-xs text-muted-foreground"
>
{{ legacyT('配置版本:') }} v{{ node.config_version }}
</div>
</form>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
{{ legacyT('取消') }}
</Button>
<Button
:disabled="saving"
@click="$emit('save')"
>
{{ saving ? legacyT('保存中...') : legacyT('保存') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Settings } from 'lucide-vue-next'
import { Button, Dialog, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import type { ProxyNode, ProxyNodeSchedulingState } from '@/api/proxy-nodes'
import { useI18n } from '@/i18n'
import type { ProxyNodeConfigForm } from './proxy-node-types'
const props = defineProps<{
open: boolean
node: ProxyNode | null
form: ProxyNodeConfigForm
saving: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'update:form': [value: ProxyNodeConfigForm]
save: []
}>()
const { legacyT } = useI18n()
const allowedPorts = computed({
get: () => props.form.allowed_ports,
set: (value: string) => emit('update:form', { ...props.form, allowed_ports: value }),
})
const logLevel = computed({
get: () => props.form.log_level,
set: (value: string) => emit('update:form', { ...props.form, log_level: value }),
})
const heartbeatInterval = computed({
get: () => props.form.heartbeat_interval,
set: (value: string) => emit('update:form', { ...props.form, heartbeat_interval: value }),
})
const schedulingState = computed({
get: () => props.form.scheduling_state,
set: (value: string) => emit('update:form', { ...props.form, scheduling_state: value as ProxyNodeSchedulingState }),
})
const upgradeTo = computed({
get: () => props.form.upgrade_to,
set: (value: string) => emit('update:form', { ...props.form, upgrade_to: value }),
})
</script>
@@ -0,0 +1,205 @@
<template>
<template>
<TableRow
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
:class="expanded ? 'bg-muted/20' : ''"
>
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
:title="expanded ? legacyT('收起数据') : legacyT('展开数据')"
@click="$emit('toggle-details')"
>
<ChevronDown
v-if="expanded"
class="h-3.5 w-3.5"
/>
<ChevronRight
v-else
class="h-3.5 w-3.5"
/>
</button>
</TableCell>
<TableCell class="py-4">
<div class="flex items-center gap-1.5">
<span class="text-sm font-semibold">{{ node.name }}</span>
<Badge
v-if="node.is_manual"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
{{ legacyT('手动') }}
</Badge>
<Badge
v-if="node.tunnel_mode"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
Tunnel
</Badge>
<Badge
v-if="schedulingBadge"
:variant="schedulingBadge.variant"
class="text-[10px] px-1.5 py-0"
>
{{ legacyT(schedulingBadge.label) }}
</Badge>
<HardwareTooltip :node="node" />
</div>
</TableCell>
<TableCell class="py-4">
<code class="text-xs text-muted-foreground">{{ proxyNodeAddress(node) }}</code>
</TableCell>
<TableCell class="py-4">
<span class="text-sm text-muted-foreground">{{ legacyT(formatProxyNodeRegion(node.region)) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<Badge
:variant="proxyNodeStatusVariant(node.status)"
:title="legacyT(proxyNodeStatusTitle(node))"
class="font-medium px-2.5 py-0.5 text-xs"
>
{{ legacyT(proxyNodeStatusLabel(node)) }}
</Badge>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ formatProxyNodeNumber(node.total_requests) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span
class="text-sm tabular-nums"
:class="proxyNodeFailureRate(node) > 5 ? 'text-destructive font-medium' : ''"
>{{ formatProxyNodeFailureRate(node) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : proxyNodeVersion(node) }}</span>
</TableCell>
<TableCell class="py-4">
<span class="text-xs text-muted-foreground">{{ formatProxyNodeTime(node.last_heartbeat_at, locale) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="testing ? legacyT('测试中...') : legacyT('测试连通性')"
:disabled="testing"
@click="$emit('test')"
>
<Loader2
v-if="testing"
class="h-4 w-4 animate-spin"
/>
<Activity
v-else
class="h-4 w-4"
/>
</Button>
<Button
v-if="node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('编辑')"
@click="$emit('edit')"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('远程配置')"
@click="$emit('config')"
>
<Settings class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('连接事件')"
@click="$emit('view-events')"
>
<History class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('删除')"
@click="$emit('delete')"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow
v-if="expanded"
class="border-b border-border/40 hover:bg-transparent"
>
<TableCell
colspan="11"
class="p-0"
>
<ProxyNodeDataPanel
:node="node"
:state="detailState"
@refresh="$emit('refresh-details')"
/>
</TableCell>
</TableRow>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Activity, ChevronDown, ChevronRight, History, Loader2, Settings, SquarePen, Trash2 } from 'lucide-vue-next'
import { Badge, Button, TableCell, TableRow } from '@/components/ui'
import type { ProxyNode } from '@/api/proxy-nodes'
import { useI18n } from '@/i18n'
import HardwareTooltip from './HardwareTooltip.vue'
import ProxyNodeDataPanel from './ProxyNodeDataPanel.vue'
import {
formatProxyNodeFailureRate,
formatProxyNodeNumber,
formatProxyNodeRegion,
formatProxyNodeTime,
proxyNodeAddress,
proxyNodeFailureRate,
proxyNodeSchedulingBadge,
proxyNodeStatusLabel,
proxyNodeStatusTitle,
proxyNodeStatusVariant,
proxyNodeVersion,
} from './proxy-node-display'
import type { ProxyNodeDetailState } from './proxy-node-types'
const props = defineProps<{
node: ProxyNode
expanded: boolean
testing: boolean
detailState?: ProxyNodeDetailState | null
}>()
defineEmits<{
'toggle-details': []
'refresh-details': []
test: []
edit: []
config: []
'view-events': []
delete: []
}>()
const { legacyT, locale } = useI18n()
const schedulingBadge = computed(() => proxyNodeSchedulingBadge(props.node))
</script>
@@ -0,0 +1,133 @@
import type { ProxyNode, ProxyNodeEvent } from '@/api/proxy-nodes'
import type { Locale } from '@/i18n'
import { formatCompactNumber } from '@/utils/format'
import { formatRegion } from '@/utils/region'
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
export function formatProxyNodeNumber(value: number): string {
return formatCompactNumber(value, { fractionDigits: 1 })
}
export function formatProxyNodeRegion(region: string | null): string {
return formatRegion(region)
}
export function formatProxyNodeTime(iso: string | null, locale: Locale): string {
if (!iso) return '-'
const date = new Date(iso)
const diff = (Date.now() - date.getTime()) / 1000
if (locale === 'en-US') {
if (diff < 60) return 'Just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return date.toLocaleDateString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
return date.toLocaleDateString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export function proxyNodeFailureRate(node: ProxyNode): number {
if (!node.total_requests) return 0
const failed = (node.failed_requests || 0) + (node.dns_failures || 0) + (node.stream_errors || 0)
return (failed / node.total_requests) * 100
}
export function formatProxyNodeFailureRate(node: ProxyNode): string {
if (!node.total_requests) return '-'
const rate = proxyNodeFailureRate(node)
if (rate === 0) return '0%'
if (rate < 0.1) return '<0.1%'
return `${rate.toFixed(1)}%`
}
export function proxyNodeAddress(node: ProxyNode): string {
if (node.is_manual) return node.proxy_url || `${node.ip}:${node.port}`
if (node.tunnel_mode) return node.ip || 'WebSocket Tunnel'
return `${node.ip}:${node.port}`
}
export function proxyNodeVersion(node: ProxyNode): string {
const metadata = node.proxy_metadata
if (!metadata || typeof metadata !== 'object') return '-'
const version = (metadata as Record<string, unknown>).version
if (typeof version !== 'string') return '-'
const normalized = version.trim()
return normalized || '-'
}
export function proxyNodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null {
switch (node.remote_config?.scheduling_state) {
case 'draining':
return { label: '排空中', variant: 'warning' }
case 'cordoned':
return { label: '已封锁', variant: 'dark' }
default:
return null
}
}
export function proxyNodeStatusVariant(status: string): BadgeVariant {
switch (status) {
case 'online': return 'success'
case 'offline': return 'destructive'
default: return 'secondary'
}
}
export function proxyNodeStatusLabel(node: ProxyNode): string {
if (node.tunnel_mode && !node.is_manual) {
switch (node.status) {
case 'online': return '隧道在线'
case 'offline': return '隧道离线'
default: return node.status
}
}
switch (node.status) {
case 'online': return '在线'
case 'offline': return '离线'
default: return node.status
}
}
export function proxyNodeStatusTitle(node: ProxyNode): string {
if (node.tunnel_mode && !node.is_manual) {
if (node.status === 'online') {
return '表示 gateway 仍能看到 tunnel/heartbeat,不代表默认探测站点一定可达'
}
return 'gateway 当前未检测到可用 tunnel 连接'
}
switch (node.status) {
case 'online': return '节点当前被标记为在线'
case 'offline': return '节点当前被标记为离线'
default: return node.status
}
}
export function proxyNodeEventTypeLabel(type: string): string {
switch (type) {
case 'connected': return '连接'
case 'disconnected': return '断开'
case 'error': return '错误'
default: return type
}
}
export function proxyNodeEventTypeVariant(type: string): BadgeVariant {
switch (type) {
case 'connected': return 'success'
case 'disconnected': return 'destructive'
case 'error': return 'destructive'
default: return 'secondary'
}
}
export function proxyNodeEventDetail(event: ProxyNodeEvent): string {
return event.detail || '-'
}
@@ -0,0 +1,47 @@
import type {
ProxyNode,
ProxyNodeEvent,
ProxyNodeMetricsResponse,
ProxyNodeSchedulingState,
} from '@/api/proxy-nodes'
export type ProxyNodeAddMode = 'script' | 'manual' | 'batch'
export type ProxyNodeInstallSystem = 'unix' | 'windows'
export interface ProxyNodeStatusFilterOption {
value: string
label: string
}
export interface ProxyNodeManualForm {
name: string
proxy_url: string
username: string
password: string
region: string
}
export interface ProxyNodeBatchForm {
content: string
}
export interface ProxyNodeInstallForm {
node_name: string
}
export interface ProxyNodeConfigForm {
allowed_ports: string
log_level: string
heartbeat_interval: string
scheduling_state: ProxyNodeSchedulingState
upgrade_to: string
}
export interface ProxyNodeDetailState {
loading: boolean
error: string | null
node: ProxyNode | null
metrics: ProxyNodeMetricsResponse | null
events: ProxyNodeEvent[]
loadedAt: number | null
}