mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 19:29:50 +08:00
refactor(frontend): modularize i18n architecture
This commit is contained in:
@@ -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 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
|
||||
}
|
||||
Reference in New Issue
Block a user