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
This commit is contained in:
2026-09-23 13:54:42 +08:00
parent a3c31c626b
commit 8a98d8e4f0
4 changed files with 1143 additions and 233 deletions
+237
View File
@@ -0,0 +1,237 @@
# 公网 Agent 页(`AgentView.vue`)设计方案 v2
> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**:
> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。
>
> 取代 `docs/agent-view-design.md`(v1 只做了结构搬家,未解决"功能不可见"问题);
> 也取代 `docs/agent-view-refactor-plan.md`。更新日期:2026-09-23
---
## 一、Pass 1a:栈与设计系统检测
| 检测项 | 实际值 |
| --- | --- |
| 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6 |
| 组件库 | **naive-ui**(`n-button` `n-select` `n-tabs` `n-modal` `n-popconfirm` `n-alert` `n-spin` `n-icon` `n-tag` `n-tooltip` `n-form`) |
| 图标 | `@vicons/ionicons5` |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--fonu-*` 前缀 |
| 共享组件 | `PageHeader` `FonuCard` `StatCard` `StatusBadge` `EmptyState` `LoadError` |
| 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind`) |
**结论**:沿用既有体系(naive-ui + `--fonu-*` + 共享组件),**不引入**第二套框架/令牌。
---
## 二、功能总清单(Capability Inventory)
> 设计原则:**没有隐式功能**。每项能力都要有固定位置、可见状态、明确反馈;
> 能做的显示按钮,不能做的显示**原因**(禁用 + 提示),而不是隐藏。
| # | 能力 | 数据来源 | 当前状态 | 新设计位置 | 可见性形态 |
| --- | --- | --- | --- | --- | --- |
| A | 选择公网服务端 | `listFrpServers`(agent/ssh 已配置) | 已有 | `PageHeader#actions` | 下拉选择 |
| B | Agent 连通性 | `getFrpAgentStatus` 成败 | 已有 | ① 运行状态 | 三态:在线 / 未连接 / 未加载 |
| C | Agent 地址 | `server.agent_url` | 已有 | ① 运行状态卡副标题 | 等宽文本 |
| D | Agent 版本 | `agent_version` | 已有 | ② 环境与依赖 | 等宽文本 |
| E | **Agent 凭据是否已配置** | `server.agent_configured` | **未展示** | ② 环境与依赖 | StatusBadge:已配置 / 未配置 |
| F | frps 运行状态 | `frps_active` | 已有 | ① 运行状态 | 在线 / 未运行 |
| G | frps 已安装版本 | `frps_version` | 已有 | ① + ② | 文本 / 徽标 |
| H | frps 二进制路径 | `frps_binary` | 已有 | ② 环境与依赖 | 等宽 + 换行 |
| I | frps 配置文件路径 | `frps_config_path` | 已有 | ② 环境与依赖 | 等宽 + 换行 |
| J | frps 启动命令 | `frps_unit_exec` | 已有 | ② 环境与依赖 | 等宽 + 换行 |
| K | frps 开机自启 | `frps_enabled` | 已有 | ② 环境与依赖 | StatusBadge |
| L | frps 管理面板入口 | `dashboard_addr/port` → `dashboardURL` | 有(未配置时**按钮隐藏**) | ③ frps 控制 | **常显**;未配置时禁用 + tooltip 指向配置位置 |
| M | 端口监听 80/443/7000/8080 | `ports` | 仅 80/443 卡 + 7000 字段(**8080 未展示**) | ① 运行状态「端口」卡 | 四端口全列:`80✓ 443✓ 7000✗ 8080✗` |
| N | **已注册反代路由数** | `routes[]` | **未展示** | ① 运行状态 | 「反代路由 N 条」+ 折叠列表 |
| O | **最后一次检查时间** | `checked_at` | **未展示** | `PageHeader` 副标题 | 「检查于 x 前」(`formatRelativeTime`) |
| P | **Nginx 可用性(本页完全未展示)** | `nginx_ok/missing/running/version/path/conf_dir/error` | **未展示**(在公网反代页) | ② 环境与依赖「Nginx」组 | 徽标 + 版本 + 路径 + 配置目录 + 错误原文 + 跳转 |
| Q | 启动 / 停止 frps | `frpsServerActionRaw(start/stop)` | 已有 | ③ frps 控制 | 按状态互斥的按钮 |
| R | 重启 frps | `frpsServerActionRaw(restart)` | 已有 | ③ frps 控制 | 按钮 |
| S | 下发 frps 配置 | `pushFrpConfigToAgent` | 已有(散在另一卡) | ③ frps 控制 | 按钮 |
| S2 | 查看即将下发的 frps 配置 | `getFrpFRPSConfig` | 本页未提供 | ③ frps 控制(紧跟下发按钮) | 按钮 + 只读弹窗(等宽代码块 + 复制) |
| T | 安装 / 升级 frps | `installFrpServer` + `listFrpReleases` | 已有 | ③ frps 控制 | 版本下拉 + 按钮(列表空时禁用) |
| U | 探测环境 | `probeFrpAgent` | 已有 | ④ Agent 生命周期 | 按钮 |
| V | 安装 / 升级 Agent | `installFrpAgent` | 已有 | ④ Agent 生命周期 | 按钮 |
| W | 卸载(保留数据 / 含数据) | `uninstallFrpAgent(keepData)` | 已有(危险无确认) | ④ Agent 生命周期 | 两按钮,含数据**带二次确认** |
| X | SSH 诊断 | `sshDiagnoseFrpAgent` | 已有 | ④ Agent 生命周期 | 按钮 |
| Y | frps 最近日志 | `frps_log_tail` | 已有(独立卡) | ⑤ 输出与日志 Tab | 日志块 + 空态 |
| Z | 操作输出(7 类) | 各接口返回 | 已有(单一 `agentOutput`) | ⑤ 输出与日志 Tab | 时间线:来源 + 时间 + 成败 |
| AA | 公网反代管理入口 | `getFrpAgentRouteCandidates` / `RouteHealth` | 有(整卡只放跳转) | 入口行 + ① 反代路由卡 | 「N 条规则 · 健康 X/Y」+ 跳转 `/public-proxy` |
### 本次必须补齐的 5 处(数据已返回,页面没显示)
| 项 | 数据字段 | 为什么重要 |
| --- | --- | --- |
| E | `agent_configured` | 决定"能不能连上 agent",是排障第一问 |
| M(8080) | `ports["8080"]` | frps vhost 端口是否在听,直接对应反代回源 |
| N | `routes[]` | 一眼看出 VPS 上已注册几条反代 |
| O | `checked_at` | 判断状态是不是陈旧的(页面 10 秒轮询,用户需要知道数据新鲜度) |
| P | `nginx_*` 七字段 | 反代的前置依赖;本页不显示就只能跳到另一页才能发现"没装 Nginx" |
---
## 三、设计方向与令牌(Pass 1b)
**方向:机架面板(controlled density)**——像看一排机架指示灯:状态永远第一眼可见,操作收进分区按钮带,输出以时间线呈现;克制、无装饰、密度优先。
- **Layout**:状态区(4 列稳定网格)→ 环境与依赖(两列)→ 控制/生命周期(两列)→ 输出与日志(整宽);左对齐,无居中大标题、无渐变、无装饰插图。
- **Signature**:**四色状态点行 + 操作输出时间线**。
**Color(5 个语义令牌,全部复用现有)**
| 语义 | 令牌 | 用于 | 判定 |
| --- | --- | --- | --- |
| 正常 | `--fonu-brand`(`-soft`/`-text`) | 在线、运行中、监听中、成功输出 | 无需干预 |
| 待处理 | `--fonu-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复 |
| 需人工干预 | `--fonu-error` | Agent 未连接、Nginx 缺失、操作失败、卸载确认 | 需排查/不可逆 |
| 中性 | `--fonu-text-secondary` / `--fonu-text-muted` | 字段名、说明、时间、空态 | — |
| 引导 | `--fonu-info`(`-soft`) | 页面级引导(反代入口等) | 非故障 |
规则:一屏 error ≤ 1 处;warning 只用于"有目标状态但未达成",不用于"没数据"。亮/暗由 `variables.css` 覆盖,**页面零硬编码色值**。
**Type(沿用项目字体,显式声明)**
- 正文 `--fonu-font`,等宽 `--fonu-mono`。skill 禁用 system-ui 系默认栈,但本项目已有设计系统,按 **"MATCH it, never add a second"** 继续使用;换字体属全局决策(改 `variables.css` + 全站回归),不在本页范围。
- 页内 4 档字号 / 3 档字重:数值词条 18px/700(`.stat-card__value--sm`)、数值数字 30px/700/`-0.03em`、字段名与卡标签 12–13px/600、说明与时间 12–12.5px/400;路径/命令/版本/日志走 `--fonu-mono` 12.5–13px。
**Spacing & Radius**:只用 `--fonu-space-1…6` 与 `--fonu-radius(-sm)`、`--fonu-shadow`;禁止魔数;内联尺寸一律改类(`.agent-select` / `.frps-version-select`)。
---
## 四、页面分区与线框(按功能分区)
```text
┌ PageHeader 公网 Agent · 检查于 12 秒前 [服务端 ▾] [刷新] ┐
├ n-alert 状态提示(互斥一条:error 未连接 / warning frps 未运行)
├ 入口行 公网反代:3 条规则 · 健康 2/3 → /public-proxy
├ ① 运行状态 section[aria-label=运行状态] 4 列稳定网格
│ ● Agent 连接 ● frps 服务 ● 反代路由 ● 端口监听
│ 在线/未连接 运行中/未运行 3 条 80✓ 443✓
│ <agent 地址> <frps 版本> 健康 2/3 已生效 7000✓ 8080✗
├ ② 环境与依赖(Fonu Agent | Nginx | frps 运行时,三列并排)
│ Fonu Agent Nginx(本页新增) frps 运行时
│ 版本 / 地址 可用性徽标 / 版本 二进制 / 版本
│ 凭据:已配置 ✓ 可执行路径 / 配置目录 配置路径
│ [探测环境] 错误原文(红) 启动命令 / 开机自启
│ → 去「公网反代」处理
├ ③+④ 两列并排(左:Agent 生命周期(SSH) / 右:frps 控制)
│ 左 [探测环境] [安装/升级 Agent] [SSH 诊断] 右 [启动|停止] [重启] [管理面板]
│ [卸载(保留数据)] [卸载(含数据) ← 二次确认] [下发配置] [版本 ▾] [安装/升级]
└ ⑤ 输出与日志 n-tabs:操作时间线(来源+时间+成败,aria-live) | frps 日志
空态 EmptyState · [清空]
```
分区原则:
1. **每项能力都有固定位置**(对照第二节清单);按钮常显,不可用时**禁用 + 说明原因**,绝不隐藏;
2. 状态区只读 / 控制区只写 / 输出区只展示,三段物理分离;
3. 新增的「环境与依赖」把三件前置条件(Agent 凭据、Nginx、frps 运行时)集中可见——排障时最先要看的就是这三项。
---
## 五、组件与状态矩阵(Pass 2 规格)
| 元素 | default | hover | pressed | focused | disabled | loading |
| --- | --- | --- | --- | --- | --- | --- |
| `n-button`(主/次/危险) | 库默认 | 库默认 | 库默认 | 库默认焦点环(键盘实测) | `busy !== null && busy !== 本动作` | `busy === '动作名'` |
| 服务端选择器 | 库默认 | 库默认 | — | 库默认 | 无服务端时禁用 | — |
| `n-tabs`(输出/日志) | 库默认 | 库默认 | 库默认 | 库默认(←/→ 可切换) | — | — |
| `n-popconfirm`(卸载含数据) | warning 触发器 | 库默认 | 库默认 | 库默认 | busy 时禁用 | 确认后走按钮 loading |
| `.stat-card`(只读) | 令牌底色 | **有意不加 hover** | — | 非交互不可聚焦 | — | 骨架 / `—` |
| 「管理面板」(未配管理接口) | **常显但禁用** | — | — | — | 是(tooltip 指向配置位置) | — |
| 「安装/升级 frps」(版本列表未加载) | **常显但禁用** | — | — | — | 是(提示「正在获取官方版本列表…」) | 列表加载中 |
**内容状态(区分"没数据"与"拿不到数据")**
| 场景 | 呈现 |
| --- | --- |
| 尚未拉取 | 数值位 `—`,副标题「读取中…」 |
| 首次拉取中 | `n-spin` 包裹状态区(仅首次,轮询不显示) |
| 拉取失败 | 数值位「未连接」+ 顶部 `error` alert(含「探测环境」动作) |
| 无已注册路由 / 无日志 | `EmptyState`(不再渲染「暂无日志」字符串) |
| 操作失败 | 时间线中该条 `ok=false` 标红 + `message.error` |
---
## 六、动效与无障碍
**动效**:首屏一次性 `fade-up`(`translateY(4px)→0`,160ms),仅 `onMounted` 触发一次;`prefers-reduced-motion: reduce` 全禁用;除此之外不做散点微动效(不加卡片 hover 位移/脉冲)。
**无障碍**:状态区 `<section aria-label="运行状态">`;装饰图标 `aria-hidden`;按钮均有可读文本;输出区 `aria-live="polite"`;Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各分区按钮带 → tabs;对比度实测 AA(`--fonu-brand/warning/error/text-muted` 在 `--fonu-surface` 上,亮+暗各一遍);≤760px 按钮 `min-height: 44px`;响应式 `1200+ / 900–1199 / ≤900 / ≤760`(运行状态 4→2→1 列;环境与依赖三列 →≤1199 降 2 列 →≤900 单列,且三列时卡片内短字段(版本/徽标)双列并排、长路径与命令整行,控制卡片高度),**320px 无横向溢出**。
---
## 七、状态与数据流(含前一轮复核发现的 3 处缺陷修复)
```ts
// 1) 切换服务端必须重置,避免串台
watch(selectedId, () => {
opOutput.value = []
agentStatus.value = null
statusLoaded.value = false
agentStatusOk.value = false
void refreshAll()
})
// 2) 三态区分:未加载 / 加载中 / 失败
const statusLoaded = ref(false) // 是否成功拉取过
const statusLoading = ref(false) // 首次加载中
// 3) 按动作粒度 loading,轮询静默
type Action = 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
const busy = ref<Action | null>(null)
async function run<T>(action: Action, fn: () => Promise<T>) {
if (busy.value) return // 安全网:按钮已 disabled
busy.value = action
try { return await fn() } finally { busy.value = null }
}
async function refreshAll(options: { silent?: boolean } = { silent: true }) { /* 轮询不触碰 busy */ }
// 4) 输出时间线:来源 + 时间 + 成败,上限 20 条
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const opOutput = ref<OpOutput[]>([])
function pushOutput(source: string, text: string, ok = true) {
opOutput.value = [{ at: new Date().toISOString(), source, text, ok }, ...opOutput.value].slice(0, 20)
}
```
**提示规则**:状态提示互斥且最多一条(`error` 未连接 > `warning` frps 未运行);「公网反代入口」不占 alert 位,独立成入口行。
**轮询**:10 秒;`document.visibilityState === 'hidden'` 时跳过;`onBeforeUnmount` 清理定时器。
---
## 八、本页不做(Never)
- 不引入第二套 UI 框架 / 令牌 / 全局样式;不改全局字体与令牌;
- 不用硬编码色值与魔数间距;不做渐变、居中大标题、统一灰卡片等 slop 模式;
- 统计卡不加装饰性 hover / 位移 / 脉冲;
- 不把页面级操作塞进正文卡片(统一进 `PageHeader#actions`);
- **不隐藏功能**:任何能力要么可用,要么可见地禁用并给出原因;
- 不改后端与 agent(纯前端单文件)。
---
## 九、验收清单
- [ ] 复用项目真实栈与令牌(naive-ui + `--fonu-*` + 共享组件),未新增第二套
- [ ] 单一设计方向(机架面板)落地,颜色/间距/圆角全走令牌,无 banned 字体引入
- [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报、图标 `aria-hidden`
- [ ] 最窄 320px 无横向溢出;≤760px 按钮 ≥44px
- [ ] **功能可见性:逐条对照第二节 A–AA,每项都有明确位置;E / M(8080) / N / O / P 五项新增展示已落地;不存在隐藏功能**
- [ ] 回归:切换服务端不串台;仅被点按钮 loading;轮询不闪;危险操作有二次确认;`vue-tsc` 0 错误 + `vite build` 成功
---
## 十、与既有文档关系与工作量
| 文档 | 关系 |
| --- | --- |
| `docs/agent-view-design.md`(v1) | 被本文取代(v1 只是结构搬家,未解决"功能不可见");确认后可删除 |
| `docs/agent-view-refactor-plan.md` | 被本文取代 |
| 工作区 skill `fonu-page-ui` | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 |
工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 5–6 小时(含 5 处能力补齐、状态机重构、无障碍与响应式)。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/fonu/web/dist` 后 `go build ./...`。
+214
View File
@@ -0,0 +1,214 @@
# 公网 Agent 页(`AgentView.vue`)设计方案
> 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**,
> 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
>
> 更新日期:2026-09-23
---
## 一、Pass 1a:栈与设计系统检测(结论先行)
| 检测项 | 实际值 |
| --- | --- |
| 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6(`web/package.json`) |
| 组件库 | **naive-ui**(`n-button` `n-select` `n-tabs` `n-modal` `n-popconfirm` `n-alert` `n-spin` `n-icon` `n-tag` `n-form`) |
| 图标 | `@vicons/ionicons5` |
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` / `[data-theme='dark']` 双主题,`--fonu-*` 前缀 |
| 共享组件 | `PageHeader` `FonuCard` `StatCard` `StatusBadge` `EmptyState` `LoadError`(另有 `MiniBarChart` 等图表件) |
| 工具 | `utils/format.ts`(`formatBytes/formatRate/formatDate/formatRelativeTime/…`)、`utils/status.ts`(`StatusKind` 语义) |
| 同族页面 | `DashboardView`(统计卡 + 插槽)、`DdnsView`(提示 + 状态卡)、`CertificatesView`(统计卡规格基准)、`ProxyView`(分组/弹窗)、`SettingsView`(分区) |
**结论(skill 的 "Already has a design system → MATCH it" 规则)**:本页**沿用** naive-ui + `--fonu-*` 令牌 + 既有共享组件,
**不引入** Tailwind / shadcn-vue 等第二套体系,**不新增**第二套令牌或全局样式。
---
## 二、Pass 1b:设计方向
### 方向:机架面板(controlled density)
一句话概念:**像看一排机架指示灯** —— 状态永远是第一眼可见的、紧凑排列的四色状态条,操作被收进分区按钮带,输出以时间线呈现。克制、无装饰,密度优先。
- **Layout**:`状态区(4 列稳定网格) → 状态详情(2 列) → 控制/生命周期(2 列) → 输出与日志(整宽)`;左对齐、无居中大标题、无渐变、无装饰插图。
- **Signature(记忆点)**:**四色状态点行 + 操作输出时间线**(每条输出带来源徽标与相对时间)。
### Color(5 个语义令牌,全部复用现有)
| 语义 | 令牌 | 用在哪 | 判定规则 |
| --- | --- | --- | --- |
| 正常 / 运行 | `--fonu-brand`(`--fonu-brand-soft`、`--fonu-brand-text`) | 在线、运行中、监听中、成功输出 | 无需人工干预 |
| 待处理 | `--fonu-warning` | frps 未运行、端口未监听、frps 未安装、开机自启未启用 | 可一键恢复(引导操作) |
| 需人工干预 | `--fonu-error` | Agent 未连接、操作失败输出、卸载确认 | 需用户排查或不可逆 |
| 中性信息 | `--fonu-text-secondary` / `--fonu-text-muted` | 字段名、说明、时间戳、空态 | — |
| 引导 | `--fonu-info`(`--fonu-info-soft`) | 页面级引导提示(反代入口等) | 非故障类提示 |
规则:**一屏内 error 不超过一处**;warning 不用于"没数据",只用于"有目标状态但未达成"。亮/暗两套值由 `variables.css` 的 `html.dark` 覆盖,**页面内零硬编码色值**。
### Type(沿用项目字体,显式声明)
- 正文字体 `--fonu-font`(`-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei'`),等宽 `--fonu-mono`。
- **关于 skill 的"禁用 system-ui 系默认栈"**:本项目已存在设计系统,按 "MATCH it, never add a second" 规则,**本页继续使用项目字体**;替换字体属**全局决策**(改 `variables.css`,影响全站并需全站回归),不在本页范围。
- 页内只用 4 档字号 + 3 档字重(与 `CertificatesView` 统计卡规格一致):
| 用途 | 字号 / 字重 | 令牌 |
| --- | --- | --- |
| 统计卡数值(词条类) | 18px / 700 | `.stat-card__value--sm` |
| 统计卡数值(数字类) | 30px / 700,`letter-spacing:-0.03em` | `.stat-card__value` |
| 字段名 / 卡片标签 | 12–13px / 600 | `.stat-card__label`、`.field-grid label` |
| 说明 / 时间戳 / 空态 | 12–12.5px / 400 | `--fonu-text-muted`、`.form-hint` |
| 路径 / 命令 / 版本 / 日志 | `--fonu-mono` 12.5–13px | `.mono` / `.mono-wrap` / `.code-block` |
### Spacing & Radius
- 间距只用 `--fonu-space-1…6`(4/8/12/16/24/32),**禁止魔数**;卡片内边距统一 `var(--fonu-space-4) var(--fonu-space-5)`。
- 圆角只用 `--fonu-radius`(12px) / `--fonu-radius-sm`(8px);阴影只用 `--fonu-shadow`。
- 页面里已有的 `style="width: 220px"` 这类内联尺寸,一律改为类(`.agent-select` / `.frps-version-select`)。
---
## 三、信息架构与线框
```text
┌ PageHeader 公网 Agent · 一句话说明 [服务端选择 ▾] [刷新] ┐
├ n-alert(最多一条状态提示:error 未连接 / warning frps 未运行)
├ 入口引导行(次级链接,不占 alert 位):反代的部署与访问控制在「公网反代」→
├ 状态区 section[aria-label=服务状态] 4 列稳定网格
│ ● Agent 连接 ● frps 服务 ● 80 端口 ● 443 端口
│ 在线/未连接 运行中/未运行 监听中/未监听 监听中/未监听
│ <agent 地址> <frps 版本> HTTP 入口 HTTPS 入口
├ 两列网格
│ FonuCard 服务状态详情 FonuCard frps 服务控制
│ field-grid 2 列: 按钮带:启动|停止 · 重启 ·
│ Agent 版本 / frps 版本 frps 管理面板 · 下发配置
│ 启动命令 / 配置文件 [版本选择 ▾] 安装/升级
│ 安装位置 / 开机自启
│ 7000 端口
│ FonuCard Agent 生命周期(SSH) FonuCard 输出与日志
│ 探测环境 · 安装/升级 Agent n-tabs:操作输出 | frps 日志
│ SSH 诊断 · 卸载(保留) 操作输出 = 时间线(来源+时间+ok)
│ 卸载(含数据) ← 二次确认 空态 EmptyState · [清空]
└ ────────────────────────────────────────────────────────────────
```
要点:**状态只读 / 操作只写 / 输出只展示** 三段物理分离;「公网 Nginx 反代」原整卡删除,改为入口引导行。
---
## 四、组件与状态矩阵(Pass 2 规格)
### 交互状态(skill:每个可交互元素都要有全状态)
| 元素 | default | hover | pressed | focused | disabled | loading |
| --- | --- | --- | --- | --- | --- | --- |
| `n-button`(主/次/危险) | 库默认 | 库默认 | 库默认 | 库默认焦点环(回归实测 Tab 可达) | `:disabled="busy !== null && busy !== 本动作"` | `:loading="busy === '动作名'"` |
| 服务端选择器 `n-select` | 库默认 | 库默认 | — | 库默认 | 无服务端时禁用 | 无 |
| `n-tabs`(输出/日志) | 库默认 | 库默认 | 库默认 | 库默认(键盘 ←/→ 可切换) | 无 | 无 |
| `n-popconfirm`(卸载含数据) | 触发器为 warning 按钮 | 库默认 | 库默认 | 库默认 | busy 时禁用 | 确认后走按钮 loading |
| `.stat-card`(只读) | 令牌底色 | **有意不加 hover** | — | 不可聚焦(非交互元素) | — | 骨架/`—` |
### 内容状态(区分"没数据"与"拿不到数据")
| 场景 | 呈现 |
| --- | --- |
| 尚未拉取(`statusLoaded=false`) | 数值位显示 `—`,`__sub` 显示「读取中…」 |
| 拉取中(首次) | `n-spin` 包裹状态区(仅首次,轮询不显示) |
| 拉取失败 | 数值位「未连接」+ 顶部 `error` alert(含「探测环境」动作) |
| 列表/日志为空 | `EmptyState`(不再渲染「暂无日志」字符串) |
| 操作失败 | 输出时间线中该条 `ok=false` 标红 + `message.error` |
---
## 五、动效与无障碍
**动效(high-impact, restrained)**
- 首屏**一次性**入场:状态区与卡片 `fade-up`(`translateY(4px)→0`,160ms,`--fonu-shadow` 不变),仅 `onMounted` 后触发一次;
- `@media (prefers-reduced-motion: reduce)` 下全部禁用;
- 除入场与按钮库内置反馈外,**不做**散点微动效(不加卡片 hover 位移、不加脉冲动画)。
**无障碍(Web 必做)**
- 语义:状态区用 `<section aria-label="服务状态">`;装饰性图标 `aria-hidden="true"`;按钮均有可读文本(不出现只有图标的裸按钮);
- 动态内容:操作输出容器 `aria-live="polite"`(异步追加需播报);`n-popconfirm` 已带键盘与焦点管理;
- 键盘:Tab 顺序 = 选择器 → 刷新 → 状态区(跳过只读)→ 各卡片按钮带 → tabs;`n-tabs` 支持方向键;
- 对比度:实测下列组合达 WCAG AA(正文 4.5:1、大字号 3:1)——`--fonu-brand`/`--fonu-warning`/`--fonu-error`/`--fonu-text-muted` 在 `--fonu-surface` 上,**亮色与暗色各测一遍**;
- 触控:≤760px 时按钮 `min-height: 44px`(或升为 `size="medium"`),满足最小触控目标;
- 响应式:`1200px+` 4 列状态 / `900–1199px` 2 列 / `≤900px` 单列 + 两列网格并一列 / `≤760px` 按钮铺满 / **`320px` 无横向溢出**。
---
## 六、状态与数据流(含前一轮复核发现的 3 处缺陷修复)
```ts
// 1) 切换服务端必须重置,避免串台(缺陷修复)
watch(selectedId, () => {
opOutput.value = []
agentStatus.value = null
statusLoaded.value = false
agentStatusOk.value = false
void refreshAll()
})
// 2) 三态区分:未加载 / 加载中 / 失败(缺陷修复)
const statusLoaded = ref(false) // 是否已成功拉取过
const statusLoading = ref(false) // 首次加载中
// 3) 操作状态机:按动作粒度 loading,轮询静默(缺陷修复)
type Action = 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
const busy = ref<Action | null>(null)
async function run<T>(action: Action, fn: () => Promise<T>) {
if (busy.value) return // 安全网:按钮已 disabled,这里只是兜底
busy.value = action
try { return await fn() } finally { busy.value = null }
}
async function refreshAll(options: { silent?: boolean } = { silent: true }) { /* 轮询不触碰 busy */ }
// 4) 输出时间线:来源 + 时间 + 成功标记,上限 20 条
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const opOutput = ref<OpOutput[]>([])
function pushOutput(source: string, text: string, ok = true) {
opOutput.value = [{ at: new Date().toISOString(), source, text, ok }, ...opOutput.value].slice(0, 20)
}
```
**提示规则(修掉原方案"互斥"与"常驻"的矛盾)**:状态提示**互斥且最多一条**(优先级 `error` 未连接 > `warning` frps 未运行);
「公网反代入口」**不占 alert 位**,改为 PageHeader 下方一行次级链接。
**轮询**:10 秒一次;`document.visibilityState === 'hidden'` 时跳过;`onBeforeUnmount` 清理定时器。
---
## 七、本页不做(Never)
- 不引入第二套 UI 框架 / 令牌 / 全局样式(naive-ui + `--fonu-*` 已存在);
- 不改全局字体与令牌(属全站决策);
- 不用硬编码色值与魔数间距(一律令牌);
- 不做渐变、居中大标题、统一灰卡片这类 slop 模式;
- 统计卡不加装饰性 hover / 位移 / 脉冲;
- 不把页面级操作塞进正文卡片(统一进 `PageHeader#actions`);
- 不改后端与 agent(本次纯前端单文件)。
---
## 八、验收清单(对照 skill self-check)
- [ ] 复用了项目真实栈与令牌(naive-ui + `--fonu-*` + 共享组件),未新增第二套
- [ ] 单一设计方向(机架面板)落地:状态条 + 输出时间线为 signature,无 banned 字体引入,颜色/间距/圆角全部走令牌
- [ ] 交互状态齐全:hover / pressed / **focused(键盘实测)** / disabled / loading / empty / error
- [ ] 无障碍:对比度 AA(亮+暗)、可见焦点环、键盘可完成全部操作、`aria-live` 播报输出、图标 `aria-hidden`
- [ ] 最窄 320px 无横向溢出;≤760px 按钮 ≥44px 触控目标
- [ ] 回归:切换服务端不串台;仅被点按钮 loading;轮询不闪;危险操作有二次确认;`vue-tsc` 0 错误 + `vite build` 成功
---
## 九、与既有文档关系与工作量
| 文档 | 关系 |
| --- | --- |
| `docs/agent-view-refactor-plan.md` | 结构整改清单(12 项问题 + 分步改造),**本文吸收其全部内容**并补上设计方向、状态矩阵、无障碍与动效规格;实施时以本文为准 |
| `web/.breezell/skills/fonu-page-ui`(工作区 skill) | 页面 UI 通用标准;本文是它在「公网 Agent」页的具体化 |
工作量:**单文件改动(`web/src/views/AgentView.vue`)**,约 4–5 小时(比原结构整改多出的部分:设计方向落地、状态矩阵、无障碍与动效、320px 适配)。
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/fonu/web/dist` 后 `go build ./...` 使本地生效。
+200
View File
@@ -0,0 +1,200 @@
# 公网 Agent 页(`AgentView.vue`)整合整改方案
> 目标:把「公网 Agent」页从"功能堆叠"整理成**状态 → 控制 → 生命周期 → 输出**四段式结构,
> 信息层级清晰、操作归属明确、危险操作可控,并完全符合 `fonu-page-ui` 页面 UI 标准。
>
> 参照页:`DdnsView.vue`(状态卡 + 提示)、`CertificatesView.vue`(统计卡规格)、`ProxyView.vue`(分组与按钮区)、`SettingsView.vue`(分区)。
> 更新日期:2026-09-23
---
## 一、现状问题清单
| # | 问题 | 具体表现 | 影响 |
| --- | --- | --- | --- |
| 1 | **状态与操作混在一起** | 4 张状态卡只覆盖「Agent 连接 / frps 服务 / 80 / 443」;而 Agent 版本、frps 启动命令、配置路径、开机自启、7000 端口散落在「frps 服务控制」卡的 `field-grid` 里,与启停按钮同处一卡 | 看状态要跨两张卡,操作区被字段挤占 |
| 2 | **两个 `agent-page-grid` 列宽漂移** | 均用 `repeat(auto-fit, minmax(360px, 1fr))`,窗口宽度变化时卡片宽度与列数跳动,与标准页 `repeat(4, minmax(0,1fr))` 的稳定网格不一致 | 视觉不整齐,窄屏断裂点不可预期 |
| 3 | **loading 粒度太粗** | 所有按钮都用 `:loading="agentActionId === server.id"`——任何操作进行中,页面**全部**按钮一起转圈 | 无法分辨在跑哪个操作,也无法阻止并发操作 |
| 4 | **输出全部塞进一个 `agentOutput`** | 探测 / 安装 / 卸载 / SSH 诊断 / frps 启停 / 下发配置 / 安装 frps 七类输出共用一个 ref,显示在「Agent 生命周期」卡底部 | 输出归属不明;执行「重启 frps」后输出出现在 SSH 卡里,语义错位 |
| 5 | **日志与输出分居两卡** | 「frps 最近日志」独立成卡,`agentOutput` 在另一张卡 | 排障时要在两处来回看 |
| 6 | **危险操作无二次确认** | 「卸载(含数据)」按钮点击即执行,`rm -rf /var/lib/fonu-agent` 不可逆 | 误点即丢路由与证书 |
| 7 | **页面级提示缺失** | agent 未连接、frps 未安装等状态只在卡片里体现,没有 `n-alert` 引导(技能要求:页面级提示放 PageHeader 之后) | 新用户不知道下一步该去哪一页 |
| 8 | **跳转卡占位浪费** | 「公网 Nginx 反代」整卡只放一句说明 + 一个跳转按钮 | 占掉半行网格,信息密度低 |
| 9 | **轮询与用户操作耦合** | 10 秒 `refreshAll()` 里设置 `agentActionId`,与用户点击共用同一状态 | 轮询时按钮闪烁 loading |
| 10 | **默认版本硬编码** | `frpsInstallVersion.value?.trim() \|\| '0.71.0'`,与「官方版本列表」加载结果脱节 | 列表加载成功后默认值仍可能是写死的旧版本 |
| 11 | **空态用字符串** | 日志空时渲染 `'暂无日志'` 文本 | 与标准页的 `EmptyState` 不一致 |
| 12 | **窄屏断点不全** | 只有 `@media (max-width: 900px)`,缺 760px 档(技能第 7 节) | 手机宽度下卡片内按钮换行拥挤 |
---
## 二、目标结构
```text
PageHeader(公网 Agent)
└─ #actions:服务端选择器 · 刷新
n-alert(条件渲染:Agent 未连接 / frps 未安装 / Nginx 缺失 → 一句引导 + 跳转)
stats-row(统一 4 列稳定网格)
├─ Agent 连接(在线/未连接 + agent 地址)
├─ frps 服务(运行中/未运行 + 版本)
├─ 80 端口
└─ 443 端口
FonuCard「服务状态详情」
└─ field-grid:Agent 版本 / 已安装 frps 版本 / frps 启动命令 / 配置文件 / 安装位置 / 开机自启 / 7000 端口(StatusBadge)
FonuCard「frps 服务控制」
└─ 按钮区:启动|停止 · 重启 · frps 管理面板 · 下发配置 · [版本选择] 安装/升级
FonuCard「Agent 生命周期(SSH)」
└─ 按钮区:探测环境 · 安装/升级 Agent · SSH 诊断 · 卸载(保留数据)· 卸载(含数据,二次确认)
FonuCard「输出与日志」(n-tabs)
├─ Tab 操作输出:带来源标签 + 时间;空态 EmptyState;「清空」按钮
└─ Tab frps 日志:frps_log_tail;空态 EmptyState
```
要点:**状态只读 → 操作只写 → 输出只展示**,三段物理分离;「公网反代」跳转改为 `n-alert` 或 PageHeader 次级入口。
---
## 三、逐项改造清单
### 1. 状态详情独立成卡(解决 #1)
- 把「frps 服务控制」里的 `field-grid` 整体搬到新的「服务状态详情」卡;
- 状态类字段用 `StatusBadge`(开机自启、7000 端口、frps 版本),值类字段用 `.mono` / `.mono-wrap`;
- 「frps 服务控制」只留按钮区与安装版本选择。
### 2. 统一网格(解决 #2、#12)
```css
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.agent-page-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
@media (max-width: 900px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .agent-page-grid { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .stats-row { grid-template-columns: 1fr; } .agent-actions { width: 100%; } .agent-actions .n-button { flex: 1 1 auto; } }
```
### 3. 操作状态精细化(解决 #3、#9)
```ts
// 当前进行中的动作:'status' | 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
// | 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
const busy = ref<string | null>(null)
const statusLoading = ref(false)
async function run<T>(action: string, fn: () => Promise<T>): Promise<T | undefined> {
if (busy.value) return // 串行化,避免并发操作
busy.value = action
try { return await fn() } finally { busy.value = null }
}
// 轮询走静默:不设 busy,只设 statusLoading
async function refreshAll(options: { silent?: boolean } = { silent: true }) { /* ... */ }
```
按钮写法:`:loading="busy === 'frps-restart'"`、`:disabled="busy !== null && busy !== 'frps-restart'"`。
### 4. 输出与日志合并 + 来源标注(解决 #4、#5、#11)
```ts
interface OpOutput { at: string; source: string; text: string; ok: boolean }
const opOutput = ref<OpOutput[]>([]) // 最多保留 20 条
function pushOutput(source: string, text: string, ok = true) {
opOutput.value = [{ at: new Date().toISOString(), source, text, ok }, ...opOutput.value].slice(0, 20)
}
```
- 每类操作写入自己的 `source`(如「重启 frps」「SSH 诊断」「安装 Agent」),失败用 `ok=false` 标红;
- 展示:`n-tabs`(操作输出 / frps 日志),操作输出项显示 `formatDate(at)` 或 `formatRelativeTime(at)` + 来源徽标 + `.code-block`;
- 空态用 `EmptyState`,不再渲染「暂无日志」字符串;
- 提供「清空」按钮(`opOutput.value = []`)。
### 5. 危险操作二次确认(解决 #6)
```vue
<n-popconfirm @positive-click="uninstallAgent(server, false)">
<template #trigger><n-button size="small" type="warning">卸载(含数据)</n-button></template>
将删除 VPS 上的 agent、frps 单元与 /var/lib/fonu-agent(路由与证书一并丢失),确认继续?
</n-popconfirm>
```
「卸载(保留路由/证书)」保持一步执行(`--keep-data`),仅加 `quaternary` 视觉区分。
### 6. 页面级提示(解决 #7、#8)
在 `PageHeader` 之后按条件渲染 `n-alert`(互斥,取最高优先级一条):
| 条件 | 类型 | 文案要点 | 动作 |
| --- | --- | --- | --- |
| `!agentStatusOk` | `error` | Agent 未连接:检查 VPS 上 fonu-agent 是否运行、Agent 地址与 Token 是否正确 | 「探测环境」 |
| `!agentStatus?.frps_active` | `warning` | frps 未运行:可点「启动 frps」或在下方安装 | 「启动 frps」 |
| 反代入口引导(常驻) | `info` | 反代的逐条部署/配置查看/访问控制在「公网反代」页 | 跳转 `/public-proxy` |
原「公网 Nginx 反代」整卡随之删除。
### 7. 版本默认值取列表(解决 #10)
```ts
const frpsVersionOptions = computed(() => releases.value.map((r) => ({ label: r.version, value: r.version })))
watch(releases, (list) => { if (!frpsInstallVersion.value && list.length) frpsInstallVersion.value = list[0].version })
```
列表为空时禁用「安装/升级 frps」并提示「正在获取官方版本列表…」,不再回退硬编码版本。
### 8. 轮询策略
- 保持 10 秒刷新,但 `refreshAll({ silent: true })` 不触碰 `busy`;
- 页面不可见时暂停:`document.visibilityState === 'hidden'` 时跳过(减少无谓请求);
- `onBeforeUnmount` 清理定时器(现有行为保留)。
---
## 四、涉及文件与验证
| 文件 | 改动 |
| --- | --- |
| `web/src/views/AgentView.vue` | 模板结构重排、脚本状态重构、样式网格与断点补齐(单文件改动) |
| `web/src/api/client.ts` / `types.ts` | 预期**无需**改动(现有 `getFrpAgentStatus` / `probeFrpAgent` / `installFrpAgent` / `uninstallFrpAgent` / `sshDiagnoseFrpAgent` / `frpsServerActionRaw` / `pushFrpConfigToAgent` / `installFrpServer` / `listFrpReleases` 已够用) |
| `cmd/fonu/web/dist` | 构建产物同步(gitignore,不入库) |
验证闭环(技能第 8 节):
```powershell
cd web
npx vue-tsc --noEmit # 0 错误
npx vite build # 成功
# 本地生效:同步 dist → cmd/fonu/web/dist 后 go build ./...
```
回归检查点(真机,VPS 38.95.74.87):
1. 页面加载:状态卡 4 张、无 loading 闪烁(轮询静默);
2. 点「重启 frps」:**仅该按钮**转圈,其余按钮 disabled;输出出现在「操作输出」且来源标为「重启 frps」;
3. 点「SSH 诊断」:输出进入同一区域但来源不同,时间正确;
4. 点「卸载(含数据)」:出现二次确认;取消后不执行;
5. agent 未连接场景:顶部出现 `error` 级 `n-alert`,点「探测环境」有反馈;
6. 窄屏(≤760px):统计卡单列、按钮区铺满不溢出;
7. 暗色主题:所有颜色随令牌切换,无残留写死色值。
---
## 五、风险与回滚
| 风险 | 处理 |
| --- | --- |
| 结构调整导致既有操作失效 | 只重排模板与状态,**不改 api 调用**;逐项对照本方案第三节,改完按第四节回归 |
| 危险操作加确认后影响老用户肌肉记忆 | 仅「卸载(含数据)」加确认,其余保持一步 |
| 输出改为列表后信息过多 | 上限 20 条 + 清空按钮 |
| 回滚 | 单文件改动,`git checkout -- web/src/views/AgentView.vue` 即可回到当前版本(当前版本已按 skill 对齐,可安全回退) |
---
## 六、工作量
| 步骤 | 内容 | 预计 |
| --- | --- | --- |
| 1 | 状态详情卡拆分 + 网格统一 + 断点 | 0.5h |
| 2 | `busy` 状态机 + 按钮 loading 精细化 | 0.5h |
| 3 | 输出与日志合并(tabs + 来源 + 空态) | 1h |
| 4 | 页面级 `n-alert` + 删跳转卡 | 0.5h |
| 5 | 危险操作确认 + 版本默认值 + 轮询静默 | 0.5h |
| 6 | 验证(tsc/build/回归清单) | 0.5h |
合计约 **3–3.5 小时**,单文件改动,不涉及后端与 agent。
+499 -240
View File
@@ -1,13 +1,17 @@
<template>
<PageHeader title="公网 Agent" description="管理部署在公网 VPS 上的 fonu-agent 与 frps 服务">
<PageHeader title="公网 Agent" :description="headerDescription">
<template #actions>
<n-select
v-model:value="selectedId"
:options="serverOptions"
placeholder="选择公网服务端"
style="width: 220px"
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>
@@ -25,137 +29,237 @@
</EmptyState>
<template v-else-if="server">
<section class="status-grid">
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatusOk ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="CloudOutline" />
<!-- 状态提示:互斥,最多一条 -->
<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>
<div class="status-card__body">
<div class="status-card__label">Agent 连接</div>
<div class="status-card__value">{{ agentStatusOk ? '在线' : '未连接' }}</div>
<div class="status-card__meta mono">{{ server.agent_url || '未配置 Agent 地址' }}</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.frps_active ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="GitNetworkOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">frps 服务</div>
<div class="status-card__value">{{ agentStatus?.frps_active ? '运行中' : '未运行' }}</div>
<div class="status-card__meta">{{ agentStatus?.frps_version?.trim() || '未安装' }}</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.ports?.['80'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="SwapHorizontalOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">80 端口</div>
<div class="status-card__value">{{ agentStatus?.ports?.['80'] ? '监听中' : '未监听' }}</div>
<div class="status-card__meta">无端口访问的入口</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.ports?.['443'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="LockClosedOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">443 端口</div>
<div class="status-card__value">{{ agentStatus?.ports?.['443'] ? '监听中' : '未监听' }}</div>
<div class="status-card__meta">HTTPS 入口</div>
</div>
</FonuCard>
</section>
</n-alert>
<section class="agent-page-grid">
<FonuCard class="section-card" title="frps 服务控制">
<template #header>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="refreshAll">刷新</n-button>
</template>
<div class="agent-section-body">
<div class="field-grid">
<div><label>Agent 版本</label><span class="field-tags"><n-tag size="small" :bordered="false">{{ agentStatus?.agent_version || '-' }}</n-tag></span></div>
<div><label>frps 启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '-' }}</span></div>
<div><label>frps 配置</label><span class="mono" :title="agentStatus?.frps_config_path">{{ agentStatus?.frps_config_path || '-' }}</span></div>
<div><label>开机自启</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_enabled ? 'success' : 'warning'">{{ agentStatus?.frps_enabled ? '已启用' : '未启用' }}</n-tag></span></div>
<div><label>7000 端口</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.ports?.['7000'] ? 'success' : 'warning'">{{ agentStatus?.ports?.['7000'] ? '监听中' : '未监听' }}</n-tag></span></div>
</div>
<div class="agent-actions">
<n-button v-if="agentStatus?.frps_active" size="small" type="warning" :loading="agentActionId === server.id" @click="frpsAction(server, 'stop')">停止 frps</n-button>
<n-button v-else size="small" type="success" :loading="agentActionId === server.id" @click="frpsAction(server, 'start')">启动 frps</n-button>
<n-button size="small" :loading="agentActionId === server.id" @click="frpsAction(server, 'restart')">重启 frps</n-button>
<n-button v-if="dashboardURL" size="small" quaternary @click="openDashboard(dashboardURL)">frps 管理</n-button>
</div>
<p v-if="!dashboardURL" class="form-hint" style="margin-top: 8px">未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存即可开启面板入口</p>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 配置与版本">
<div class="agent-section-body">
<div class="field-grid">
<div><label>已安装 frps 版本</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_version ? 'success' : 'warning'">{{ installedFrpsVersion || '未安装' }}</n-tag></span></div>
<div><label>安装位置</label><span class="mono" :title="agentStatus?.frps_binary">{{ agentStatus?.frps_binary || '-' }}</span></div>
</div>
<div class="agent-actions" style="margin-top: var(--fonu-space-3)">
<n-button size="small" :loading="agentActionId === server.id" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
</div>
<div class="frps-install-row">
<n-select v-model:value="frpsInstallVersion" :options="frpsVersionOptions" placeholder="选择 frps 官方版本" style="width: 200px" filterable />
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installFrps(server)">安装/升级 frps</n-button>
</div>
<p class="form-hint">VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置。</p>
</div>
</FonuCard>
</section>
<section class="agent-page-grid">
<FonuCard class="section-card" title="公网 Nginx 反代">
<div class="agent-section-body">
<p class="form-hint">反代管理已独立成「公网反代」页面:逐条部署/移除、配置查看、强制跳转、IP 白名单、Basic Auth、WebSocket 等都在那里。</p>
<!-- 入口行:公网反代(不占 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="frps 最近日志">
<div class="agent-section-body">
<pre class="code-block">{{ agentStatus?.frps_log_tail || '暂无日志' }}</pre>
<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)">
<template #header>
<div class="section-actions">
<n-button size="small" type="primary" :loading="agentActionId === server.id" @click="probeAgent(server)">探测环境</n-button>
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
<n-button size="small" type="warning" :loading="agentActionId === server.id" @click="uninstallAgent(server, false)">卸载(含数据)</n-button>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="uninstallAgent(server, true)">卸载(保留路由/证书)</n-button>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="diagnoseAgent(server)">SSH 诊断</n-button>
</div>
</template>
<div class="agent-section-body">
<div class="section-body">
<p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
</p>
<pre v-if="agentOutput" class="code-block">{{ agentOutput }}</pre>
<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 } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { CloudOutline, GitNetworkOutline, LockClosedOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
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[]>([])
@@ -163,90 +267,83 @@ const selectedId = ref<number | null>(null)
const loadError = ref('')
const agentStatus = ref<FrpAgentStatus | null>(null)
const agentStatusOk = ref(false)
const agentActionId = ref<number | null>(null)
const agentOutput = ref('')
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 })))
const installedFrpsVersion = computed(() => (agentStatus.value?.frps_version || '').trim() || '')
const frpsVersionOptions = computed(() => [
...(releases.value.length ? [] : [{ label: '0.71.0(默认,官方版本未加载)', value: '0.71.0' }]),
...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 服务'
})
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) void refreshAll()
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
// 三态:未加载 / 加载中 / 失败——避免把"没拉到"显示成"未连接"
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 ? '已安装未运行' : '正常'
})
async function refreshAll() {
// 状态提示:互斥,最多一条(error 未连接 > warning frps 未运行)
const statusAlert = computed<{ type: 'error' | 'warning'; text: string; action?: { label: string; run: () => void } } | null>(() => {
const current = server.value
if (!current) return
agentActionId.value = current.id
try {
agentStatus.value = await api.getFrpAgentStatus(current.id)
agentStatusOk.value = true
} catch (error) {
agentStatusOk.value = false
agentOutput.value = error instanceof Error ? error.message : 'Agent 状态获取失败'
} finally { agentActionId.value = null }
}
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
})
async function probeAgent(target: FrpServer) {
agentActionId.value = target.id
try {
const result = await api.probeFrpAgent(target.id)
agentOutput.value = Object.entries(result).map(([key, value]) => `${key}: ${value}`).join('\n')
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '探测失败' }
finally { agentActionId.value = null }
}
async function installAgent(target: FrpServer) {
agentActionId.value = target.id
try {
await api.installFrpAgent(target.id)
agentOutput.value = 'Agent 安装完成,已自动回填 Agent 地址与 Token'
message.success('Agent 安装成功')
await load()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装失败' }
finally { agentActionId.value = null }
}
async function uninstallAgent(target: FrpServer, keepData: boolean) {
agentActionId.value = target.id
try {
const result = await api.uninstallFrpAgent(target.id, keepData)
agentOutput.value = result.output || '已卸载'
message.success('Agent 已卸载')
await load()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '卸载失败' }
finally { agentActionId.value = null }
}
async function diagnoseAgent(target: FrpServer) {
agentActionId.value = target.id
try {
const result = await api.sshDiagnoseFrpAgent(target.id)
agentOutput.value = result.output || '无输出'
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '诊断失败' }
finally { agentActionId.value = null }
}
// frps 管理面板地址:直接读服务端表单里已配置的「frps 管理接口」(dashboard_addr/port);
// 监听 0.0.0.0/:: 时用服务端公网地址拼 URL(浏览器在外网访问)
const dashboardURL = computed(() => {
const current = server.value
if (!current) return ''
@@ -257,97 +354,234 @@ const dashboardURL = computed(() => {
return `http://${host}:${port}`
})
function openDashboard(url: string) {
if (url) window.open(url, '_blank', 'noopener')
function portOn(port: string) {
return agentStatus.value?.ports?.[port] === true
}
async function frpsAction(target: FrpServer, action: 'start' | 'stop' | 'restart') {
agentActionId.value = target.id
const actionText = action === 'stop' ? '停止' : action === 'start' ? '启动' : '重启'
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 {
// agent 对"命令成功但服务未进入运行态"返回 200+ok:false+error(含 journal 日志)
const result = await api.frpsServerActionRaw(target.id, action)
if (result && result.ok === false) {
agentOutput.value = result.error || `frps ${actionText}后未进入预期状态`
message.error(`frps ${actionText}失败,详见输出`)
} else {
message.success(`frps 已${actionText}`)
}
await refreshAll()
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) {
agentOutput.value = error instanceof Error ? error.message : 'frps 操作失败'
message.error(`frps ${actionText}失败,详见输出`)
} finally { agentActionId.value = null }
const text = error instanceof Error ? error.message : `${source}失败`
pushOutput(source, text, false)
message.error(`${source}失败,详见「输出与日志」`)
return undefined
} finally { busy.value = null }
}
async function pushAgentConfig(target: FrpServer) {
agentActionId.value = target.id
async function load() {
loadError.value = ''
try {
await api.pushFrpConfigToAgent(target.id)
message.success('frps 配置已下发')
agentOutput.value = 'frps 同源配置已下发到 VPS,点「重启 frps」后生效'
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '下发配置失败' }
finally { agentActionId.value = null }
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 : '读取服务端失败' }
}
async function installFrps(target: FrpServer) {
agentActionId.value = target.id
// 轮询走静默(不触碰 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 {
const version = frpsInstallVersion.value?.trim() || '0.71.0'
const result = await api.installFrpServer(target.id, version, '')
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') : ''
agentOutput.value = `frps ${result?.version ?? version} 安装完成\n${notes}`
message.success('VPS frps 已安装')
await refreshAll()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装 frps 失败' }
finally { agentActionId.value = null }
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 > 0) return
try { releases.value = await api.listFrpReleases() } catch { /* 回退默认版本 */ }
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) void refreshAll() }, 10000)
timer = window.setInterval(() => {
if (!server.value) return
if (document.visibilityState === 'hidden') return // 页面不可见时暂停轮询
void refreshAll()
}, 10000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
</script>
<style scoped>
/* 对齐 DDNS 页风格:surface 卡片、settings-grid 字段、统一代码块 */
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.status-card { display: flex; gap: var(--fonu-space-3); align-items: center; padding: var(--fonu-space-4) var(--fonu-space-5); }
.status-card__icon { width: 44px; height: 44px; border-radius: var(--fonu-radius-sm); display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0; }
.status-card__icon--running { background: rgba(34, 197, 94, 0.14); color: #16a34a; }
.status-card__icon--stopped { background: rgba(148, 163, 184, 0.16); color: #64748b; }
.status-card__body { min-width: 0; }
.status-card__label { color: var(--fonu-text-secondary); font-size: 12px; }
.status-card__value { font-size: 18px; font-weight: 700; color: var(--fonu-text); }
.status-card__meta { color: var(--fonu-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-page-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.agent-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-4); }
/* 分区:① 运行状态 → ② 环境与依赖 → ③ 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-grid .mono { font-family: var(--fonu-mono); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field-grid .mono-wrap { white-space: normal; word-break: break-all; overflow: visible; text-overflow: unset; }
.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-4); }
.route-list { display: grid; gap: 8px; margin-top: var(--fonu-space-3); }
.route-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
.route-item__main { display: grid; gap: 3px; min-width: 0; }
.route-item__domains { color: var(--fonu-text); font-size: 12px; overflow-wrap: anywhere; }
.route-item__upstream { color: var(--fonu-text-muted); font-size: 12px; }
.route-item__actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
.empty-route { margin-top: var(--fonu-space-3); padding: 18px; border: 1px dashed var(--fonu-border); border-radius: var(--fonu-radius-sm); color: var(--fonu-text-muted); text-align: center; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-4); flex-wrap: wrap; }
.frps-install-row .form-hint { flex-basis: 100%; margin: 0; }
.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-3) 0 0;
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);
@@ -362,7 +596,32 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
word-break: break-all;
}
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); }
.section-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 900px) { .field-grid { grid-template-columns: 1fr; } }
.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>