Files
-Fonu/web/src/views/FrpView-1.0.2.vue
T
mrlee 278f8267b7 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
2026-09-23 09:40:59 +08:00

1100 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageHeader
title="内网穿透"
description="通过 VPS + FRP,让你即使没有公网 IP,也能随时访问 NAS 上的服务。"
>
<template #actions>
<n-button quaternary @click="showHelp = true">
<template #icon><n-icon :component="HelpCircleOutline" /></template>
使用说明
</n-button>
</template>
</PageHeader>
<LoadError v-if="pageError" :message="pageError" @retry="load" />
<n-spin v-else :show="loading">
<div class="frp-page">
<div class="top-grid">
<FonuCard class="status-card" flush>
<div class="status-card__body">
<div class="status-hero" :class="statusHeroClass">
<div class="status-hero__main">
<div class="status-hero__icon-wrap">
<n-icon :component="statusHeroIcon" />
</div>
<div>
<div class="status-hero__title">{{ connectionTitle }}</div>
<div class="status-hero__sub">{{ connectionSubtitle }}</div>
</div>
</div>
<n-button size="small" secondary :disabled="!frpForm.enabled" @click="openLogs">
<template #icon><n-icon :component="DocumentTextOutline" /></template>
查看日志
</n-button>
</div>
<div class="status-flow">
<span class="status-flow__node">外网用户</span>
<span class="status-flow__arrow">→</span>
<span class="status-flow__node status-flow__node--accent">frps</span>
<span class="status-flow__arrow">→</span>
<span class="status-flow__node">frpc</span>
<span class="status-flow__arrow">→</span>
<span class="status-flow__node">Fonu Nginx</span>
</div>
<div class="status-tags">
<span class="status-tag" :class="{ 'status-tag--on': frpForm.enabled }">
{{ frpForm.enabled ? '穿透已启用' : '穿透未启用' }}
</span>
<span class="status-tag" :class="{ 'status-tag--on': frpForm.tls_enabled }">
TLS {{ frpForm.tls_enabled ? '已开启' : '未开启' }}
</span>
<span class="status-tag" :class="{ 'status-tag--on': domainCount > 0 }">
{{ domainCount }} 个域名
</span>
</div>
<div class="status-metrics">
<div class="status-metric">
<div class="status-metric__label">服务器地址</div>
<code class="status-metric__value">{{ displayServerEndpoint }}</code>
</div>
<div class="status-metric">
<div class="status-metric__label">连接时间</div>
<div class="status-metric__value">{{ connectedAtLabel }}</div>
</div>
<div class="status-metric">
<div class="status-metric__label">运行时长</div>
<div class="status-metric__value">{{ uptimeLabel }}</div>
</div>
<div class="status-metric">
<div class="status-metric__label">客户端版本</div>
<div class="status-metric__value">{{ frpStatus.client_version || '—' }}</div>
</div>
</div>
<div class="status-gateways">
<div class="status-gateway" :class="{ 'status-gateway--on': httpGatewayOn }">
<span class="status-gateway__dot" />
<div>
<div class="status-gateway__title">HTTP 网关</div>
<div class="status-gateway__hint">{{ httpGatewayOn ? '转发到 Nginx HTTP' : '等待配置域名' }}</div>
</div>
</div>
<div class="status-gateway" :class="{ 'status-gateway--on': httpsGatewayOn }">
<span class="status-gateway__dot" />
<div>
<div class="status-gateway__title">HTTPS 网关</div>
<div class="status-gateway__hint">{{ httpsGatewayOn ? '转发到 Nginx HTTPS' : '等待配置域名' }}</div>
</div>
</div>
</div>
<div v-if="domainPreview.length" class="status-domains">
<div class="status-domains__label">穿透域名</div>
<div class="status-domains__list">
<span v-for="domain in domainPreview" :key="domain" class="status-domain-chip">{{ domain }}</span>
<span v-if="domainCount > domainPreview.length" class="status-domain-more">
+{{ domainCount - domainPreview.length }}
</span>
</div>
</div>
</div>
</FonuCard>
<FonuCard subtitle="填写你的 FRP 服务器信息,保存后即可连接。">
<template #title>
<n-icon :component="SettingsOutline" class="card-title-icon" />
<span>连接设置</span>
</template>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">启用内网穿透</div>
</div>
<n-switch v-model:value="frpForm.enabled" />
</div>
<div class="settings-fields">
<div class="settings-field">
<div class="settings-field__label">服务器地址 <span class="required">*</span></div>
<n-input
v-model:value="frpForm.server_addr"
placeholder="127.0.0.1 或 frp.example.com"
:disabled="!frpForm.enabled"
/>
</div>
<div class="settings-field">
<div class="settings-field__label">服务器端口 <span class="required">*</span></div>
<n-input-number
v-model:value="frpForm.server_port"
:min="1"
:max="65535"
class="settings-field__input-full"
:disabled="!frpForm.enabled"
/>
</div>
<div class="settings-field">
<div class="settings-field__label frp-secret-label">
<span>Token <span class="required">*</span></span>
<n-tag v-if="frpForm.has_auth_token" size="small" type="success" :bordered="false">
{{ CONFIGURED_SECRET_TAG }}
</n-tag>
</div>
<n-input
v-model:value="frpForm.auth_token"
type="password"
show-password-on="click"
:disabled="!frpForm.enabled"
:placeholder="frpForm.has_auth_token ? CONFIGURED_SECRET_PLACEHOLDER : '与 frps 配置一致'"
/>
</div>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">启用 TLS</div>
<div class="form-switch-row__hint">使用 TLS 连接到 frps,建议开启</div>
</div>
<n-switch v-model:value="frpForm.tls_enabled" :disabled="!frpForm.enabled" />
</div>
</div>
<div class="save-row">
<n-button type="primary" :loading="saving" @click="save">
<template #icon><n-icon :component="LinkOutline" /></template>
{{ frpForm.enabled ? '保存并连接' : '保存并关闭' }}
</n-button>
</div>
</FonuCard>
</div>
<FonuCard>
<template #title>
<n-icon :component="GlobeOutline" class="card-title-icon card-title-icon--blue" />
<span>Web 穿透</span>
</template>
<template #header>
<n-button size="small" :loading="syncing" @click="syncDomains">
<template #icon><n-icon :component="RefreshOutline" /></template>
同步域名
</n-button>
</template>
<p class="section-desc">
将当前启用的反向代理域名同步到 FRP Web 网关,所有域名将通过 Fonu Nginx 进行分流。点击「已同步域名」可查看或编辑。
</p>
<div class="gateway-grid">
<div class="gateway-item">
<span class="gateway-dot" :class="{ 'gateway-dot--on': httpGatewayOn }" />
<div>
<div class="gateway-item__title">HTTP 网关</div>
<div class="gateway-item__state">{{ httpGatewayOn ? '已启用' : '未启用' }}</div>
<div class="gateway-item__hint">转发到 Fonu Nginx (HTTP)</div>
</div>
</div>
<div class="gateway-item">
<span class="gateway-dot" :class="{ 'gateway-dot--on': httpsGatewayOn }" />
<div>
<div class="gateway-item__title">HTTPS 网关</div>
<div class="gateway-item__state">{{ httpsGatewayOn ? '已启用' : '未启用' }}</div>
<div class="gateway-item__hint">转发到 Fonu Nginx (HTTPS)</div>
</div>
</div>
<button type="button" class="gateway-item gateway-item--action" @click="showDomains = true">
<n-icon :component="LinkOutline" class="gateway-item__icon" />
<div>
<div class="gateway-item__title">已同步域名</div>
<div class="gateway-item__state gateway-item__state--count">{{ domainCount }}</div>
</div>
<n-icon :component="ChevronForwardOutline" class="gateway-item__chevron" />
</button>
</div>
</FonuCard>
<FonuCard>
<template #title>
<n-icon :component="OptionsOutline" class="card-title-icon" />
<span>高级设置</span>
</template>
<p class="section-desc">
在 VPS 部署 frps 后,将下方配置保存为 <code>frps.toml</code> 并启动。配置会随上方连接设置实时更新,保存 Fonu 后同步生效。
</p>
<div class="settings-field">
<div class="settings-field__label">VPS 服务端配置 (frps.toml)</div>
<n-input type="textarea" :rows="12" readonly :value="frpsConfigPreview" class="frps-config-input" />
<p class="field-hint field-hint--inline">
网关端口与 Fonu Nginx 一致(HTTP {{ nginxHttpPort }} / HTTPS {{ nginxHttpsPort }})。VPS 防火墙需放行
{{ frpForm.server_port || 7000 }}、{{ nginxHttpPort }}、{{ nginxHttpsPort }}。
</p>
<div class="frps-config-actions">
<n-button secondary @click="copyFrpsConfig">复制配置</n-button>
<n-button quaternary @click="openLogs">
<template #icon><n-icon :component="TerminalOutline" /></template>
查看 frpc 日志
</n-button>
</div>
</div>
</FonuCard>
</div>
</n-spin>
<n-modal v-model:show="showHelp" preset="card" title="内网穿透使用说明" style="width: min(640px, 92vw)">
<div class="help-content">
<p>1. 在 VPS 部署 frps,放行 7000、80、443 端口。</p>
<p>2. 在本页填写 frps 地址、端口与 Token,保存并连接。</p>
<p>3. 点击「同步域名」,从反代规则导入域名到 FRP Web 网关。</p>
<p>4. 在 <router-link :to="{ name: 'ddns' }">DDNS</router-link> 将域名解析到 VPS 公网 IP。</p>
<p>5. HTTPS 证书推荐继续使用 DNS-01,由 Fonu Nginx 终结 TLS。</p>
</div>
</n-modal>
<n-modal v-model:show="showDomains" preset="card" title="穿透域名" style="width: min(520px, 92vw)">
<n-input
v-model:value="frpDomainsText"
type="textarea"
:rows="8"
placeholder="每行一个域名,支持 *.example.com"
:disabled="!frpForm.enabled"
/>
<p class="field-hint field-hint--inline">可手动编辑,或点击「同步域名」从已启用的反向代理规则导入。</p>
<div class="modal-actions">
<n-button @click="showDomains = false">取消</n-button>
<n-button type="primary" :loading="saving" :disabled="!frpForm.enabled" @click="saveDomains">保存</n-button>
</div>
</n-modal>
<n-modal v-model:show="showLogs" preset="card" title="frpc 运行日志" style="width: min(860px, 92vw)">
<n-spin :show="loadingLogs">
<pre class="log-view">{{ logText || '暂无日志' }}</pre>
</n-spin>
<n-button class="modal-action" quaternary :loading="loadingLogs" @click="fetchLogs">刷新</n-button>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
NButton,
NIcon,
NInput,
NInputNumber,
NModal,
NSpin,
NSwitch,
NTag,
useMessage,
} from 'naive-ui'
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
import {
AlertCircleOutline,
CheckmarkCircleOutline,
ChevronForwardOutline,
DocumentTextOutline,
GlobeOutline,
HelpCircleOutline,
LinkOutline,
OptionsOutline,
PauseCircleOutline,
RefreshOutline,
SettingsOutline,
TerminalOutline,
} from '@vicons/ionicons5'
import { api } from '../api/client'
import type { FRPStatus } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import { formatDate } from '../utils/format'
const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const syncing = ref(false)
const loadingLogs = ref(false)
const pageError = ref('')
const showHelp = ref(false)
const showDomains = ref(false)
const showLogs = ref(false)
const frpForm = reactive({
enabled: false,
server_addr: '',
server_port: 7000,
auth_token: '',
has_auth_token: false,
tls_enabled: false,
})
const frpDomainsText = ref('')
const frpsConfigSaved = ref('')
const nginxHttpPort = ref(80)
const nginxHttpsPort = ref(443)
const frpStatus = ref<FRPStatus>({ enabled: false, connected: false, message: '未启用' })
const logLines = ref<string[]>([])
const domainCount = computed(() => parseDomains().length)
const logText = computed(() => logLines.value.join('\n'))
const httpGatewayOn = computed(() => frpForm.enabled && (frpStatus.value.http_gateway_enabled ?? false))
const httpsGatewayOn = computed(() => frpForm.enabled && (frpStatus.value.https_gateway_enabled ?? false))
const statusHeroClass = computed(() => {
if (!frpForm.enabled) return 'status-hero--off'
if (frpStatus.value.connected) return 'status-hero--on'
return 'status-hero--warn'
})
const statusHeroIcon = computed(() => {
if (!frpForm.enabled) return PauseCircleOutline
if (frpStatus.value.connected) return CheckmarkCircleOutline
return AlertCircleOutline
})
const displayServerEndpoint = computed(() => {
if (frpStatus.value.server_endpoint) return frpStatus.value.server_endpoint
const addr = frpForm.server_addr.trim()
if (!addr) return '—'
const port = frpForm.server_port > 0 ? frpForm.server_port : 7000
return `${addr}:${port}`
})
const domainPreview = computed(() => parseDomains().slice(0, 3))
const frpsConfigPreview = computed(() =>
buildFrpsConfig({
bindPort: frpForm.server_port > 0 ? frpForm.server_port : 7000,
httpPort: nginxHttpPort.value,
httpsPort: nginxHttpsPort.value,
token: resolveFrpsTokenPreview(),
}),
)
const connectionTitle = computed(() => {
if (!frpForm.enabled) return '未启用'
if (frpStatus.value.connected) return '已连接'
return frpStatus.value.message || '未连接'
})
const connectionSubtitle = computed(() => {
if (!frpForm.enabled) return '内网穿透未开启'
if (frpStatus.value.connected) return 'FRP 客户端运行正常'
return '请检查服务器配置或查看运行日志'
})
const connectedAtLabel = computed(() => {
if (!frpStatus.value.connected_at) return '—'
return formatDate(frpStatus.value.connected_at)
})
const uptimeLabel = computed(() => formatUptime(frpStatus.value.uptime_seconds ?? 0))
function formatUptime(seconds: number) {
if (!seconds || seconds <= 0) return '—'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days} 天 ${hours} 小时`
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`
return `${minutes} 分钟`
}
function buildFrpsConfig(opts: { bindPort: number; httpPort: number; httpsPort: number; token: string }) {
const token = opts.token.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `bindAddr = "0.0.0.0"
bindPort = ${opts.bindPort}
vhostHTTPPort = ${opts.httpPort}
vhostHTTPSPort = ${opts.httpsPort}
[auth]
method = "token"
token = "${token}"
`
}
function resolveFrpsTokenPreview() {
const typed = frpForm.auth_token.trim()
if (typed) return typed
if (frpForm.has_auth_token && frpsConfigSaved.value) {
const match = frpsConfigSaved.value.match(/token\s*=\s*"([^"]*)"/)
if (match?.[1]) return match[1]
}
return '请填写 Token 后保存'
}
function applyFrpsMeta(data: { frps_config?: string; nginx_http_port?: number; nginx_https_port?: number }) {
frpsConfigSaved.value = data.frps_config ?? ''
if (data.nginx_http_port && data.nginx_http_port > 0) nginxHttpPort.value = data.nginx_http_port
if (data.nginx_https_port && data.nginx_https_port > 0) nginxHttpsPort.value = data.nginx_https_port
}
function tokenForSave() {
const token = frpForm.auth_token.trim()
if (!token) return undefined
return token
}
function applyData(data: Awaited<ReturnType<typeof api.getFRP>>) {
frpForm.enabled = data.enabled
frpForm.server_addr = data.server_addr ?? ''
frpForm.server_port = data.server_port > 0 ? data.server_port : 7000
frpForm.has_auth_token = data.has_auth_token
frpForm.auth_token = ''
frpForm.tls_enabled = data.tls_enabled
frpDomainsText.value = (data.custom_domains ?? []).join('\n')
applyFrpsMeta(data)
frpStatus.value = data.status
}
async function load() {
loading.value = true
pageError.value = ''
try {
applyData(await api.getFRP())
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} finally {
loading.value = false
}
}
function normalizeServerAddr(addr: string) {
return addr
.trim()
.replace(/^https?:\/\//i, '')
.replace(/[/?#].*$/, '')
.trim()
}
function parseDomains() {
return frpDomainsText.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function ensureDomains() {
const existing = parseDomains()
if (existing.length > 0) return existing
try {
const result = await api.syncFRPDomains()
frpDomainsText.value = (result.domains ?? []).join('\n')
return result.domains ?? []
} catch {
return []
}
}
async function save(): Promise<boolean> {
saving.value = true
try {
frpForm.server_addr = normalizeServerAddr(frpForm.server_addr)
let domains = parseDomains()
if (frpForm.enabled && domains.length === 0) {
domains = await ensureDomains()
}
if (frpForm.enabled && domains.length === 0) {
message.warning('请填写穿透域名,或先在反向代理中启用规则后点击「同步域名」')
return false
}
const result = await api.saveFRP({
enabled: frpForm.enabled,
server_addr: frpForm.server_addr,
server_port: frpForm.server_port || 7000,
auth_token: tokenForSave(),
tls_enabled: frpForm.tls_enabled,
custom_domains: domains,
})
frpForm.has_auth_token = result.config.has_auth_token
frpForm.auth_token = ''
frpDomainsText.value = (result.config.custom_domains ?? []).join('\n')
applyFrpsMeta(result)
frpStatus.value = result.status
message.success(result.message || (frpForm.enabled ? '已保存并连接' : '已关闭内网穿透'))
return true
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
return false
} finally {
saving.value = false
}
}
async function saveDomains() {
if (await save()) {
showDomains.value = false
}
}
async function syncDomains() {
syncing.value = true
try {
const result = await api.syncFRPDomains()
frpDomainsText.value = (result.domains ?? []).join('\n')
applyFrpsMeta(result)
frpStatus.value = result.status
message.success(result.message || `已同步 ${result.domains.length} 个域名`)
} catch (error) {
message.error(error instanceof Error ? error.message : '同步失败')
} finally {
syncing.value = false
}
}
async function fetchLogs() {
loadingLogs.value = true
try {
logLines.value = await api.getFRPLogs(300)
} catch (error) {
message.error(error instanceof Error ? error.message : '读取日志失败')
} finally {
loadingLogs.value = false
}
}
async function openLogs() {
showLogs.value = true
await fetchLogs()
}
async function copyFrpsConfig() {
const typed = frpForm.auth_token.trim()
if (!typed && !frpForm.has_auth_token) {
message.warning('请先填写 Token 并保存')
return
}
const text =
!typed && frpForm.has_auth_token && frpsConfigSaved.value
? frpsConfigSaved.value
: frpsConfigPreview.value
try {
await navigator.clipboard.writeText(text)
message.success('已复制 frps 配置')
} catch {
message.error('复制失败')
}
}
onMounted(load)
</script>
<style scoped>
.frp-page {
display: flex;
flex-direction: column;
gap: var(--fonu-space-5);
}
.top-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
gap: var(--fonu-space-5);
align-items: stretch;
}
.card-title-icon {
font-size: 18px;
margin-right: 8px;
color: var(--fonu-text-secondary);
vertical-align: -3px;
}
.card-title-icon--blue {
color: #3b82f6;
}
.status-card :deep(.fonu-card__body) {
height: 100%;
}
.status-card__body {
display: flex;
flex-direction: column;
gap: var(--fonu-space-4);
height: 100%;
padding: var(--fonu-space-5);
}
.status-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: 16px;
border-radius: 12px;
border: 1px solid var(--fonu-border);
background: var(--fonu-bg);
}
.status-hero--on {
border-color: color-mix(in srgb, #10b981 35%, var(--fonu-border));
background: color-mix(in srgb, #10b981 6%, var(--fonu-bg));
}
.status-hero--warn {
border-color: color-mix(in srgb, #f59e0b 35%, var(--fonu-border));
background: color-mix(in srgb, #f59e0b 6%, var(--fonu-bg));
}
.status-hero--off {
border-color: var(--fonu-border);
background: color-mix(in srgb, #94a3b8 6%, var(--fonu-bg));
}
.status-hero__main {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.status-hero__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--fonu-surface);
color: var(--fonu-text-secondary);
font-size: 22px;
flex-shrink: 0;
}
.status-hero--on .status-hero__icon-wrap {
color: #10b981;
}
.status-hero--warn .status-hero__icon-wrap {
color: #f59e0b;
}
.status-hero__title {
font-size: 20px;
font-weight: 700;
color: var(--fonu-text);
line-height: 1.2;
}
.status-hero__sub {
margin-top: 4px;
font-size: 13px;
color: var(--fonu-text-muted);
line-height: 1.5;
}
.status-flow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 10px 12px;
border-radius: 10px;
background: color-mix(in srgb, #3b82f6 5%, var(--fonu-bg));
border: 1px dashed color-mix(in srgb, #3b82f6 20%, var(--fonu-border));
font-size: 12px;
color: var(--fonu-text-secondary);
}
.status-flow__node {
padding: 2px 8px;
border-radius: 999px;
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
}
.status-flow__node--accent {
color: #2563eb;
border-color: color-mix(in srgb, #3b82f6 30%, var(--fonu-border));
}
.status-flow__arrow {
color: var(--fonu-text-muted);
}
.status-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-tag {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
color: var(--fonu-text-muted);
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
}
.status-tag--on {
color: #047857;
background: color-mix(in srgb, #10b981 8%, var(--fonu-bg));
border-color: color-mix(in srgb, #10b981 25%, var(--fonu-border));
}
.status-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.status-metric {
padding: 12px;
border-radius: 10px;
border: 1px solid var(--fonu-border);
background: var(--fonu-bg);
}
.status-metric__label {
font-size: 12px;
color: var(--fonu-text-muted);
}
.status-metric__value {
margin-top: 6px;
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
word-break: break-all;
}
.status-gateways {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.status-gateway {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--fonu-border);
background: var(--fonu-bg);
}
.status-gateway--on {
border-color: color-mix(in srgb, #10b981 25%, var(--fonu-border));
}
.status-gateway__dot {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
background: #cbd5e1;
flex-shrink: 0;
}
.status-gateway--on .status-gateway__dot {
background: #10b981;
}
.status-gateway__title {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text);
}
.status-gateway__hint {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
}
.status-domains {
margin-top: auto;
padding-top: 4px;
}
.status-domains__label {
font-size: 12px;
color: var(--fonu-text-muted);
margin-bottom: 8px;
}
.status-domains__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-domain-chip {
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
color: #1d4ed8;
background: color-mix(in srgb, #3b82f6 8%, var(--fonu-bg));
border: 1px solid color-mix(in srgb, #3b82f6 18%, var(--fonu-border));
}
.status-domain-more {
padding: 4px 10px;
font-size: 12px;
color: var(--fonu-text-muted);
}
.section-desc {
margin: 0 0 var(--fonu-space-4);
font-size: 13px;
line-height: 1.6;
color: var(--fonu-text-muted);
}
.field-hint--inline {
margin: 8px 0 var(--fonu-space-4);
font-size: 12px;
line-height: 1.6;
color: var(--fonu-text-muted);
}
.settings-fields {
display: flex;
flex-direction: column;
gap: 18px;
margin-top: var(--fonu-space-4);
}
.settings-field__label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--fonu-text);
}
.frp-secret-label {
display: flex;
align-items: center;
gap: 8px;
}
.settings-field__input-full {
width: 100%;
}
.required {
color: #ef4444;
}
.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);
}
.form-switch-row__label {
font-size: 14px;
font-weight: 500;
color: var(--fonu-text);
}
.form-switch-row__hint {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
}
.save-row {
display: flex;
justify-content: flex-end;
margin-top: var(--fonu-space-5);
}
.gateway-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--fonu-space-4);
}
.gateway-item {
display: flex;
align-items: flex-start;
gap: 12px;
min-height: 92px;
padding: 16px;
border: 1px solid var(--fonu-border);
border-radius: 12px;
background: var(--fonu-bg);
text-align: left;
}
.gateway-item--action {
align-items: center;
cursor: pointer;
transition: border-color 0.15s;
font: inherit;
color: inherit;
}
.gateway-item--action:hover {
border-color: color-mix(in srgb, #3b82f6 35%, var(--fonu-border));
}
.gateway-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background: #cbd5e1;
flex-shrink: 0;
}
.gateway-dot--on {
background: #10b981;
}
.gateway-item__icon {
font-size: 18px;
color: #3b82f6;
}
.gateway-item__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
}
.gateway-item__state {
margin-top: 4px;
font-size: 13px;
color: var(--fonu-text-secondary);
}
.gateway-item__state--count {
font-size: 24px;
font-weight: 700;
color: var(--fonu-text);
}
.gateway-item__hint {
margin-top: 4px;
font-size: 12px;
color: var(--fonu-text-muted);
}
.gateway-item__chevron {
margin-left: auto;
color: var(--fonu-text-muted);
}
.frps-config-input {
margin-top: 8px;
}
.frps-config-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.advanced-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.advanced-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px 16px;
border: 1px solid var(--fonu-border);
border-radius: 12px;
background: var(--fonu-bg);
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
transition: border-color 0.15s;
}
.advanced-item:hover {
border-color: color-mix(in srgb, #10b981 30%, var(--fonu-border));
}
.advanced-item__icon {
font-size: 18px;
color: var(--fonu-text-secondary);
}
.advanced-item__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
}
.advanced-item__hint {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
line-height: 1.5;
}
.advanced-item__chevron {
margin-left: auto;
color: var(--fonu-text-muted);
}
.help-content p {
margin: 0 0 10px;
line-height: 1.7;
color: var(--fonu-text-secondary);
}
.modal-action {
margin-top: var(--fonu-space-4);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: var(--fonu-space-4);
}
.log-view {
max-height: 420px;
overflow: auto;
margin: 0;
padding: 14px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.domain-list {
margin: 0;
padding-left: 18px;
line-height: 1.8;
}
.empty-domains {
color: var(--fonu-text-muted);
font-size: 14px;
}
@media (max-width: 1080px) {
.top-grid,
.gateway-grid {
grid-template-columns: 1fr;
}
}
</style>