Files
Havline/web/src/views/AgentView.vue
T
mrlee 8a98d8e4f0 feat(web): 公网 Agent 页按设计 v2 重构(功能清单驱动)
- 重写 AgentView.vue:运行状态 / 环境与依赖 / frps 控制 / Agent 生命周期 / 输出与日志 五段分区
- 补齐 5 处「数据已返回但页面未展示」:Agent 凭据状态、8080 端口、反代路由数、最后检查时间、Nginx 可用性
- 状态机:busy 按动作粒度 loading、轮询静默、切换服务端重置状态、三态区分(未加载/加载中/失败)
- 交互:卸载(含数据)二次确认;管理面板与安装 frps 常显但按条件禁用;输出改为带来源与时间的时间线
- 新增「配置查看」按钮 + 只读弹窗(getFrpFRPSConfig)
- 无障碍与响应式:section[aria-label]、aria-live、aria-hidden、320px、≤760px 触控目标、prefers-reduced-motion
- 文档:docs/agent-view-design-v2.md(设计规格)、docs/agent-view-design.md、docs/agent-view-refactor-plan.md
2026-09-23 13:54:42 +08:00

628 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageHeader title="公网 Agent" :description="headerDescription">
<template #actions>
<n-select
v-model:value="selectedId"
:options="serverOptions"
placeholder="选择公网服务端"
class="agent-select"
:disabled="servers.length === 0"
/>
<n-button :loading="statusLoading" :disabled="!server" @click="refreshAll({ silent: false })">
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新
</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else>
<EmptyState
v-if="servers.length === 0"
title="尚未配置 FRP 服务端"
description="先在「内网穿透」页添加公网服务端并保存 SSH 信息,再回到本页管理 Agent。"
>
<template #action>
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
</template>
</EmptyState>
<template v-else-if="server">
<!-- 状态提示:互斥,最多一条 -->
<n-alert v-if="statusAlert" :type="statusAlert.type" :bordered="false" class="page-alert">
<div class="page-alert__body">
<span>{{ statusAlert.text }}</span>
<n-button v-if="statusAlert.action" size="small" :disabled="busy !== null" @click="statusAlert.action.run()">
{{ statusAlert.action.label }}
</n-button>
</div>
</n-alert>
<!-- 入口行:公网反代(不占 alert 位) -->
<div class="entrance-row">
<span class="entrance-row__text">
已注册公网反代 <strong>{{ routesCount }}</strong> 条;逐条部署、配置查看与访问控制都在「公网反代」页
</span>
<router-link to="/public-proxy"><n-button size="small" type="primary">前往公网反代</n-button></router-link>
</div>
<!-- ① 运行状态 -->
<section class="stats-row" aria-label="运行状态">
<div class="stat-card">
<n-icon :component="CloudOutline" class="stat-card__icon" :class="stateClass(agentStatusOk, statusLoaded)" aria-hidden="true" />
<div class="stat-card__value stat-card__value--sm">{{ agentConnectionText }}</div>
<div class="stat-card__label">Agent 连接</div>
<div class="stat-card__sub mono" :title="server.agent_url">{{ server.agent_url || '未配置 Agent 地址' }}</div>
</div>
<div class="stat-card">
<n-icon :component="GitNetworkOutline" class="stat-card__icon" :class="stateClass(!!agentStatus?.frps_active, statusLoaded)" aria-hidden="true" />
<div class="stat-card__value stat-card__value--sm">{{ frpsStateText }}</div>
<div class="stat-card__label">frps 服务</div>
<div class="stat-card__sub">{{ installedFrpsVersion || '未安装' }}</div>
</div>
<div class="stat-card">
<n-icon :component="LayersOutline" class="stat-card__icon" :class="routesCount > 0 ? 'stat-card__icon--blue' : 'stat-card__icon--amber'" aria-hidden="true" />
<div class="stat-card__value stat-card__value--sm">{{ routesCount }} 条</div>
<div class="stat-card__label">反代路由</div>
<div class="stat-card__sub">VPS 上已注册</div>
</div>
<div class="stat-card">
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon" :class="portIconClass" aria-hidden="true" />
<div class="port-list">
<span v-for="port in watchedPorts" :key="port" class="port-item" :class="{ 'port-item--on': portOn(port) }">
{{ port }} {{ portOn(port) ? '✓' : '✗' }}
</span>
</div>
<div class="stat-card__label">端口监听</div>
<div class="stat-card__sub">frps 运行所需端口</div>
</div>
</section>
<!-- ② 环境与依赖 -->
<section class="dep-grid" aria-label="环境与依赖">
<FonuCard class="section-card" title="Fonu Agent">
<div class="section-body">
<div class="field-grid">
<div><label>Agent 版本</label><span class="mono">{{ agentStatus?.agent_version || '—' }}</span></div>
<div><label>凭据状态</label><span class="field-tags">
<StatusBadge :kind="server.agent_configured ? 'success' : 'warning'" :text="server.agent_configured ? '已配置' : '未配置'" />
</span></div>
<div><label>SSH 凭据</label><span class="field-tags">
<StatusBadge :kind="server.ssh_configured ? 'success' : 'disabled'" :text="server.ssh_configured ? '已配置' : '未配置'" />
</span></div>
<div><label>Agent 地址</label><span class="mono mono-wrap">{{ server.agent_url || '未配置' }}</span></div>
</div>
<div class="agent-actions">
<n-button size="small" :loading="busy === 'probe'" :disabled="busy !== null && busy !== 'probe'" @click="probe(server)">探测环境</n-button>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="Nginx(公网反代依赖)">
<div class="section-body">
<div class="field-grid">
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
<div><label>进程</label><span>{{ agentStatus?.nginx_running ? '运行中' : '未运行' }}</span></div>
<div><label>版本</label><span class="mono">{{ agentStatus?.nginx_version || '—' }}</span></div>
<div><label>可执行文件</label><span class="mono mono-wrap">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span></div>
<div class="field-grid__wide"><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
</div>
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
<div class="agent-actions">
<router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 运行时">
<div class="section-body">
<div class="field-grid">
<div><label>已安装版本</label><span class="field-tags"><StatusBadge :kind="agentStatus?.frps_version ? 'success' : 'warning'" :text="installedFrpsVersion || '未安装'" /></span></div>
<div><label>开机自启</label><span class="field-tags"><StatusBadge :kind="agentStatus?.frps_enabled ? 'success' : 'warning'" :text="agentStatus?.frps_enabled ? '已启用' : '未启用'" /></span></div>
<div><label>二进制路径</label><span class="mono mono-wrap">{{ agentStatus?.frps_binary || '—' }}</span></div>
<div><label>配置文件</label><span class="mono mono-wrap">{{ agentStatus?.frps_config_path || '—' }}</span></div>
<div class="field-grid__wide"><label>启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '—' }}</span></div>
</div>
</div>
</FonuCard>
</section>
<!-- ④ Agent 生命周期(SSH) + ③ frps 控制:两列并排(生命周期在左) -->
<div class="control-grid">
<FonuCard class="section-card" title="Agent 生命周期(SSH)">
<div class="section-body">
<p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
</p>
<div class="agent-actions">
<n-button size="small" :loading="busy === 'agent-install'" :disabled="busy !== null && busy !== 'agent-install'" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
<n-button size="small" :loading="busy === 'diagnose'" :disabled="busy !== null && busy !== 'diagnose'" @click="diagnoseAgent(server)">SSH 诊断</n-button>
<n-button size="small" quaternary :loading="busy === 'agent-uninstall'" :disabled="busy !== null && busy !== 'agent-uninstall'" @click="uninstallAgent(server, true)">卸载(保留路由/证书)</n-button>
<n-popconfirm @positive-click="uninstallAgent(server, false)">
<template #trigger>
<n-button size="small" type="warning" :disabled="busy !== null">卸载(含数据)</n-button>
</template>
将删除 VPS 上的 fonu-agent、frps 单元与 /var/lib/fonu-agent(已注册的路由与证书一并丢失),确认继续?
</n-popconfirm>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 控制">
<div class="section-body">
<div class="agent-actions">
<n-button v-if="agentStatus?.frps_active" size="small" type="warning" :loading="busy === 'frps-stop'" :disabled="busy !== null && busy !== 'frps-stop'" @click="frpsAction(server, 'stop')">停止 frps</n-button>
<n-button v-else size="small" type="success" :loading="busy === 'frps-start'" :disabled="busy !== null && busy !== 'frps-start'" @click="frpsAction(server, 'start')">启动 frps</n-button>
<n-button size="small" :loading="busy === 'frps-restart'" :disabled="busy !== null && busy !== 'frps-restart'" @click="frpsAction(server, 'restart')">重启 frps</n-button>
<n-tooltip :disabled="!!dashboardURL">
<template #trigger>
<span class="tooltip-trigger">
<n-button size="small" quaternary :disabled="!dashboardURL" @click="openDashboard">frps 管理面板</n-button>
</span>
</template>
未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存
</n-tooltip>
<n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
<n-button size="small" quaternary :loading="configLoading" :disabled="busy !== null" @click="viewConfig(server)">配置查看</n-button>
</div>
<div class="frps-install-row">
<n-select
v-model:value="frpsInstallVersion"
:options="frpsVersionOptions"
placeholder="选择 frps 官方版本"
class="frps-version-select"
filterable
:loading="releasesLoading"
/>
<n-button size="small" type="success" :loading="busy === 'frps-install'" :disabled="(busy !== null && busy !== 'frps-install') || !releases.length" @click="installFrps(server)">安装 / 升级 frps</n-button>
<span v-if="!releases.length" class="form-hint">正在获取官方版本列表…(获取失败时可手动输入版本号)</span>
<span v-else class="form-hint">VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置</span>
</div>
</div>
</FonuCard>
</div>
<!-- ⑤ 输出与日志 -->
<FonuCard class="section-card" title="输出与日志">
<template #header>
<n-button size="small" quaternary :disabled="!opOutput.length" @click="opOutput = []">清空操作输出</n-button>
</template>
<div class="section-body">
<n-tabs type="line" animated>
<n-tab-pane name="ops" tab="操作输出">
<div v-if="opOutput.length" class="op-list" aria-live="polite">
<div v-for="(item, index) in opOutput" :key="index" class="op-item" :class="{ 'op-item--fail': !item.ok }">
<div class="op-item__head">
<StatusBadge :kind="item.ok ? 'success' : 'error'" :text="item.ok ? '成功' : '失败'" />
<span class="op-item__source">{{ item.source }}</span>
<span class="op-item__time">{{ formatRelativeTime(item.at) }}</span>
</div>
<pre class="code-block">{{ item.text }}</pre>
</div>
</div>
<EmptyState v-else title="暂无操作输出" description="执行探测、安装、重启等操作后,这里会按时间倒序显示来源、时间与结果。" />
</n-tab-pane>
<n-tab-pane name="logs" tab="frps 日志">
<pre v-if="agentStatus?.frps_log_tail" class="code-block">{{ agentStatus.frps_log_tail }}</pre>
<EmptyState v-else title="暂无 frps 日志" description="VPS 上 frps 服务输出为空,或 frps 尚未启动。" />
</n-tab-pane>
</n-tabs>
</div>
</FonuCard>
<!-- 配置查看弹窗:展示即将下发的 frps.toml -->
<n-modal v-model:show="configModal" style="width: 640px">
<div class="config-modal">
<div class="config-modal__header">
<h3 class="modal-title">frps 配置(即将下发的内容)</h3>
<n-button size="small" quaternary @click="configModal = false">
<template #icon><n-icon :component="CloseOutline" /></template>
</n-button>
</div>
<div class="config-modal__body">
<p class="form-hint">
由 Fonu 本地生成;点「下发 frps 配置」后写入 VPS 的
<span class="mono">{{ agentStatus?.frps_config_path || '/etc/frp/frps.toml' }}</span>,重启 frps 后生效。
</p>
<pre class="code-block config-modal__code">{{ configContent || '# 暂无内容' }}</pre>
</div>
<div class="config-modal__footer">
<n-button size="small" :disabled="!configContent" @click="copyConfig">复制</n-button>
<n-button size="small" type="primary" @click="configModal = false">关闭</n-button>
</div>
</div>
</n-modal>
</template>
</template>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import {
CloseOutline, CloudOutline, GitNetworkOutline, LayersOutline, RefreshOutline, SwapHorizontalOutline,
} from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client'
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
import { formatRelativeTime } from '../utils/format'
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
type Action =
| 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const message = useMessage()
const servers = ref<FrpServer[]>([])
const selectedId = ref<number | null>(null)
const loadError = ref('')
const agentStatus = ref<FrpAgentStatus | null>(null)
const agentStatusOk = ref(false)
const statusLoaded = ref(false)
const statusLoading = ref(false)
const busy = ref<Action | null>(null)
const opOutput = ref<OpOutput[]>([])
const frpsInstallVersion = ref<string | null>(null)
const releases = ref<FrpRelease[]>([])
const releasesLoading = ref(false)
const configModal = ref(false)
const configContent = ref('')
const configLoading = ref(false)
let timer: number | undefined
const watchedPorts = ['80', '443', '7000', '8080'] as const
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
const serverOptions = computed(() => servers.value
.filter((item) => item.agent_configured || item.ssh_configured)
.map((item) => ({ label: item.name, value: item.id })))
const installedFrpsVersion = computed(() => (agentStatus.value?.frps_version || '').trim())
const routesCount = computed(() => agentStatus.value?.routes?.length ?? 0)
const frpsVersionOptions = computed(() => releases.value.map((release) => ({ label: release.version, value: release.version })))
// O:最后检查时间(PageHeader 副标题,判断数据新鲜度)
const headerDescription = computed(() => {
if (!server.value) return '管理部署在公网 VPS 上的 fonu-agent 与 frps 服务'
if (!statusLoaded.value) return '正在读取 VPS 状态…'
const checkedAt = agentStatus.value?.checked_at
return checkedAt ? `管理部署在公网 VPS 上的 fonu-agent 与 frps · 检查于 ${formatRelativeTime(checkedAt)}` : '管理部署在公网 VPS 上的 fonu-agent 与 frps 服务'
})
// 三态:未加载 / 加载中 / 失败——避免把"没拉到"显示成"未连接"
const agentConnectionText = computed(() => {
if (!statusLoaded.value) return statusLoading.value ? '读取中…' : '—'
return agentStatusOk.value ? '在线' : '未连接'
})
const frpsStateText = computed(() => {
if (!statusLoaded.value) return '—'
return agentStatus.value?.frps_active ? '运行中' : '未运行'
})
const portIconClass = computed(() => {
if (!statusLoaded.value) return 'stat-card__icon--blue'
return watchedPorts.some((port) => portOn(port)) ? 'stat-card__icon--green' : 'stat-card__icon--amber'
})
const nginxKind = computed<'success' | 'warning' | 'error' | 'disabled'>(() => {
if (!statusLoaded.value) return 'disabled'
if (agentStatus.value?.nginx_missing) return 'error'
if (!agentStatus.value?.nginx_ok) return 'error'
return agentStatus.value?.nginx_running === false ? 'warning' : 'success'
})
const nginxText = computed(() => {
if (!statusLoaded.value) return '未读取'
if (agentStatus.value?.nginx_missing) return '未安装'
if (!agentStatus.value?.nginx_ok) return '配置校验未通过'
return agentStatus.value?.nginx_running === false ? '已安装未运行' : '正常'
})
// 状态提示:互斥,最多一条(error 未连接 > warning frps 未运行)
const statusAlert = computed<{ type: 'error' | 'warning'; text: string; action?: { label: string; run: () => void } } | null>(() => {
const current = server.value
if (!current || !statusLoaded.value) return null
if (!agentStatusOk.value) {
return {
type: 'error',
text: 'Agent 未连接:请确认 VPS 上 fonu-agent 正在运行,且 Agent 地址与 Token 与页面一致。',
action: { label: '探测环境', run: () => probe(current) },
}
}
if (!agentStatus.value?.frps_active) {
return {
type: 'warning',
text: 'frps 未运行:反代与隧道都不会生效,可先启动;未安装则用下方「安装 / 升级 frps」。',
action: { label: '启动 frps', run: () => frpsAction(current, 'start') },
}
}
return null
})
const dashboardURL = computed(() => {
const current = server.value
if (!current) return ''
const addr = (current.options.dashboard_addr || '').trim()
const port = current.options.dashboard_port || 0
if (!addr || !port) return ''
const host = addr === '0.0.0.0' || addr === '::' ? (current.server_addr || '127.0.0.1') : addr
return `http://${host}:${port}`
})
function portOn(port: string) {
return agentStatus.value?.ports?.[port] === true
}
function stateClass(ok: boolean, loaded: boolean) {
if (!loaded) return 'stat-card__icon--blue'
return ok ? 'stat-card__icon--green' : 'stat-card__icon--red'
}
function pushOutput(source: string, text: string, ok = true) {
const trimmed = (text || '').trim()
if (!trimmed) return
opOutput.value = [{ at: new Date().toISOString(), source, text: trimmed, ok }, ...opOutput.value].slice(0, 20)
}
// 统一的操作执行器:按动作粒度 loading + 串行化(按钮已 disabled,这里只是安全网)
async function run<T>(action: Action, source: string, fn: () => Promise<T>, describe?: (result: T) => { text: string; ok: boolean }) {
if (busy.value) return
busy.value = action
try {
const result = await fn()
const described = describe ? describe(result) : { text: '操作完成', ok: true }
pushOutput(source, described.text, described.ok)
if (!described.ok) message.error(`${source}失败,详见「输出与日志」`)
return result
} catch (error) {
const text = error instanceof Error ? error.message : `${source}失败`
pushOutput(source, text, false)
message.error(`${source}失败,详见「输出与日志」`)
return undefined
} finally { busy.value = null }
}
async function load() {
loadError.value = ''
try {
servers.value = (await api.listFrpServers()).filter((item) => item.agent_configured || item.ssh_configured)
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
selectedId.value = servers.value[0]?.id ?? null
}
if (server.value) await refreshAll({ silent: false })
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
const current = server.value
if (!current) return
if (options.silent === false) statusLoading.value = true
try {
agentStatus.value = await api.getFrpAgentStatus(current.id)
agentStatusOk.value = true
statusLoaded.value = true
} catch (error) {
agentStatusOk.value = false
statusLoaded.value = true
if (options.silent === false) {
message.error(error instanceof Error ? error.message : 'Agent 状态获取失败')
}
} finally { statusLoading.value = false }
}
function probe(target: FrpServer) {
return run('probe', '探测环境', () => api.probeFrpAgent(target.id), (result) => ({
text: Object.entries(result as Record<string, unknown>).map(([key, value]) => `${key}: ${value}`).join('\n'),
ok: true,
}))
}
function installAgent(target: FrpServer) {
return run('agent-install', target.agent_configured ? '升级 Agent' : '安装 Agent',
() => api.installFrpAgent(target.id),
() => ({ text: 'Agent 安装/升级完成,已自动回填 Agent 地址与 Token', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function uninstallAgent(target: FrpServer, keepData: boolean) {
const source = keepData ? '卸载 Agent(保留路由/证书)' : '卸载 Agent(含数据)'
return run('agent-uninstall', source, () => api.uninstallFrpAgent(target.id, keepData),
(result) => ({ text: result?.output || '已卸载', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function diagnoseAgent(target: FrpServer) {
return run('diagnose', 'SSH 诊断', () => api.sshDiagnoseFrpAgent(target.id),
(result) => ({ text: result?.output || '无输出', ok: true }))
}
function frpsAction(target: FrpServer, action: 'start' | 'stop' | 'restart') {
const actionText = action === 'stop' ? '停止' : action === 'start' ? '启动' : '重启'
return run(`frps-${action}` as Action, `${actionText} frps`,
() => api.frpsServerActionRaw(target.id, action),
(result) => result && result.ok === false
? { text: result.error || `frps ${actionText}后未进入预期状态`, ok: false }
: { text: `frps 已${actionText}`, ok: true })
.then(async () => { await refreshAll() })
}
function pushAgentConfig(target: FrpServer) {
return run('push-config', '下发 frps 配置', () => api.pushFrpConfigToAgent(target.id),
() => ({ text: 'frps 同源配置已下发到 VPS,点「重启 frps」后生效', ok: true }))
}
// 配置查看:只读,展示 Fonu 本地生成的 frps.toml(即「下发」会写入 VPS 的内容)
async function viewConfig(target: FrpServer) {
configLoading.value = true
try {
const result = await api.getFrpFRPSConfig(target.id)
configContent.value = result?.content || ''
configModal.value = true
} catch (error) {
message.error(error instanceof Error ? error.message : '读取 frps 配置失败')
} finally { configLoading.value = false }
}
async function copyConfig() {
try {
await navigator.clipboard.writeText(configContent.value)
message.success('已复制 frps 配置')
} catch { message.error('复制失败,请手动选择文本复制') }
}
function installFrps(target: FrpServer) {
const version = frpsVersionOptions.value[0]?.value ? (frpsInstallVersion.value?.trim() || frpsVersionOptions.value[0].value) : ''
return run('frps-install', '安装 / 升级 frps',
() => api.installFrpServer(target.id, version, ''),
(result) => {
const notes = Array.isArray(result?.notes) ? result.notes.join('\n') : ''
return { text: `frps ${result?.version ?? version} 安装完成\n${notes}`.trim(), ok: true }
})
.then(async () => { await refreshAll() })
}
function openDashboard() {
if (dashboardURL.value) window.open(dashboardURL.value, '_blank', 'noopener')
}
async function loadReleases() {
if (releases.value.length) return
releasesLoading.value = true
try { releases.value = await api.listFrpReleases() } catch { /* 拉取失败时按钮保持禁用并提示 */ }
finally {
releasesLoading.value = false
if (!frpsInstallVersion.value && releases.value.length) frpsInstallVersion.value = releases.value[0].version
}
}
// 切换服务端必须重置,避免上一台的状态与输出串台
watch(selectedId, () => {
opOutput.value = []
agentStatus.value = null
agentStatusOk.value = false
statusLoaded.value = false
void refreshAll({ silent: false })
})
onMounted(() => {
void load()
void loadReleases()
timer = window.setInterval(() => {
if (!server.value) return
if (document.visibilityState === 'hidden') return // 页面不可见时暂停轮询
void refreshAll()
}, 10000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
</script>
<style scoped>
/* 分区:① 运行状态 → ② 环境与依赖 → ③ frps 控制 → ④ 生命周期 → ⑤ 输出与日志 */
.page-alert { margin-bottom: var(--fonu-space-3); }
.page-alert__body { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); }
.entrance-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); padding: 10px var(--fonu-space-5); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-info-soft); }
.entrance-row__text { font-size: 13px; color: var(--fonu-text); }
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.stat-card { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); padding: var(--fonu-space-4) var(--fonu-space-5); min-height: 118px; }
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
.stat-card__icon--blue { color: var(--fonu-info); }
.stat-card__icon--green { color: var(--fonu-brand); }
.stat-card__icon--amber { color: var(--fonu-warning); }
.stat-card__icon--red { color: var(--fonu-error); }
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--fonu-text); letter-spacing: -0.03em; }
.stat-card__value--sm { font-size: 18px; }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.port-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.port-item { font-family: var(--fonu-mono); font-size: 12.5px; padding: 1px 6px; border-radius: 999px; background: var(--fonu-bg-muted); color: var(--fonu-text-muted); }
.port-item--on { background: var(--fonu-brand-soft); color: var(--fonu-brand-text); }
.dep-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
/* 三列下卡片较窄:短字段(版本/徽标)双列并排,长路径与命令用 .field-grid__wide 整行,控制卡片高度 */
.dep-grid .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-2) var(--fonu-space-3); }
/* frps 控制与 Agent 生命周期两列并排,卡片等高 */
.control-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.control-grid .section-card { margin-bottom: 0; }
.section-card { margin-bottom: var(--fonu-space-4); }
.section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-3) var(--fonu-space-4); }
.field-grid > div { display: grid; gap: 4px; min-width: 0; }
.field-grid__wide { grid-column: 1 / -1; }
.field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-3); }
.tooltip-trigger { display: inline-flex; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-3); flex-wrap: wrap; }
.frps-version-select { width: 200px; }
.agent-select { width: 220px; }
.nginx-error { margin-top: var(--fonu-space-2); color: var(--fonu-error); overflow-wrap: anywhere; }
/* 配置查看弹窗(只读) */
.config-modal { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow-md); padding: var(--fonu-space-4) var(--fonu-space-5); }
.config-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.config-modal__body { margin-top: var(--fonu-space-3); }
.config-modal__code { max-height: 380px; }
.config-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-4); }
.modal-title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); }
.op-list { display: grid; gap: var(--fonu-space-3); }
.op-item { padding: var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
.op-item--fail { border-color: var(--fonu-error); }
.op-item__head { display: flex; align-items: center; gap: 8px; }
.op-item__source { font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.op-item__time { margin-left: auto; font-size: 12px; color: var(--fonu-text-muted); }
.code-block {
margin: var(--fonu-space-2) 0 0;
padding: var(--fonu-space-3) var(--fonu-space-4);
background: var(--fonu-bg-muted);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
font-family: var(--fonu-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--fonu-text);
overflow: auto;
max-height: 240px;
white-space: pre-wrap;
word-break: break-all;
}
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; }
/* 首屏一次性入场;用户偏好减少动效时关闭 */
@keyframes fonu-fade-up {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.stats-row, .dep-grid, .section-card, .entrance-row { animation: fonu-fade-up 160ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
.stats-row, .dep-grid, .section-card, .entrance-row { animation: none; }
}
@media (max-width: 1199px) {
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dep-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.dep-grid { grid-template-columns: 1fr; }
.control-grid { grid-template-columns: 1fr; }
.field-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.stats-row { grid-template-columns: 1fr; }
.agent-actions { width: 100%; }
.agent-actions .n-button { min-height: 44px; flex: 1 1 auto; }
.entrance-row { flex-direction: column; align-items: flex-start; }
}
</style>