feat(web): 公网反代页重构(部署台账 + 弹窗对齐本地页)
- 台账改用 n-data-table:规则/域名(含复制与打开)、协议、上游/回源、健康、连接、当前上传、当前下载、总上传、总下载、更新时间、操作 - 单元格样式走 :deep():render 函数创建的节点不带 scoped 的 data-v 属性 - 修复 loadErrorMsg 从不渲染、按动作粒度 loading、切换服务端重置、轮询静默与可见性保护、移除反代二次确认 - 部署设置弹窗对齐本地反代页:900px、三段式、页脚分隔线、开关行底色,并新增右侧配置说明栏 - 新增统计区(可部署规则/已生效/健康通过/当前连接)与环境与 Nginx 卡(4 列) - 文档:docs/proxy-remote-design.md
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
# 公网反代页(`ProxyRemoteView.vue`)设计方案
|
||||||
|
|
||||||
|
> 目标:以**本地反向代理页 `ProxyView.vue` 为交互参照**,结合公网反代特有语境(部署到 VPS、Nginx 由 agent 管理、证书由 agent 推送、frps 提供隧道),
|
||||||
|
> 把公网反代页从"能用的列表页"整理成**功能齐全、状态可见、与本地页同族**的页面。
|
||||||
|
>
|
||||||
|
> 按 `breezell-design` 两遍流程产出:Pass 1a 栈检测 → Pass 1b 设计方向与令牌 → Pass 2 实施规格。
|
||||||
|
> 参考页:`ProxyView.vue`(3485 行,本地反代)、`AgentView.vue`(刚按 v2 重构,本页与其保持同族)。
|
||||||
|
> 更新日期:2026-09-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、Pass 1a:栈与设计系统检测
|
||||||
|
|
||||||
|
| 检测项 | 实际值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 框架 | Vue 3 + `<script setup lang="ts">` + Vite 6 |
|
||||||
|
| 组件库 | **naive-ui**(`n-select` `n-button` `n-modal` `n-form` `n-switch` `n-input` `n-input-number` `n-tag`) |
|
||||||
|
| 图标 | `@vicons/ionicons5`(本页目前未用图标) |
|
||||||
|
| 令牌系统 | `web/src/styles/variables.css`:`:root` + `html.dark` 双主题,`--fonu-*` 前缀 |
|
||||||
|
| 共享组件 | `PageHeader` `FonuCard` `LoadError` `EmptyState` `StatusBadge`(本页**未用** `StatusBadge`) |
|
||||||
|
| 工具 | `utils/format.ts`(已用 `formatBytes` / `formatRate`) |
|
||||||
|
| 同族页面 | `ProxyView`(本地反代,交互参照)、`AgentView`(已按 v2 重构,同为公网侧) |
|
||||||
|
|
||||||
|
**结论**:沿用既有体系(naive-ui + `--fonu-*` + 共享组件),**不引入**第二套框架/令牌。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状盘点(含实测缺陷)
|
||||||
|
|
||||||
|
`ProxyRemoteView.vue` 现为 584 行,结构是:`PageHeader` → Nginx 提示条 → 三张卡片(Nginx 状态 / 穿透规则与公网反代状态 / 使用说明)→ 两个弹窗(部署设置 / 反代配置)。
|
||||||
|
|
||||||
|
| # | 问题 | 证据 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **刷新失败静默** | `refreshAll()` 的 catch 写入 `loadErrorMsg`,但该变量**从未在模板中渲染** | 拉取规则/健康/流量失败时页面毫无反馈,用户以为"就是没数据" |
|
||||||
|
| 2 | **没有统计区** | 全页无 `.stats-row` / `.stat-card`,而数据齐全(规则数、已部署数、健康通过数、流量、连接数) | 与所有标准页(Dashboard/Ddns/Certificates/Proxy)不一致,一眼看不到全局 |
|
||||||
|
| 3 | **硬编码颜色 6 处** | `#f0c36d`、`#e79b9b`、`#dc2626`、`#16a34a`、`rgba(240,195,109,.12)`、`rgba(34,197,94,.12)` | 违反令牌规则;暗色主题下对比度未验证 |
|
||||||
|
| 4 | **自造状态组件** | `nginxState()` 返回自造 `n-tag`;`healthBadges()` 自造 `.health-badge/.health-ok/.health-bad` | 与 `StatusBadge` + `utils/status.ts` 语义分叉,同类状态在不同页面长得不一样 |
|
||||||
|
| 5 | **「使用说明」整卡占位** | 一整张 `FonuCard` 只有一段 `form-hint` | 占掉整行网格,信息密度低(`AgentView` 已把这类内容改成 `n-alert` + 入口行) |
|
||||||
|
| 6 | **空态与折叠区未令牌化** | 自造 `.empty-route` div;`<details class="raw-routes">` 原生折叠 | 空态应为 `EmptyState`;原生折叠样式不随主题 |
|
||||||
|
| 7 | **loading 粒度粗** | 单个 `refreshing` 同时驱动「刷新」「重新检测」与轮询 | 轮询时按钮闪 loading;无法区分"在部署"与"在刷新" |
|
||||||
|
| 8 | **切换服务端不重置** | `watch(selectedId)` 不存在 | 上一台的健康/流量/agent 状态残留在新服务端视图里(串台) |
|
||||||
|
| 9 | **轮询未做可见性/冲突保护** | 15 秒 `setInterval` 里只判断 `!refreshing` | 页面隐藏时仍打 6 个接口;`refreshAll` 串行 6 请求,慢时可能叠压 |
|
||||||
|
| 10 | **内联尺寸** | `style="width: 220px"` | 应为类名(如 `AgentView` 的 `.agent-select`) |
|
||||||
|
| 11 | **能力缺口(本地页有、本页无)** | 本地页有:批量排序(`reorderProxies`)、扫描发现(`scanDiscovery`)、Nginx 配置编辑与**回滚**(`saveProxyNginx`/`rollbackProxyNginx`)、客户端连接列表(`getProxyClients`)、实时流量曲线 | 公网侧对应位置缺失:无批量部署、无历史回滚、无已部署路由的客户端/连接明细视图 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能总清单(Capability Inventory)
|
||||||
|
|
||||||
|
> 原则同 `AgentView` v2:**没有隐式功能**——每个能力有固定位置、可见状态、明确反馈;能做的显示按钮,不能做的显示原因(禁用 + 提示)。
|
||||||
|
|
||||||
|
| # | 能力 | 数据来源 | 现状 | 新设计位置 | 可见性形态 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| A | 选择公网服务端 | `listFrpServers`(agent/ssh 已配置) | 已有 | `PageHeader#actions` | 下拉 + 类名宽度 |
|
||||||
|
| B | 刷新全部数据 | `refreshAll()` | 已有 | `PageHeader#actions` | 主按钮 + `:loading`(独立于轮询) |
|
||||||
|
| C | **页面级状态提示** | `agentStatus.nginx_missing / nginx_ok / nginx_error` | 自造 alert(硬编码色) | `PageHeader` 之后 | `n-alert`(error/warning),含动作按钮 |
|
||||||
|
| D | Nginx 可用性概览 | `nginx_missing/ok/running/version/path/conf_dir/error` | 8 项 `nginx-status-grid` | ① 环境区(与 AgentView 同族) | 徽标 + 值字段(令牌化) |
|
||||||
|
| E | 一键安装 Nginx | `installFrpAgentNginx` | 已有(提示条 + 卡片各一处,重复) | ① 环境区(**只保留一处**) | 按钮 + `:loading` |
|
||||||
|
| F | **规则汇总统计** | 由 `routeCandidates` + `routeDetails` + `routeHealth` 计算 | **无** | ② 统计区(4 列) | 统计卡:规则总数 / 已生效 / 健康通过 / 连接数 |
|
||||||
|
| G | 规则列表(每行) | `getFrpAgentRouteCandidates` | 已有 | ③ 规则区 | 行:名称 + 域名 + 上游 + 回源端口 |
|
||||||
|
| H | 部署态徽标(四态) | `getFrpAgentRoutes` → `stateOf()` | 已有(自造 tag) | ③ 规则行 | `StatusBadge`:已生效 / 已写入未加载 / 语法错误 / 未部署 |
|
||||||
|
| I | 健康检查(4 段) | `getFrpAgentRouteHealth` → DNS/证书/隧道/服务 | 已有(自造 badge) | ③ 规则行 | `StatusBadge` × 4 + 悬停详情 |
|
||||||
|
| J | 流量指标(6 项) | `listFrpProxiesTraffic` | 已有(自造 `.traffic-metric`) | ③ 规则行 | 等宽小字 + `formatBytes/formatRate`;采集失败标红 |
|
||||||
|
| K | 部署反代 | `deployFrpAgentRoute` | 已有 | ③ 规则行 | 主按钮(仅该行 loading) |
|
||||||
|
| L | 移除反代 | `removeFrpAgentRoute` | 已有 | ③ 规则行 | warning 按钮 + **二次确认**(现无确认) |
|
||||||
|
| M | 部署设置(3 Tab) | `updateFrpProxy` + 重新部署 | 已有(字段齐全) | 弹窗(沿用 `proxy-modal` 结构) | 基础 / 安全 / 高级 |
|
||||||
|
| N | 查看生成的 nginx 配置 | `getFrpAgentRouteConf` | 已有 | ③ 规则行 → 弹窗 | 只读代码块 + 复制 |
|
||||||
|
| O | **VPS 上 agent 管理的路由文件** | `getFrpAgentRoutes`(含 syntax_ok/loaded) | `<details>` 折叠 | ③ 规则区底部 | 与部署态合并展示,令牌化折叠(或独立 Tab) |
|
||||||
|
| P | 空态引导 | — | 自造 `.empty-route` | ③ 规则区 | `EmptyState` + 「去内网穿透页」动作 |
|
||||||
|
| Q | 前往公网 Agent 页 | — | 无入口 | `PageHeader` 下方入口行 | 次级链接(Nginx/agent 异常时指向排查页) |
|
||||||
|
| R | **批量部署**(能力缺口) | 循环 `deployFrpAgentRoute` | **无** | ③ 规则区头部 | 「部署全部未部署」+ 进度反馈(**分批实施,见第十一节**) |
|
||||||
|
| S | **配置回滚**(能力缺口) | agent 侧需新增历史版本接口 | **无** | 弹窗内(部署设置 → 高级)或规则行 | 暂列"不做",需后端支持(见第九节) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、与本地反代页(`ProxyView.vue`)的对照
|
||||||
|
|
||||||
|
| 本地页能力 | 公网反代对应做法 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 6 张统计卡(服务总数/总上传/总下载/当前上传/当前下载/当前连接) | 公网侧统计对象是**规则与部署态**,不是单机流量 | **对齐形式**(`.stats-row` + `.stat-card`),**换指标**:规则总数 / 已生效 / 健康通过 / 连接数 |
|
||||||
|
| 概览区:域名 · 监听 · 目标 · 协议(`overview-strip`) | 公网侧对应"这台 VPS 的入口":域名 / 回源 / vhost 端口 / 协议 | **对齐**:放进规则行的上游行,不单开概览卡 |
|
||||||
|
| 实时流量卡(最近 5 分钟 + `live-badge` 实时) | frps 侧只有**当日累计 + 相邻两次采样速率**,无时间序列接口 | **不对齐**:保留"当前速率"文本,不做曲线;若要做需后端提供采样序列 |
|
||||||
|
| 客户端连接列表(`getProxyClients`) | 公网侧无对应接口(frps dashboard 只给连接数) | **不对齐**:显示连接数;明细需后端扩展 |
|
||||||
|
| Nginx 配置编辑 + 保存 + **回滚** | 公网侧配置由 agent 生成(模板化),手工编辑会与模板冲突 | **不对齐(有意)**:只提供"查看配置";回滚需 agent 侧版本化,列入不做 |
|
||||||
|
| 扫描发现(`scanDiscovery`)、排序(`reorderProxies`) | 公网侧规则来源是穿透规则(内网侧),排序/发现属内网语义 | **不对齐**:排序按"名称 + 域名"稳定排序;发现入口指向「内网穿透」页 |
|
||||||
|
| 规则弹窗 3 Tab(基础配置/安全设置/Nginx) | 公网侧弹窗已同构(基础/安全/高级) | **已对齐**(本轮不改字段,只做校验与提示) |
|
||||||
|
| 表单里「使用目标 Host 头」「忽略后端 TLS 校验」 | 公网侧明确不做(破坏 frps vhost 路由 / 回源为明文) | **不照搬**(已写入 `docs/agent-proxy-settings-plan.md` 排除清单) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、设计方向与令牌(Pass 1b)
|
||||||
|
|
||||||
|
**方向:部署台账(deployment ledger)**——与 `AgentView` 的"机架面板"同族:一行一条规则,左边是"这条反代是什么",右边是"它现在什么状态、能做什么";颜色只用来表达状态,不用来装饰。
|
||||||
|
|
||||||
|
- **Layout**:状态提示 → 统计区(4 列)→ 环境与 Nginx(2 列)→ 规则台账(整宽)→ 入口行;左对齐,无居中、无渐变。
|
||||||
|
- **Signature**:**规则行左侧的「四段健康徽标串」**(DNS · 证书 · 隧道 · 服务),一眼扫完所有规则的健康。
|
||||||
|
|
||||||
|
**Color**:沿用 5 个语义令牌(同 `AgentView` v2):`--fonu-brand` 正常 / `--fonu-warning` 待处理 / `--fonu-error` 需人工干预 / 中性 `--fonu-text-*` / `--fonu-info` 引导。**页面零硬编码色值**(现状 6 处全部替换:`#16a34a`→`--fonu-brand`、`#dc2626`→`--fonu-error`、`#f0c36d`→`--fonu-warning`、`#e79b9b`→`--fonu-error`、两处 `rgba(...)`→`--fonu-brand-soft` / `--fonu-warning` 的 soft 变体或 `--fonu-bg-muted`)。
|
||||||
|
|
||||||
|
**Type**:沿用 `--fonu-font` / `--fonu-mono`;页内 4 档字号(12 / 12.5 / 13 / 18-30)+ 3 档字重,与 `CertificatesView` 统计卡规格一致。
|
||||||
|
|
||||||
|
**Spacing & Radius**:只用 `--fonu-space-1…6` 与 `--fonu-radius(-sm)`、`--fonu-shadow`;`style="width: 220px"` → `.proxy-remote-select`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、页面分区与线框
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌ PageHeader 公网反代 · 一句话说明 [服务端 ▾] [刷新] ┐
|
||||||
|
├ n-alert(Nginx 缺失 / 校验失败,含「一键安装 Nginx」动作)
|
||||||
|
├ 入口行:Agent 与 frps 的生命周期、配置下发在「公网 Agent」页 →
|
||||||
|
├ ① 统计区(4 列) 规则总数 · 已生效 · 健康通过 · 当前连接
|
||||||
|
├ ② 环境与 Nginx(2 列)
|
||||||
|
│ 可用性 / 进程 / 版本 / 可执行文件 / vhost 目录 / 配置校验 / 端口监听
|
||||||
|
├ ③ 规则台账(整宽)
|
||||||
|
│ [名称] 域名(mono) [已生效] [查看配置] [部署设置] [部署反代] [移除反代]
|
||||||
|
│ 上游 192.168.31.152:7023(回源 127.0.0.1:8080)
|
||||||
|
│ DNS ✓ 证书 ✓ 隧道 ✓ 服务 ✓ 连接 3 · 当前 ↑12KB/s ↓2MB/s · 总 ↑1.2GB ↓8.4GB
|
||||||
|
│ …(每行一条;空态 EmptyState + 「去内网穿透页」)
|
||||||
|
│ 折叠:VPS 上 agent 管理的路由文件(3)—— 7023.36800.cc 已加载 / …
|
||||||
|
└ 弹窗:部署设置(基础 / 安全 / 高级)· 反代配置(只读 + 复制)
|
||||||
|
```
|
||||||
|
|
||||||
|
分区原则:**统计区只读汇总 / 环境区只读诊断 + 安装动作 / 规则区一行一规则(状态在左、动作在右)**;「使用说明」整卡删除,内容并入 `PageHeader.description` 与 `n-alert`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、组件与状态矩阵(Pass 2 规格)
|
||||||
|
|
||||||
|
| 元素 | default | hover | pressed | focused | disabled | loading |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 刷新按钮 | 库默认 | 库默认 | 库默认 | 库默认焦点环 | 无服务端时禁用 | `:loading="manualRefreshing"`(轮询不驱动) |
|
||||||
|
| 服务端选择器 | 库默认 | 库默认 | — | 库默认 | 无服务端时禁用 | — |
|
||||||
|
| 「部署反代」 | primary | 库默认 | 库默认 | 库默认 | 该行操作进行中 | `busy === 'deploy:<id>'` |
|
||||||
|
| 「移除反代」 | warning quaternary | 库默认 | 库默认 | 库默认 | 同上 | `busy === 'remove:<id>'` |
|
||||||
|
| 「一键安装 Nginx」 | primary | 库默认 | 库默认 | 库默认 | agent 未连接 | `:loading="installingNginx"` |
|
||||||
|
| 健康徽标 / 部署态 | `StatusBadge` | —(只读) | — | 不可聚焦 | — | 数据未加载时显示 `—` |
|
||||||
|
| 统计卡 | 令牌底色 | **有意不加 hover** | — | 不可聚焦 | — | 未加载显示 `—`(不是 0) |
|
||||||
|
| 折叠「路由文件」 | 原生 summary + 令牌样式 | 文字色变 | — | 库默认 | — | — |
|
||||||
|
|
||||||
|
**内容状态**:未加载 `—` / 首次加载 `n-spin` / 拉取失败 `n-alert error`(修 #1,`loadErrorMsg` 必须渲染)/ 无规则 `EmptyState` / 无路由文件 折叠区隐藏 / 流量采集失败该行标红并给 tooltip。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、状态与数据流(含缺陷修复)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 1) 切换服务端必须重置(修 #8)
|
||||||
|
watch(selectedId, () => {
|
||||||
|
routeCandidates.value = []; routeDetails.value = []; routeHealth.value = []
|
||||||
|
proxyTraffic.value = {}; trafficErrors.value = {}; agentStatus.value = null
|
||||||
|
loadErrorMsg.value = ''
|
||||||
|
void refreshAll({ silent: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) 错误必须可见(修 #1)
|
||||||
|
const loadErrorMsg = ref('') // 模板:<n-alert v-if="loadErrorMsg" type="error" :bordered="false">{{ loadErrorMsg }}</n-alert>
|
||||||
|
|
||||||
|
// 3) loading 分粒度(修 #7)
|
||||||
|
const manualRefreshing = ref(false) // 仅"刷新/重新检测"按钮
|
||||||
|
type Action = `deploy:${number}` | `remove:${number}` | 'install-nginx' | 'save-settings'
|
||||||
|
const busy = ref<Action | null>(null)
|
||||||
|
async function run<T>(action: Action, fn: () => Promise<T>) { /* 同 AgentView:串行 + 失败写入 loadErrorMsg */ }
|
||||||
|
|
||||||
|
// 4) 轮询:静默 + 可见性保护(修 #9)
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
if (!server.value || document.visibilityState === 'hidden') return
|
||||||
|
void refreshAll({ silent: true }) // 不触碰 manualRefreshing,不覆盖 loadErrorMsg(失败仅记 console + 顶部提示去抖)
|
||||||
|
}, 15000)
|
||||||
|
```
|
||||||
|
|
||||||
|
`refreshAll` 的 6 个接口调用保持串行(`routeCandidates → routeDetails → routeHealth → traffic → status`),但**单个接口失败不再中断整轮**:分别 try/catch,把失败项汇总成一句提示(现状是一个失败就整轮丢失后续数据)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、本页不做(Never)
|
||||||
|
|
||||||
|
- 不引入第二套 UI 框架 / 令牌;不改全局字体与令牌;
|
||||||
|
- 不用硬编码色值(现状 6 处全部清除)与魔数间距;
|
||||||
|
- **不做** Nginx 配置手工编辑与回滚(公网配置由 agent 模板生成,手工编辑会与模板冲突;回滚需 agent 侧版本化,属后端能力);
|
||||||
|
- **不做**流量时间序列曲线(frps 只提供当日累计 + 采样速率,无历史接口);
|
||||||
|
- **不做**目标 Host 头改写、忽略后端 TLS 校验(会破坏 frps vhost 路由 / 回源是明文);
|
||||||
|
- 不把「使用说明」这类静态文案单独占一张卡;
|
||||||
|
- 不在本页重复 Agent 生命周期操作(安装/升级/卸载 agent 归「公网 Agent」页,本页只放入口链接)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、验收清单
|
||||||
|
|
||||||
|
- [ ] 复用项目真实栈与令牌(naive-ui + `--fonu-*` + `PageHeader/FonuCard/LoadError/EmptyState/StatusBadge`),未新增第二套
|
||||||
|
- [ ] 单一设计方向(部署台账)落地;零硬编码色值;`style="width: …"` 清零
|
||||||
|
- [ ] 交互状态齐全:hover / pressed / focused(键盘实测)/ disabled / loading / empty / error
|
||||||
|
- [ ] 无障碍:对比度 AA(亮+暗,重点验 `--fonu-brand` / `--fonu-warning` / `--fonu-error` 在 `--fonu-surface` 上)、键盘可达全部动作、装饰图标 `aria-hidden`、健康徽标有可读文本
|
||||||
|
- [ ] 最窄 320px 无横向溢出;≤760px 规则行纵向堆叠、动作按钮铺满且 ≥44px
|
||||||
|
- [ ] **功能可见性:对照第三节 A–Q,每项有明确位置;#1 错误可见、#8 切换不串台、#7 loading 分粒度 三项缺陷已修**
|
||||||
|
- [ ] 回归:部署/移除/保存部署设置三条路径真机验证;`vue-tsc` 0 错误 + `vite build` 成功
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、实施顺序与工作量
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预计 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 缺陷修复 | #1 错误可见、#8 切换重置、#7 loading 分粒度、#9 轮询保护、#10 内联尺寸、#3 硬编码色值 | 1h |
|
||||||
|
| P2 结构对齐 | 统计区(4 卡)、环境与 Nginx 卡令牌化、删「使用说明」卡改 `n-alert` + 入口行、空态换 `EmptyState` | 1.5h |
|
||||||
|
| P3 规则行重排 | `StatusBadge` 化部署态与健康徽标、流量指标排版、移除二次确认、折叠区令牌化 | 1.5h |
|
||||||
|
| P4 弹窗收尾 | 部署设置字段校验与提示统一、反代配置弹窗加「复制」 | 0.5h |
|
||||||
|
| P5 验证 | tsc / build / 真机回归(部署·移除·保存设置) | 1h |
|
||||||
|
|
||||||
|
合计约 **5.5–6 小时**,单文件改动(`ProxyRemoteView.vue`),**不涉及后端与 agent**。
|
||||||
|
能力缺口 R(批量部署)与 S(配置回滚)不在本轮:R 可后续单独做(纯前端循环 + 进度),S 需 agent 侧新增历史接口。
|
||||||
|
|
||||||
|
验证:`npx vue-tsc --noEmit` + `npx vite build`,再同步 `web/dist → cmd/fonu/web/dist` 后 `go build ./...`。
|
||||||
+556
-255
@@ -1,14 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader title="公网反代" description="管理 VPS 上 Nginx 反代:部署/移除穿透规则的公网入口,支持强制跳转、IP 白名单、Basic Auth 与 WebSocket">
|
<PageHeader
|
||||||
|
title="公网反代"
|
||||||
|
description="把穿透规则的域名发布到 VPS 的 Nginx:部署 / 移除、部署设置(HTTPS、跳转、白名单、Basic Auth、限流)、健康检查与配置查看"
|
||||||
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="selectedId"
|
v-model:value="selectedId"
|
||||||
:options="serverOptions"
|
:options="serverOptions"
|
||||||
placeholder="选择公网服务端"
|
placeholder="选择公网服务端"
|
||||||
style="width: 220px"
|
class="proxy-remote-select"
|
||||||
:disabled="servers.length === 0"
|
:disabled="servers.length === 0"
|
||||||
/>
|
/>
|
||||||
<n-button :loading="refreshing" @click="refreshAll">刷新</n-button>
|
<n-button :loading="manualRefreshing" :disabled="!server" @click="refreshAll({ silent: false })">
|
||||||
|
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||||
|
刷新
|
||||||
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -26,118 +32,139 @@
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
|
|
||||||
<template v-else-if="server">
|
<template v-else-if="server">
|
||||||
<div v-if="agentStatus?.nginx_missing" class="nginx-alert">
|
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
||||||
<div class="nginx-alert__text">
|
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
||||||
<strong>VPS 未安装 Nginx</strong>
|
|
||||||
<span>公网反代需要 Nginx 承接 80/443 并终结 HTTPS。可点右侧按钮由 agent 自动安装(识别 apt-get / dnf / yum / apk,需 root),或在 VPS 手动执行:<code>apt install -y nginx</code> / <code>yum install -y nginx</code></span>
|
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
|
||||||
|
<n-alert v-if="nginxBlocking" :type="nginxBlocking.type" :bordered="false" class="page-alert">
|
||||||
|
<div class="alert-body">
|
||||||
|
<div class="alert-body__text">
|
||||||
|
<strong>{{ nginxBlocking.title }}</strong>
|
||||||
|
<span>{{ nginxBlocking.text }}</span>
|
||||||
|
<span v-if="nginxBlocking.detail" class="mono alert-body__detail">{{ nginxBlocking.detail }}</span>
|
||||||
</div>
|
</div>
|
||||||
<n-button size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
|
<n-button
|
||||||
</div>
|
v-if="agentStatus?.nginx_missing"
|
||||||
<div v-else-if="agentStatus && agentStatus.nginx_ok === false" class="nginx-alert nginx-alert--warn">
|
size="small"
|
||||||
<div class="nginx-alert__text">
|
type="primary"
|
||||||
<strong>Nginx 配置校验未通过</strong>
|
:loading="busy === 'install-nginx'"
|
||||||
<span class="mono">{{ agentStatus.nginx_error || 'nginx -t 执行失败' }}</span>
|
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
|
||||||
|
@click="installNginx"
|
||||||
|
>一键安装 Nginx</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<!-- 入口行:agent / frps 生命周期在另一页 -->
|
||||||
|
<div class="entrance-row">
|
||||||
|
<span class="entrance-row__text">
|
||||||
|
Agent 与 frps 的安装、升级、启停、配置下发在「公网 Agent」页;本页只负责把规则发布到 Nginx
|
||||||
|
</span>
|
||||||
|
<router-link to="/frp-agent"><n-button size="small" quaternary>前往公网 Agent</n-button></router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FonuCard class="section-card" title="Nginx 状态">
|
<!-- ① 统计区 -->
|
||||||
|
<section class="stats-row" aria-label="反代概览">
|
||||||
|
<div class="stat-card">
|
||||||
|
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||||
|
<div class="stat-card__value">{{ candidates.length }}</div>
|
||||||
|
<div class="stat-card__label">可部署规则</div>
|
||||||
|
<div class="stat-card__sub">来自「内网穿透」的 http 规则</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<n-icon :component="CheckmarkCircleOutline" class="stat-card__icon" :class="deployedCount ? 'stat-card__icon--green' : 'stat-card__icon--amber'" aria-hidden="true" />
|
||||||
|
<div class="stat-card__value">{{ deployedCount }}</div>
|
||||||
|
<div class="stat-card__label">已生效</div>
|
||||||
|
<div class="stat-card__sub">{{ pendingCount }} 条未部署 / 未加载</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<n-icon :component="PulseOutline" class="stat-card__icon" :class="healthyCount ? 'stat-card__icon--green' : 'stat-card__icon--amber'" aria-hidden="true" />
|
||||||
|
<div class="stat-card__value">{{ healthyCount }}<span class="stat-card__value-total">/{{ candidates.length }}</span></div>
|
||||||
|
<div class="stat-card__label">健康通过</div>
|
||||||
|
<div class="stat-card__sub">DNS · 证书 · 隧道 · 服务 四段全通过</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||||
|
<div class="stat-card__value">{{ totalConns }}</div>
|
||||||
|
<div class="stat-card__label">当前连接</div>
|
||||||
|
<div class="stat-card__sub">frps 侧实时连接数</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ② 环境与 Nginx -->
|
||||||
|
<FonuCard class="section-card nginx-card" title="Nginx(公网反代依赖)">
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<div class="nginx-status-grid">
|
<div class="field-grid">
|
||||||
<div class="nginx-status-item">
|
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
|
||||||
<span class="nginx-status-item__label">状态</span>
|
<div><label>进程</label><span>{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}</span></div>
|
||||||
<span class="nginx-status-item__value">
|
<div><label>版本</label><span class="mono">{{ agentStatus?.nginx_version || '—' }}</span></div>
|
||||||
<n-tag :type="nginxState().type" size="small" :bordered="false">{{ nginxState().label }}</n-tag>
|
<div><label>配置校验</label><span class="field-tags">
|
||||||
</span>
|
<StatusBadge v-if="statusLoaded" :kind="agentStatus?.nginx_ok ? 'success' : 'error'" :text="agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过'" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</span></div>
|
||||||
|
<div><label>可执行文件</label><span class="mono mono-wrap">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span></div>
|
||||||
|
<div><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
|
||||||
|
<div><label>端口监听</label><span class="field-tags">
|
||||||
|
<StatusBadge :kind="agentStatus?.ports?.['80'] ? 'success' : 'warning'" :text="`80 ${agentStatus?.ports?.['80'] ? '✓' : '✗'}`" />
|
||||||
|
<StatusBadge :kind="agentStatus?.ports?.['443'] ? 'success' : 'warning'" :text="`443 ${agentStatus?.ports?.['443'] ? '✓' : '✗'}`" />
|
||||||
|
</span></div>
|
||||||
|
<div><label>反代配置文件</label><span>{{ routeDetails.length }} 个</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nginx-status-item">
|
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
|
||||||
<span class="nginx-status-item__label">进程</span>
|
<div class="agent-actions">
|
||||||
<span class="nginx-status-item__value">{{ agentStatus?.nginx_running ? '运行中' : '未运行' }}</span>
|
<n-button
|
||||||
</div>
|
v-if="agentStatus?.nginx_missing"
|
||||||
<div class="nginx-status-item">
|
size="small"
|
||||||
<span class="nginx-status-item__label">版本</span>
|
type="primary"
|
||||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_version || '—' }}</span>
|
:loading="busy === 'install-nginx'"
|
||||||
</div>
|
:disabled="busy !== null && busy !== 'install-nginx'"
|
||||||
<div class="nginx-status-item">
|
@click="installNginx"
|
||||||
<span class="nginx-status-item__label">可执行文件</span>
|
>一键安装 Nginx</n-button>
|
||||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span>
|
<n-button size="small" :loading="manualRefreshing" @click="refreshAll({ silent: false })">重新检测</n-button>
|
||||||
</div>
|
|
||||||
<div class="nginx-status-item">
|
|
||||||
<span class="nginx-status-item__label">vhost 配置目录</span>
|
|
||||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_conf_dir || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="nginx-status-item">
|
|
||||||
<span class="nginx-status-item__label">配置校验</span>
|
|
||||||
<span class="nginx-status-item__value">{{ agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="nginx-status-item">
|
|
||||||
<span class="nginx-status-item__label">端口监听</span>
|
|
||||||
<span class="nginx-status-item__value mono">80 {{ agentStatus?.ports?.['80'] ? '✓' : '✗' }} · 443 {{ agentStatus?.ports?.['443'] ? '✓' : '✗' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="nginx-status-item">
|
|
||||||
<span class="nginx-status-item__label">反代配置文件</span>
|
|
||||||
<span class="nginx-status-item__value">{{ agentStatus?.routes?.length ?? 0 }} 个</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="agentStatus?.nginx_error" class="form-hint mono nginx-status-error">{{ agentStatus.nginx_error }}</p>
|
|
||||||
<div class="nginx-status-actions">
|
|
||||||
<n-button v-if="agentStatus?.nginx_missing" size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
|
|
||||||
<n-button size="small" :loading="refreshing" @click="refreshAll">重新检测</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
<FonuCard class="section-card" title="穿透规则与公网反代状态">
|
<!-- ③ 规则台账 -->
|
||||||
|
<FonuCard class="section-card" title="规则台账">
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<p class="form-hint">穿透规则保存后不会自动修改 VPS。点击「部署反代」把该规则的域名发布到公网 Nginx;部署时自动推送证书并做回源自检。</p>
|
<p class="form-hint">穿透规则保存后不会自动改 VPS:点「部署反代」才会发布到 Nginx(自动推送证书并做回源自检)</p>
|
||||||
<div v-if="routeCandidates.length" class="route-list">
|
<div v-if="candidates.length" class="route-table-wrap">
|
||||||
<div v-for="candidate in routeCandidates" :key="candidate.id" class="route-item">
|
<n-data-table
|
||||||
<div class="route-item__main">
|
class="route-table"
|
||||||
<strong>{{ candidate.name }}</strong>
|
:columns="columns"
|
||||||
<span class="route-item__domains mono">{{ candidate.custom_domains.join('、') }}</span>
|
:data="candidates"
|
||||||
<span class="route-item__upstream mono">{{ candidate.local_ip }}:{{ candidate.local_port }}(回源 127.0.0.1:{{ vhostPortOf(candidate) }})</span>
|
:bordered="false"
|
||||||
<div class="route-item__health">
|
size="small"
|
||||||
<span v-for="h in healthBadges(candidate)" :key="h.label" class="health-badge" :class="h.ok ? 'health-ok' : 'health-bad'" :title="h.detail">{{ h.label }} {{ h.ok ? '✓' : '✗' }}</span>
|
:scroll-x="1780"
|
||||||
|
:row-key="(row: FrpProxy) => row.id"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="route-item__traffic">
|
<EmptyState
|
||||||
<span class="traffic-metric" title="frps 侧当前连接数">连接 {{ trafficOf(candidate).cur_conns }}</span>
|
v-else
|
||||||
<span class="traffic-metric" title="当前上传速率(近两次采样均值)">当前上传 {{ formatRate(trafficOf(candidate).traffic_in_rate) }}</span>
|
title="暂无可部署的反代规则"
|
||||||
<span class="traffic-metric" title="当前下载速率(近两次采样均值)">当前下载 {{ formatRate(trafficOf(candidate).traffic_out_rate) }}</span>
|
description="公网反代的来源是穿透规则:先到「内网穿透」页创建启用的 http 类型规则并填写自定义域名。"
|
||||||
<span class="traffic-metric" title="frps 侧当日累计上传">总上传 {{ formatBytes(trafficOf(candidate).traffic_in) }}</span>
|
>
|
||||||
<span class="traffic-metric" title="frps 侧当日累计下载">总下载 {{ formatBytes(trafficOf(candidate).traffic_out) }}</span>
|
<template #action>
|
||||||
<span v-if="trafficErrors[String(candidate.id)]" class="traffic-metric traffic-metric--error" :title="trafficErrors[String(candidate.id)]">流量采集失败</span>
|
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</EmptyState>
|
||||||
<div class="route-item__actions">
|
|
||||||
<n-tag :type="stateMeta(candidate).type" size="small" :bordered="false">{{ stateMeta(candidate).label }}</n-tag>
|
|
||||||
<n-button size="small" quaternary :loading="confLoadingId === candidate.id" @click="viewConf(candidate)">查看配置</n-button>
|
|
||||||
<n-button size="small" @click="openDeploySettings(candidate)">部署设置</n-button>
|
|
||||||
<n-button size="small" type="primary" :loading="routeActionId === candidate.id" @click="deployRoute(candidate)">部署反代</n-button>
|
|
||||||
<n-button v-if="stateOf(candidate) !== 'none'" size="small" type="warning" quaternary :loading="routeActionId === candidate.id" @click="removeRoute(candidate)">移除反代</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="empty-route">暂无可部署的 HTTP/HTTPS 穿透规则——先到「内网穿透」页创建启用的 http 类型规则(填自定义域名)</div>
|
|
||||||
<details v-if="routeDetails.length" class="raw-routes">
|
<details v-if="routeDetails.length" class="raw-routes">
|
||||||
<summary>VPS 上 agent 管理的路由文件({{ routeDetails.length }})</summary>
|
<summary>VPS 上 agent 管理的路由文件({{ routeDetails.length }})</summary>
|
||||||
<div v-for="detail in routeDetails" :key="detail.domain" class="raw-route-row mono">
|
<div class="raw-routes__list">
|
||||||
{{ detail.domain }} — {{ detail.loaded ? '已加载' : detail.syntax_ok ? '已写入未加载' : '语法错误' }}
|
<div v-for="detail in routeDetails" :key="detail.domain" class="raw-route-row">
|
||||||
|
<span class="mono">{{ detail.domain }}</span>
|
||||||
|
<StatusBadge :kind="detail.loaded ? 'success' : detail.syntax_ok ? 'warning' : 'error'" :text="detail.loaded ? '已加载' : detail.syntax_ok ? '已写入未加载' : '语法错误'" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
<FonuCard class="section-card" title="使用说明">
|
<!-- 部署设置 -->
|
||||||
<div class="section-body">
|
<n-modal v-model:show="settingsModal" :mask-closable="false">
|
||||||
<p class="form-hint">部署选项(启用 HTTPS / 强制跳转 / WebSocket / 上传大小 / 代理超时 / IP 白名单与黑名单 / Basic Auth / 仅 TLS 1.3 / 安全响应头)在本页规则行的「部署设置」里配置,点「保存并重新部署」即生效。Basic Auth 密码留空时首次部署自动生成,写在生成的配置注释里(点「查看配置」可见)。注意:这些选项需要 VPS 上已升级到新版 agent。</p>
|
<div class="proxy-modal proxy-modal--with-help">
|
||||||
</div>
|
<div class="proxy-modal__form">
|
||||||
</FonuCard>
|
|
||||||
|
|
||||||
<n-modal
|
|
||||||
v-model:show="settingsModal"
|
|
||||||
:mask-closable="false"
|
|
||||||
style="width: 640px"
|
|
||||||
>
|
|
||||||
<div class="proxy-modal">
|
|
||||||
<div class="proxy-modal__header">
|
<div class="proxy-modal__header">
|
||||||
<h3 class="modal-title">部署设置 — {{ settingsTarget?.name }}</h3>
|
<h3 class="modal-title">部署设置 — {{ settingsTarget?.name }}</h3>
|
||||||
<n-button size="small" quaternary @click="settingsModal = false">取消</n-button>
|
<n-button size="small" quaternary @click="settingsModal = false">取消</n-button>
|
||||||
@@ -212,13 +239,13 @@
|
|||||||
<div class="panel-title">流量控制</div>
|
<div class="panel-title">流量控制</div>
|
||||||
<div class="field-grid field-grid--three">
|
<div class="field-grid field-grid--three">
|
||||||
<n-form-item label="请求限流(次/秒)">
|
<n-form-item label="请求限流(次/秒)">
|
||||||
<n-input-number v-model:value="settingsForm.rate_limit_rate" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
|
<n-input-number v-model:value="settingsForm.rate_limit_rate" :min="0" :max="100000" placeholder="留空不限制" class="full-width" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="突发允许量">
|
<n-form-item label="突发允许量">
|
||||||
<n-input-number v-model:value="settingsForm.rate_limit_burst" :min="0" :max="100000" placeholder="留空按 0" style="width: 100%" />
|
<n-input-number v-model:value="settingsForm.rate_limit_burst" :min="0" :max="100000" placeholder="留空按 0" class="full-width" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="连接数上限(每 IP)">
|
<n-form-item label="连接数上限(每 IP)">
|
||||||
<n-input-number v-model:value="settingsForm.conn_limit_max" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
|
<n-input-number v-model:value="settingsForm.conn_limit_max" :min="0" :max="100000" placeholder="留空不限制" class="full-width" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</div>
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
@@ -239,26 +266,45 @@
|
|||||||
<n-switch v-model:value="settingsForm.tls13_only" size="small" :disabled="!settingsForm.https_enabled" />
|
<n-switch v-model:value="settingsForm.tls13_only" size="small" :disabled="!settingsForm.https_enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
这些选项存放在穿透规则的 options 里,点「保存并重新部署」后立即生效。Basic Auth 密码留空时首次部署自动生成,写在生成的配置注释中(点「查看配置」可见)。
|
||||||
|
</p>
|
||||||
</n-form>
|
</n-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="proxy-modal__footer">
|
<div class="modal-footer">
|
||||||
<n-button size="small" @click="settingsModal = false">取消</n-button>
|
<n-button @click="settingsModal = false">取消</n-button>
|
||||||
<n-button size="small" type="primary" :loading="routeActionId === settingsTarget?.id" @click="saveDeploySettings">保存并重新部署</n-button>
|
<n-button type="primary" :loading="busy === `save-settings:${settingsTarget?.id}`" :disabled="busy !== null && busy !== `save-settings:${settingsTarget?.id}`" @click="saveDeploySettings">保存并重新部署</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="proxy-modal__help" aria-label="配置说明">
|
||||||
|
<h4>配置说明</h4>
|
||||||
|
<ol>
|
||||||
|
<li><strong>保存后会发生什么</strong>:选项写入穿透规则 options → 立即重新部署该域名 → 自动推送证书 → 回源自检(DNS / 证书 / 隧道 / 服务)。</li>
|
||||||
|
<li><strong>前置条件</strong>:VPS 需已装 Nginx 且 nginx -t 通过;未装时页面顶部有「一键安装 Nginx」。</li>
|
||||||
|
<li><strong>访问控制优先级</strong>:IP 白名单 > 黑名单 > 仅中国大陆 IP;后者依赖本地中国 IP 段,未更新时部署会明确报错。</li>
|
||||||
|
<li><strong>Basic Auth</strong>:填用户名即启用;密码留空时首次部署自动生成随机密码,写在生成的 Nginx 配置注释里(点「查看配置」可见)。</li>
|
||||||
|
<li><strong>限流与连接数</strong>:限流触发返回 429,超过每 IP 连接数返回 503;留空表示不限制。</li>
|
||||||
|
</ol>
|
||||||
|
<div class="proxy-modal__tip">
|
||||||
|
<n-icon class="proxy-modal__tip-icon" :component="InformationCircleOutline" aria-hidden="true" />
|
||||||
|
<span>改动只影响这一条规则的公网入口;域名与内网目标在「内网穿透」页修改。</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<n-modal
|
<!-- 反代配置查看 -->
|
||||||
v-model:show="confModal"
|
<n-modal v-model:show="confModal" :mask-closable="true">
|
||||||
:mask-closable="true"
|
|
||||||
style="width: 720px"
|
|
||||||
>
|
|
||||||
<div class="proxy-modal">
|
<div class="proxy-modal">
|
||||||
<div class="proxy-modal__header">
|
<div class="proxy-modal__header">
|
||||||
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
|
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
|
||||||
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
|
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
|
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<n-button :disabled="!confContent" @click="copyConf">复制</n-button>
|
||||||
|
<n-button type="primary" @click="confModal = false">关闭</n-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -266,30 +312,41 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useMessage } from 'naive-ui'
|
import { NButton, NIcon, NPopconfirm, useMessage, type DataTableColumns } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
import FonuCard from '../components/FonuCard.vue'
|
import FonuCard from '../components/FonuCard.vue'
|
||||||
import LoadError from '../components/LoadError.vue'
|
import LoadError from '../components/LoadError.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { FrpAgentRouteDetail, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer } from '../api/types'
|
import type {
|
||||||
import { formatBytes, formatRate } from '../utils/format'
|
FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer,
|
||||||
|
} from '../api/types'
|
||||||
|
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx'
|
||||||
|
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
|
||||||
|
type BadgeKind = 'success' | 'warning' | 'error' | 'disabled'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const servers = ref<FrpServer[]>([])
|
const servers = ref<FrpServer[]>([])
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const loadErrorMsg = ref('')
|
const loadErrorMsg = ref('')
|
||||||
const refreshing = ref(false)
|
const manualRefreshing = ref(false)
|
||||||
const routeCandidates = ref<FrpProxy[]>([])
|
const busy = ref<Action | null>(null)
|
||||||
|
const candidates = ref<FrpProxy[]>([])
|
||||||
const routeDetails = ref<FrpAgentRouteDetail[]>([])
|
const routeDetails = ref<FrpAgentRouteDetail[]>([])
|
||||||
const routeHealth = ref<import('../api/types').FrpAgentRouteHealth[]>([])
|
const routeHealth = ref<FrpAgentRouteHealth[]>([])
|
||||||
const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
|
const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
|
||||||
const trafficErrors = ref<Record<string, string>>({})
|
const trafficErrors = ref<Record<string, string>>({})
|
||||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||||
const installingNginx = ref(false)
|
const statusLoaded = ref(false)
|
||||||
const routeActionId = ref<number | null>(null)
|
|
||||||
const settingsModal = ref(false)
|
const settingsModal = ref(false)
|
||||||
const settingsTarget = ref<FrpProxy | null>(null)
|
const settingsTarget = ref<FrpProxy | null>(null)
|
||||||
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
|
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
|
||||||
@@ -311,7 +368,6 @@ const settingsForm = ref({
|
|||||||
rate_limit_burst: null as number | null,
|
rate_limit_burst: null as number | null,
|
||||||
conn_limit_max: null as number | null,
|
conn_limit_max: null as number | null,
|
||||||
})
|
})
|
||||||
const confLoadingId = ref<number | null>(null)
|
|
||||||
const confModal = ref(false)
|
const confModal = ref(false)
|
||||||
const confDomain = ref('')
|
const confDomain = ref('')
|
||||||
const confContent = ref('')
|
const confContent = ref('')
|
||||||
@@ -321,6 +377,47 @@ const server = computed(() => servers.value.find((item) => item.id === selectedI
|
|||||||
const serverOptions = computed(() => servers.value
|
const serverOptions = computed(() => servers.value
|
||||||
.filter((item) => item.agent_configured || item.ssh_configured)
|
.filter((item) => item.agent_configured || item.ssh_configured)
|
||||||
.map((item) => ({ label: item.name, value: item.id })))
|
.map((item) => ({ label: item.name, value: item.id })))
|
||||||
|
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
|
||||||
|
|
||||||
|
// 统计:已生效 / 待处理 / 健康通过 / 当前连接
|
||||||
|
const deployedCount = computed(() => candidates.value.filter((item) => stateOf(item).state === 'loaded').length)
|
||||||
|
const pendingCount = computed(() => candidates.value.length - deployedCount.value)
|
||||||
|
const healthyCount = computed(() => candidates.value.filter((item) => isHealthy(item)).length)
|
||||||
|
const totalConns = computed(() => candidates.value.reduce((sum, item) => sum + trafficOf(item).cur_conns, 0))
|
||||||
|
|
||||||
|
// Nginx 前置条件提示:缺失 / 校验未通过
|
||||||
|
const nginxBlocking = computed<{ type: 'error' | 'warning'; title: string; text: string; detail?: string } | null>(() => {
|
||||||
|
if (!statusLoaded.value) return null
|
||||||
|
if (agentStatus.value?.nginx_missing) {
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
title: 'VPS 未安装 Nginx',
|
||||||
|
text: '公网反代需要 Nginx 承接 80/443 并终结 HTTPS。可点右侧按钮由 agent 自动安装(识别 apt-get / dnf / yum / apk,需 root),或手动执行 apt install -y nginx / yum install -y nginx。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (agentStatus.value && agentStatus.value.nginx_ok === false) {
|
||||||
|
return {
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Nginx 配置校验未通过',
|
||||||
|
text: '部署前请先修掉配置错误,否则新规则不会生效。',
|
||||||
|
detail: agentStatus.value.nginx_error || 'nginx -t 执行失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nginxKind = computed<BadgeKind>(() => {
|
||||||
|
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 load() {
|
async function load() {
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
@@ -329,26 +426,55 @@ async function load() {
|
|||||||
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
|
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
|
||||||
selectedId.value = servers.value[0]?.id ?? null
|
selectedId.value = servers.value[0]?.id ?? null
|
||||||
}
|
}
|
||||||
await refreshAll()
|
await refreshAll({ silent: false })
|
||||||
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
// refreshAll:单个接口失败不再中断整轮,失败项汇总成一句可见提示(原先 loadErrorMsg 从未渲染)
|
||||||
|
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
refreshing.value = true
|
if (options.silent === false) manualRefreshing.value = true
|
||||||
|
const failures: string[] = []
|
||||||
|
const track = async <T>(label: string, fn: () => Promise<T>, assign: (value: T) => void) => {
|
||||||
|
try { assign(await fn()) } catch (error) {
|
||||||
|
failures.push(`${label}${error instanceof Error ? `:${error.message}` : '失败'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value })
|
||||||
|
await track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value })
|
||||||
|
await track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value })
|
||||||
|
await track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
||||||
|
proxyTraffic.value = value.items ?? {}
|
||||||
|
trafficErrors.value = value.errors ?? {}
|
||||||
|
})
|
||||||
|
await track('Agent 状态', () => api.getFrpAgentStatus(current.id), (value) => {
|
||||||
|
agentStatus.value = value
|
||||||
|
statusLoaded.value = true
|
||||||
|
})
|
||||||
|
loadErrorMsg.value = failures.length ? `数据读取不完整 —— ${failures.join(';')}` : ''
|
||||||
|
if (options.silent === false) manualRefreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// run:按动作粒度 loading + 串行化(按钮已 disabled,这里只是安全网)
|
||||||
|
async function run<T>(action: Action, source: string, fn: () => Promise<T>) {
|
||||||
|
if (busy.value) return undefined
|
||||||
|
busy.value = action
|
||||||
try {
|
try {
|
||||||
routeCandidates.value = await api.getFrpAgentRouteCandidates(current.id)
|
const result = await fn()
|
||||||
routeDetails.value = await api.getFrpAgentRoutes(current.id)
|
message.success(`${source}完成`)
|
||||||
routeHealth.value = await api.getFrpAgentRouteHealth(current.id)
|
return result
|
||||||
// 流量按规则 ID 聚合(frps dashboard);旧版 frps 会在后端回退为逐规则查询
|
} catch (error) {
|
||||||
const traffic = await api.listFrpProxiesTraffic()
|
const text = error instanceof Error ? error.message : `${source}失败`
|
||||||
proxyTraffic.value = traffic.items ?? {}
|
loadErrorMsg.value = `${source}失败 —— ${text}`
|
||||||
trafficErrors.value = traffic.errors ?? {}
|
message.error(`${source}失败`)
|
||||||
// agent 状态用于 Nginx 可用性提示(nginx_missing / nginx_ok)
|
return undefined
|
||||||
try { agentStatus.value = await api.getFrpAgentStatus(current.id) } catch { agentStatus.value = null }
|
} finally { busy.value = null }
|
||||||
} catch (error) { loadErrorMsg.value = error instanceof Error ? error.message : '读取规则失败' }
|
}
|
||||||
finally { refreshing.value = false }
|
|
||||||
|
// 接口返回的 custom_domains 理论上必填,但历史数据可能为 null;统一收口避免渲染期抛错
|
||||||
|
function domainsOf(candidate: FrpProxy) {
|
||||||
|
return candidate.custom_domains ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
function vhostPortOf(candidate: FrpProxy) {
|
function vhostPortOf(candidate: FrpProxy) {
|
||||||
@@ -356,51 +482,155 @@ function vhostPortOf(candidate: FrpProxy) {
|
|||||||
return candidate.type === 'https' ? (server.value?.options.vhost_https_port || 8443) : port
|
return candidate.type === 'https' ? (server.value?.options.vhost_https_port || 8443) : port
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
|
// 部署态:已生效 / 已写入未加载 / 语法错误 / 未部署
|
||||||
|
function stateOf(candidate: FrpProxy): { state: RouteState; label: string; kind: BadgeKind } {
|
||||||
function stateOf(candidate: FrpProxy): RouteState {
|
for (const domain of domainsOf(candidate)) {
|
||||||
for (const domain of candidate.custom_domains) {
|
const detail = routeDetails.value.find((item) => item.domain === domain)
|
||||||
const d = routeDetails.value.find((item) => item.domain === domain)
|
if (!detail) continue
|
||||||
if (d) {
|
if (detail.loaded) return { state: 'loaded', label: '已生效', kind: 'success' }
|
||||||
if (d.loaded) return 'loaded'
|
if (detail.syntax_ok) return { state: 'written', label: '已写入未加载', kind: 'warning' }
|
||||||
if (d.syntax_ok) return 'written'
|
return { state: 'broken', label: '语法错误', kind: 'error' }
|
||||||
return 'broken'
|
|
||||||
}
|
}
|
||||||
}
|
return { state: 'none', label: '未部署', kind: 'disabled' }
|
||||||
return 'none'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateMeta(candidate: FrpProxy): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
|
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务
|
||||||
switch (stateOf(candidate)) {
|
function healthOf(domain: string) {
|
||||||
case 'loaded': return { label: '已生效', type: 'success' }
|
const health = routeHealth.value.find((item) => item.domain === domain)
|
||||||
case 'written': return { label: '已写入未加载', type: 'warning' }
|
return [
|
||||||
case 'broken': return { label: '语法错误', type: 'error' }
|
{ label: 'DNS', ok: health?.dns_ok ?? false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
||||||
default: return { label: '未部署', type: 'default' }
|
{ label: '证书', ok: health?.cert_ok ?? false, detail: health?.cert_detail || (health?.cert_ok ? '证书已覆盖该域名' : '未发现该域名的证书') },
|
||||||
|
{ label: '隧道', ok: health?.tunnel_ok ?? false, detail: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
||||||
|
{ label: '服务', ok: health?.service_ok ?? false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHealthy(candidate: FrpProxy) {
|
||||||
|
const domains = domainsOf(candidate)
|
||||||
|
if (!domains.length) return false
|
||||||
|
return domains.every((domain) => healthOf(domain).every((item) => item.ok))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https)
|
||||||
|
async function copyDomain(domain: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(domain)
|
||||||
|
message.success(`已复制 ${domain}`)
|
||||||
|
} catch { message.error('复制失败,请手动选择文本复制') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDomain(candidate: FrpProxy, domain: string) {
|
||||||
|
window.open(`${candidate.type === 'https' ? 'https' : 'http'}://${domain}`, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采集失败时把原因挂到四个流量单元格的 title 上,不再单占一列
|
||||||
|
function trafficErrorOf(candidate: FrpProxy) {
|
||||||
|
return trafficErrors.value[String(candidate.id)] || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则台账列定义:与本地反向代理页一致,用 n-data-table 呈现(列宽固定,各行列天然对齐)
|
||||||
|
const columns = computed<DataTableColumns<FrpProxy>>(() => [
|
||||||
|
{
|
||||||
|
title: '规则 / 域名', key: 'name', width: 250,
|
||||||
|
render: (row) => h('div', { class: 'cell-name' }, [
|
||||||
|
h('div', { class: 'cell-name__head' }, [
|
||||||
|
h('strong', row.name),
|
||||||
|
h(StatusBadge, { kind: stateOf(row).kind, text: stateOf(row).label }),
|
||||||
|
row.enabled ? null : h('span', { class: 'cell-muted' }, '已停用'),
|
||||||
|
]),
|
||||||
|
domainsOf(row).length
|
||||||
|
? h('div', { class: 'cell-domains' }, domainsOf(row).map((domain) => h('div', { class: 'cell-domain' }, [
|
||||||
|
h('span', { class: 'mono cell-wrap' }, domain),
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, title: `复制 ${domain}`, onClick: () => copyDomain(domain) }, { icon: () => h(NIcon, { component: CopyOutline }) }),
|
||||||
|
h(NButton, { size: 'tiny', quaternary: true, title: `打开 ${domain}`, onClick: () => openDomain(row, domain) }, { icon: () => h(NIcon, { component: OpenOutline }) }),
|
||||||
|
])))
|
||||||
|
: h('span', { class: 'mono cell-wrap' }, '未填域名'),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '协议', key: 'protocol', width: 80,
|
||||||
|
render: (row) => h('span', { class: 'mono' }, row.type.toUpperCase()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上游 / 回源', key: 'upstream', width: 200,
|
||||||
|
render: (row) => h('div', { class: 'cell-upstream' }, [
|
||||||
|
h('span', { class: 'mono cell-wrap' }, `${row.local_ip}:${row.local_port}`),
|
||||||
|
h('span', { class: 'mono cell-wrap cell-muted' }, `→ 127.0.0.1:${vhostPortOf(row)}`),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '健康(DNS · 证书 · 隧道 · 服务)', key: 'health', width: 260,
|
||||||
|
render: (row) => h('div', { class: 'cell-health' }, domainsOf(row).flatMap((domain) =>
|
||||||
|
healthOf(domain).map((item) => h('span', { title: item.detail },
|
||||||
|
[h(StatusBadge, { kind: item.ok ? 'success' : 'error', text: item.label })])))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '连接', key: 'conns', width: 80, align: 'right',
|
||||||
|
render: (row) => h('span', { class: 'mono' }, String(trafficOf(row).cur_conns)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前上传', key: 'in_rate', width: 100, align: 'right',
|
||||||
|
render: (row) => h('span', { class: 'mono', title: trafficErrorOf(row) }, formatRate(trafficOf(row).traffic_in_rate)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前下载', key: 'out_rate', width: 100, align: 'right',
|
||||||
|
render: (row) => h('span', { class: 'mono', title: trafficErrorOf(row) }, formatRate(trafficOf(row).traffic_out_rate)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '总上传', key: 'in_total', width: 95, align: 'right',
|
||||||
|
render: (row) => h('span', { class: 'mono', title: trafficErrorOf(row) }, formatBytes(trafficOf(row).traffic_in)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '总下载', key: 'out_total', width: 95, align: 'right',
|
||||||
|
render: (row) => h('span', { class: 'mono', title: trafficErrorOf(row) }, formatBytes(trafficOf(row).traffic_out)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '更新时间', key: 'updated_at', width: 155,
|
||||||
|
render: (row) => h('div', { class: 'cell-time' }, [
|
||||||
|
h('span', { class: 'mono' }, formatDate(row.updated_at)),
|
||||||
|
h('span', { class: 'cell-muted' }, formatRelativeTime(row.updated_at)),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', key: 'actions', width: 330, align: 'right',
|
||||||
|
render: (row) => h('div', { class: 'cell-actions' }, [
|
||||||
|
h(NButton, { size: 'small', quaternary: true, loading: busy.value === `conf:${row.id}`,
|
||||||
|
disabled: busy.value !== null && busy.value !== `conf:${row.id}`, onClick: () => viewConf(row) },
|
||||||
|
{ default: () => '查看配置' }),
|
||||||
|
h(NButton, { size: 'small', disabled: busy.value !== null, onClick: () => openDeploySettings(row) },
|
||||||
|
{ default: () => '部署设置' }),
|
||||||
|
h(NButton, { size: 'small', type: 'primary', loading: busy.value === `deploy:${row.id}`,
|
||||||
|
disabled: busy.value !== null && busy.value !== `deploy:${row.id}`, onClick: () => deployRoute(row) },
|
||||||
|
{ default: () => '部署反代' }),
|
||||||
|
stateOf(row).state === 'none' ? null : h(NPopconfirm, { onPositiveClick: () => removeRoute(row) }, {
|
||||||
|
trigger: () => h(NButton, { size: 'small', type: 'warning', quaternary: true,
|
||||||
|
loading: busy.value === `remove:${row.id}`,
|
||||||
|
disabled: busy.value !== null && busy.value !== `remove:${row.id}` },
|
||||||
|
{ default: () => '移除反代' }),
|
||||||
|
default: () => `将从 VPS 删除 ${domainsOf(row).join('、')} 的反代配置并重载 Nginx(穿透规则本身保留),确认继续?`,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// trafficOf 返回该规则的 frps 流量快照;无数据时回落到全零,避免模板出现 undefined
|
||||||
|
function trafficOf(candidate: FrpProxy): FrpProxyTraffic {
|
||||||
|
return proxyTraffic.value[String(candidate.id)] ?? {
|
||||||
|
traffic_in: 0, traffic_out: 0, cur_conns: 0, traffic_in_rate: 0, traffic_out_rate: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deployRoute(candidate: FrpProxy) {
|
async function deployRoute(candidate: FrpProxy) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
routeActionId.value = candidate.id
|
const result = await run(`deploy:${candidate.id}`, '部署反代', () => api.deployFrpAgentRoute(current.id, candidate.id))
|
||||||
try {
|
if (result !== undefined) await refreshAll()
|
||||||
await api.deployFrpAgentRoute(current.id, candidate.id)
|
|
||||||
message.success('公网反代已部署')
|
|
||||||
await refreshAll()
|
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '部署反代失败') }
|
|
||||||
finally { routeActionId.value = null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeRoute(candidate: FrpProxy) {
|
async function removeRoute(candidate: FrpProxy) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
routeActionId.value = candidate.id
|
const result = await run(`remove:${candidate.id}`, '移除反代', () => api.removeFrpAgentRoute(current.id, candidate.id))
|
||||||
try {
|
if (result !== undefined) await refreshAll()
|
||||||
await api.removeFrpAgentRoute(current.id, candidate.id)
|
|
||||||
message.success('公网反代已移除')
|
|
||||||
await refreshAll()
|
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '移除反代失败') }
|
|
||||||
finally { routeActionId.value = null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeploySettings(candidate: FrpProxy) {
|
function openDeploySettings(candidate: FrpProxy) {
|
||||||
@@ -430,8 +660,6 @@ async function saveDeploySettings() {
|
|||||||
const target = settingsTarget.value
|
const target = settingsTarget.value
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!target || !current) return
|
if (!target || !current) return
|
||||||
routeActionId.value = target.id
|
|
||||||
try {
|
|
||||||
// 选项存在规则的 options 里(与穿透配置同库),更新后立即重部署生效
|
// 选项存在规则的 options 里(与穿透配置同库),更新后立即重部署生效
|
||||||
const options = {
|
const options = {
|
||||||
...target.options,
|
...target.options,
|
||||||
@@ -451,133 +679,206 @@ async function saveDeploySettings() {
|
|||||||
rate_limit_burst: settingsForm.value.rate_limit_burst ?? 0,
|
rate_limit_burst: settingsForm.value.rate_limit_burst ?? 0,
|
||||||
conn_limit_max: settingsForm.value.conn_limit_max ?? 0,
|
conn_limit_max: settingsForm.value.conn_limit_max ?? 0,
|
||||||
}
|
}
|
||||||
await api.updateFrpProxy(target.id, { server_id: target.server_id, name: target.name, type: target.type, local_ip: target.local_ip, local_port: target.local_port, remote_port: target.remote_port ?? null, custom_domains: target.custom_domains, host_header_rewrite: target.host_header_rewrite, options, enabled: target.enabled, remark: target.remark })
|
const result = await run(`save-settings:${target.id}`, '保存部署设置', () => api.updateFrpProxy(target.id, {
|
||||||
|
server_id: target.server_id, name: target.name, type: target.type,
|
||||||
|
local_ip: target.local_ip, local_port: target.local_port,
|
||||||
|
remote_port: target.remote_port ?? null, custom_domains: target.custom_domains,
|
||||||
|
host_header_rewrite: target.host_header_rewrite, options,
|
||||||
|
enabled: target.enabled, remark: target.remark,
|
||||||
|
}))
|
||||||
|
if (result === undefined) return
|
||||||
settingsModal.value = false
|
settingsModal.value = false
|
||||||
message.success('部署设置已保存,正在重新部署…')
|
|
||||||
await deployRoute(target)
|
await deployRoute(target)
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '保存部署设置失败') }
|
|
||||||
finally { routeActionId.value = null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nginxState 汇总 Nginx 状态徽标:未安装 / 配置校验未通过 / 已安装未运行 / 正常。
|
|
||||||
function nginxState(): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
|
|
||||||
const status = agentStatus.value
|
|
||||||
if (!status) return { label: '未知(未连接 agent)', type: 'default' }
|
|
||||||
if (status.nginx_missing) return { label: '未安装', type: 'error' }
|
|
||||||
if (!status.nginx_ok) return { label: '配置校验未通过', type: 'error' }
|
|
||||||
if (status.nginx_running === false) return { label: '已安装未运行', type: 'warning' }
|
|
||||||
return { label: '正常', type: 'success' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// installNginx 触发 VPS 上一键安装 Nginx(agent 侧识别包管理器并执行,需 root)。
|
|
||||||
async function installNginx() {
|
async function installNginx() {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
installingNginx.value = true
|
const result = await run('install-nginx', '安装 Nginx', () => api.installFrpAgentNginx(current.id))
|
||||||
try {
|
if (result && !result.ok) {
|
||||||
const result = await api.installFrpAgentNginx(current.id)
|
|
||||||
if (result.ok) {
|
|
||||||
message.success(`Nginx 安装完成${result.path ? `(${result.path})` : ''}`)
|
|
||||||
} else {
|
|
||||||
message.warning(`安装命令已执行,但 Nginx 仍不可用:${(result.error || '').slice(0, 300)}`)
|
message.warning(`安装命令已执行,但 Nginx 仍不可用:${(result.error || '').slice(0, 300)}`)
|
||||||
}
|
}
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '一键安装 Nginx 失败') }
|
|
||||||
finally { installingNginx.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// trafficOf 返回该规则的 frps 流量快照;无数据时回落到全零,避免模板出现 undefined。
|
|
||||||
function trafficOf(candidate: FrpProxy): FrpProxyTraffic {
|
|
||||||
return proxyTraffic.value[String(candidate.id)] ?? {
|
|
||||||
traffic_in: 0, traffic_out: 0, cur_conns: 0, traffic_in_rate: 0, traffic_out_rate: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 四段健康徽标:DNS 指向 / 证书覆盖 / 隧道注册 / 内网服务
|
|
||||||
function healthBadges(candidate: FrpProxy) {
|
|
||||||
return candidate.custom_domains.map((domain) => {
|
|
||||||
const h = routeHealth.value.find((item) => item.domain === domain)
|
|
||||||
return [
|
|
||||||
{ label: 'DNS', ok: h?.dns_ok ?? false, detail: h?.dns_detail || (h?.dns_ok ? '已指向服务端' : '待检测') },
|
|
||||||
{ label: '证书', ok: h?.cert_ok ?? false, detail: h?.cert_detail || (h?.cert_ok ? '已覆盖' : '未发现证书') },
|
|
||||||
{ label: '隧道', ok: h?.tunnel_ok ?? false, detail: h?.tunnel_detail || (h?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
|
||||||
{ label: '服务', ok: h?.service_ok ?? false, detail: h?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
|
||||||
]
|
|
||||||
}).flat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewConf(candidate: FrpProxy) {
|
async function viewConf(candidate: FrpProxy) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
confLoadingId.value = candidate.id
|
if (busy.value) return
|
||||||
|
busy.value = `conf:${candidate.id}`
|
||||||
try {
|
try {
|
||||||
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
|
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
|
||||||
confDomain.value = conf.domain
|
confDomain.value = conf.domain
|
||||||
confContent.value = conf.content
|
confContent.value = conf.content
|
||||||
confModal.value = true
|
confModal.value = true
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '读取配置失败') }
|
} catch (error) {
|
||||||
finally { confLoadingId.value = null }
|
loadErrorMsg.value = `读取配置失败 —— ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
} finally { busy.value = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyConf() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(confContent.value)
|
||||||
|
message.success('已复制反代配置')
|
||||||
|
} catch { message.error('复制失败,请手动选择文本复制') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换服务端必须重置,避免上一台的健康 / 流量 / agent 状态串台
|
||||||
|
watch(selectedId, () => {
|
||||||
|
candidates.value = []
|
||||||
|
routeDetails.value = []
|
||||||
|
routeHealth.value = []
|
||||||
|
proxyTraffic.value = {}
|
||||||
|
trafficErrors.value = {}
|
||||||
|
agentStatus.value = null
|
||||||
|
statusLoaded.value = false
|
||||||
|
loadErrorMsg.value = ''
|
||||||
|
void refreshAll({ silent: false })
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load()
|
void load()
|
||||||
timer = window.setInterval(() => { if (server.value && !refreshing.value) void refreshAll() }, 15000)
|
timer = window.setInterval(() => {
|
||||||
|
if (!server.value) return
|
||||||
|
if (document.visibilityState === 'hidden') return // 页面不可见时暂停轮询
|
||||||
|
if (busy.value) return // 有操作进行中不叠加轮询
|
||||||
|
void refreshAll()
|
||||||
|
}, 15000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.section-card { margin-bottom: var(--fonu-space-4); }
|
/* 分区:提示 → 入口行 → ① 统计区 → ② 环境与 Nginx → ③ 规则台账 */
|
||||||
.nginx-alert { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); padding: 12px 16px; border: 1px solid #f0c36d; border-radius: var(--fonu-radius-sm); background: rgba(240, 195, 109, 0.12); }
|
.page-alert { margin-bottom: var(--fonu-space-3); }
|
||||||
.nginx-alert--warn { border-color: #e79b9b; background: rgba(231, 155, 155, 0.12); }
|
.alert-body { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); }
|
||||||
.nginx-alert__text { display: grid; gap: 4px; font-size: 13px; color: var(--fonu-text); }
|
.alert-body__text { display: grid; gap: 4px; font-size: 13px; }
|
||||||
.nginx-alert__text code { padding: 1px 5px; border-radius: 4px; background: var(--fonu-bg-muted); font-family: var(--fonu-mono); font-size: 12px; }
|
.alert-body__detail { font-size: 12px; color: var(--fonu-error); overflow-wrap: anywhere; }
|
||||||
.nginx-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px 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-5); padding: 10px var(--fonu-space-5); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-info-soft); }
|
||||||
.nginx-status-item { display: grid; gap: 3px; min-width: 0; }
|
.entrance-row__text { font-size: 13px; color: var(--fonu-text); }
|
||||||
.nginx-status-item__label { font-size: 12px; color: var(--fonu-text-secondary); }
|
|
||||||
.nginx-status-item__value { font-size: 13px; color: var(--fonu-text); overflow-wrap: anywhere; }
|
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-5); }
|
||||||
.nginx-status-error { margin-top: var(--fonu-space-3); color: #dc2626; }
|
.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; }
|
||||||
.nginx-status-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-3); }
|
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
|
||||||
.section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
|
.stat-card__icon--blue { color: var(--fonu-info); }
|
||||||
.route-list { display: grid; gap: 8px; margin-top: var(--fonu-space-3); }
|
.stat-card__icon--green { color: var(--fonu-brand); }
|
||||||
.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); }
|
.stat-card__icon--amber { color: var(--fonu-warning); }
|
||||||
.route-item__main { display: grid; gap: 3px; min-width: 0; }
|
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--fonu-text); letter-spacing: -0.03em; }
|
||||||
.route-item__domains { color: var(--fonu-text); font-size: 12px; overflow-wrap: anywhere; }
|
.stat-card__value-total { margin-left: 2px; font-size: 15px; font-weight: 600; color: var(--fonu-text-muted); }
|
||||||
.route-item__upstream { color: var(--fonu-text-muted); font-size: 12px; }
|
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); }
|
||||||
.route-item__actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
|
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); }
|
||||||
.route-item__health { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
|
|
||||||
.health-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; }
|
/* FonuCard 自身已有 24px 内边距,这里不再叠加(与 FrpView 的用法一致) */
|
||||||
.health-ok { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
|
.section-card { margin-bottom: var(--fonu-space-5); }
|
||||||
.health-bad { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
|
.section-body { padding: 0; }
|
||||||
.route-item__traffic { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-3) var(--fonu-space-4); }
|
||||||
.traffic-metric { font-size: 11px; color: var(--fonu-text-secondary); }
|
/* Nginx 卡的 label/value 双列:只在本卡生效,避免影响弹窗里 n-form-item 的内部布局 */
|
||||||
.traffic-metric--error { color: #dc2626; }
|
.nginx-card .field-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.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; }
|
.nginx-card .field-grid > div { display: grid; gap: 4px; min-width: 0; }
|
||||||
.raw-routes { margin-top: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
|
.nginx-card .field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
|
||||||
.raw-route-row { padding: 2px 0; }
|
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
.conf-view { max-height: 60vh; }
|
.field-grid--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.proxy-modal { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); padding: var(--fonu-space-4) var(--fonu-space-5); box-shadow: var(--fonu-shadow-md); }
|
.full-width { width: 100%; }
|
||||||
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--fonu-space-3); }
|
.agent-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-3); }
|
||||||
.proxy-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-4); }
|
.nginx-error { margin-top: var(--fonu-space-2); color: var(--fonu-error); overflow-wrap: anywhere; }
|
||||||
.proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: 0 0 var(--fonu-space-3); margin-bottom: var(--fonu-space-2); border-bottom: 1px solid var(--fonu-border); }
|
|
||||||
.proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--fonu-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
|
/* 规则台账:与本地反向代理页一致,用 n-data-table 呈现(列宽固定,各行列天然对齐) */
|
||||||
|
/* 单元格元素由 render 函数的 h() 创建,不带 scoped 的 data-v 属性,必须用 :deep() 才能命中 */
|
||||||
|
.route-table-wrap { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
|
||||||
|
.route-table-wrap :deep(.cell-name) { display: grid; gap: 4px; }
|
||||||
|
.route-table-wrap :deep(.cell-name__head) { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.route-table-wrap :deep(.cell-name strong) { font-size: 13.5px; color: var(--fonu-text); }
|
||||||
|
.route-table-wrap :deep(.cell-muted) { font-size: 12px; color: var(--fonu-text-muted); }
|
||||||
|
.route-table-wrap :deep(.cell-wrap) { overflow-wrap: anywhere; }
|
||||||
|
.route-table-wrap :deep(.cell-health) { display: flex; gap: var(--fonu-space-2); flex-wrap: wrap; }
|
||||||
|
.route-table-wrap :deep(.cell-upstream) { display: grid; gap: 2px; }
|
||||||
|
.route-table-wrap :deep(.cell-time) { display: grid; gap: 2px; }
|
||||||
|
.route-table-wrap :deep(.cell-domains) { display: grid; gap: 2px; }
|
||||||
|
.route-table-wrap :deep(.cell-domain) { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||||
|
/* 单元格内容整体上下居中(行高被多行内容撑高时,按钮/文字不再贴顶) */
|
||||||
|
.route-table-wrap :deep(.n-data-table-td) { vertical-align: middle; }
|
||||||
|
.route-table-wrap :deep(.cell-name) { align-content: center; }
|
||||||
|
/* 域名不撑满整格:按钮紧跟在域名后面(不贴近右侧的协议列) */
|
||||||
|
.route-table-wrap :deep(.cell-domain .mono) { flex: 0 1 auto; min-width: 0; }
|
||||||
|
.route-table-wrap :deep(.cell-actions) { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.route-table-wrap :deep(.mono) { font-family: var(--fonu-mono); font-size: 13px; }
|
||||||
|
|
||||||
|
.raw-routes { margin-top: var(--fonu-space-4); font-size: 12px; color: var(--fonu-text-secondary); }
|
||||||
|
.raw-routes summary { cursor: pointer; color: var(--fonu-text-secondary); }
|
||||||
|
.raw-routes summary:hover { color: var(--fonu-text); }
|
||||||
|
.raw-routes__list { display: grid; gap: 6px; margin-top: var(--fonu-space-3); }
|
||||||
|
.raw-route-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.raw-route-row .mono { color: var(--fonu-text); }
|
||||||
|
|
||||||
|
/* 弹窗样式对齐本地反向代理页(ProxyView)的规则弹窗:900px 宽、无外层内边距、头部/tabbar/页脚各自带内边距 */
|
||||||
|
.proxy-modal { display: flex; flex-direction: column; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
|
||||||
|
/* 带右侧说明栏的弹窗:整体改为横向,左表单列 + 右说明栏 */
|
||||||
|
.proxy-modal--with-help { flex-direction: row; }
|
||||||
|
.proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; }
|
||||||
|
.proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--fonu-space-5); background: var(--fonu-bg-muted); border-left: 1px solid var(--fonu-border); font-size: 13px; color: var(--fonu-text-secondary); overflow: auto; }
|
||||||
|
.proxy-modal__help h4 { margin: 0 0 var(--fonu-space-3); font-size: 14px; font-weight: 600; color: var(--fonu-text); }
|
||||||
|
.proxy-modal__help ol { margin: 0; padding-left: 18px; line-height: 1.75; }
|
||||||
|
.proxy-modal__help li + li { margin-top: 10px; }
|
||||||
|
.proxy-modal__help code { font-size: 12px; background: var(--fonu-surface); padding: 1px 4px; border-radius: 4px; }
|
||||||
|
.proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--fonu-space-5); padding: 12px; border-radius: 8px; background: var(--fonu-info-soft); color: var(--fonu-info); font-size: 12px; line-height: 1.6; }
|
||||||
|
.proxy-modal__tip-icon { flex-shrink: 0; margin-top: 1px; font-size: 16px; }
|
||||||
|
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; flex-shrink: 0; }
|
||||||
|
.proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: var(--fonu-space-3) var(--fonu-space-5) 0; border-bottom: 1px solid var(--fonu-border); flex-shrink: 0; }
|
||||||
|
.proxy-modal__tab { margin: 0; padding: 8px 2px 10px; border: none; background: none; font: inherit; font-size: 14px; color: var(--fonu-text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||||
.proxy-modal__tab:hover { color: var(--fonu-text); }
|
.proxy-modal__tab:hover { color: var(--fonu-text); }
|
||||||
.proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
|
.proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
|
||||||
.proxy-modal__scroll { max-height: 60vh; overflow-y: auto; }
|
.proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; }
|
||||||
.proxy-modal__pane { width: 100%; }
|
.proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
|
||||||
.form-switch-list { display: grid; gap: 2px; margin-bottom: var(--fonu-space-3); }
|
.proxy-modal__pane :deep(.n-form) { width: 100%; }
|
||||||
.form-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
|
.proxy-modal__pane :deep(.n-form-item .n-form-item-blank) { display: block; width: 100%; }
|
||||||
.form-switch-row__label { font-size: 13px; color: var(--fonu-text); }
|
.modal-footer { display: flex; justify-content: flex-end; gap: var(--fonu-space-3); padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); border-top: 1px solid var(--fonu-border); margin-top: var(--fonu-space-2); flex-shrink: 0; }
|
||||||
.form-switch-row__hint { margin-top: 2px; font-size: 12px; color: var(--fonu-text-secondary); }
|
.form-switch-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: var(--fonu-space-4); }
|
||||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--fonu-space-4); }
|
.form-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); padding: 12px 14px; border: 1px solid var(--fonu-border); border-radius: 10px; background: var(--fonu-bg); }
|
||||||
.field-grid--three { grid-template-columns: 1fr 1fr 1fr; }
|
.form-switch-row__label { font-size: 14px; font-weight: 600; color: var(--fonu-text); }
|
||||||
.panel-title { margin: var(--fonu-space-2) 0; font-size: 13px; font-weight: 600; color: var(--fonu-text-secondary); }
|
.form-switch-row__hint { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); line-height: 1.5; }
|
||||||
.modal-title { margin: 0; font-size: 16px; font-weight: 700; color: var(--fonu-text); }
|
.field-hint { margin: 0; font-size: 12px; color: var(--fonu-text-muted); line-height: 1.5; }
|
||||||
|
.panel-title { margin: var(--fonu-space-4) 0 var(--fonu-space-3); font-size: 14px; font-weight: 600; color: var(--fonu-text); }
|
||||||
|
.modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--fonu-text); }
|
||||||
.code-block {
|
.code-block {
|
||||||
margin: 0; padding: var(--fonu-space-3) var(--fonu-space-4); background: var(--fonu-bg-muted);
|
margin: 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);
|
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);
|
font-family: var(--fonu-mono); font-size: 12.5px; line-height: 1.65; color: var(--fonu-text);
|
||||||
overflow: auto; white-space: pre-wrap; word-break: break-all;
|
overflow: auto; white-space: pre-wrap; word-break: break-all;
|
||||||
}
|
}
|
||||||
|
.conf-view { max-height: 60vh; }
|
||||||
|
.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; }
|
||||||
|
.proxy-remote-select { width: 220px; }
|
||||||
|
|
||||||
|
/* 首屏一次性入场;用户偏好减少动效时关闭 */
|
||||||
|
@keyframes fonu-fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.stats-row, .entrance-row, .section-card { animation: fonu-fade-up 160ms ease-out both; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stats-row, .entrance-row, .section-card { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.nginx-card .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
/* 台账为表格:窄屏靠 scroll-x 横向滚动,不改列 */
|
||||||
|
.proxy-modal__help { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.field-grid { grid-template-columns: 1fr; }
|
||||||
|
.field-grid--three { grid-template-columns: 1fr; }
|
||||||
|
.nginx-card .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.stats-row { grid-template-columns: 1fr; }
|
||||||
|
.nginx-card .field-grid { grid-template-columns: 1fr; }
|
||||||
|
.entrance-row { flex-direction: column; align-items: flex-start; }
|
||||||
|
.route-table-wrap :deep(.cell-actions) { width: 100%; justify-content: flex-start; }
|
||||||
|
.route-table-wrap :deep(.cell-actions .n-button) { min-height: 44px; }
|
||||||
|
.alert-body { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user