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
+73
View File
@@ -0,0 +1,73 @@
<template>
<n-config-provider :theme="naiveTheme" :theme-overrides="themeOverrides">
<n-message-provider>
<n-dialog-provider>
<router-view />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { NConfigProvider, NDialogProvider, NMessageProvider } from 'naive-ui'
import { useTheme } from './composables/useTheme'
import './styles/variables.css'
const { naiveTheme, themeOverrides } = useTheme()
</script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--fonu-font);
background: var(--fonu-bg);
color: var(--fonu-text);
}
.mono {
font-family: var(--fonu-mono);
}
.text-secondary {
color: var(--fonu-text-secondary);
}
.text-muted {
color: var(--fonu-text-muted);
}
.page-shell,
.page-content {
width: 100%;
}
.page-narrow {
width: 100%;
max-width: var(--fonu-form-max);
margin-inline: auto;
}
.form-narrow {
max-width: var(--fonu-form-max);
}
.toolbar {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
flex-wrap: wrap;
margin-bottom: var(--fonu-space-4);
padding-top: var(--fonu-space-4);
}
.n-data-table .n-data-table-th {
background: var(--fonu-bg-muted) !important;
font-weight: 500;
font-size: 13px;
color: var(--fonu-text-secondary);
}
</style>
+372
View File
@@ -0,0 +1,372 @@
import type {
AccessLogEntry,
ApiError,
AppVersion,
AuthStatus,
CertificateCAOption,
CertificateRecord,
DashboardStatus,
DDNSConfig,
DDNSSavePayload,
DDNSTestPayload,
FRPResponse,
FRPSavePayload,
FRPSaveResponse,
FRPStatus,
FrpBinary,
FrpDownloadTask,
FrpProxy,
FrpProxyPayload,
FrpProxiesTraffic,
FrpRelease,
FrpRuntimeInfo,
FrpServer,
FrpServerDiagnostics,
FrpServerDetail,
FrpServerPayload,
FrpStatus,
DiscoveredService,
ProxyClientConn,
ChinaCIDRStatus,
ProxyRule,
ProxySavePayload,
ProxyTraffic,
GlobalNginxView,
RuleNginxView,
SettingsMap,
SystemLogEntry,
NotifyTestPayload,
} from './types'
export function asList<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
const REQUEST_TIMEOUT_MS = 30_000
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
return await requestWithSignal<T>(path, init, controller.signal)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(
timeoutMs > REQUEST_TIMEOUT_MS
? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出'
: '请求超时,请检查 Fonu 服务或 Nginx 状态',
)
}
throw error
} finally {
clearTimeout(timeout)
}
}
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, {
credentials: 'include',
signal,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
})
if (!response.ok) {
let message = '请求失败'
try {
const body = (await response.json()) as ApiError
if (body.error) {
message = body.error
}
} catch {
// ignore
}
throw new Error(message)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export const api = {
getVersion: () => request<AppVersion>('/api/version'),
authStatus: () => request<AuthStatus>('/api/auth/status'),
login: (username: string, password: string) =>
request<{ message: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
changePassword: (old_password: string, new_password: string) =>
request<{ message: string }>('/api/auth/password', {
method: 'POST',
body: JSON.stringify({ old_password, new_password }),
}),
getStatus: () => request<DashboardStatus>('/api/status'),
listProxies: () => request<ProxyRule[]>('/api/proxies'),
createProxy: (payload: ProxySavePayload) =>
request<ProxyRule>('/api/proxies', { method: 'POST', body: JSON.stringify(payload) }),
updateProxy: (id: number, payload: Partial<ProxySavePayload>) =>
request<ProxyRule>(`/api/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteProxy: (id: number) => request<void>(`/api/proxies/${id}`, { method: 'DELETE' }),
reorderProxies: (ids: number[]) =>
request<void>('/api/proxies/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }),
getProxyTraffic: () => request<ProxyTraffic[]>('/api/proxies/traffic'),
getProxyClients: (id: number) => request<ProxyClientConn[]>(`/api/proxies/${id}/clients`),
getProxyNginx: (id: number) => request<RuleNginxView>(`/api/proxies/${id}/nginx`),
saveProxyNginx: (id: number, payload: { mode: 'auto' | 'custom'; content?: string }) =>
request<RuleNginxView>(`/api/proxies/${id}/nginx`, { method: 'PUT', body: JSON.stringify(payload) }),
rollbackProxyNginx: (id: number, backup?: string) =>
request<RuleNginxView>(`/api/proxies/${id}/nginx/rollback`, {
method: 'POST',
body: JSON.stringify(backup ? { backup } : {}),
}),
listDDNS: () => request<DDNSConfig[]>('/api/ddns'),
listDDNSLite: () => request<DDNSConfig[]>('/api/ddns?lite=1'),
createDDNS: (payload: DDNSSavePayload) =>
request<DDNSConfig>('/api/ddns', { method: 'POST', body: JSON.stringify(payload) }),
updateDDNS: (id: number, payload: DDNSSavePayload) =>
request<DDNSConfig>(`/api/ddns/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteDDNS: (id: number) => request<void>(`/api/ddns/${id}`, { method: 'DELETE' }),
testDDNS: (payload: DDNSTestPayload) =>
request<{ message: string }>('/api/ddns/test', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateAllDDNS: () => request<DDNSConfig[]>('/api/ddns/update', { method: 'POST' }),
updateDDNSOne: (id: number) => request<DDNSConfig>(`/api/ddns/${id}/update`, { method: 'POST' }),
listCertificates: () => request<CertificateRecord[]>('/api/certificates'),
listCertificateCAOptions: () => request<CertificateCAOption[]>('/api/certificates/ca-options'),
applyCertificate: (payload: {
dns_zone?: string
ddns_config_id?: number
domains: string[]
ca?: string
email?: string
}) =>
request<{ job_id: string }>('/api/certificates/apply', {
method: 'POST',
body: JSON.stringify({
dns_zone: payload.dns_zone ?? '',
ddns_config_id: payload.ddns_config_id ?? 0,
domains: payload.domains,
ca: payload.ca ?? '',
email: payload.email ?? '',
}),
}),
certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`,
importCertificate: (payload: {
certificate?: string
private_key?: string
cert_path?: string
key_path?: string
}) =>
request<CertificateRecord>('/api/certificates/import', {
method: 'POST',
body: JSON.stringify(payload),
}),
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
const response = await fetch(
`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`,
{ credentials: 'include' },
)
if (!response.ok) {
let message = '下载证书失败'
try {
const body = (await response.json()) as ApiError
if (body.error) message = body.error
} catch {
// ignore
}
throw new Error(message)
}
const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition') ?? ''
const match = disposition.match(/filename="?([^";\n]+)"?/)
const filename = match?.[1] ?? `${domain}.zip`
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
},
deleteCertificate: (domain: string) =>
request<{ message: string }>(`/api/certificates/${encodeURIComponent(domain)}`, { method: 'DELETE' }),
renewCertificate: (domain?: string, ca?: string) =>
request<CertificateRecord>(
'/api/certificates/renew',
{
method: 'POST',
body: JSON.stringify({ domain: domain ?? '', ca: ca ?? '' }),
},
CERT_REQUEST_TIMEOUT_MS,
),
getSettings: () => request<SettingsMap>('/api/settings'),
saveSettings: (payload: SettingsMap) =>
request<SettingsMap>('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }),
getGlobalNginx: () => request<GlobalNginxView>('/api/settings/nginx/global'),
saveGlobalNginx: (payload: { mode: 'auto' | 'custom'; content?: string }) =>
request<GlobalNginxView>('/api/settings/nginx/global', { method: 'PUT', body: JSON.stringify(payload) }),
rollbackGlobalNginx: (backup?: string) =>
request<GlobalNginxView>('/api/settings/nginx/global/rollback', {
method: 'POST',
body: JSON.stringify(backup ? { backup } : {}),
}),
getChinaCIDRStatus: () => request<ChinaCIDRStatus>('/api/settings/china-cidr'),
refreshChinaCIDR: () =>
request<ChinaCIDRStatus>('/api/settings/china-cidr/refresh', { method: 'POST' }),
testNotify: (payload: NotifyTestPayload) =>
request<{ message: string }>('/api/settings/notify/test', {
method: 'POST',
body: JSON.stringify(payload),
}),
getFRP: () => request<FRPResponse>('/api/frp'),
saveFRP: (payload: FRPSavePayload) =>
request<FRPSaveResponse>('/api/frp', {
method: 'PUT',
body: JSON.stringify(payload),
}),
getFRPStatus: () => request<FRPStatus>('/api/frp/status'),
syncFRPDomains: () =>
request<FRPSaveResponse & { domains: string[] }>('/api/frp/sync-domains', {
method: 'POST',
}),
getFRPLogs: (limit = 200) => request<string[]>(`/api/frp/logs?limit=${limit}`),
// ===== FRP 多服务端模块(/api/frpmulti)=====
getFrpStatus: () => request<FrpStatus>('/api/frpmulti/status'),
getFrpRuntime: () => request<FrpRuntimeInfo>('/api/frpmulti/runtime'),
setFrpDownloadProxy: (proxy: string) => request<FrpRuntimeInfo>('/api/frpmulti/runtime/download-proxy', { method: 'PUT', body: JSON.stringify({ proxy }) }),
listFrpReleases: () => request<FrpRelease[]>('/api/frpmulti/releases'),
listFrpBinaries: () => request<FrpBinary[]>('/api/frpmulti/binaries'),
getFrpDownloadStatus: () => request<FrpDownloadTask>('/api/frpmulti/download'),
downloadFrpRelease: (version: string) => request<FrpDownloadTask>(`/api/frpmulti/releases/${encodeURIComponent(version)}/download`, { method: 'POST' }),
activateFrpBinary: (version: string) => request<FrpStatus>(`/api/frpmulti/binaries/${encodeURIComponent(version)}/activate`, { method: 'POST' }),
deleteFrpBinary: (version: string) => request<void>(`/api/frpmulti/binaries/${encodeURIComponent(version)}`, { method: 'DELETE' }),
listFrpServers: () => request<FrpServer[]>('/api/frpmulti/servers'),
getFrpServerDiagnostics: (id: number) => request<FrpServerDiagnostics>(`/api/frpmulti/servers/${id}/diagnostics`),
getFrpServerDetail: (id: number) => request<FrpServerDetail>(`/api/frpmulti/servers/${id}/detail`),
startFrpServer: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/start`, { method: 'POST' }),
stopFrpServer: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/stop`, { method: 'POST' }),
createFrpServer: (payload: FrpServerPayload) =>
request<FrpServer>('/api/frpmulti/servers', { method: 'POST', body: JSON.stringify(payload) }),
updateFrpServer: (id: number, payload: FrpServerPayload) =>
request<FrpServer>(`/api/frpmulti/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteFrpServer: (id: number) => request<void>(`/api/frpmulti/servers/${id}`, { method: 'DELETE' }),
testFrpServer: (id: number) => request<{ message: string }>(`/api/frpmulti/servers/${id}/test`, { method: 'POST' }),
listFrpProxies: () => request<FrpProxy[]>('/api/frpmulti/proxies'),
getFrpProxyLogs: (id: number, limit = 200) => request<string[]>(`/api/frpmulti/proxies/${id}/logs?limit=${limit}`),
listFrpProxiesTraffic: () => request<FrpProxiesTraffic>('/api/frpmulti/proxies/traffic'),
createFrpProxy: (payload: FrpProxyPayload) =>
request<FrpProxy>('/api/frpmulti/proxies', { method: 'POST', body: JSON.stringify(payload) }),
updateFrpProxy: (id: number, payload: FrpProxyPayload) =>
request<FrpProxy>(`/api/frpmulti/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteFrpProxy: (id: number) => request<void>(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }),
startFrp: () => request<FrpStatus>('/api/frpmulti/start', { method: 'POST' }),
stopFrp: () => request<FrpStatus>('/api/frpmulti/stop', { method: 'POST' }),
reloadFrp: () => request<FrpStatus>('/api/frpmulti/reload', { method: 'POST' }),
getFrpLogs: (limit = 200, serverID?: number) => {
const query = new URLSearchParams({ limit: String(limit) })
if (serverID) query.set('server_id', String(serverID))
return request<string[]>(`/api/frpmulti/logs?${query.toString()}`)
},
exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'),
getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`),
testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }),
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
getFrpAgentRoutes: (id: number) => request<import('./types').FrpAgentRouteDetail[]>(`/api/frpmulti/servers/${id}/agent/routes`),
getFrpAgentRouteCandidates: (id: number) => request<import('./types').FrpProxy[]>(`/api/frpmulti/servers/${id}/agent/route-candidates`),
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
installFrpAgentNginx: (serverId: number) =>
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
getFrpAgentRouteHealth: (serverId: number) =>
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') =>
request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
installFrpServer: (id: number, version: string, proxy: string) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }),
getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.keyword) q.set('keyword', params.keyword)
if (params?.status) q.set('status', String(params.status))
return request<AccessLogEntry[]>(`/api/logs/access?${q}`)
},
getErrorLogs: (params?: { limit?: number; keyword?: string }) => {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.keyword) q.set('keyword', params.keyword)
return request<string[]>(`/api/logs/error?${q}`)
},
getSystemLogs: (params?: { limit?: number; level?: string; keyword?: string }) => {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.level) q.set('level', params.level)
if (params?.keyword) q.set('keyword', params.keyword)
return request<SystemLogEntry[]>(`/api/logs/system?${q}`)
},
scanDiscovery: (host?: string) => {
const q = new URLSearchParams()
if (host) q.set('host', host)
return request<DiscoveredService[]>(`/api/discovery/scan?${q}`)
},
async exportBackup() {
const response = await fetch('/api/backup/export', { credentials: 'include' })
if (!response.ok) {
throw new Error('导出备份失败')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `fonu-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`
link.click()
URL.revokeObjectURL(url)
},
async restoreBackup(file: File) {
const form = new FormData()
form.append('file', file)
const response = await fetch('/api/backup/restore', {
method: 'POST',
credentials: 'include',
body: form,
})
if (!response.ok) {
let message = '恢复备份失败'
try {
const body = (await response.json()) as ApiError
if (body.error) message = body.error
} catch {
// ignore
}
throw new Error(message)
}
return (await response.json()) as { message: string }
},
}
+794
View File
@@ -0,0 +1,794 @@
export interface ProxyHost {
id: number
hostname: string
listen_port?: number | null
}
export interface BasicAuthConfig {
enabled?: boolean
username?: string
has_password?: boolean
}
export interface RateLimitConfig {
enabled?: boolean
rate?: number
burst?: number
}
export interface ConnLimitConfig {
enabled?: boolean
max?: number
}
export interface ProxySecurityConfig {
ip_blacklist?: string[]
ip_whitelist?: string[]
ip_whitelist_mode?: boolean
china_only?: boolean
basic_auth?: BasicAuthConfig
rate_limit?: RateLimitConfig
conn_limit?: ConnLimitConfig
proxy_ssl_verify_off?: boolean
proxy_host_upstream?: boolean
tls_min_13_only?: boolean
security_headers?: boolean
}
export interface ProxyRule {
id: number
domain: string
upstream: string
listen_port: number
listen_ipv4: boolean
listen_ipv6: boolean
hosts: ProxyHost[]
https_enabled: boolean
http_redirect: boolean
enabled: boolean
nginx_mode?: string
name: string
sort_order: number
security: ProxySecurityConfig
created_at: string
updated_at: string
}
export interface ChinaCIDRStatus {
updated_at?: string
entry_count_v4: number
entry_count_v6: number
last_error?: string
updating?: boolean
ready?: boolean
}
export interface ProxyTraffic {
rule_id: number
upload_total: number
download_total: number
upload_rate: number
download_rate: number
connections: number
}
export interface ProxyClientConn {
ip: string
last_seen: string
}
export interface ProxySecurityPayload {
ip_blacklist?: string[]
ip_whitelist?: string[]
ip_whitelist_mode?: boolean
ip_blacklist_text?: string
ip_whitelist_text?: string
china_only?: boolean
basic_auth?: {
enabled?: boolean
username?: string
password?: string
}
rate_limit?: RateLimitConfig
conn_limit?: ConnLimitConfig
proxy_ssl_verify_off?: boolean
proxy_host_upstream?: boolean
tls_min_13_only?: boolean
security_headers?: boolean
}
export interface ProxySavePayload {
upstream: string
listen_port?: number
listen_ipv4?: boolean
listen_ipv6?: boolean
hosts: string[]
https_enabled?: boolean
http_redirect?: boolean
enabled?: boolean
name?: string
security?: ProxySecurityPayload
}
export interface AuthStatus {
initialized: boolean
authenticated: boolean
}
export interface AppVersion {
version: string
nginx_http_port: number
nginx_https_port: number
}
export interface ApiError {
error: string
}
export interface DashboardStatus {
public_ipv4: string
public_ipv6: string
public_ip_source?: 'ddns' | 'detect' | 'none'
ddns_status: string
ddns_count: number
ddns_last_updated?: string
certificate_status: string
certificate_days: number
certificate_count: number
certificate_summary?: string
proxy_count: number
nginx_status: string
request_today: number
request_yesterday?: number
request_trend?: number | null
requests_hourly: number[]
requests_hourly_labels?: string[]
error_today: number
avg_response_ms: number
started_at: string
uptime_seconds: number
}
export interface DDNSDomainRecord {
domain: string
ipv4?: string
ipv6?: string
status: string
message?: string
}
export interface DDNSConfig {
id: number
provider: string
root_domain: string
record_name: string
record_names?: string[]
ipv4_enabled: boolean
ipv6_enabled: boolean
enabled: boolean
has_token: boolean
last_ipv4?: string
last_ipv6?: string
last_status?: string
last_error?: string
custom_ipv4?: string
custom_ipv6?: string
domain_records?: DDNSDomainRecord[]
last_updated_at?: string
}
export interface CertificateCAOption {
value: string
label: string
}
export interface CertificateJobDone {
ok: boolean
error?: string
domain?: string
cert_path?: string
key_path?: string
cert_dir?: string
expires_at?: string
}
export interface CertificateJobEvent {
type: 'log' | 'done'
level?: string
message?: string
result?: CertificateJobDone
}
export interface CertificateRecord {
id: number
domain: string
domains?: string[]
wildcard: boolean
acme_ca?: string
status: string
days_left: number
last_error?: string
expires_at?: string
last_renew_at?: string
}
export interface AccessLogEntry {
time: string
domain: string
server_port?: number
method: string
path: string
status: number
response_time: number
client_ip: string
upstream: string
}
export interface SystemLogEntry {
time: string
level: string
module: string
message: string
}
export type NotifyType = '' | 'email' | 'webhook' | 'telegram'
export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'custom'
export interface NotifyEmailConfig {
host: string
port: number
username: string
from: string
to: string[]
tls: boolean
has_password?: boolean
}
export interface NotifyWebhookConfig {
provider: WebhookProvider
server: string
key: string
topic: string
url: string
has_secret?: boolean
}
export interface NotifyTelegramConfig {
chat_id: string
proxy_url: string
has_bot_token?: boolean
}
export interface NotifyTestPayload {
type: NotifyType
email: NotifyEmailConfig
webhook: NotifyWebhookConfig
telegram: NotifyTelegramConfig
on_ddns_ip_change: boolean
on_ddns_failure: boolean
on_cert_expiry: boolean
on_cert_renew_success: boolean
on_cert_renew_failure: boolean
on_ip_frequent_access: boolean
on_login_failure: boolean
on_nginx_reload_failure: boolean
ip_frequent_threshold: number
ip_frequent_window_sec: number
login_failure_threshold: number
login_failure_window_sec: number
smtp_password?: string
webhook_secret?: string
telegram_token?: string
}
export type SettingsMap = Record<string, string>
export interface DiscoveredService {
name: string
port: number
host: string
upstream: string
detected: boolean
platform: string
title?: string
suggestion?: string
}
export interface DDNSSavePayload {
provider?: string
root_domain?: string
record_name?: string
record_names?: string[]
domains?: string[]
ipv4_enabled?: boolean
ipv6_enabled?: boolean
enabled?: boolean
custom_ipv4?: string
custom_ipv6?: string
api_token?: string
api_token_id?: string
api_secret?: string
}
export interface DDNSTestPayload {
config_id?: number
provider?: string
api_token?: string
api_token_id?: string
api_secret?: string
}
export interface FRPStatus {
enabled: boolean
connected: boolean
message: string
last_error?: string
server_endpoint?: string
connected_at?: string
uptime_seconds?: number
client_version?: string
http_gateway_enabled?: boolean
https_gateway_enabled?: boolean
synced_domain_count?: number
}
export interface FRPConfig {
enabled: boolean
server_addr: string
server_port: number
auth_token: string
has_auth_token: boolean
tls_enabled: boolean
custom_domains: string[]
}
export interface FRPResponse {
enabled: boolean
server_addr: string
server_port: number
auth_token: string
has_auth_token: boolean
tls_enabled: boolean
custom_domains: string[]
status: FRPStatus
frps_config: string
nginx_http_port: number
nginx_https_port: number
}
export interface FRPSaveResponse {
message: string
config: FRPConfig
status: FRPStatus
frps_config: string
nginx_http_port: number
nginx_https_port: number
}
export interface FRPSavePayload {
enabled: boolean
server_addr: string
server_port: number
auth_token?: string
tls_enabled: boolean
custom_domains: string[]
}
// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)=====
export interface FrpServer {
agent_url?: string
agent_configured?: boolean
ssh_configured?: boolean
mgmt_enabled?: boolean
ssh_host?: string
ssh_port?: number
ssh_user?: string
ssh_auth?: string
id: number
name: string
server_addr: string
server_port: number
tls_enabled: boolean
tls_server_name?: string
enabled: boolean
has_token: boolean
has_oidc_client_secret: boolean
dashboard_has_pwd?: boolean
options: FrpServerOptions
boot_status: 'starting' | 'running' | 'stopped' | 'error'
desired_state: 'starting' | 'running' | 'stopped' | 'error' | string
process_state: 'starting' | 'running' | 'stopped' | 'error' | 'unknown' | string
connection_state: 'connected' | 'connecting' | 'disconnected' | 'error' | 'unknown' | string
state_source: string
process_pid?: number
exit_code?: number
last_connected_at?: string
last_disconnected_at?: string
last_error?: string
last_started_at?: string
created_at: string
updated_at: string
}
export interface FrpServerOptions {
user?: string
location?: string
/** frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 和 Nginx upstream 同源;留空用默认 8080/8443 */
vhost_http_port?: number
vhost_https_port?: number
/** frps 进阶:子域名根(规则 subdomain 生效前提)*/
sub_domain_host?: string
/** HTTP vhost 404 页面路径(VPS 本地路径)*/
custom_404_page?: string
/** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/
tls_force?: boolean
/** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */
allow_ports?: string
/** 每客户端可绑定端口上限;0/留空不限 */
max_ports_per_client?: number
/** tcpmux 规则的 HTTP CONNECT 监听端口 */
tcpmux_http_connect_port?: number
/** frps 写文件日志 /var/log/frps.log(journalctl 之外)*/
frps_log_to_file?: boolean
/** 文件日志保留天数,默认 3 */
frps_log_max_days?: number
/** frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息 */
dashboard_addr?: string
dashboard_port?: number
dashboard_user?: string
dashboard_has_pwd?: boolean
auth_method: 'none' | 'token' | 'oidc'
oidc_client_id?: string
oidc_audience?: string
oidc_scope?: string
oidc_token_endpoint_url?: string
oidc_trusted_ca_path?: string
oidc_insecure_skip_verify?: boolean
oidc_proxy_url?: string
log_level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
log_max_days: number
protocol: 'tcp' | 'kcp' | 'quic' | 'websocket' | 'wss'
proxy_url?: string
dial_server_timeout: number
dial_server_keepalive: number
connect_server_local_ip?: string
dns_server?: string
pool_count: number
tcp_mux: boolean
tcp_mux_keepalive_interval: number
heartbeat_interval: number
heartbeat_timeout: number
login_fail_exit: boolean
tls_disable_custom_first_byte: boolean
auto_start: boolean
remark?: string
tls_certificate_configured?: boolean
tls_key_configured?: boolean
tls_trusted_ca_configured?: boolean
}
export interface FrpProxy {
id: number
server_id: number
name: string
type: 'tcp' | 'udp' | 'http' | 'https' | 'tcpmux' | 'stcp' | 'sudp' | 'xtcp'
local_ip: string
local_port: number
remote_port?: number | null
custom_domains: string[]
host_header_rewrite?: string
options: FrpProxyOptions
enabled: boolean
remark?: string
created_at: string
updated_at: string
}
export interface FrpProxyOptions {
subdomain?: string
/** WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头) */
websocket?: boolean
/** 公网反代:HTTP 80 强制 301 跳转 HTTPS(需公网证书已部署)*/
redirect_https?: boolean
/** 公网反代:IP 白名单(allow/deny),空则不限制 */
allow_ips?: string[]
/** 公网反代:Basic Auth 用户名;密码由 agent 首次部署自动生成(见配置注释)*/
basic_auth_user?: string
/** 公网反代:IP 黑名单(deny),与白名单可同时使用 */
deny_ips?: string[]
/** 公网反代:Basic Auth 自定义密码;留空则首次部署自动生成 */
basic_auth_password?: string
/** 公网反代:显式关闭 HTTPS(证书已推送也不监听 443);缺省 = 启用 */
https_disabled?: boolean
/** 公网反代:客户端上传大小限制,如 50m */
client_max_body_size?: string
/** 公网反代:代理读超时,如 3600s(长轮询 / 流媒体 / 大文件必需)*/
proxy_read_timeout?: string
/** 公网反代:安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy)*/
security_headers?: boolean
/** 公网反代:仅允许 TLS 1.3 */
tls13_only?: boolean
/** 公网反代:请求限流(次/秒);0 = 不限制 */
rate_limit_rate?: number
/** 公网反代:限流突发允许量 */
rate_limit_burst?: number
/** 公网反代:每 IP 并发连接数上限;0 = 不限制 */
conn_limit_max?: number
/** 公网反代:仅允许中国大陆 IP(部署时自动下发本地维护的 IP 段)*/
china_only?: boolean
locations?: string[]
route_by_http_user?: string
http_user?: string
http_password?: string
multiplexer?: string
secret_key?: string
allow_users?: string[]
transport: {
bandwidth_limit?: string
bandwidth_limit_mode?: 'client' | 'server' | string
use_encryption?: boolean
use_compression?: boolean
proxy_protocol_version?: 'v1' | 'v2' | string
}
health_check: {
type?: 'tcp' | 'http' | string
path?: string
interval_seconds?: number
max_failed?: number
timeout_seconds?: number
http_headers?: Array<{ name: string; value: string }>
}
load_balancer: { group?: string; group_key?: string }
metadatas?: Record<string, string>
request_headers?: Record<string, string>
response_headers?: Record<string, string>
plugin?: {
type: string
unix_path?: string
local_path?: string
strip_prefix?: string
local_addr?: string
http_user?: string
http_password?: string
username?: string
password?: string
crt_path?: string
key_path?: string
host_header_rewrite?: string
destination_ip?: string
}
nat_traversal: { disable_assisted_addrs?: boolean }
}
/** frps 侧单条穿透规则的流量快照;速率为两次采样间隔内的均值 */
export interface FrpProxyTraffic {
traffic_in: number
traffic_out: number
cur_conns: number
traffic_in_rate: number
traffic_out_rate: number
}
/** 按 frp 规则 ID 聚合的流量数据;errors 记录采集失败的服务端/规则错误 */
export interface FrpProxiesTraffic {
items: Record<string, FrpProxyTraffic>
errors?: Record<string, string>
sampled_at: string
}
export interface FrpStatus {
status: 'running' | 'stopped' | 'unknown'
pid?: number
frpc_version?: string
last_started_at?: string
last_reloaded_at?: string
last_error?: string
configured: boolean
enabled_proxies: number
}
export interface FrpRuntimeInfo {
platform: string
configured_bin: string
active_bin?: string
active_version?: string
detected_version?: string
download_proxy: string
config_path: string
config_paths?: string[]
config_exists: boolean
}
export interface FrpRelease {
version: string
published_at?: string
asset_name: string
size: number
}
export interface FrpBinary {
version: string
path: string
size: number
installed_at: string
active: boolean
}
/** 单项诊断结果;state 为 ok/failed/unknown,unknown 表示无法确认而非失败 */
export interface FrpDiagnosticCheck {
name: 'tcp_reachable' | 'frpc_login' | 'proxy_reachable' | 'dns_resolution' | 'location' | string
state: 'ok' | 'failed' | 'unknown' | string
detail?: string
latency_ms?: number
}
export interface FrpServerDiagnostics {
server_id: number
available: boolean
latency_ms?: number
location?: string
checks?: FrpDiagnosticCheck[]
error?: string
}
export interface FrpServerSample {
checked_at: string
available: boolean
latency_ms?: number
error?: string
}
export interface FrpServerStatusDetail {
available: boolean
availability: number
average_latency_ms: number
peak_latency_ms: number
samples: FrpServerSample[]
last_checked_at?: string
last_error?: string
}
export interface FrpServerDetail {
server: FrpServer
status: FrpServerStatusDetail
proxies: FrpProxy[]
}
export interface FrpDownloadTask {
version?: string
status: 'idle' | 'queued' | 'downloading' | 'verifying' | 'extracting' | 'completed' | 'failed'
message: string
downloaded: number
total: number
error?: string
updated_at?: string
}
export interface FrpServerPayload {
agent_url?: string
agent_token?: string
clear_agent_token?: boolean
ssh_host?: string
ssh_port?: number
ssh_user?: string
ssh_auth?: string
ssh_secret?: string
clear_ssh_secret?: boolean
mgmt_enabled?: boolean
name: string
server_addr: string
server_port: number
tls_enabled?: boolean
tls_server_name?: string
enabled?: boolean
token?: string
clear_token?: boolean
options: FrpServerOptions
oidc_client_secret?: string
clear_oidc_client_secret?: boolean
dashboard_password?: string
clear_dashboard_password?: boolean
tls_certificate?: string
tls_key?: string
tls_trusted_ca?: string
clear_tls_certificate?: boolean
clear_tls_key?: boolean
clear_tls_trusted_ca?: boolean
}
export interface FrpAgentRouteCandidate {
id: number
name: string
type: 'http' | 'https'
custom_domains: string[]
local_ip: string
local_port: number
enabled: boolean
}
export interface FrpAgentRouteHealth {
proxy_id: number
domain: string
dns_ok: boolean
dns_detail?: string
cert_ok: boolean
cert_detail?: string
tunnel_ok: boolean
tunnel_name?: string
tunnel_detail?: string
service_ok: boolean
}
export interface FrpAgentRouteDetail {
domain: string
file_exists: boolean
syntax_ok: boolean
loaded: boolean
}
export interface FrpAgentStatus {
agent_version?: string
ports?: Record<string, boolean>
frps_active?: boolean
frps_enabled?: boolean
frps_version?: string
frps_log_tail?: string
frps_unit_exec?: string
dashboard_addr?: string
service: string
nginx_binary: string
nginx_conf_dir: string
/** nginx -t 是否通过 */
nginx_ok?: boolean
/** 未找到 nginx 可执行文件 */
nginx_missing?: boolean
/** 解析到的 nginx 路径 */
nginx_path?: string
/** 有 nginx 进程在运行 */
nginx_running?: boolean
/** nginx 版本,如 nginx/1.24.0 */
nginx_version?: string
/** 探测失败原因(缺失或配置有误) */
nginx_error?: string
frps_binary: string
frps_config_path: string
routes: string[]
checked_at: string
}
export interface FrpProxyPayload {
server_id: number
name: string
type: FrpProxy['type']
local_ip: string
local_port: number
remote_port?: number | null
custom_domains?: string[]
host_header_rewrite?: string
options?: FrpProxyOptions
enabled?: boolean
remark?: string
}
export interface NginxBackupEntry {
name: string
created_at: string
}
export interface RuleNginxView {
mode: 'auto' | 'custom'
enabled: boolean
active: boolean
generated: string
content: string
backups: NginxBackupEntry[]
}
export interface GlobalNginxView {
mode: 'auto' | 'custom'
generated_framework: string
generated_snippet: string
content: string
backups: NginxBackupEntry[]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

@@ -0,0 +1,45 @@
<template>
<div class="settings-field">
<div class="settings-field__label configured-secret-label">
<span>{{ label }}</span>
<n-tag v-if="configured" size="small" type="success" :bordered="false">{{ CONFIGURED_SECRET_TAG }}</n-tag>
</div>
<n-input
:value="modelValue"
type="password"
show-password-on="click"
:placeholder="configured ? CONFIGURED_SECRET_PLACEHOLDER : placeholder"
@update:value="emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { NInput, NTag } from 'naive-ui'
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
withDefaults(
defineProps<{
label: string
modelValue: string
configured?: boolean
placeholder?: string
}>(),
{
configured: false,
placeholder: '',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<style scoped>
.configured-secret-label {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="empty-state">
<div class="empty-state__icon">{{ icon }}</div>
<h3 class="empty-state__title">{{ title }}</h3>
<p class="empty-state__desc">{{ description }}</p>
<div v-if="$slots.action" class="empty-state__action">
<slot name="action" />
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
description: string
icon?: string
}>(),
{ icon: '○' },
)
</script>
<style scoped>
.empty-state {
padding: var(--fonu-space-6) var(--fonu-space-5);
text-align: center;
}
.empty-state__icon {
width: 48px;
height: 48px;
margin: 0 auto var(--fonu-space-4);
border-radius: 50%;
background: var(--fonu-brand-soft);
color: var(--fonu-brand);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.empty-state__title {
margin: 0 0 var(--fonu-space-2);
font-size: 16px;
font-weight: 600;
color: var(--fonu-text);
}
.empty-state__desc {
margin: 0 auto;
max-width: 360px;
font-size: 14px;
line-height: 1.6;
color: var(--fonu-text-secondary);
}
.empty-state__action {
margin-top: var(--fonu-space-5);
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div class="fonu-card" :class="{ 'fonu-card--flush': flush }">
<div v-if="title || $slots.title || $slots.header" class="fonu-card__header">
<div>
<h3 v-if="title" class="fonu-card__title">{{ title }}</h3>
<div v-else-if="$slots.title" class="fonu-card__title fonu-card__title-row">
<slot name="title" />
</div>
<p v-if="subtitle" class="fonu-card__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.header" class="fonu-card__header-extra">
<slot name="header" />
</div>
</div>
<div class="fonu-card__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
subtitle?: string
flush?: boolean
}>()
</script>
<style scoped>
.fonu-card {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
overflow: hidden;
}
.fonu-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
}
.fonu-card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--fonu-text);
}
.fonu-card__title-row {
display: flex;
align-items: center;
gap: var(--fonu-space-2);
}
.fonu-card__subtitle {
margin: var(--fonu-space-1) 0 0;
font-size: 13px;
color: var(--fonu-text-secondary);
}
.fonu-card__body {
padding: var(--fonu-space-5);
}
.fonu-card--flush .fonu-card__body {
padding-top: 0;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<template>
<div class="load-error">
<n-result status="error" title="加载失败" :description="message">
<template #footer>
<n-button type="primary" @click="$emit('retry')">重新加载</n-button>
</template>
</n-result>
</div>
</template>
<script setup lang="ts">
import { NButton, NResult } from 'naive-ui'
withDefaults(
defineProps<{
message?: string
}>(),
{ message: '请检查 Fonu 服务是否正常运行' },
)
defineEmits<{ retry: [] }>()
</script>
<style scoped>
.load-error {
padding: var(--fonu-space-6) 0;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<VChart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { useTheme } from '../composables/useTheme'
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
const props = defineProps<{
values: number[]
labels?: string[]
}>()
const { isDark } = useTheme()
const option = computed(() => {
const labels = props.labels ?? ['00', '04', '08', '12', '16', '20']
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
return {
grid: { left: 8, right: 8, top: 12, bottom: 28, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: isDark.value ? '#1e293b' : '#fff',
borderColor: isDark.value ? '#334155' : '#e5e7eb',
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}<br/>请求数 ${p.value}`
},
},
xAxis: {
type: 'category',
data: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: textColor,
fontSize: 10,
interval: 0,
hideOverlap: false,
showMinLabel: true,
showMaxLabel: true,
rotate: labels.length > 8 ? 30 : 0,
},
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
axisLabel: { color: textColor, fontSize: 11 },
},
series: [
{
type: 'bar',
data: props.values,
barMaxWidth: 28,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#6ee7b7' },
{ offset: 1, color: '#10b981' },
],
},
},
emphasis: {
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#a7f3d0' },
{ offset: 1, color: '#059669' },
],
},
},
},
},
],
}
})
</script>
<style scoped>
.chart {
width: 100%;
height: 200px;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<VChart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { useTheme } from '../composables/useTheme'
use([CanvasRenderer, PieChart, LegendComponent, TitleComponent, TooltipComponent])
const props = defineProps<{
segments: { label: string; value: number; color: string }[]
}>()
const { isDark } = useTheme()
const option = computed(() => {
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
const titleColor = isDark.value ? '#f1f5f9' : '#111827'
const total = props.segments.reduce((sum, s) => sum + s.value, 0)
return {
title: [
{
text: String(total),
left: '31%',
top: '38%',
textAlign: 'center',
textStyle: { fontSize: 22, fontWeight: 700, color: titleColor },
},
{
text: '总请求',
left: '31%',
top: '52%',
textAlign: 'center',
textStyle: { fontSize: 12, color: textColor },
},
],
tooltip: {
trigger: 'item',
backgroundColor: isDark.value ? '#1e293b' : '#fff',
borderColor: isDark.value ? '#334155' : '#e5e7eb',
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
formatter: '{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
right: 0,
top: 'center',
itemWidth: 8,
itemHeight: 8,
itemGap: 12,
textStyle: { color: textColor, fontSize: 13 },
formatter: (name: string) => {
const seg = props.segments.find((s) => s.label === name)
const total = props.segments.reduce((sum, s) => sum + s.value, 0) || 1
const pct = seg ? Math.round((seg.value / total) * 100) : 0
return `${name} ${pct}% (${seg?.value ?? 0})`
},
},
series: [
{
type: 'pie',
radius: ['52%', '72%'],
center: ['32%', '50%'],
avoidLabelOverlap: false,
label: { show: false },
emphasis: {
scale: true,
scaleSize: 4,
},
data: props.segments.map((s) => ({
name: s.label,
value: s.value,
itemStyle: { color: s.color },
})),
},
],
}
})
</script>
<style scoped>
.chart {
width: 100%;
height: 200px;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<VChart class="chart" :option="option" autoresize />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { useTheme } from '../composables/useTheme'
import { formatRate } from '../utils/format'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
const props = defineProps<{
labels: string[]
upload: number[]
download: number[]
}>()
const { isDark } = useTheme()
function axisRate(value: number): string {
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(0)} MB/s`
if (value >= 1024) return `${(value / 1024).toFixed(0)} KB/s`
return `${Math.round(value)} B/s`
}
const option = computed(() => {
const textColor = isDark.value ? '#94a3b8' : '#9ca3af'
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
return {
grid: { left: 4, right: 8, top: 8, bottom: 4, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: isDark.value ? '#1e293b' : '#fff',
borderColor: isDark.value ? '#334155' : '#e5e7eb',
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
formatter: (params: { seriesName: string; value: number; axisValue: string }[]) => {
const time = params[0]?.axisValue ?? ''
const lines = params.map((p) => `${p.seriesName} ${formatRate(p.value)}`)
return `${time}<br/>${lines.join('<br/>')}`
},
},
xAxis: {
type: 'category',
boundaryGap: false,
data: props.labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
},
yAxis: {
type: 'value',
min: 0,
splitNumber: 3,
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
axisLabel: {
color: textColor,
fontSize: 11,
formatter: (v: number) => axisRate(v),
},
},
series: [
{
name: '上传',
type: 'line',
smooth: true,
showSymbol: false,
data: props.upload,
lineStyle: { width: 2, color: '#10b981' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(16, 185, 129, 0.25)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0.02)' },
],
},
},
},
{
name: '下载',
type: 'line',
smooth: true,
showSymbol: false,
data: props.download,
lineStyle: { width: 2, color: '#3b82f6' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(59, 130, 246, 0.25)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0.02)' },
],
},
},
},
],
}
})
</script>
<style scoped>
.chart {
width: 100%;
height: 160px;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div
class="nginx-code-editor"
:class="{
'nginx-code-editor--readonly': readonly,
'nginx-code-editor--embedded': embedded,
}"
>
<Codemirror
:model-value="modelValue"
:placeholder="placeholder"
:style="editorStyle"
:autofocus="false"
:indent-with-tab="true"
:tab-size="4"
:extensions="extensions"
@update:model-value="onUpdate"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Codemirror } from 'vue-codemirror'
import { EditorState } from '@codemirror/state'
import { EditorView, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
import { StreamLanguage, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { oneDark } from '@codemirror/theme-one-dark'
import { useTheme } from '../composables/useTheme'
const props = withDefaults(
defineProps<{
modelValue: string
readonly?: boolean
embedded?: boolean
placeholder?: string
minHeight?: string
}>(),
{
readonly: false,
embedded: false,
placeholder: '',
minHeight: '',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { isDark } = useTheme()
const nginxLanguage = StreamLanguage.define(nginx)
const lightTheme = EditorView.theme(
{
'&': {
backgroundColor: 'var(--fonu-surface)',
color: 'var(--fonu-text)',
},
'.cm-content': {
caretColor: 'var(--fonu-brand)',
fontFamily: 'var(--fonu-mono)',
fontSize: '12px',
lineHeight: '1.7',
},
'.cm-gutters': {
backgroundColor: 'var(--fonu-bg-muted)',
color: 'var(--fonu-text-muted)',
borderRight: '1px solid var(--fonu-border)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--fonu-bg)',
color: 'var(--fonu-text-secondary)',
},
'.cm-activeLine': {
backgroundColor: 'var(--fonu-brand-soft)',
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'color-mix(in srgb, var(--fonu-brand) 22%, transparent) !important',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--fonu-brand)',
},
'&.cm-focused': {
outline: 'none',
},
},
{ dark: false },
)
const editorStyle = computed(() => {
if (props.embedded) {
return { height: '100%' }
}
if (props.minHeight) {
return { height: '100%', minHeight: props.minHeight }
}
return { height: '100%', minHeight: '240px' }
})
const extensions = computed(() => {
const base = [
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
nginxLanguage,
EditorView.lineWrapping,
]
if (isDark.value) {
base.push(oneDark)
} else {
base.push(lightTheme, syntaxHighlighting(defaultHighlightStyle, { fallback: true }))
}
if (props.readonly) {
base.push(EditorState.readOnly.of(true), EditorView.editable.of(false))
}
return base
})
function onUpdate(value: string) {
if (!props.readonly) {
emit('update:modelValue', value)
}
}
</script>
<style scoped>
.nginx-code-editor {
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
overflow: hidden;
background: var(--fonu-surface);
}
.nginx-code-editor--embedded {
flex: 1 1 0;
min-height: 0;
}
.nginx-code-editor :deep(.cm-editor) {
height: 100%;
}
.nginx-code-editor :deep(.cm-scroller) {
overflow: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 35%, transparent) transparent;
}
.nginx-code-editor:hover :deep(.cm-scroller) {
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 65%, transparent) var(--fonu-bg-muted);
}
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar {
width: 7px;
height: 7px;
}
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-track {
background: transparent;
}
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fonu-text-muted) 30%, transparent);
border-radius: 4px;
}
.nginx-code-editor:hover :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fonu-text-muted) 55%, transparent);
}
.nginx-code-editor--readonly :deep(.cm-cursor) {
display: none;
}
.nginx-code-editor--readonly :deep(.cm-content) {
caret-color: transparent;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page-header">
<div class="page-header__main">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="description" class="page-header__desc">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="page-header__actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-5);
flex-wrap: wrap;
}
.page-header__title {
margin: 0;
font-size: 24px;
font-weight: 700;
line-height: 1.3;
color: var(--fonu-text);
letter-spacing: -0.02em;
}
.page-header__desc {
margin: var(--fonu-space-2) 0 0;
font-size: 14px;
color: var(--fonu-text-secondary);
line-height: 1.5;
}
.page-header__actions {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
flex-wrap: wrap;
}
</style>
+307
View File
@@ -0,0 +1,307 @@
<template>
<div
ref="root"
class="proxy-log-box"
:class="{
'proxy-log-box--embedded': embedded,
'proxy-log-box--fullscreen': fullscreen,
'is-empty': parsedLines.length === 0,
}"
@scroll="onScroll"
>
<template v-for="(item, i) in parsedLines" :key="i">
<div
v-if="item.parsed"
class="proxy-log-entry"
:class="logEntryStatusClass(item.parsed.status)"
>
<time class="proxy-log-entry__time" :title="item.parsed.time">{{ formatLogTime(item.parsed.time) }}</time>
<span class="proxy-log-entry__method" :class="`is-${item.parsed.method.toLowerCase()}`">
{{ item.parsed.method }}
</span>
<span class="proxy-log-entry__path" :title="pathTitle(item.parsed)">{{ item.parsed.path }}</span>
<span class="proxy-log-entry__status">{{ item.parsed.status }}</span>
<span class="proxy-log-entry__ms">{{ item.parsed.ms }}ms</span>
<span class="proxy-log-entry__client mono">{{ item.parsed.client }}</span>
<span v-if="!hideUpstream" class="proxy-log-entry__upstream mono" :title="item.parsed.upstream">
→ {{ formatUpstream(item.parsed.upstream) }}
</span>
</div>
<div v-else class="proxy-log-line proxy-log-line--raw">{{ item.raw }}</div>
</template>
<div v-if="parsedLines.length === 0" class="proxy-log-empty">暂无记录,通过反代域名访问后会显示在这里。</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
type AccessLogEntry = {
time: string
host: string
method: string
path: string
status: number
ms: string
client: string
upstream: string
}
const props = withDefaults(
defineProps<{
lines: string[]
embedded?: boolean
fullscreen?: boolean
hideUpstream?: boolean
}>(),
{
hideUpstream: true,
},
)
const emit = defineEmits<{
scroll: [el: HTMLElement]
}>()
const root = ref<HTMLElement | null>(null)
const accessLineReWithPort =
/^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)(?:\s+(\d+)\s+(\d+))?$/
const accessLineReLegacy =
/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)(?:\s+(\d+)\s+(\d+))?$/
function parseAccessLog(line: string): AccessLogEntry | null {
let m = line.match(accessLineReWithPort)
if (m) {
const [, time, host, , method, path, status, rt, client, upstream] = m
return buildEntry(time, host, method, path, status, rt, client, upstream)
}
m = line.match(accessLineReLegacy)
if (!m) return null
const [, time, host, method, path, status, rt, client, upstream] = m
return buildEntry(time, host, method, path, status, rt, client, upstream)
}
function buildEntry(
time: string,
host: string,
method: string,
path: string,
status: string,
rt: string,
client: string,
upstream: string,
): AccessLogEntry {
return {
time: time.replace('T', ' ').replace(/([+-]\d{2}:\d{2}|Z)$/, ''),
host,
method,
path,
status: Number(status),
ms: (parseFloat(rt) * 1000).toFixed(1),
client,
upstream,
}
}
const parsedLines = computed(() =>
props.lines.map((raw) => ({ raw, parsed: parseAccessLog(raw) })),
)
function formatLogTime(raw: string): string {
const normalized = raw.replace('T', ' ')
const space = normalized.indexOf(' ')
if (space >= 0 && space < normalized.length - 1) {
return normalized.slice(space + 1).replace(/([+-]\d{2}:\d{2}|Z)$/, '')
}
return raw
}
function formatUpstream(upstream: string): string {
if (!upstream || upstream === '-') return '-'
return upstream
}
function pathTitle(entry: AccessLogEntry): string {
if (entry.host) return `${entry.host}${entry.path}`
return entry.path
}
function logEntryStatusClass(status: number): string {
if (status >= 500) return 'is-error'
if (status >= 400) return 'is-warn'
return ''
}
function onScroll() {
if (root.value) emit('scroll', root.value)
}
function scrollToBottom() {
if (root.value) root.value.scrollTop = root.value.scrollHeight
}
defineExpose({ scrollToBottom })
</script>
<style scoped>
.proxy-log-box {
background: #0f172a;
color: #e2e8f0;
overflow-y: auto;
overflow-x: auto;
padding: var(--fonu-space-3);
font-family: var(--fonu-mono);
font-size: 12px;
line-height: 1.7;
border-radius: var(--fonu-radius-sm);
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.2) transparent;
}
.proxy-log-box:hover {
scrollbar-color: rgba(148, 163, 184, 0.55) rgba(15, 23, 42, 0.35);
}
.proxy-log-box::-webkit-scrollbar {
width: 7px;
height: 7px;
}
.proxy-log-box::-webkit-scrollbar-track {
background: transparent;
}
.proxy-log-box::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.18);
border-radius: 4px;
}
.proxy-log-box:hover::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.55);
}
.proxy-log-box--embedded {
flex: 1 1 0;
min-height: 0;
}
.proxy-log-box--embedded.is-empty {
display: flex;
flex-direction: column;
justify-content: center;
}
.proxy-log-box--fullscreen {
flex: 1;
min-height: 0;
border-radius: var(--fonu-radius);
}
.proxy-log-entry {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
line-height: 1.45;
border: 1px solid transparent;
white-space: nowrap;
min-width: max-content;
}
.proxy-log-entry + .proxy-log-entry {
margin-top: 2px;
}
.proxy-log-entry:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(148, 163, 184, 0.12);
}
.proxy-log-entry__time {
flex: 0 0 auto;
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
.proxy-log-entry__method {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.proxy-log-entry__method.is-get { background: rgba(59, 130, 246, 0.18); color: #93c5fd; }
.proxy-log-entry__method.is-post { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
.proxy-log-entry__method.is-put,
.proxy-log-entry__method.is-patch { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
.proxy-log-entry__method.is-delete { background: rgba(239, 68, 68, 0.18); color: #fca5a5; }
.proxy-log-entry__path {
flex: 1 1 auto;
min-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
color: #e2e8f0;
}
.proxy-log-entry__status {
flex: 0 0 auto;
min-width: 28px;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: right;
color: #86efac;
}
.proxy-log-entry.is-warn .proxy-log-entry__status { color: #fbbf24; }
.proxy-log-entry.is-error .proxy-log-entry__status { color: #f87171; }
.proxy-log-entry__ms {
flex: 0 0 auto;
min-width: 52px;
color: #94a3b8;
font-variant-numeric: tabular-nums;
text-align: right;
}
.proxy-log-entry__client {
flex: 0 0 auto;
color: #cbd5e1;
}
.proxy-log-entry__upstream {
flex: 0 0 auto;
color: #64748b;
font-size: 11px;
}
.proxy-log-line {
white-space: pre-wrap;
word-break: break-all;
padding: 6px 10px;
color: #cbd5e1;
}
.proxy-log-line--raw + .proxy-log-entry,
.proxy-log-entry + .proxy-log-line--raw {
margin-top: 4px;
}
.proxy-log-empty {
padding: var(--fonu-space-5);
text-align: center;
color: #94a3b8;
white-space: normal;
}
.mono { font-family: var(--fonu-mono); }
</style>
+124
View File
@@ -0,0 +1,124 @@
<template>
<div class="stat-card">
<div class="stat-card__header">
<div class="stat-card__title-row">
<div v-if="$slots.icon" class="stat-card__icon" :class="`stat-card__icon--${tone}`">
<slot name="icon" />
</div>
<span class="stat-card__label">{{ label }}</span>
</div>
<slot name="extra" />
</div>
<div v-if="$slots.value" class="stat-card__value-slot">
<slot name="value" />
</div>
<div v-else-if="value !== undefined && value !== ''" class="stat-card__value" :class="{ 'stat-card__value--sm': valueSmall }">
{{ value }}
</div>
<div v-if="sub" class="stat-card__sub">{{ sub }}</div>
<slot />
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
value?: string | number
sub?: string
tone?: 'brand' | 'blue' | 'green' | 'teal'
valueSmall?: boolean
}>(),
{ tone: 'brand', valueSmall: false },
)
</script>
<style scoped>
.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-5);
min-height: 140px;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.stat-card:hover {
box-shadow: var(--fonu-shadow-md);
}
.stat-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-2);
margin-bottom: var(--fonu-space-3);
}
.stat-card__title-row {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
}
.stat-card__icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.stat-card__icon--brand {
background: var(--fonu-brand-soft);
color: var(--fonu-brand);
}
.stat-card__icon--blue {
background: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
.stat-card__icon--green {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
}
.stat-card__icon--teal {
background: rgba(20, 184, 166, 0.12);
color: #14b8a6;
}
.stat-card__label {
font-size: 13px;
font-weight: 500;
color: var(--fonu-text-secondary);
}
.stat-card__value,
.stat-card__value-slot {
font-size: 26px;
font-weight: 700;
line-height: 1.25;
color: var(--fonu-text);
word-break: break-all;
letter-spacing: -0.02em;
}
.stat-card__value--sm {
font-size: 18px;
}
.stat-card__sub {
margin-top: var(--fonu-space-2);
font-size: 13px;
color: var(--fonu-text-muted);
line-height: 1.5;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<span class="status-badge" :class="[kind, { pill }]">
<span class="dot" />
<span class="label">{{ label }}</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { statusKind, statusLabel, type StatusKind } from '../utils/status'
const props = withDefaults(
defineProps<{
value?: string
text?: string
kind?: StatusKind
pill?: boolean
}>(),
{ pill: true },
)
const kind = computed(() => props.kind ?? statusKind(props.value))
const label = computed(() => props.text ?? statusLabel(props.value))
</script>
<style scoped>
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.status-badge.pill {
padding: 4px 10px;
border-radius: 999px;
background: var(--fonu-bg);
}
.status-badge.pill.success {
background: rgba(16, 185, 129, 0.12);
color: var(--fonu-success);
}
.status-badge.pill.warning {
background: rgba(245, 158, 11, 0.12);
color: var(--fonu-warning);
}
.status-badge.pill.error {
background: rgba(239, 68, 68, 0.12);
color: var(--fonu-error);
}
.status-badge.pill.disabled,
.status-badge.pill.unknown {
background: var(--fonu-bg-muted);
color: var(--fonu-text-secondary);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.success .dot { background: var(--fonu-success); }
.warning .dot { background: var(--fonu-warning); }
.error .dot { background: var(--fonu-error); }
.disabled .dot { background: var(--fonu-disabled); }
.unknown .dot { background: var(--fonu-text-muted); }
.success .label { color: inherit; }
.warning .label { color: inherit; }
.error .label { color: inherit; }
.disabled .label { color: inherit; }
.unknown .label { color: inherit; }
.status-badge:not(.pill) .success .label { color: var(--fonu-success); }
.status-badge:not(.pill) .warning .label { color: var(--fonu-warning); }
.status-badge:not(.pill) .error .label { color: var(--fonu-error); }
.status-badge:not(.pill) .disabled .label,
.status-badge:not(.pill) .unknown .label { color: var(--fonu-text-secondary); }
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="provider-form">
<div class="provider-form__head">
<span class="provider-form__title">{{ editing ? '编辑服务商' : '添加服务商' }}</span>
</div>
<div class="provider-form__field">
<label class="provider-form__label">DNS 服务商</label>
<div class="provider-select">
<span class="provider-logo provider-logo--md">
<img :src="providerIcon(form.provider)" :alt="providerLabel(form.provider)" />
</span>
<n-select
v-model:value="form.provider"
:options="providerOptions"
:disabled="!!editing"
size="small"
class="provider-select__input"
/>
</div>
</div>
<div v-if="form.provider === 'dnspod'" class="provider-form__field">
<label class="provider-form__label">Token ID</label>
<n-input v-model:value="form.api_token_id" placeholder="DNSPod ID" size="small" />
</div>
<div class="provider-form__field">
<label class="provider-form__label provider-secret-label">
<span>{{ credentialLabel }}</span>
<n-tag v-if="editing?.has_token" size="small" type="success" :bordered="false">
{{ CONFIGURED_SECRET_TAG }}
</n-tag>
</label>
<n-input
v-model:value="form.api_token"
type="password"
show-password-on="click"
:placeholder="credentialPlaceholder"
size="small"
/>
</div>
<div v-if="form.provider === 'alidns' || form.provider === 'tencentcloud' || form.provider === 'volcengine'" class="provider-form__field">
<label class="provider-form__label provider-secret-label">
<span>{{ secretLabel }}</span>
<n-tag v-if="editing?.has_token" size="small" type="success" :bordered="false">
{{ CONFIGURED_SECRET_TAG }}
</n-tag>
</label>
<n-input
v-model:value="form.api_secret"
type="password"
show-password-on="click"
:placeholder="editing?.has_token ? CONFIGURED_SECRET_PLACEHOLDER : secretPlaceholder"
size="small"
/>
</div>
<div class="provider-form__switches">
<div class="settings-switch">
<span>IPv4</span>
<n-switch v-model:value="form.ipv4_enabled" size="small" />
</div>
<div class="settings-switch">
<span>IPv6</span>
<n-switch v-model:value="form.ipv6_enabled" size="small" />
</div>
<div class="settings-switch">
<span>启用</span>
<n-switch v-model:value="form.enabled" size="small" />
</div>
</div>
<div class="provider-form__actions">
<n-button size="small" quaternary @click="emit('cancel')">取消</n-button>
<n-button size="small" :loading="testing" @click="emit('test')">测试连接</n-button>
<n-button size="small" type="primary" :loading="saving" @click="emit('save')">保存</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NInput, NSelect, NSwitch, NTag } from 'naive-ui'
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
import aliyunIcon from '../assets/brand/dns/aliyun.png'
import cloudflareIcon from '../assets/brand/dns/cloudflare.png'
import dnspodIcon from '../assets/brand/dns/dnspod.png'
import tencentcloudIcon from '../assets/brand/dns/tencentcloud.png'
import volcengineIcon from '../assets/brand/dns/volcengine.png'
import type { DDNSConfig } from '../api/types'
export type ProviderForm = {
provider: string
api_token: string
api_token_id: string
api_secret: string
ipv4_enabled: boolean
ipv6_enabled: boolean
enabled: boolean
custom_ipv4: string
custom_ipv6: string
}
const props = defineProps<{
form: ProviderForm
editing?: DDNSConfig | null
saving?: boolean
testing?: boolean
}>()
const emit = defineEmits<{
save: []
test: []
cancel: []
}>()
const providerOptions = [
{ label: 'Cloudflare', value: 'cloudflare' },
{ label: 'DNSPod', value: 'dnspod' },
{ label: '阿里云 DNS', value: 'alidns' },
{ label: '腾讯云 DNS', value: 'tencentcloud' },
{ label: '火山引擎 DNS', value: 'volcengine' },
]
const providerMap: Record<string, { label: string; icon: string }> = {
cloudflare: { label: 'Cloudflare', icon: cloudflareIcon },
dnspod: { label: 'DNSPod', icon: dnspodIcon },
alidns: { label: '阿里云 DNS', icon: aliyunIcon },
tencentcloud: { label: '腾讯云 DNS', icon: tencentcloudIcon },
volcengine: { label: '火山引擎 DNS', icon: volcengineIcon },
}
const credentialLabel = computed(() => {
if (props.form.provider === 'alidns' || props.form.provider === 'volcengine') return 'AccessKey ID'
if (props.form.provider === 'tencentcloud') return 'SecretId'
if (props.form.provider === 'dnspod') return 'Token'
return 'API Token'
})
const secretLabel = computed(() => {
if (props.form.provider === 'tencentcloud') return 'SecretKey'
return 'AccessKey Secret'
})
const secretPlaceholder = computed(() => {
if (props.form.provider === 'tencentcloud') return '腾讯云 SecretKey'
return 'AccessKey Secret'
})
const credentialPlaceholder = computed(() => {
if (props.editing?.has_token) return CONFIGURED_SECRET_PLACEHOLDER
if (props.form.provider === 'alidns' || props.form.provider === 'volcengine') return 'AccessKey ID'
if (props.form.provider === 'tencentcloud') return '腾讯云 SecretId'
if (props.form.provider === 'dnspod') return 'DNSPod Token'
return 'Cloudflare API Token'
})
function providerLabel(v: string) {
return providerMap[v]?.label ?? v
}
function providerIcon(v: string) {
return providerMap[v]?.icon ?? ''
}
</script>
<style scoped>
.provider-form__head {
margin-bottom: 10px;
}
.provider-form__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
}
.provider-form__field {
margin-bottom: 10px;
}
.provider-form__label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--fonu-text-secondary);
}
.provider-select {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.provider-select__input {
flex: 1;
min-width: 0;
}
.provider-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: #fff;
border: 1px solid var(--fonu-border);
}
.provider-logo--md {
width: 32px;
height: 32px;
}
.provider-logo img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.provider-form__switches {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 8px 0 12px;
}
.settings-switch {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--fonu-text);
}
.provider-form__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.provider-secret-label {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
import { computed, ref, watch } from 'vue'
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
const themeKey = 'fonu-theme'
const themeMode = ref(localStorage.getItem(themeKey) ?? 'system')
function systemDark() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
const shared = {
primaryColor: '#10B981',
primaryColorHover: '#059669',
primaryColorPressed: '#047857',
primaryColorSuppl: '#34D399',
borderRadius: '8px',
fontFamily: 'var(--fonu-font)',
}
const lightOverrides: GlobalThemeOverrides = {
common: {
...shared,
bodyColor: '#F8FAFC',
cardColor: '#FFFFFF',
modalColor: '#FFFFFF',
popoverColor: '#FFFFFF',
borderColor: '#E5E7EB',
dividerColor: '#E5E7EB',
textColor1: '#111827',
textColor2: '#6B7280',
textColor3: '#9CA3AF',
hoverColor: 'rgba(16, 185, 129, 0.08)',
},
Card: { borderRadius: '12px', color: '#FFFFFF' },
Button: { borderRadiusMedium: '8px', heightMedium: '36px' },
Input: { borderRadius: '8px' },
DataTable: { borderRadius: '12px', thColor: '#F9FAFB', tdColor: '#FFFFFF' },
Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' },
Layout: { color: '#FFFFFF', siderColor: '#FFFFFF' },
Menu: {
itemColorActive: 'rgba(16, 185, 129, 0.12)',
itemTextColorActive: '#047857',
itemIconColorActive: '#10B981',
itemColorActiveHover: 'rgba(16, 185, 129, 0.16)',
},
Radio: {
buttonBorderColorActive: '#10B981',
buttonTextColorActive: '#047857',
buttonColorActive: 'rgba(16, 185, 129, 0.12)',
buttonBoxShadowHover: 'none',
buttonBoxShadowFocus: 'none',
},
}
const darkOverrides: GlobalThemeOverrides = {
common: {
...shared,
bodyColor: '#0F172A',
cardColor: '#1E293B',
modalColor: '#1E293B',
popoverColor: '#1E293B',
borderColor: '#334155',
dividerColor: '#334155',
textColor1: '#F1F5F9',
textColor2: '#94A3B8',
textColor3: '#64748B',
hoverColor: 'rgba(16, 185, 129, 0.12)',
},
Card: { borderRadius: '12px', color: '#1E293B', borderColor: '#334155' },
Button: { borderRadiusMedium: '8px', heightMedium: '36px' },
Input: { borderRadius: '8px', color: '#0F172A' },
DataTable: { borderRadius: '12px', thColor: '#0F172A', tdColor: '#1E293B', borderColor: '#334155' },
Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' },
Layout: { color: '#0F172A', siderColor: '#1E293B' },
Menu: {
itemColorActive: 'rgba(16, 185, 129, 0.18)',
itemTextColorActive: '#6EE7B7',
itemIconColorActive: '#34D399',
itemColorActiveHover: 'rgba(16, 185, 129, 0.24)',
},
Radio: {
buttonBorderColorActive: '#34D399',
buttonTextColorActive: '#6EE7B7',
buttonColorActive: 'rgba(16, 185, 129, 0.22)',
buttonBoxShadowHover: 'none',
buttonBoxShadowFocus: 'none',
},
}
function resolveDark(mode: string) {
if (mode === 'dark') return true
if (mode === 'light') return false
return systemDark()
}
function applyDocumentTheme(dark: boolean) {
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
document.documentElement.classList.toggle('dark', dark)
}
export function useTheme() {
const isDark = computed(() => resolveDark(themeMode.value))
const naiveTheme = computed(() => (isDark.value ? darkTheme : null))
const themeOverrides = computed<GlobalThemeOverrides>(() =>
isDark.value ? darkOverrides : lightOverrides,
)
function setThemeMode(mode: string) {
themeMode.value = mode
}
function toggleTheme() {
themeMode.value = isDark.value ? 'light' : 'dark'
}
watch(
themeMode,
(value) => {
localStorage.setItem(themeKey, value)
applyDocumentTheme(resolveDark(value))
},
{ immediate: true },
)
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (themeMode.value === 'system') {
applyDocumentTheme(systemDark())
}
})
}
return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode }
}
+4
View File
@@ -0,0 +1,4 @@
export const GITHUB_REPO = 'O96u/Fonu'
export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
+26
View File
@@ -0,0 +1,26 @@
/** Mirrors internal/proxy.DefaultPrivateCIDRs — always exempt from china_only / global allow. */
export const DEFAULT_PRIVATE_CIDRS = [
'10.0.0.0/8',
'127.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'::1/128',
'fc00::/7',
]
export function mergeIPList(defaults: string[], custom: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const cidr of [...defaults, ...custom]) {
const value = cidr.trim()
if (!value || seen.has(value)) continue
seen.add(value)
out.push(value)
}
return out
}
export function stripDefaultIPList(defaults: string[], list: string[]): string[] {
const defaultSet = new Set(defaults)
return list.map((item) => item.trim()).filter((item) => item && !defaultSet.has(item))
}
+81
View File
@@ -0,0 +1,81 @@
export interface NotifyEventOption {
key: string
label: string
hint?: string
hasThreshold?: boolean
thresholdKey?: 'ip_frequent' | 'login_failure'
}
export interface NotifyEventGroup {
title: string
events: NotifyEventOption[]
}
export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
{
title: 'DDNS',
events: [
{
key: 'on_ddns_ip_change',
label: 'IP 变更',
hint: '公网 IP 变更并成功同步到 DNS',
},
{
key: 'on_ddns_failure',
label: '更新失败',
},
],
},
{
title: '证书',
events: [
{
key: 'on_cert_expiry',
label: '即将到期',
hint: '进入续签阈值后每天提醒一次',
},
{
key: 'on_cert_renew_success',
label: '续签成功',
},
{
key: 'on_cert_renew_failure',
label: '续签失败',
},
],
},
{
title: '安全',
events: [
{
key: 'on_ip_frequent_access',
label: 'IP 频繁访问',
hint: '同一 IP 在统计窗口内超过阈值',
hasThreshold: true,
thresholdKey: 'ip_frequent',
},
{
key: 'on_login_failure',
label: '登录异常',
hint: '同一 IP 在窗口内多次登录失败',
hasThreshold: true,
thresholdKey: 'login_failure',
},
],
},
{
title: '系统',
events: [
{
key: 'on_nginx_reload_failure',
label: 'Nginx 重载失败',
hint: '保存反代或证书后 Nginx 未能成功重载',
},
],
},
]
export const DEFAULT_IP_FREQUENT_THRESHOLD = 100
export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60
export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5
export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300
+19
View File
@@ -0,0 +1,19 @@
import type { NotifyType, WebhookProvider } from '../api/types'
export const NOTIFY_TYPE_OPTIONS: { label: string; value: NotifyType }[] = [
{ label: '关闭', value: '' },
{ label: '邮件(SMTP)', value: 'email' },
{ label: 'Webhook', value: 'webhook' },
{ label: 'Telegram 机器人', value: 'telegram' },
]
export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }[] = [
{ label: 'Bark', value: 'bark' },
{ label: 'ntfy', value: 'ntfy' },
{ label: 'Gotify', value: 'gotify' },
{ label: '自定义 Webhook', value: 'custom' },
]
export const DEFAULT_BARK_SERVER = 'https://api.day.app'
export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
export const MASKED_SECRET = '********'
+3
View File
@@ -0,0 +1,3 @@
/** 敏感凭据字段:标签与占位符全站统一 */
export const CONFIGURED_SECRET_TAG = '已配置'
export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改'
+14
View File
@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
declare module '*.png' {
const src: string
export default src
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+484
View File
@@ -0,0 +1,484 @@
<template>
<n-layout has-sider class="app-layout">
<n-layout-sider
class="app-sider"
bordered
collapse-mode="width"
:collapsed-width="72"
:width="240"
:collapsed="collapsed"
show-trigger="bar"
@collapse="collapsed = true"
@expand="collapsed = false"
>
<router-link class="brand" :class="{ 'brand--collapsed': collapsed }" :to="{ name: 'dashboard' }">
<img class="brand__logo" :src="logoImg" alt="Fonu" />
<div v-if="!collapsed" class="brand__text">
<div class="brand__name">Fonu</div>
<div class="brand__tagline">让 NAS 访问更简单</div>
</div>
</router-link>
<n-menu
class="app-menu"
:value="activeKey"
:collapsed="collapsed"
:collapsed-width="72"
:options="menuOptions"
@update:value="handleMenu"
/>
<div v-if="!collapsed" class="sider-footer">
<div class="sider-footer__version-row">
<div class="sider-footer__title">
<span class="sider-footer__brand">Fonu</span>
<span class="sider-footer__version">v{{ appVersion }}</span>
</div>
<a
v-if="hasUpdate"
class="sider-footer__update"
:href="latestReleaseUrl"
target="_blank"
rel="noopener noreferrer"
>
<n-icon :component="ArrowUpOutline" />
有新版本
<n-icon :component="OpenOutline" />
</a>
</div>
<div class="sider-footer__status">
<n-icon :component="Ellipse" class="sider-footer__status-icon" />
<span>运行中</span>
</div>
<a
class="sider-footer__github"
:href="GITHUB_REPO_URL"
target="_blank"
rel="noopener noreferrer"
>
<n-icon :component="LogoGithub" />
<span>{{ GITHUB_REPO }}</span>
</a>
</div>
</n-layout-sider>
<n-layout class="app-main">
<header class="topbar">
<span class="topbar__datetime">{{ datetime }}</span>
<div class="topbar__right">
<n-button quaternary circle @click="toggleTheme">
<template #icon>
<n-icon :component="isDark ? MoonOutline : SunnyOutline" />
</template>
</n-button>
<n-dropdown :options="userMenuOptions" @select="handleUserMenu">
<n-button quaternary class="user-btn">
<n-icon :component="PersonOutline" class="user-btn__icon" />
<span class="user-name">管理员</span>
<n-icon :component="ChevronDownOutline" class="user-btn__chevron" />
</n-button>
</n-dropdown>
</div>
</header>
<n-layout-content class="content">
<div class="page-shell page-body">
<router-view />
</div>
</n-layout-content>
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NButton,
NDropdown,
NIcon,
NLayout,
NLayoutContent,
NLayoutSider,
NMenu,
useMessage,
type MenuOption,
} from 'naive-ui'
import {
ArrowUpOutline,
ChevronDownOutline,
CloudOutline,
CogOutline,
DocumentTextOutline,
Ellipse,
GitNetworkOutline,
GridOutline,
LockClosedOutline,
LogoGithub,
MoonOutline,
OpenOutline,
PersonOutline,
SunnyOutline,
SwapHorizontalOutline,
HardwareChipOutline,
ShieldCheckmarkOutline,
} from '@vicons/ionicons5'
import { api } from '../api/client'
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
import { useTheme } from '../composables/useTheme'
import { fetchLatestRelease, isNewerVersion } from '../utils/version'
import logoImg from '../assets/brand/logo.png'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { isDark, toggleTheme } = useTheme()
const collapsed = ref(false)
const datetime = ref('')
const appVersion = ref(__APP_VERSION__)
const latestVersion = ref('')
const latestReleaseUrl = ref('')
const hasUpdate = ref(false)
let clockTimer: number | undefined
async function checkForUpdate() {
const release = await fetchLatestRelease()
if (!release) {
return
}
latestVersion.value = release.version
latestReleaseUrl.value = release.url
hasUpdate.value = isNewerVersion(release.version, appVersion.value)
}
async function loadVersion() {
try {
const { version } = await api.getVersion()
if (version) {
appVersion.value = version
}
} catch {
// keep build-time fallback
}
await checkForUpdate()
}
const menuDefs = [
{ label: '仪表盘', key: 'dashboard', icon: GridOutline },
{ label: 'DDNS', key: 'ddns', icon: CloudOutline },
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网 Agent', key: 'frp-agent', icon: HardwareChipOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
]
const menuOptions = computed<MenuOption[]>(() =>
menuDefs.map((item) => ({
label: item.label,
key: item.key,
icon: () => h(NIcon, null, { default: () => h(item.icon) }),
})),
)
const activeKey = computed(() => {
const name = route.name
return typeof name === 'string' ? name : 'dashboard'
})
const userMenuOptions = [{ label: '退出登录', key: 'logout' }]
function updateClock() {
const now = new Date()
const date = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
const time = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
const weekday = now.toLocaleDateString('zh-CN', { weekday: 'long' })
datetime.value = `${date} ${time} · ${weekday}`
}
function handleMenu(key: string) {
router.push({ name: key })
}
async function handleUserMenu(key: string) {
if (key === 'logout') {
await api.logout()
message.success('已退出登录')
router.push({ name: 'login' })
}
}
onMounted(() => {
loadVersion()
updateClock()
clockTimer = window.setInterval(updateClock, 30_000)
})
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer)
})
</script>
<style scoped>
.app-layout {
min-height: 100vh;
height: 100vh;
background: var(--fonu-bg);
}
.app-layout :deep(.n-layout-scroll-container) {
min-height: 100%;
}
.app-sider {
background: var(--fonu-surface) !important;
border-right: 1px solid var(--fonu-border) !important;
min-height: 100vh;
}
.app-sider :deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
padding: var(--fonu-space-5) var(--fonu-space-4);
border-bottom: 1px solid var(--fonu-border);
color: inherit;
text-decoration: none;
cursor: pointer;
transition: background 0.15s ease;
}
.brand:hover {
background: var(--fonu-bg-muted);
}
.brand--collapsed {
justify-content: center;
padding: var(--fonu-space-4) 0;
}
.brand__logo {
width: 40px;
height: 40px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
.brand__name {
font-size: 18px;
font-weight: 700;
color: var(--fonu-text);
line-height: 1.2;
}
.brand__tagline {
font-size: 11px;
color: var(--fonu-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.app-menu {
flex: 1;
padding: var(--fonu-space-3);
}
.app-menu :deep(.n-menu-item) {
margin-bottom: 2px;
}
.app-menu :deep(.n-menu-item-content) {
border-radius: var(--fonu-radius-sm) !important;
padding-left: 14px !important;
}
.app-menu :deep(.n-menu-item-content--selected) {
background: var(--fonu-brand-soft) !important;
font-weight: 600;
}
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header),
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header *) {
color: var(--fonu-brand-text) !important;
}
.app-menu :deep(.n-menu-item-content--selected .n-icon),
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content__icon) {
color: var(--fonu-brand) !important;
}
.app-menu :deep(.n-menu-item-content--selected::before) {
display: none !important;
}
.sider-footer {
padding: var(--fonu-space-4);
border-top: 1px solid var(--fonu-border);
}
.sider-footer__version-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: var(--fonu-space-4);
}
.sider-footer__title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.sider-footer__brand {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text);
}
.sider-footer__version {
font-size: 12px;
color: var(--fonu-text-muted);
}
.sider-footer__update {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 500;
color: var(--fonu-brand);
text-decoration: none;
white-space: nowrap;
transition: opacity 0.15s;
}
.sider-footer__update:hover {
opacity: 0.8;
}
.sider-footer__update :deep(.n-icon) {
font-size: 12px;
}
.sider-footer__status {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: var(--fonu-space-4);
font-size: 12px;
color: var(--fonu-success);
font-weight: 500;
}
.sider-footer__status-icon {
font-size: 8px;
color: var(--fonu-success);
}
.sider-footer__github {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--fonu-text-secondary);
text-decoration: none;
transition: color 0.15s;
}
.sider-footer__github:hover {
color: var(--fonu-text);
}
.sider-footer__github :deep(.n-icon) {
font-size: 15px;
}
.app-main {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--fonu-bg);
}
.topbar {
flex-shrink: 0;
height: var(--fonu-topbar-height);
padding: 0 var(--fonu-space-5);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
background: var(--fonu-surface);
border-bottom: 1px solid var(--fonu-border);
}
.topbar__datetime {
font-size: 13px;
color: var(--fonu-text-secondary);
}
.topbar__right {
display: flex;
align-items: center;
gap: var(--fonu-space-4);
}
.user-btn {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
padding: 0 var(--fonu-space-2);
}
.user-btn__icon,
.user-btn__chevron {
font-size: 18px;
color: var(--fonu-text-secondary);
}
.user-name {
font-size: 14px;
color: var(--fonu-text);
}
.content {
flex: 1;
padding: var(--fonu-space-5);
overflow: auto;
}
.page-body {
min-height: 100%;
}
@media (max-width: 768px) {
.topbar,
.content {
padding-inline: var(--fonu-space-4);
}
.user-name,
.topbar__datetime {
display: none;
}
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import naive from 'naive-ui'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.config.errorHandler = (err, _instance, info) => {
console.error('[fonu]', info, err)
}
app.use(router).use(naive).mount('#app')
+92
View File
@@ -0,0 +1,92 @@
import { createRouter, createWebHistory } from 'vue-router'
import { api } from '../api/client'
import AppLayout from '../layouts/AppLayout.vue'
import LoginView from '../views/LoginView.vue'
import ProxyView from '../views/ProxyView.vue'
import DashboardView from '../views/DashboardView.vue'
import DdnsView from '../views/DdnsView.vue'
import CertificatesView from '../views/CertificatesView.vue'
import LogsView from '../views/LogsView.vue'
import SettingsView from '../views/SettingsView.vue'
import FrpView from '../views/FrpView.vue'
import AgentView from '../views/AgentView.vue'
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/setup', redirect: '/login' },
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/dashboard' },
{
path: 'dashboard',
name: 'dashboard',
component: DashboardView,
meta: { title: '仪表盘', description: '查看 Fonu 运行状态与关键指标' },
},
{
path: 'ddns',
name: 'ddns',
component: DdnsView,
meta: { title: 'DDNS', description: '自动同步公网 IP 到 DNS 解析' },
},
{
path: 'certificates',
name: 'certificates',
component: CertificatesView,
meta: { title: '证书管理', description: '通过 ACME 自动申请和续签证书' },
},
{
path: 'proxies',
name: 'proxies',
component: ProxyView,
meta: { title: '反向代理', description: '管理通过域名访问的 NAS 服务' },
},
{
path: 'frp',
name: 'frp',
component: FrpView,
meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Fonu Nginx' },
},
{
path: 'frp-agent',
name: 'frp-agent',
component: AgentView,
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' },
},
{
path: 'public-proxy',
name: 'public-proxy',
component: ProxyRemoteView,
meta: { title: '公网反代', description: '管理 VPS 上 Nginx 反代的部署/移除与安全选项' },
},
{
path: 'logs',
name: 'logs',
component: LogsView,
meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' },
},
{
path: 'settings',
name: 'settings',
component: SettingsView,
meta: { title: '系统设置', description: '配置系统参数与运行策略' },
},
],
},
],
})
router.beforeEach(async (to) => {
const status = await api.authStatus()
if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' }
if (to.name === 'login' && status.authenticated) return { name: 'dashboard' }
return true
})
export default router
+63
View File
@@ -0,0 +1,63 @@
:root {
--fonu-brand: #10b981;
--fonu-brand-hover: #059669;
--fonu-brand-soft: rgba(16, 185, 129, 0.12);
--fonu-brand-text: #047857;
--fonu-brand-sidebar: #10b981;
--fonu-success: #10b981;
--fonu-warning: #f59e0b;
--fonu-error: #ef4444;
--fonu-disabled: #94a3b8;
--fonu-info: #3b82f6;
--fonu-info-soft: rgba(59, 130, 246, 0.1);
--fonu-bg: #f8fafc;
--fonu-bg-muted: #f3f4f6;
--fonu-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%);
--fonu-banner-border: rgba(16, 185, 129, 0.2);
--fonu-surface: #ffffff;
--fonu-border: #e5e7eb;
--fonu-text: #111827;
--fonu-text-secondary: #6b7280;
--fonu-text-muted: #9ca3af;
--fonu-radius: 12px;
--fonu-radius-sm: 8px;
--fonu-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--fonu-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06);
--fonu-sidebar-width: 240px;
--fonu-topbar-height: 56px;
--fonu-form-max: 720px;
--fonu-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--fonu-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace;
--fonu-space-1: 4px;
--fonu-space-2: 8px;
--fonu-space-3: 12px;
--fonu-space-4: 16px;
--fonu-space-5: 24px;
--fonu-space-6: 32px;
}
html.dark,
[data-theme='dark'] {
--fonu-brand-soft: rgba(16, 185, 129, 0.18);
--fonu-brand-text: #6ee7b7;
--fonu-info-soft: rgba(59, 130, 246, 0.16);
--fonu-bg: #0f172a;
--fonu-bg-muted: #1e293b;
--fonu-surface: #1e293b;
--fonu-border: #334155;
--fonu-text: #f1f5f9;
--fonu-text-secondary: #94a3b8;
--fonu-text-muted: #64748b;
--fonu-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%);
--fonu-banner-border: rgba(52, 211, 153, 0.25);
--fonu-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--fonu-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
}
+138
View File
@@ -0,0 +1,138 @@
import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types'
export type ProxyBinding = {
name: string
upstream: string
host: string
port: number
}
function bindingKey(host: string, port: number): string {
return `${host.toLowerCase()}:${port}`
}
function normalizeAccessHost(domain: string): string {
const raw = domain.trim().toLowerCase()
if (!raw) return ''
if (raw.startsWith('[')) {
const end = raw.indexOf(']')
if (end > 0) return raw.slice(0, end + 1)
}
if (raw.includes(':') && !raw.includes(']')) {
const idx = raw.lastIndexOf(':')
const maybePort = Number.parseInt(raw.slice(idx + 1), 10)
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
return raw.slice(0, idx)
}
}
return raw
}
export function buildProxyBindingIndex(rules: ProxyRule[]): Map<string, ProxyBinding> {
const index = new Map<string, ProxyBinding>()
for (const rule of rules) {
const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}`
const name = rule.name?.trim() || fallbackName
const hosts: ProxyHost[] = rule.hosts?.length
? rule.hosts
: rule.domain
? [{ id: 0, hostname: rule.domain }]
: []
for (const host of hosts) {
const hostname = host.hostname?.trim()
if (!hostname) continue
const port = host.listen_port ?? rule.listen_port
if (port <= 0) continue
index.set(bindingKey(hostname, port), {
name,
upstream: rule.upstream,
host: hostname.toLowerCase(),
port,
})
}
}
return index
}
function formatUpstreamShort(upstream: string): string {
return upstream.replace(/^https?:\/\//i, '')
}
/** Raw host:port / domain / IP shown when no proxy rule matches. */
export function formatAccessFallback(entry: AccessLogEntry): string {
const rawDomain = entry.domain?.trim()
const host = normalizeAccessHost(entry.domain)
const port = entry.server_port ?? 0
if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) {
return rawDomain
}
if (rawDomain?.includes(':') && !rawDomain.includes(']')) {
const idx = rawDomain.lastIndexOf(':')
const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10)
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
return rawDomain
}
}
if (host && port > 0) return `${host}:${port}`
if (rawDomain) return rawDomain
if (host) return host
return entry.client_ip?.trim() || '—'
}
export function resolveAccessServiceLabel(
entry: AccessLogEntry,
index: Map<string, ProxyBinding>,
): string {
const host = normalizeAccessHost(entry.domain)
const port = entry.server_port ?? 0
// 域名 + 端口(同域多端口)
if (host && port > 0) {
const hit = index.get(bindingKey(host, port))
if (hit) return hit.name
}
// 仅域名(单规则或同域单端口)
if (host) {
const matches = [...index.values()].filter((binding) => binding.host === host)
if (matches.length === 1) return matches[0].name
if (matches.length > 1 && port > 0) {
const byPort = matches.find((binding) => binding.port === port)
if (byPort) return byPort.name
}
}
// 仅 IP / 域名直连、无 Host 匹配时走 upstream
const upstream = entry.upstream?.trim()
if (upstream && upstream !== '-') {
return formatUpstreamShort(upstream)
}
return formatAccessFallback(entry)
}
export function accessServiceTooltip(entry: AccessLogEntry, index: Map<string, ProxyBinding>): string {
const host = normalizeAccessHost(entry.domain)
const port = entry.server_port ?? 0
const label = resolveAccessServiceLabel(entry, index)
const parts: string[] = [label]
const requestHost = formatAccessFallback(entry)
if (requestHost !== label) {
parts.push(`请求 ${requestHost}`)
} else if (host) {
parts.push(port > 0 ? `${host}:${port}` : host)
}
const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined
if (binding?.upstream) {
parts.push(`→ ${binding.upstream}`)
} else if (entry.upstream && entry.upstream !== '-') {
parts.push(`→ ${entry.upstream}`)
}
if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`)
if (entry.path) parts.push(entry.path)
return parts.join(' · ')
}
+103
View File
@@ -0,0 +1,103 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2
return `${value.toFixed(digits)} ${units[unit]}`
}
export function formatRate(bytesPerSec: number): string {
if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s'
return `${formatBytes(bytesPerSec)}/s`
}
export function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h} 小时 ${m} 分钟`
return `${m} 分钟`
}
export function formatRelativeTime(iso?: string): string {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const diff = Date.now() - date.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
export function formatMs(seconds: number): string {
const ms = Math.round(seconds * 1000)
return `${ms} ms`
}
export function formatDate(iso?: string): string {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
export function formatLogTime(value?: string): string {
if (!value) return '-'
const trimmed = value.trim()
const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
if (standard) return standard[1]
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/)
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}`
const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}`
const date = new Date(trimmed)
if (!Number.isNaN(date.getTime())) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
return trimmed
}
/** Normalize leading timestamp in a raw log line */
export function formatLogLine(line: string): string {
const trimmed = line.trim()
if (!trimmed) return line
const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/)
if (nginx) return formatLogTime(nginx[1]) + nginx[2]
const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/)
if (iso) return formatLogTime(iso[1]) + iso[2]
if (trimmed.startsWith('{')) {
try {
const raw = JSON.parse(trimmed) as { time?: string }
if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time))
} catch {
// not JSON
}
}
return trimmed
}
+165
View File
@@ -0,0 +1,165 @@
import type { SystemLogEntry } from '../api/types'
import { formatLogTime } from './format'
export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default'
export interface DisplaySystemLogEntry extends SystemLogEntry {
displayLevel: string
displayModule: string
}
const SYSTEM_LEVEL_LABELS: Record<string, string> = {
INFO: '信息',
WARN: '警告',
ERROR: '错误',
DEBUG: '调试',
}
const SYSTEM_MODULE_LABELS: Record<string, string> = {
SYSTEM: '系统',
NGINX: 'Nginx',
DDNS: '域名解析',
ACME: '证书',
CERT: '证书',
CHINA_CIDR: '中国 IP 段',
API: 'API',
HTTP: 'HTTP',
FRP: '内网穿透',
}
export function systemLevelLabel(level: string): string {
const upper = level.trim().toUpperCase()
return SYSTEM_LEVEL_LABELS[upper] ?? level
}
export function systemModuleLabel(module: string): string {
const trimmed = module.trim()
if (!trimmed) return '系统'
const upper = trimmed.toUpperCase()
return SYSTEM_MODULE_LABELS[upper] ?? trimmed
}
export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry {
const message = entry.message.trim()
const msgLower = message.toLowerCase()
let level = entry.level.trim().toUpperCase()
let displayModule = systemModuleLabel(entry.module)
if (
level === 'INFO' &&
(msgLower.includes('server error') ||
msgLower.includes(' failed') ||
msgLower.startsWith('error') ||
msgLower.includes('bind:'))
) {
level = 'ERROR'
}
return {
...entry,
displayLevel: systemLevelLabel(level),
displayModule,
}
}
export interface ParsedNginxError {
time: string
level: string
message: string
raw: string
}
export function parseNginxErrorLine(line: string): ParsedNginxError {
const raw = line.trim()
const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/)
if (!match) {
return { time: '', level: 'unknown', message: raw, raw }
}
return {
time: formatLogTime(match[1]),
level: match[2].toLowerCase(),
message: match[3],
raw,
}
}
export function nginxLevelTagType(level: string): LogTagType {
switch (level.toLowerCase()) {
case 'emerg':
case 'alert':
case 'crit':
case 'error':
return 'error'
case 'warn':
case 'warning':
return 'warning'
case 'notice':
case 'info':
return 'info'
default:
return 'default'
}
}
export function systemLevelTagType(level: string): LogTagType {
const upper = level.trim().toUpperCase()
if (upper === 'ERROR' || level === '错误') return 'error'
if (upper === 'WARN' || level === '警告') return 'warning'
if (upper === 'INFO' || level === '信息') return 'info'
return 'default'
}
export function systemModuleTagType(module: string): LogTagType {
const upper = module.trim().toUpperCase()
switch (upper) {
case 'NGINX':
case 'Nginx':
return 'warning'
case 'SYSTEM':
case '系统':
return 'success'
case 'CHINA_CIDR':
case '中国 IP 段':
return 'info'
case 'DDNS':
case '域名解析':
return 'info'
case 'CERT':
case 'ACME':
case '证书':
return 'warning'
default:
return 'default'
}
}
export function latencyClass(seconds: number): string {
const ms = seconds * 1000
if (ms >= 3000) return 'log-duration log-duration--critical'
if (ms >= 1000) return 'log-duration log-duration--slow'
return 'log-duration'
}
export function streamLineClass(line: string): string {
const parsed = parseNginxErrorLine(line)
if (parsed.level !== 'unknown') {
const type = nginxLevelTagType(parsed.level)
if (type === 'error') return 'level-error'
if (type === 'warning') return 'level-warn'
if (type === 'info') return 'level-info'
}
const lower = line.toLowerCase()
if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) {
return 'level-error'
}
if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn'
const statusMatch = line.match(/\s(\d{3})\s+\d/)
if (statusMatch) {
const code = Number(statusMatch[1])
if (code >= 500) return 'level-error'
if (code >= 400) return 'level-warn'
}
return ''
}
+42
View File
@@ -0,0 +1,42 @@
export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown'
const LABELS: Record<string, string> = {
ok: '正常',
running: '运行中',
warning: '部分异常',
error: '异常',
disabled: '停用',
none: '未配置',
stopped: '已停止',
unknown: '未知',
}
export function statusLabel(value?: string): string {
if (!value) return '未知'
return LABELS[value] ?? value
}
export function statusKind(value?: string): StatusKind {
switch (value) {
case 'ok':
case 'running':
return 'success'
case 'warning':
return 'warning'
case 'error':
return 'error'
case 'disabled':
case 'stopped':
case 'none':
return 'disabled'
default:
return 'unknown'
}
}
export function httpStatusKind(code: number): StatusKind {
if (code >= 500) return 'error'
if (code >= 400) return 'warning'
if (code >= 300) return 'unknown'
return 'success'
}
+34
View File
@@ -0,0 +1,34 @@
import { h, type VNode } from 'vue'
import { NButton, NSpace } from 'naive-ui'
import type { ButtonProps } from 'naive-ui'
export interface TableRowAction {
label: string
onClick: () => void
type?: ButtonProps['type']
/** 为 false 时隐藏该操作,默认显示 */
show?: boolean
}
/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */
export function renderTableRowActions(actions: TableRowAction[]): VNode {
const buttons = actions
.filter((action) => action.show !== false)
.map((action) =>
h(
NButton,
{
size: 'small',
quaternary: true,
type: action.type,
onClick: (e: MouseEvent) => {
e.stopPropagation()
action.onClick()
},
},
() => action.label,
),
)
return h(NSpace, { size: 4, wrap: false }, () => buttons)
}
+56
View File
@@ -0,0 +1,56 @@
import { GITHUB_REPO } from '../constants/app'
export interface LatestRelease {
version: string
url: string
}
function parseVersion(version: string): number[] {
const clean = version.replace(/^v/i, '').split('-')[0]
return clean.split('.').map((part) => Number.parseInt(part, 10) || 0)
}
export function compareVersions(a: string, b: string): number {
const left = parseVersion(a)
const right = parseVersion(b)
const length = Math.max(left.length, right.length)
for (let i = 0; i < length; i += 1) {
const diff = (left[i] ?? 0) - (right[i] ?? 0)
if (diff !== 0) {
return diff > 0 ? 1 : -1
}
}
return 0
}
export function isNewerVersion(latest: string, current: string): boolean {
if (!latest || !current || current === 'dev') {
return false
}
return compareVersions(latest, current) > 0
}
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
try {
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
})
if (!response.ok) {
return null
}
const data = (await response.json()) as { tag_name?: string; html_url?: string }
if (!data.tag_name || !data.html_url) {
return null
}
return {
version: data.tag_name.replace(/^v/i, ''),
url: data.html_url,
}
} catch {
return null
}
}
+368
View File
@@ -0,0 +1,368 @@
<template>
<PageHeader title="公网 Agent" description="管理部署在公网 VPS 上的 fonu-agent 与 frps 服务">
<template #actions>
<n-select
v-model:value="selectedId"
:options="serverOptions"
placeholder="选择公网服务端"
style="width: 220px"
:disabled="servers.length === 0"
/>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else>
<EmptyState
v-if="servers.length === 0"
title="尚未配置 FRP 服务端"
description="先在「内网穿透」页添加公网服务端并保存 SSH 信息,再回到本页管理 Agent。"
>
<template #action>
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
</template>
</EmptyState>
<template v-else-if="server">
<section class="status-grid">
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatusOk ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="CloudOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">Agent 连接</div>
<div class="status-card__value">{{ agentStatusOk ? '在线' : '未连接' }}</div>
<div class="status-card__meta mono">{{ server.agent_url || '未配置 Agent 地址' }}</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.frps_active ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="GitNetworkOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">frps 服务</div>
<div class="status-card__value">{{ agentStatus?.frps_active ? '运行中' : '未运行' }}</div>
<div class="status-card__meta">{{ agentStatus?.frps_version?.trim() || '未安装' }}</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.ports?.['80'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="SwapHorizontalOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">80 端口</div>
<div class="status-card__value">{{ agentStatus?.ports?.['80'] ? '监听中' : '未监听' }}</div>
<div class="status-card__meta">无端口访问的入口</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.ports?.['443'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="LockClosedOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">443 端口</div>
<div class="status-card__value">{{ agentStatus?.ports?.['443'] ? '监听中' : '未监听' }}</div>
<div class="status-card__meta">HTTPS 入口</div>
</div>
</FonuCard>
</section>
<section class="agent-page-grid">
<FonuCard class="section-card" title="frps 服务控制">
<template #header>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="refreshAll">刷新</n-button>
</template>
<div class="agent-section-body">
<div class="field-grid">
<div><label>Agent 版本</label><span class="field-tags"><n-tag size="small" :bordered="false">{{ agentStatus?.agent_version || '-' }}</n-tag></span></div>
<div><label>frps 启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '-' }}</span></div>
<div><label>frps 配置</label><span class="mono" :title="agentStatus?.frps_config_path">{{ agentStatus?.frps_config_path || '-' }}</span></div>
<div><label>开机自启</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_enabled ? 'success' : 'warning'">{{ agentStatus?.frps_enabled ? '已启用' : '未启用' }}</n-tag></span></div>
<div><label>7000 端口</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.ports?.['7000'] ? 'success' : 'warning'">{{ agentStatus?.ports?.['7000'] ? '监听中' : '未监听' }}</n-tag></span></div>
</div>
<div class="agent-actions">
<n-button v-if="agentStatus?.frps_active" size="small" type="warning" :loading="agentActionId === server.id" @click="frpsAction(server, 'stop')">停止 frps</n-button>
<n-button v-else size="small" type="success" :loading="agentActionId === server.id" @click="frpsAction(server, 'start')">启动 frps</n-button>
<n-button size="small" :loading="agentActionId === server.id" @click="frpsAction(server, 'restart')">重启 frps</n-button>
<n-button v-if="dashboardURL" size="small" quaternary @click="openDashboard(dashboardURL)">frps 管理</n-button>
</div>
<p v-if="!dashboardURL" class="form-hint" style="margin-top: 8px">未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存即可开启面板入口</p>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 配置与版本">
<div class="agent-section-body">
<div class="field-grid">
<div><label>已安装 frps 版本</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_version ? 'success' : 'warning'">{{ installedFrpsVersion || '未安装' }}</n-tag></span></div>
<div><label>安装位置</label><span class="mono" :title="agentStatus?.frps_binary">{{ agentStatus?.frps_binary || '-' }}</span></div>
</div>
<div class="agent-actions" style="margin-top: var(--fonu-space-3)">
<n-button size="small" :loading="agentActionId === server.id" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
</div>
<div class="frps-install-row">
<n-select v-model:value="frpsInstallVersion" :options="frpsVersionOptions" placeholder="选择 frps 官方版本" style="width: 200px" filterable />
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installFrps(server)">安装/升级 frps</n-button>
</div>
<p class="form-hint">VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置。</p>
</div>
</FonuCard>
</section>
<section class="agent-page-grid">
<FonuCard class="section-card" title="公网 Nginx 反代">
<div class="agent-section-body">
<p class="form-hint">反代管理已独立成「公网反代」页面:逐条部署/移除、配置查看、强制跳转、IP 白名单、Basic Auth、WebSocket 等都在那里。</p>
<router-link to="/public-proxy"><n-button size="small" type="primary">前往公网反代</n-button></router-link>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 最近日志">
<div class="agent-section-body">
<pre class="code-block">{{ agentStatus?.frps_log_tail || '暂无日志' }}</pre>
</div>
</FonuCard>
</section>
<FonuCard class="section-card" title="Agent 生命周期(SSH)">
<template #header>
<div class="section-actions">
<n-button size="small" type="primary" :loading="agentActionId === server.id" @click="probeAgent(server)">探测环境</n-button>
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
<n-button size="small" type="warning" :loading="agentActionId === server.id" @click="uninstallAgent(server, false)">卸载(含数据)</n-button>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="uninstallAgent(server, true)">卸载(保留路由/证书)</n-button>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="diagnoseAgent(server)">SSH 诊断</n-button>
</div>
</template>
<div class="agent-section-body">
<p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
</p>
<pre v-if="agentOutput" class="code-block">{{ agentOutput }}</pre>
</div>
</FonuCard>
</template>
</template>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import { CloudOutline, GitNetworkOutline, LockClosedOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import { api } from '../api/client'
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
const message = useMessage()
const servers = ref<FrpServer[]>([])
const selectedId = ref<number | null>(null)
const loadError = ref('')
const agentStatus = ref<FrpAgentStatus | null>(null)
const agentStatusOk = ref(false)
const agentActionId = ref<number | null>(null)
const agentOutput = ref('')
const frpsInstallVersion = ref<string | null>(null)
const releases = ref<FrpRelease[]>([])
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 })))
const installedFrpsVersion = computed(() => (agentStatus.value?.frps_version || '').trim() || '')
const frpsVersionOptions = computed(() => [
...(releases.value.length ? [] : [{ label: '0.71.0(默认,官方版本未加载)', value: '0.71.0' }]),
...releases.value.map((release) => ({ label: release.version, value: release.version })),
])
async function load() {
loadError.value = ''
try {
servers.value = (await api.listFrpServers()).filter((item) => item.agent_configured || item.ssh_configured)
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
selectedId.value = servers.value[0]?.id ?? null
}
if (server.value) void refreshAll()
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
async function refreshAll() {
const current = server.value
if (!current) return
agentActionId.value = current.id
try {
agentStatus.value = await api.getFrpAgentStatus(current.id)
agentStatusOk.value = true
} catch (error) {
agentStatusOk.value = false
agentOutput.value = error instanceof Error ? error.message : 'Agent 状态获取失败'
} finally { agentActionId.value = null }
}
async function probeAgent(target: FrpServer) {
agentActionId.value = target.id
try {
const result = await api.probeFrpAgent(target.id)
agentOutput.value = Object.entries(result).map(([key, value]) => `${key}: ${value}`).join('\n')
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '探测失败' }
finally { agentActionId.value = null }
}
async function installAgent(target: FrpServer) {
agentActionId.value = target.id
try {
await api.installFrpAgent(target.id)
agentOutput.value = 'Agent 安装完成,已自动回填 Agent 地址与 Token'
message.success('Agent 安装成功')
await load()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装失败' }
finally { agentActionId.value = null }
}
async function uninstallAgent(target: FrpServer, keepData: boolean) {
agentActionId.value = target.id
try {
const result = await api.uninstallFrpAgent(target.id, keepData)
agentOutput.value = result.output || '已卸载'
message.success('Agent 已卸载')
await load()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '卸载失败' }
finally { agentActionId.value = null }
}
async function diagnoseAgent(target: FrpServer) {
agentActionId.value = target.id
try {
const result = await api.sshDiagnoseFrpAgent(target.id)
agentOutput.value = result.output || '无输出'
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '诊断失败' }
finally { agentActionId.value = null }
}
// frps 管理面板地址:直接读服务端表单里已配置的「frps 管理接口」(dashboard_addr/port);
// 监听 0.0.0.0/:: 时用服务端公网地址拼 URL(浏览器在外网访问)
const dashboardURL = computed(() => {
const current = server.value
if (!current) return ''
const addr = (current.options.dashboard_addr || '').trim()
const port = current.options.dashboard_port || 0
if (!addr || !port) return ''
const host = addr === '0.0.0.0' || addr === '::' ? (current.server_addr || '127.0.0.1') : addr
return `http://${host}:${port}`
})
function openDashboard(url: string) {
if (url) window.open(url, '_blank', 'noopener')
}
async function frpsAction(target: FrpServer, action: 'start' | 'stop' | 'restart') {
agentActionId.value = target.id
const actionText = action === 'stop' ? '停止' : action === 'start' ? '启动' : '重启'
try {
// agent 对"命令成功但服务未进入运行态"返回 200+ok:false+error(含 journal 日志)
const result = await api.frpsServerActionRaw(target.id, action)
if (result && result.ok === false) {
agentOutput.value = result.error || `frps ${actionText}后未进入预期状态`
message.error(`frps ${actionText}失败,详见输出`)
} else {
message.success(`frps 已${actionText}`)
}
await refreshAll()
} catch (error) {
agentOutput.value = error instanceof Error ? error.message : 'frps 操作失败'
message.error(`frps ${actionText}失败,详见输出`)
} finally { agentActionId.value = null }
}
async function pushAgentConfig(target: FrpServer) {
agentActionId.value = target.id
try {
await api.pushFrpConfigToAgent(target.id)
message.success('frps 配置已下发')
agentOutput.value = 'frps 同源配置已下发到 VPS,点「重启 frps」后生效'
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '下发配置失败' }
finally { agentActionId.value = null }
}
async function installFrps(target: FrpServer) {
agentActionId.value = target.id
try {
const version = frpsInstallVersion.value?.trim() || '0.71.0'
const result = await api.installFrpServer(target.id, version, '')
const notes = Array.isArray(result?.notes) ? result.notes.join('\n') : ''
agentOutput.value = `frps ${result?.version ?? version} 安装完成\n${notes}`
message.success('VPS frps 已安装')
await refreshAll()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装 frps 失败' }
finally { agentActionId.value = null }
}
async function loadReleases() {
if (releases.value.length > 0) return
try { releases.value = await api.listFrpReleases() } catch { /* 回退默认版本 */ }
}
onMounted(() => {
void load()
void loadReleases()
timer = window.setInterval(() => { if (server.value) void refreshAll() }, 10000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
</script>
<style scoped>
/* 对齐 DDNS 页风格:surface 卡片、settings-grid 字段、统一代码块 */
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.status-card { display: flex; gap: var(--fonu-space-3); align-items: center; padding: var(--fonu-space-4) var(--fonu-space-5); }
.status-card__icon { width: 44px; height: 44px; border-radius: var(--fonu-radius-sm); display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0; }
.status-card__icon--running { background: rgba(34, 197, 94, 0.14); color: #16a34a; }
.status-card__icon--stopped { background: rgba(148, 163, 184, 0.16); color: #64748b; }
.status-card__body { min-width: 0; }
.status-card__label { color: var(--fonu-text-secondary); font-size: 12px; }
.status-card__value { font-size: 18px; font-weight: 700; color: var(--fonu-text); }
.status-card__meta { color: var(--fonu-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-page-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.agent-section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
.field-grid > div { display: grid; gap: 4px; min-width: 0; }
.field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.field-grid .mono { font-family: var(--fonu-mono); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field-grid .mono-wrap { white-space: normal; word-break: break-all; overflow: visible; text-overflow: unset; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-4); }
.route-list { display: grid; gap: 8px; margin-top: var(--fonu-space-3); }
.route-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
.route-item__main { display: grid; gap: 3px; min-width: 0; }
.route-item__domains { color: var(--fonu-text); font-size: 12px; overflow-wrap: anywhere; }
.route-item__upstream { color: var(--fonu-text-muted); font-size: 12px; }
.route-item__actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
.empty-route { margin-top: var(--fonu-space-3); padding: 18px; border: 1px dashed var(--fonu-border); border-radius: var(--fonu-radius-sm); color: var(--fonu-text-muted); text-align: center; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-4); flex-wrap: wrap; }
.frps-install-row .form-hint { flex-basis: 100%; margin: 0; }
.code-block {
margin: var(--fonu-space-3) 0 0;
padding: var(--fonu-space-3) var(--fonu-space-4);
background: var(--fonu-bg-muted);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
font-family: var(--fonu-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--fonu-text);
overflow: auto;
max-height: 240px;
white-space: pre-wrap;
word-break: break-all;
}
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); }
.section-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 900px) { .field-grid { grid-template-columns: 1fr; } }
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-brand">
<img class="auth-brand__logo" :src="logoImg" alt="Fonu" />
<div>
<h1>Fonu</h1>
<p>让 NAS 访问更简单</p>
</div>
</div>
<h2 class="auth-title">登录管理后台</h2>
<n-form @submit.prevent="submit">
<n-form-item label="用户名">
<n-input v-model:value="username" size="large" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="password" type="password" show-password-on="click" size="large" />
</n-form-item>
<n-button type="primary" block size="large" :loading="loading" attr-type="submit">登录</n-button>
</n-form>
<p class="auth-hint">首次使用请查看系统日志中的初始密码,登录后请在「设置」中修改。</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
import { api } from '../api/client'
import logoImg from '../assets/brand/logo.png'
const router = useRouter()
const message = useMessage()
const username = ref('admin')
const password = ref('')
const loading = ref(false)
async function submit() {
loading.value = true
try {
await api.login(username.value, password.value)
message.success('登录成功')
router.push({ name: 'dashboard' })
} catch (error) {
message.error(error instanceof Error ? error.message : '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--fonu-space-5);
background: var(--fonu-bg);
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow-md);
padding: var(--fonu-space-6) var(--fonu-space-5);
}
.auth-brand {
display: flex;
align-items: center;
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-5);
}
.auth-brand__logo {
width: 48px;
height: 48px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.22);
}
.auth-brand h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.auth-brand p {
margin: 4px 0 0;
font-size: 13px;
color: var(--fonu-text-muted);
}
.auth-title {
margin: 0 0 var(--fonu-space-5);
font-size: 16px;
font-weight: 600;
color: var(--fonu-text);
}
.auth-hint {
margin: var(--fonu-space-4) 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--fonu-text-muted);
}
</style>
+637
View File
@@ -0,0 +1,637 @@
<template>
<PageHeader
title="日志中心"
description="系统日志记录 Fonu 全部运行输出;访问日志与 Nginx 错误日志来自反向代理"
/>
<LoadError v-if="pageError" :message="pageError" @retry="loadAll" />
<FonuCard v-else flush class="logs-card">
<n-tabs v-model:value="tab" type="line" animated class="logs-tabs">
<n-tab-pane name="system" tab="系统日志">
<LogToolbar
v-model:keyword="systemKeyword"
v-model:level="systemLevel"
:show-level="true"
@refresh="loadSystemLogs"
/>
<p class="tab-hint">Fonu 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。</p>
<n-data-table
v-if="filteredSystemLogs.length > 0"
class="log-table"
:columns="systemColumns"
:data="pagedSystemLogs"
:loading="loadingSystem"
:bordered="false"
:row-class-name="systemRowClassName"
/>
<LogPagination
v-if="filteredSystemLogs.length > 0"
v-model:page="systemPage"
:item-count="filteredSystemLogs.length"
/>
<EmptyState
v-if="!loadingSystem && systemLogs.length === 0"
title="暂无系统日志"
description="应用启动、DDNS 更新、证书操作或 API 请求会记录在这里。"
/>
</n-tab-pane>
<n-tab-pane name="access" tab="访问日志">
<LogToolbar
v-model:keyword="accessKeyword"
v-model:status="accessStatus"
:show-domain="true"
:show-status="true"
:auto-refresh="autoRefresh"
@refresh="loadAccess"
@toggle-auto="autoRefresh = !autoRefresh"
/>
<n-data-table
v-if="filteredAccessLogs.length > 0"
class="log-table"
:columns="accessColumns"
:data="pagedAccessLogs"
:loading="loadingAccess"
:bordered="false"
:scroll-x="920"
:row-class-name="accessRowClassName"
/>
<LogPagination
v-if="filteredAccessLogs.length > 0"
v-model:page="accessPage"
:item-count="filteredAccessLogs.length"
/>
<EmptyState
v-if="!loadingAccess && accessLogs.length === 0"
title="暂无访问日志"
description="此处记录经 Nginx 反向代理的访问(非 Fonu 管理界面本身)。请通过代理域名访问后刷新。"
/>
</n-tab-pane>
<n-tab-pane name="nginx" tab="Nginx 日志">
<p class="tab-hint">Nginx 错误日志(error.log),记录 SSL 握手失败、上游连接异常、配置冲突等。</p>
<LogToolbar v-model:keyword="errorKeyword" @refresh="loadErrorLogs" />
<n-data-table
v-if="filteredErrorLogs.length > 0"
class="log-table"
:columns="errorColumns"
:data="pagedErrorLogs"
:loading="loadingError"
:bordered="false"
:row-class-name="errorRowClassName"
/>
<LogPagination
v-if="filteredErrorLogs.length > 0"
v-model:page="errorPage"
:item-count="filteredErrorLogs.length"
/>
<EmptyState
v-if="!loadingError && errorLogs.length === 0"
title="暂无 Nginx 错误日志"
description="Nginx 出现 SSL、上游或配置相关错误时会记录在这里。"
/>
</n-tab-pane>
</n-tabs>
</FonuCard>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
NButton,
NDataTable,
NInput,
NPagination,
NSelect,
NTabPane,
NTabs,
NTag,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import { api, asList } from '../api/client'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
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 {
accessServiceTooltip,
buildProxyBindingIndex,
formatAccessFallback,
resolveAccessServiceLabel,
} from '../utils/accessService'
import { formatLogTime, formatMs } from '../utils/format'
import {
latencyClass,
nginxLevelTagType,
parseNginxErrorLine,
type ParsedNginxError,
displaySystemLog,
systemLevelTagType,
systemModuleTagType,
} from '../utils/logDisplay'
import { httpStatusKind } from '../utils/status'
const PAGE_SIZE = 20
const LogPagination = defineComponent({
name: 'LogPagination',
props: {
page: { type: Number, required: true },
itemCount: { type: Number, required: true },
},
emits: ['update:page'],
setup(props, { emit }) {
return () =>
h('div', { class: 'log-pagination' }, [
h(NPagination, {
page: props.page,
pageSize: PAGE_SIZE,
itemCount: props.itemCount,
showSizePicker: false,
'onUpdate:page': (p: number) => emit('update:page', p),
}),
])
},
})
const LogToolbar = defineComponent({
name: 'LogToolbar',
props: {
keyword: { type: String, default: '' },
status: { type: Number as () => number | null, default: null },
level: { type: String, default: '' },
showDomain: Boolean,
showStatus: Boolean,
showLevel: Boolean,
autoRefresh: Boolean,
},
emits: ['update:keyword', 'update:status', 'update:level', 'refresh', 'toggle-auto'],
setup(props, { emit }) {
return () =>
h('div', { class: 'toolbar' }, [
h(NInput, {
value: props.keyword,
placeholder: '关键词搜索',
clearable: true,
style: 'max-width: 220px',
'onUpdate:value': (v: string) => emit('update:keyword', v),
}),
props.showStatus
? h(NSelect, {
value: props.status,
placeholder: '状态码',
clearable: true,
style: 'width: 120px',
options: [200, 301, 400, 403, 404, 500].map((c) => ({ label: String(c), value: c })),
'onUpdate:value': (v: number | null) => emit('update:status', v),
})
: null,
props.showLevel
? h(NSelect, {
value: props.level,
placeholder: '日志级别',
clearable: true,
style: 'width: 120px',
options: [
{ label: '信息', value: 'INFO' },
{ label: '警告', value: 'WARN' },
{ label: '错误', value: 'ERROR' },
],
'onUpdate:value': (v: string) => emit('update:level', v),
})
: null,
h(NButton, { onClick: () => emit('refresh') }, () => '刷新'),
props.autoRefresh !== undefined
? h(NButton, { quaternary: true, onClick: () => emit('toggle-auto') }, () =>
props.autoRefresh ? '关闭自动刷新' : '自动刷新',
)
: null,
])
},
})
const message = useMessage()
const route = useRoute()
const LOG_TABS = ['system', 'access', 'nginx'] as const
type LogTab = (typeof LOG_TABS)[number]
function resolveTab(queryTab: unknown): LogTab {
if (queryTab === 'error' || queryTab === 'nginx') return 'nginx'
if (queryTab === 'stream') return 'access'
if (typeof queryTab === 'string' && (LOG_TABS as readonly string[]).includes(queryTab)) {
return queryTab as LogTab
}
return 'system'
}
const tab = ref(resolveTab(route.query.tab))
const pageError = ref('')
const autoRefresh = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const accessLogs = ref<AccessLogEntry[]>([])
const proxyRules = ref<ProxyRule[]>([])
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
const errorLogs = ref<ParsedNginxError[]>([])
const systemLogs = ref<SystemLogEntry[]>([])
const loadingAccess = ref(false)
const loadingError = ref(false)
const loadingSystem = ref(false)
const accessKeyword = ref('')
const accessStatus = ref<number | null>(null)
const errorKeyword = ref('')
const systemKeyword = ref('')
const systemLevel = ref('')
const accessPage = ref(1)
const errorPage = ref(1)
const systemPage = ref(1)
const filteredAccessLogs = computed(() => {
return accessLogs.value.filter((log) => {
if (accessKeyword.value) {
const kw = accessKeyword.value.toLowerCase()
const service = resolveAccessServiceLabel(log, proxyBindingIndex.value)
const fallback = formatAccessFallback(log)
if (!`${service} ${fallback} ${log.domain} ${log.path} ${log.client_ip}`.toLowerCase().includes(kw)) return false
}
if (accessStatus.value && log.status !== accessStatus.value) return false
return true
})
})
const filteredErrorLogs = computed(() => {
if (!errorKeyword.value) return errorLogs.value
const kw = errorKeyword.value.toLowerCase()
return errorLogs.value.filter((entry) => entry.raw.toLowerCase().includes(kw))
})
const filteredSystemLogs = computed(() => {
return systemLogs.value.filter((log) => {
const display = displaySystemLog(log)
if (systemLevel.value && log.level.toUpperCase() !== systemLevel.value) return false
if (
systemKeyword.value &&
!`${display.displayModule} ${log.message}`.toLowerCase().includes(systemKeyword.value.toLowerCase())
) {
return false
}
return true
})
})
function paginate<T>(items: T[], page: number) {
const start = (page - 1) * PAGE_SIZE
return items.slice(start, start + PAGE_SIZE)
}
const pagedAccessLogs = computed(() => paginate(filteredAccessLogs.value, accessPage.value))
const pagedErrorLogs = computed(() => paginate(filteredErrorLogs.value, errorPage.value))
const pagedSystemLogs = computed(() => paginate(filteredSystemLogs.value, systemPage.value))
const methodTagType = (method: string) => {
const map: Record<string, 'success' | 'info' | 'warning' | 'error' | 'default'> = {
GET: 'success',
POST: 'info',
PUT: 'warning',
DELETE: 'error',
}
return map[method] ?? 'default'
}
const statusTagType = (code: number) => {
const kind = httpStatusKind(code)
if (kind === 'success') return 'success'
if (kind === 'warning') return 'warning'
if (kind === 'error') return 'error'
return 'info'
}
const logTimeCell = (time: string) =>
h('span', { class: 'log-time' }, formatLogTime(time))
function accessRowClassName(row: AccessLogEntry) {
if (row.status >= 500) return 'log-row log-row--error'
if (row.status >= 400) return 'log-row log-row--warn'
return 'log-row'
}
function errorRowClassName(row: ParsedNginxError) {
const type = nginxLevelTagType(row.level)
if (type === 'error') return 'log-row log-row--error'
if (type === 'warning') return 'log-row log-row--warn'
return 'log-row'
}
function systemRowClassName(row: SystemLogEntry) {
const display = displaySystemLog(row)
if (display.displayLevel === '错误') return 'log-row log-row--error'
if (display.displayLevel === '警告') return 'log-row log-row--warn'
return 'log-row'
}
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
title: '服务',
key: 'service',
width: 128,
render: (row) => {
const tooltip = accessServiceTooltip(row, proxyBindingIndex.value)
const label = resolveAccessServiceLabel(row, proxyBindingIndex.value)
return h('span', { class: 'log-service nowrap', title: tooltip }, label)
},
},
{
title: '方法',
key: 'method',
width: 68,
render: (row) => h(NTag, { size: 'small', bordered: false, type: methodTagType(row.method) }, () => row.method),
},
{
title: '路径',
key: 'path',
width: 220,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-path mono nowrap', title: row.path }, row.path),
},
{
title: '状态',
key: 'status',
width: 68,
render: (row) => h(NTag, { size: 'small', bordered: false, type: statusTagType(row.status) }, () => String(row.status)),
},
{
title: '耗时',
key: 'response_time',
width: 80,
render: (row) => h('span', { class: latencyClass(row.response_time) }, formatMs(row.response_time)),
},
{
title: '来源 IP',
key: 'client_ip',
width: 140,
render: (row) => h('span', { class: 'mono nowrap', title: row.client_ip }, row.client_ip),
},
])
const errorColumns: DataTableColumns<ParsedNginxError> = [
{ title: '时间', key: 'time', width: 170, render: (row) => logTimeCell(row.time || '-') },
{
title: '级别',
key: 'level',
width: 88,
render: (row) =>
h(
NTag,
{ size: 'small', bordered: false, type: nginxLevelTagType(row.level) },
() => (row.level === 'unknown' ? '未知' : row.level.toUpperCase()),
),
},
{
title: '消息',
key: 'message',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-message mono' }, row.message),
},
]
const systemColumns: DataTableColumns<SystemLogEntry> = [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
title: '级别',
key: 'level',
width: 80,
render: (row) => {
const display = displaySystemLog(row)
return h(
NTag,
{ size: 'small', bordered: false, type: systemLevelTagType(display.displayLevel) },
() => display.displayLevel,
)
},
},
{
title: '模块',
key: 'module',
width: 108,
render: (row) => {
const display = displaySystemLog(row)
return h(
NTag,
{
size: 'small',
bordered: false,
type: systemModuleTagType(display.displayModule),
class: 'log-module-tag',
},
() => display.displayModule,
)
},
},
{
title: '消息',
key: 'message',
ellipsis: { tooltip: true },
render: (row) => {
const display = displaySystemLog(row)
return h('span', { class: 'log-message' }, display.message)
},
},
]
async function loadProxyRules() {
try {
proxyRules.value = asList(await api.listProxies())
} catch {
proxyRules.value = []
}
}
async function loadAccess() {
loadingAccess.value = true
try {
await loadProxyRules()
accessLogs.value = asList(await api.getAccessLogs({ limit: 100 }))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载访问日志失败')
} finally {
loadingAccess.value = false
}
}
async function loadErrorLogs() {
loadingError.value = true
try {
const errors = asList(await api.getErrorLogs({ limit: 100 }))
errorLogs.value = errors.map((line) => parseNginxErrorLine(line))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载错误日志失败')
} finally {
loadingError.value = false
}
}
async function loadSystemLogs() {
loadingSystem.value = true
try {
systemLogs.value = asList(await api.getSystemLogs({ limit: 100 }))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载系统日志失败')
} finally {
loadingSystem.value = false
}
}
async function loadAll() {
pageError.value = ''
try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
}
}
watch([accessKeyword, accessStatus], () => {
accessPage.value = 1
})
watch(errorKeyword, () => {
errorPage.value = 1
})
watch([systemKeyword, systemLevel], () => {
systemPage.value = 1
})
watch(autoRefresh, (on) => {
if (refreshTimer) clearInterval(refreshTimer)
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
})
watch(
() => route.query.tab,
(queryTab) => {
tab.value = resolveTab(queryTab)
},
)
watch(tab, (name) => {
if (name === 'access') loadAccess()
else if (name === 'nginx') loadErrorLogs()
else if (name === 'system') loadSystemLogs()
})
onMounted(() => {
tab.value = resolveTab(route.query.tab)
loadAll()
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped>
.logs-card :deep(.n-tabs-nav) {
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
}
.logs-card :deep(.n-tabs-tab) {
font-size: 14px;
}
.logs-card :deep(.n-tab-pane) {
padding-top: var(--fonu-space-2);
}
.tab-hint {
margin: 0 var(--fonu-space-5) var(--fonu-space-3);
font-size: 13px;
color: var(--fonu-text-muted);
}
.log-pagination {
display: flex;
justify-content: flex-end;
padding: var(--fonu-space-4) var(--fonu-space-5);
border-top: 1px solid var(--fonu-border);
}
.logs-card :deep(.log-table .n-data-table-th) {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text-secondary);
}
.logs-card :deep(.log-table .n-data-table-base-table) {
table-layout: fixed;
}
.logs-card :deep(.log-table .n-data-table-td) {
font-size: 13px;
padding-top: 10px;
padding-bottom: 10px;
}
.nowrap {
white-space: nowrap;
}
.logs-card :deep(.log-table .n-data-table-tr.log-row--error .n-data-table-td) {
background: color-mix(in srgb, #ef4444 7%, transparent);
}
.logs-card :deep(.log-table .n-data-table-tr.log-row--warn .n-data-table-td) {
background: color-mix(in srgb, #f59e0b 7%, transparent);
}
.logs-card :deep(.log-table .n-data-table-tr.log-row:hover .n-data-table-td) {
background: color-mix(in srgb, var(--fonu-brand) 5%, var(--fonu-surface));
}
.log-time {
white-space: nowrap;
color: var(--fonu-text-secondary);
font-variant-numeric: tabular-nums;
}
.log-service {
font-weight: 500;
}
.log-path {
color: var(--fonu-text-secondary);
}
.log-message {
line-height: 1.5;
}
.mono {
font-family: var(--fonu-mono);
font-size: 12px;
}
.log-duration {
font-variant-numeric: tabular-nums;
}
.log-duration--slow {
color: #d97706;
font-weight: 600;
}
.log-duration--critical {
color: #dc2626;
font-weight: 600;
}
.logs-card :deep(.log-module-tag) {
white-space: nowrap;
}
</style>
+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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff