chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+583
View File
@@ -0,0 +1,583 @@
<template>
<PageHeader title="公网反代" description="管理 VPS 上 Nginx 反代:部署/移除穿透规则的公网入口,支持强制跳转、IP 白名单、Basic Auth 与 WebSocket">
<template #actions>
<n-select
v-model:value="selectedId"
:options="serverOptions"
placeholder="选择公网服务端"
style="width: 220px"
:disabled="servers.length === 0"
/>
<n-button :loading="refreshing" @click="refreshAll">刷新</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else>
<EmptyState
v-if="servers.length === 0"
title="尚未配置 FRP 服务端"
description="先在「内网穿透」页添加公网服务端并保存 Agent/SSH 信息,再回到本页管理公网反代。"
>
<template #action>
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
</template>
</EmptyState>
<template v-else-if="server">
<div v-if="agentStatus?.nginx_missing" class="nginx-alert">
<div class="nginx-alert__text">
<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>
</div>
<n-button size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
</div>
<div v-else-if="agentStatus && agentStatus.nginx_ok === false" class="nginx-alert nginx-alert--warn">
<div class="nginx-alert__text">
<strong>Nginx 配置校验未通过</strong>
<span class="mono">{{ agentStatus.nginx_error || 'nginx -t 执行失败' }}</span>
</div>
</div>
<FonuCard class="section-card" title="Nginx 状态">
<div class="section-body">
<div class="nginx-status-grid">
<div class="nginx-status-item">
<span class="nginx-status-item__label">状态</span>
<span class="nginx-status-item__value">
<n-tag :type="nginxState().type" size="small" :bordered="false">{{ nginxState().label }}</n-tag>
</span>
</div>
<div class="nginx-status-item">
<span class="nginx-status-item__label">进程</span>
<span class="nginx-status-item__value">{{ agentStatus?.nginx_running ? '运行中' : '未运行' }}</span>
</div>
<div class="nginx-status-item">
<span class="nginx-status-item__label">版本</span>
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_version || '—' }}</span>
</div>
<div class="nginx-status-item">
<span class="nginx-status-item__label">可执行文件</span>
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span>
</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>
</FonuCard>
<FonuCard class="section-card" title="穿透规则与公网反代状态">
<div class="section-body">
<p class="form-hint">穿透规则保存后不会自动修改 VPS。点击「部署反代」把该规则的域名发布到公网 Nginx;部署时自动推送证书并做回源自检。</p>
<div v-if="routeCandidates.length" class="route-list">
<div v-for="candidate in routeCandidates" :key="candidate.id" class="route-item">
<div class="route-item__main">
<strong>{{ candidate.name }}</strong>
<span class="route-item__domains mono">{{ candidate.custom_domains.join('、') }}</span>
<span class="route-item__upstream mono">{{ candidate.local_ip }}:{{ candidate.local_port }}(回源 127.0.0.1:{{ vhostPortOf(candidate) }})</span>
<div class="route-item__health">
<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>
</div>
<div class="route-item__traffic">
<span class="traffic-metric" title="frps 侧当前连接数">连接 {{ trafficOf(candidate).cur_conns }}</span>
<span class="traffic-metric" title="当前上传速率(近两次采样均值)">当前上传 {{ formatRate(trafficOf(candidate).traffic_in_rate) }}</span>
<span class="traffic-metric" title="当前下载速率(近两次采样均值)">当前下载 {{ formatRate(trafficOf(candidate).traffic_out_rate) }}</span>
<span class="traffic-metric" title="frps 侧当日累计上传">总上传 {{ formatBytes(trafficOf(candidate).traffic_in) }}</span>
<span class="traffic-metric" title="frps 侧当日累计下载">总下载 {{ formatBytes(trafficOf(candidate).traffic_out) }}</span>
<span v-if="trafficErrors[String(candidate.id)]" class="traffic-metric traffic-metric--error" :title="trafficErrors[String(candidate.id)]">流量采集失败</span>
</div>
</div>
<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">
<summary>VPS 上 agent 管理的路由文件({{ routeDetails.length }})</summary>
<div v-for="detail in routeDetails" :key="detail.domain" class="raw-route-row mono">
{{ detail.domain }} — {{ detail.loaded ? '已加载' : detail.syntax_ok ? '已写入未加载' : '语法错误' }}
</div>
</details>
</div>
</FonuCard>
<FonuCard class="section-card" title="使用说明">
<div class="section-body">
<p class="form-hint">部署选项(启用 HTTPS / 强制跳转 / WebSocket / 上传大小 / 代理超时 / IP 白名单与黑名单 / Basic Auth / 仅 TLS 1.3 / 安全响应头)在本页规则行的「部署设置」里配置,点「保存并重新部署」即生效。Basic Auth 密码留空时首次部署自动生成,写在生成的配置注释里(点「查看配置」可见)。注意:这些选项需要 VPS 上已升级到新版 agent。</p>
</div>
</FonuCard>
<n-modal
v-model:show="settingsModal"
:mask-closable="false"
style="width: 640px"
>
<div class="proxy-modal">
<div class="proxy-modal__header">
<h3 class="modal-title">部署设置 — {{ settingsTarget?.name }}</h3>
<n-button size="small" quaternary @click="settingsModal = false">取消</n-button>
</div>
<div class="proxy-modal__tabbar">
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'basic' }" @click="settingsTab = 'basic'">基础</button>
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'security' }" @click="settingsTab = 'security'">安全</button>
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">高级</button>
</div>
<div class="proxy-modal__scroll">
<n-form v-show="settingsTab === 'basic'" label-placement="top" class="proxy-modal__pane">
<div class="form-switch-list">
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">启用 HTTPS</div>
<div class="form-switch-row__hint">证书已部署时监听 443;关闭后仅保留 80 明文入口</div>
</div>
<n-switch v-model:value="settingsForm.https_enabled" size="small" />
</div>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">HTTP 强制跳转 HTTPS</div>
<div class="form-switch-row__hint">80 端口访问自动 301 到 HTTPS(需启用 HTTPS)</div>
</div>
<n-switch v-model:value="settingsForm.redirect_https" size="small" :disabled="!settingsForm.https_enabled" />
</div>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">WebSocket 支持</div>
<div class="form-switch-row__hint">附加 Upgrade / Connection 头,实时服务(家庭影院、监控等)需要</div>
</div>
<n-switch v-model:value="settingsForm.websocket" size="small" />
</div>
</div>
<div class="field-grid">
<n-form-item label="上传大小限制">
<n-input v-model:value="settingsForm.client_max_body_size" placeholder="留空用 Nginx 默认,如 50m" />
</n-form-item>
<n-form-item label="代理读超时">
<n-input v-model:value="settingsForm.proxy_read_timeout" placeholder="留空用 Nginx 默认,如 3600s" />
</n-form-item>
</div>
</n-form>
<n-form v-show="settingsTab === 'security'" label-placement="top" class="proxy-modal__pane">
<div class="panel-title">IP 访问控制</div>
<div class="field-grid">
<n-form-item label="IP 白名单(逗号分隔)">
<n-input v-model:value="settingsForm.allow_ips_text" placeholder="如 1.2.3.4,10.0.0.0/24,留空不限制" />
</n-form-item>
<n-form-item label="IP 黑名单(逗号分隔)">
<n-input v-model:value="settingsForm.deny_ips_text" placeholder="如 5.6.7.8,留空不限制" />
</n-form-item>
</div>
<div class="form-switch-list">
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">仅中国大陆 IP</div>
<div class="form-switch-row__hint">部署时自动把本地维护的中国 IP 段下发到 VPS;非大陆 IP 返回 403</div>
</div>
<n-switch v-model:value="settingsForm.china_only" size="small" />
</div>
</div>
<div class="panel-title">认证</div>
<div class="field-grid">
<n-form-item label="Basic Auth 用户名">
<n-input v-model:value="settingsForm.basic_auth_user" placeholder="留空不启用" />
</n-form-item>
<n-form-item label="Basic Auth 密码">
<n-input v-model:value="settingsForm.basic_auth_password" type="password" show-password-on="click" placeholder="留空则首次部署自动生成" />
</n-form-item>
</div>
<div class="panel-title">流量控制</div>
<div class="field-grid field-grid--three">
<n-form-item label="请求限流(次/秒)">
<n-input-number v-model:value="settingsForm.rate_limit_rate" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
</n-form-item>
<n-form-item label="突发允许量">
<n-input-number v-model:value="settingsForm.rate_limit_burst" :min="0" :max="100000" placeholder="留空按 0" style="width: 100%" />
</n-form-item>
<n-form-item label="连接数上限(每 IP)">
<n-input-number v-model:value="settingsForm.conn_limit_max" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
</n-form-item>
</div>
</n-form>
<n-form v-show="settingsTab === 'advanced'" label-placement="top" class="proxy-modal__pane">
<div class="form-switch-list">
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">安全响应头</div>
<div class="form-switch-row__hint">HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy</div>
</div>
<n-switch v-model:value="settingsForm.security_headers" size="small" />
</div>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">仅 TLS 1.3</div>
<div class="form-switch-row__hint">只允许 TLS 1.3 握手(需启用 HTTPS)</div>
</div>
<n-switch v-model:value="settingsForm.tls13_only" size="small" :disabled="!settingsForm.https_enabled" />
</div>
</div>
</n-form>
</div>
<div class="proxy-modal__footer">
<n-button size="small" @click="settingsModal = false">取消</n-button>
<n-button size="small" type="primary" :loading="routeActionId === settingsTarget?.id" @click="saveDeploySettings">保存并重新部署</n-button>
</div>
</div>
</n-modal>
<n-modal
v-model:show="confModal"
:mask-closable="true"
style="width: 720px"
>
<div class="proxy-modal">
<div class="proxy-modal__header">
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
</div>
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
</div>
</n-modal>
</template>
</template>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
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 { api } from '../api/client'
import type { FrpAgentRouteDetail, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer } from '../api/types'
import { formatBytes, formatRate } from '../utils/format'
const message = useMessage()
const servers = ref<FrpServer[]>([])
const selectedId = ref<number | null>(null)
const loadError = ref('')
const loadErrorMsg = ref('')
const refreshing = ref(false)
const routeCandidates = ref<FrpProxy[]>([])
const routeDetails = ref<FrpAgentRouteDetail[]>([])
const routeHealth = ref<import('../api/types').FrpAgentRouteHealth[]>([])
const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
const trafficErrors = ref<Record<string, string>>({})
const agentStatus = ref<FrpAgentStatus | null>(null)
const installingNginx = ref(false)
const routeActionId = ref<number | null>(null)
const settingsModal = ref(false)
const settingsTarget = ref<FrpProxy | null>(null)
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
const settingsForm = ref({
websocket: false,
// 存储用反向字段 https_disabled(缺省 = 启用 HTTPS,保持旧数据行为)
https_enabled: true,
redirect_https: false,
client_max_body_size: '',
proxy_read_timeout: '',
allow_ips_text: '',
deny_ips_text: '',
basic_auth_user: '',
basic_auth_password: '',
security_headers: false,
tls13_only: false,
china_only: false,
rate_limit_rate: null as number | null,
rate_limit_burst: null as number | null,
conn_limit_max: null as number | null,
})
const confLoadingId = ref<number | null>(null)
const confModal = ref(false)
const confDomain = ref('')
const confContent = ref('')
let timer: number | undefined
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 })))
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
}
await refreshAll()
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
async function refreshAll() {
const current = server.value
if (!current) return
refreshing.value = true
try {
routeCandidates.value = await api.getFrpAgentRouteCandidates(current.id)
routeDetails.value = await api.getFrpAgentRoutes(current.id)
routeHealth.value = await api.getFrpAgentRouteHealth(current.id)
// 流量按规则 ID 聚合(frps dashboard);旧版 frps 会在后端回退为逐规则查询
const traffic = await api.listFrpProxiesTraffic()
proxyTraffic.value = traffic.items ?? {}
trafficErrors.value = traffic.errors ?? {}
// agent 状态用于 Nginx 可用性提示(nginx_missing / nginx_ok)
try { agentStatus.value = await api.getFrpAgentStatus(current.id) } catch { agentStatus.value = null }
} catch (error) { loadErrorMsg.value = error instanceof Error ? error.message : '读取规则失败' }
finally { refreshing.value = false }
}
function vhostPortOf(candidate: FrpProxy) {
const port = server.value?.options.vhost_http_port || 8080
return candidate.type === 'https' ? (server.value?.options.vhost_https_port || 8443) : port
}
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
function stateOf(candidate: FrpProxy): RouteState {
for (const domain of candidate.custom_domains) {
const d = routeDetails.value.find((item) => item.domain === domain)
if (d) {
if (d.loaded) return 'loaded'
if (d.syntax_ok) return 'written'
return 'broken'
}
}
return 'none'
}
function stateMeta(candidate: FrpProxy): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
switch (stateOf(candidate)) {
case 'loaded': return { label: '已生效', type: 'success' }
case 'written': return { label: '已写入未加载', type: 'warning' }
case 'broken': return { label: '语法错误', type: 'error' }
default: return { label: '未部署', type: 'default' }
}
}
async function deployRoute(candidate: FrpProxy) {
const current = server.value
if (!current) return
routeActionId.value = candidate.id
try {
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) {
const current = server.value
if (!current) return
routeActionId.value = candidate.id
try {
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) {
settingsTarget.value = candidate
settingsTab.value = 'basic'
settingsForm.value = {
websocket: candidate.options?.websocket ?? false,
https_enabled: !(candidate.options?.https_disabled ?? false),
redirect_https: candidate.options?.redirect_https ?? false,
client_max_body_size: candidate.options?.client_max_body_size ?? '',
proxy_read_timeout: candidate.options?.proxy_read_timeout ?? '',
allow_ips_text: (candidate.options?.allow_ips || []).join(','),
deny_ips_text: (candidate.options?.deny_ips || []).join(','),
basic_auth_user: candidate.options?.basic_auth_user ?? '',
basic_auth_password: candidate.options?.basic_auth_password ?? '',
security_headers: candidate.options?.security_headers ?? false,
tls13_only: candidate.options?.tls13_only ?? false,
china_only: candidate.options?.china_only ?? false,
rate_limit_rate: candidate.options?.rate_limit_rate ?? null,
rate_limit_burst: candidate.options?.rate_limit_burst ?? null,
conn_limit_max: candidate.options?.conn_limit_max ?? null,
}
settingsModal.value = true
}
async function saveDeploySettings() {
const target = settingsTarget.value
const current = server.value
if (!target || !current) return
routeActionId.value = target.id
try {
// 选项存在规则的 options 里(与穿透配置同库),更新后立即重部署生效
const options = {
...target.options,
websocket: settingsForm.value.websocket,
https_disabled: !settingsForm.value.https_enabled,
redirect_https: settingsForm.value.redirect_https,
client_max_body_size: settingsForm.value.client_max_body_size.trim(),
proxy_read_timeout: settingsForm.value.proxy_read_timeout.trim(),
allow_ips: settingsForm.value.allow_ips_text.split(',').map((s) => s.trim()).filter(Boolean),
deny_ips: settingsForm.value.deny_ips_text.split(',').map((s) => s.trim()).filter(Boolean),
basic_auth_user: settingsForm.value.basic_auth_user.trim(),
basic_auth_password: settingsForm.value.basic_auth_password,
security_headers: settingsForm.value.security_headers,
tls13_only: settingsForm.value.tls13_only,
china_only: settingsForm.value.china_only,
rate_limit_rate: settingsForm.value.rate_limit_rate ?? 0,
rate_limit_burst: settingsForm.value.rate_limit_burst ?? 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 })
settingsModal.value = false
message.success('部署设置已保存,正在重新部署…')
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() {
const current = server.value
if (!current) return
installingNginx.value = true
try {
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)}`)
}
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) {
const current = server.value
if (!current) return
confLoadingId.value = candidate.id
try {
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
confDomain.value = conf.domain
confContent.value = conf.content
confModal.value = true
} catch (error) { message.error(error instanceof Error ? error.message : '读取配置失败') }
finally { confLoadingId.value = null }
}
onMounted(() => {
void load()
timer = window.setInterval(() => { if (server.value && !refreshing.value) void refreshAll() }, 15000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
</script>
<style scoped>
.section-card { margin-bottom: var(--fonu-space-4); }
.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); }
.nginx-alert--warn { border-color: #e79b9b; background: rgba(231, 155, 155, 0.12); }
.nginx-alert__text { display: grid; gap: 4px; font-size: 13px; color: var(--fonu-text); }
.nginx-alert__text code { padding: 1px 5px; border-radius: 4px; background: var(--fonu-bg-muted); font-family: var(--fonu-mono); font-size: 12px; }
.nginx-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px var(--fonu-space-4); }
.nginx-status-item { display: grid; gap: 3px; min-width: 0; }
.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; }
.nginx-status-error { margin-top: var(--fonu-space-3); color: #dc2626; }
.nginx-status-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-3); }
.section-body { padding: var(--fonu-space-2) var(--fonu-space-5) 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; }
.route-item__health { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
.health-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; }
.health-ok { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.health-bad { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
.route-item__traffic { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.traffic-metric { font-size: 11px; color: var(--fonu-text-secondary); }
.traffic-metric--error { color: #dc2626; }
.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; }
.raw-routes { margin-top: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
.raw-route-row { padding: 2px 0; }
.conf-view { max-height: 60vh; }
.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); }
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--fonu-space-3); }
.proxy-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-4); }
.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; }
.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__scroll { max-height: 60vh; overflow-y: auto; }
.proxy-modal__pane { width: 100%; }
.form-switch-list { display: grid; gap: 2px; margin-bottom: var(--fonu-space-3); }
.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); }
.form-switch-row__label { font-size: 13px; color: var(--fonu-text); }
.form-switch-row__hint { margin-top: 2px; font-size: 12px; color: var(--fonu-text-secondary); }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--fonu-space-4); }
.field-grid--three { grid-template-columns: 1fr 1fr 1fr; }
.panel-title { margin: var(--fonu-space-2) 0; font-size: 13px; font-weight: 600; color: var(--fonu-text-secondary); }
.modal-title { margin: 0; font-size: 16px; font-weight: 700; color: var(--fonu-text); }
.code-block {
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);
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;
}
</style>