chore(release): 发布 v2.0.0
CI / verify (push) Canceled after 0s

- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验
- 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令
- DDNS 服务商名称与自定义 IP,新增迁移 037/038/039
- CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
2026-10-16 20:18:04 +08:00
parent a0cc553d2d
commit 302248a907
65 changed files with 7263 additions and 606 deletions
+26 -2
View File
@@ -1,12 +1,12 @@
{
"name": "havline-web",
"version": "1.9.3",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "havline-web",
"version": "1.9.3",
"version": "2.0.0",
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
@@ -16,6 +16,9 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@vicons/ionicons5": "^0.13.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"echarts": "^6.1.0",
"naive-ui": "^2.40.1",
@@ -1440,6 +1443,27 @@
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "havline-web",
"private": true,
"version": "1.9.3",
"version": "2.0.0",
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
@@ -20,6 +20,9 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@vicons/ionicons5": "^0.13.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"echarts": "^6.1.0",
"naive-ui": "^2.40.1",
+77 -1
View File
@@ -46,6 +46,14 @@ import type {
CfTunnelRoutes,
CfPreflightResult,
CfRouteTestResult,
TerminalTarget,
TerminalAuditEntry,
TerminalSessionInfo,
TerminalCreateResult,
TerminalUnlockStatus,
TerminalUnlockResult,
TerminalCommand,
TerminalRecording,
} from './types'
import { withBase } from './base'
@@ -56,6 +64,22 @@ export function asList<T>(value: T[] | null | undefined): T[] {
const REQUEST_TIMEOUT_MS = 30_000
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000
// SSH 建连包含认证 + 申请 PTY,给到 60 秒(后端自身 30 秒超时)
const TERMINAL_CREATE_TIMEOUT_MS = 60_000
// ApiRequestError 带上 HTTP 状态码与后端返回的稳定错误码,
// 让调用方能按 code 分支(例如 terminal_locked),而不是去匹配提示文案。
export class ApiRequestError extends Error {
readonly status: number
readonly code: string
constructor(message: string, status: number, code = '') {
super(message)
this.name = 'ApiRequestError'
this.status = status
this.code = code
}
}
async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
const controller = new AbortController()
@@ -89,15 +113,19 @@ async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: A
if (!response.ok) {
let message = '请求失败'
let code = ''
try {
const body = (await response.json()) as ApiError
if (body.error) {
message = body.error
}
if (body.code) {
code = body.code
}
} catch {
// ignore
}
throw new Error(message)
throw new ApiRequestError(message, response.status, code)
}
if (response.status === 204) {
@@ -550,4 +578,52 @@ export const api = {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
// ===== 内置 SSH 终端 =====
// 二次认证:解锁一次 10 分钟,期间新建会话不再重复输入管理员密码
getTerminalUnlock: () => request<TerminalUnlockStatus>('/api/terminal/unlock'),
unlockTerminal: (password: string) =>
request<TerminalUnlockResult>('/api/terminal/unlock', {
method: 'POST',
body: JSON.stringify({ password }),
}),
lockTerminal: () => request<{ ok: boolean }>('/api/terminal/unlock', { method: 'DELETE' }),
listTerminalTargets: () => request<{ targets: TerminalTarget[] }>('/api/terminal/targets'),
listTerminalSessions: () => request<{ sessions: TerminalSessionInfo[] }>('/api/terminal/sessions'),
createTerminalSession: (payload: { server_id: number; cols: number; rows: number }) =>
request<TerminalCreateResult>('/api/terminal/sessions', {
method: 'POST',
body: JSON.stringify(payload),
}, TERMINAL_CREATE_TIMEOUT_MS),
closeTerminalSession: (id: string) =>
request<{ ok: boolean }>(`/api/terminal/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// 会话审计:谁、何时、连了哪台、多久、为什么断开
listTerminalAudits: (limit = 100) =>
request<{ entries: TerminalAuditEntry[] }>(`/api/terminal/audits?limit=${limit}`),
// 常用命令
listTerminalCommands: () => request<{ commands: TerminalCommand[] }>('/api/terminal/commands'),
createTerminalCommand: (payload: { name: string; command: string }) =>
request<TerminalCommand>('/api/terminal/commands', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteTerminalCommand: (id: number) =>
request<{ ok: boolean }>(`/api/terminal/commands/${id}`, { method: 'DELETE' }),
// 录像审计:列表是 JSON,内容本身是 asciinema v2 文本,单独取
listTerminalRecordings: () => request<{ recordings: TerminalRecording[] }>('/api/terminal/recordings'),
getTerminalRecording: async (id: string) => {
const response = await fetch(withBase(`/api/terminal/recordings/${encodeURIComponent(id)}`), {
credentials: 'include',
})
if (!response.ok) {
throw new ApiRequestError('读取录像失败,可能已被清理', response.status)
}
return await response.text()
},
}
// terminalSocketURL 拼出终端通道地址:按当前协议选择 ws/wss,并带上部署前缀。
export function terminalSocketURL(id: string): string {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${scheme}//${window.location.host}${withBase(`/api/terminal/sessions/${encodeURIComponent(id)}/ws`)}`
}
+72
View File
@@ -141,6 +141,8 @@ export interface UpdateStatus {
export interface ApiError {
error: string
// 稳定错误码(如 terminal_locked),用于前端分支判断
code?: string
}
export interface DashboardStatus {
@@ -187,6 +189,7 @@ export interface DDNSDomainRecord {
export interface DDNSConfig {
id: number
provider: string
name?: string
root_domain: string
record_name: string
record_names?: string[]
@@ -323,6 +326,7 @@ export interface DiscoveredService {
export interface DDNSSavePayload {
provider?: string
name?: string
root_domain?: string
record_name?: string
record_names?: string[]
@@ -1160,3 +1164,71 @@ export interface CfAccessStatus {
enabled: boolean
name?: string
}
// ===== 内置 SSH 终端(/terminal)=====
export interface TerminalTarget {
value: string
label: string
detail: string
ready: boolean
disabled_reason?: string
}
export interface TerminalSessionInfo {
id: string
label: string
server_id: number
closed: boolean
reason?: string
created_at: string
last_seen: string
}
export interface TerminalCreateResult {
session_id: string
label: string
}
// 终端二次认证状态:unlocked 为 true 时带到期时间
export interface TerminalUnlockStatus {
unlocked: boolean
expires_at?: string
}
// 终端解锁结果:票据到期时间(RFC3339)
export interface TerminalUnlockResult {
expires_at: string
}
// 常用命令:右侧面板保存的快捷命令
export interface TerminalCommand {
id: number
name: string
command: string
created_at: string
}
// 会话录制文件(asciinema v2),用于「录像审计」列表与回放
export interface TerminalRecording {
session_id: string
size: number
started_at: string
ended_at: string
}
// 终端会话审计:谁、何时、连了哪台、多久、为什么断开
export interface TerminalAuditEntry {
id?: number
session_id: string
server_id: number
target: string
user: string
client_ip: string
started_at: string
ended_at: string
duration_ms: number
reason?: string
bytes_in: number
bytes_out: number
}
+3 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="settings-field">
<div class="settings-field__label configured-secret-label">
<div v-if="!hideLabel" 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>
@@ -24,10 +24,12 @@ withDefaults(
modelValue: string
configured?: boolean
placeholder?: string
hideLabel?: boolean
}>(),
{
configured: false,
placeholder: '',
hideLabel: false,
},
)
+35 -1
View File
@@ -20,6 +20,12 @@
</div>
</div>
<!-- 名称用于区分同一服务商下的多条配置(如「家庭」「公司」),留空则列表显示服务商名 -->
<div class="provider-form__field">
<label class="provider-form__label">名称</label>
<n-input v-model:value="form.name" size="small" placeholder="可选,例如:家庭 / 公司 / 主域名" />
</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" />
@@ -66,12 +72,28 @@
<span>IPv6</span>
<n-switch v-model:value="form.ipv6_enabled" size="small" />
</div>
<!-- 自定义 IP:填了就用填的地址,不填仍自动检测本机公网 IP -->
<n-button size="tiny" quaternary :type="customIPOpen || hasCustomIP ? 'primary' : 'default'" @click="customIPOpen = !customIPOpen">
{{ customIPOpen ? '收起自定义 IP' : '自定义 IP' }}
</n-button>
<div class="settings-switch">
<span>启用</span>
<n-switch v-model:value="form.enabled" size="small" />
</div>
</div>
<template v-if="customIPOpen">
<div class="provider-form__field">
<label class="provider-form__label">自定义 IPv4(可选)</label>
<n-input v-model:value="form.custom_ipv4" size="small" placeholder="留空则自动检测本机公网 IPv4" />
</div>
<div class="provider-form__field">
<label class="provider-form__label">自定义 IPv6(可选)</label>
<n-input v-model:value="form.custom_ipv6" size="small" placeholder="留空则自动检测本机公网 IPv6" />
</div>
<p class="provider-form__hint">填写后更新解析记录会直接使用该 IP(如 VPS 的公网 IP),不再检测本机出口 IP。</p>
</template>
<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>
@@ -81,7 +103,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } 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'
@@ -93,6 +115,7 @@ import type { DDNSConfig } from '../api/types'
export type ProviderForm = {
provider: string
name: string
api_token: string
api_token_id: string
api_secret: string
@@ -124,6 +147,10 @@ const providerOptions = [
{ label: '火山引擎 DNS', value: 'volcengine' },
]
// 编辑已有配置时若已设置自定义 IP,默认展开输入区,避免用户以为没保存
const customIPOpen = ref(!!(props.form.custom_ipv4.trim() || props.form.custom_ipv6.trim()))
const hasCustomIP = computed(() => !!(props.form.custom_ipv4.trim() || props.form.custom_ipv6.trim()))
const providerMap: Record<string, { label: string; icon: string }> = {
cloudflare: { label: 'Cloudflare', icon: cloudflareIcon },
dnspod: { label: 'DNSPod', icon: dnspodIcon },
@@ -247,6 +274,13 @@ function providerIcon(v: string) {
flex-wrap: wrap;
}
.provider-form__hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.5;
color: var(--havline-text-muted);
}
.provider-secret-label {
display: flex;
align-items: center;
+4
View File
@@ -133,6 +133,7 @@ import {
PersonOutline,
SunnyOutline,
SwapHorizontalOutline,
TerminalOutline,
HardwareChipOutline,
ShieldCheckmarkOutline,
} from '@vicons/ionicons5'
@@ -203,6 +204,7 @@ const menuDefs = [
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: 'SSH 终端', key: 'terminal', icon: TerminalOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
@@ -218,6 +220,8 @@ const menuOptions = computed<MenuOption[]>(() =>
const activeKey = computed(() => {
const name = route.name
// 终端审计是终端页的子页面:菜单里只有「SSH 终端」,这里让它保持高亮
if (name === 'terminal-audits') return 'terminal'
return typeof name === 'string' ? name : 'dashboard'
})
+14
View File
@@ -89,6 +89,20 @@ const router = createRouter({
component: LogsView,
meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' },
},
{
path: 'terminal',
name: 'terminal',
// 懒加载:xterm 体积不小,只有真正打开终端时才下载
component: () => import('../views/TerminalView.vue'),
meta: { title: 'SSH 终端', description: '在浏览器里直接连接公网服务端的 SSH 终端' },
},
{
path: 'terminal/audits',
name: 'terminal-audits',
// 终端页面里的「审计记录」入口,同样懒加载
component: () => import('../views/TerminalAuditView.vue'),
meta: { title: '终端审计', description: '查看 SSH 会话审计记录' },
},
{
path: 'settings',
name: 'settings',
+14
View File
@@ -55,6 +55,20 @@ export function formatDate(iso?: string): string {
})
}
/** 会话/任务时长:按量级切换单位,短时长不能被「0 分钟」吞掉 */
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '-'
if (ms < 1000) return `${Math.round(ms)} ms`
const totalSeconds = Math.round(ms / 1000)
if (totalSeconds < 60) return `${totalSeconds} 秒`
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
}
/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
export function formatLogTime(value?: string): string {
if (!value) return '-'
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -274,7 +274,11 @@
<img :src="providerIcon(cfg.provider)" :alt="providerLabel(cfg.provider)" />
</span>
<div class="verify-option__body">
<div class="verify-option__title">{{ providerLabel(cfg.provider) }}</div>
<!-- 有自定义名称时优先显示名称,服务商名退到后面,多个同服务商任务才分得清 -->
<div class="verify-option__title">
{{ cfg.name || providerLabel(cfg.provider) }}
<span v-if="cfg.name" class="verify-option__provider">{{ providerLabel(cfg.provider) }}</span>
</div>
<div class="verify-option__sub">上次同步 {{ formatRelativeTime(cfg.last_updated_at) || '从未' }}</div>
<div class="verify-option__zones">{{ ddnsTaskSummary(cfg) }}</div>
</div>
@@ -1804,6 +1808,13 @@ html.dark .apply-log-box {
color: var(--havline-text);
}
.verify-option__provider {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--havline-text-muted);
}
.verify-option__sub {
margin-top: 2px;
font-size: 12px;
+96 -31
View File
@@ -1,6 +1,22 @@
<template>
<PageHeader :title="`配置 · ${tunnel?.name || 'Cloudflare 隧道'}`" description="配置隧道、查看副本指标与已发布路由">
<template #actions>
<!-- 进程操作提到页头:原来在页面最底部的「运行控制」卡,要滚动到底才能点到 -->
<n-button
v-if="runtimeState === 'connected'"
type="warning"
:loading="actionRunning === '停止'"
:disabled="actionRunning !== null"
@click="runAction('停止')"
>停止</n-button>
<n-button
v-else
type="success"
:loading="actionRunning === '启动'"
:disabled="actionRunning !== null"
@click="runAction('启动')"
>启动</n-button>
<n-button :loading="actionRunning === '重启'" :disabled="actionRunning !== null" @click="runAction('重启')">重启</n-button>
<n-button @click="router.push('/cloudflare')">
<template #icon><n-icon :component="ArrowBackOutline" /></template>
返回列表
@@ -22,21 +38,32 @@
<template #icon><n-icon :component="PulseOutline" /></template>
<template #value><StatusBadge :kind="statusKind(runtimeState)" :text="statusText(runtimeState)" /></template>
</StatCard>
<StatCard label="运行时间" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
<template #icon><n-icon :component="TimeOutline" /></template>
<!-- 原来叫「运行时间」但值是进程状态,名实不符:改为「进程」,并换成服务端图标 -->
<StatCard label="进程" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
<template #icon><n-icon :component="ServerOutline" /></template>
</StatCard>
</div>
<!-- 原来这张卡只有一个按钮/一条提示,等于白占一屏;改成直接展示关键字段,编辑按钮放卡头 -->
<HavlineCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
<template #header>
<n-button v-if="!tunnel.managed" size="small" @click="basicVisible = true">编辑基础配置</n-button>
</template>
<div class="section-body">
<n-alert v-if="tunnel.managed" type="info" :bordered="false">
这是自动托管隧道。暴露域名与回源请到
<div class="config-facts">
<div class="config-fact"><label>名称</label><span>{{ tunnel.name || '—' }}</span></div>
<div class="config-fact"><label>模式</label><span>{{ tunnel.managed ? '自动托管' : '手工隧道' }}</span></div>
<div class="config-fact"><label>隧道 ID</label><span class="mono">{{ tunnel.tunnel_id || '—' }}</span></div>
<div class="config-fact"><label>完整主机名</label><span class="mono">{{ tunnel.hostnames?.join('、') || '—' }}</span></div>
<div class="config-fact"><label>回源服务</label><span class="mono">{{ tunnel.origin_service || '—' }}</span></div>
<div class="config-fact"><label>随 Havline 启动</label><span>{{ tunnel.auto_start ? '是' : '否' }}</span></div>
</div>
<n-alert v-if="tunnel.managed" type="info" :bordered="false" class="config-managed-hint">
自动托管隧道:暴露域名与回源在
<router-link to="/proxies">反向代理</router-link>
的 Cloudflare 出口中维护。
</n-alert>
<div v-else class="config-basic-action">
<n-button type="primary" @click="basicVisible = true">编辑基础配置</n-button>
</div>
<p v-else class="config-hint">名称、主机名与回源服务可在右上角「编辑基础配置」里调整;路由在下方「路由」卡片维护。</p>
</div>
</HavlineCard>
@@ -114,17 +141,13 @@
<span class="mono">{{ route.service }}</span>
<n-tag size="small" :bordered="false" round>{{ route.source === 'rule' ? '反向代理规则' : 'config.yml' }}</n-tag>
<div class="routes-table__actions">
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === 0" @click="moveRoute(index, -1)">上移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === (routeData?.routes.length ?? 1) - 1" @click="moveRoute(index, 1)">下移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed" @click="copyRoute(route)">复制</n-button>
<n-button size="tiny" quaternary @click="testRoute(route)">测试</n-button>
<!-- 原来一行平铺 6 个按钮,把这列撑到 360px;改为主操作「编辑」+「更多」 -->
<n-button size="tiny" :disabled="tunnel.managed" @click="openEditRoute(route, index)">编辑</n-button>
<n-popconfirm :disabled="tunnel.managed" @positive-click="deleteRoute(index)">
<template #trigger>
<n-button size="tiny" type="error" quaternary :disabled="tunnel.managed">删除</n-button>
</template>
删除路由 {{ route.hostname }}?保存后会同步到 Cloudflare。
</n-popconfirm>
<n-dropdown trigger="click" :options="routeActionOptions(index)" @select="handleRouteAction($event, route, index)">
<n-button size="tiny" quaternary circle title="更多操作">
<template #icon><n-icon :component="EllipsisHorizontalOutline" /></template>
</n-button>
</n-dropdown>
</div>
</div>
</div>
@@ -144,15 +167,6 @@
</div>
</HavlineCard>
<HavlineCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
<div class="section-body">
<n-space>
<n-button type="success" :disabled="runtimeState === 'connected'" @click="runAction('启动')">启动</n-button>
<n-button type="warning" :disabled="runtimeState !== 'connected'" @click="runAction('停止')">停止</n-button>
<n-button @click="runAction('重启')">重启</n-button>
</n-space>
</div>
</HavlineCard>
</template>
<n-modal v-model:show="basicVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="基础配置">
@@ -238,8 +252,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NForm, NFormItem, NIcon, NInput, NModal, NPopconfirm, NSelect, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
import { AddOutline, ArrowBackOutline, CloudOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline, TimeOutline } from '@vicons/ionicons5'
import { NAlert, NButton, NDropdown, NForm, NFormItem, NIcon, NInput, NModal, NSelect, NSwitch, NTag, useDialog, useMessage, type DropdownOption } from 'naive-ui'
import { AddOutline, ArrowBackOutline, CloudOutline, EllipsisHorizontalOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline } from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { CfRouteView, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfTunnelRoutes, CfZone } from '../api/types'
import HavlineCard from '../components/HavlineCard.vue'
@@ -253,12 +267,15 @@ import type { StatusKind } from '../utils/status'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const tunnel = ref<CfTunnel | null>(null)
const status = ref<CfRuntimeStatus | null>(null)
const routeData = ref<CfTunnelRoutes | null>(null)
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
// 进程操作(启动 / 停止 / 重启)进行中:按钮显示 loading 并互相禁用
const actionRunning = ref<'启动' | '停止' | '重启' | null>(null)
const basicVisible = ref(false)
const routeVisible = ref(false)
const routeSaving = ref(false)
@@ -435,6 +452,40 @@ async function saveRoute() {
}
}
// 路由行的「更多」菜单:托管隧道与首/末行按情况禁用,避免点了才发现不可用
function routeActionOptions(index: number): DropdownOption[] {
const locked = tunnel.value?.managed ?? false
const isLast = index === ((routeData.value?.routes.length ?? 1) - 1)
return [
{ label: '上移', key: 'up', disabled: locked || index === 0 },
{ label: '下移', key: 'down', disabled: locked || isLast },
{ type: 'divider', key: 'd1' },
{ label: '复制主机名与服务', key: 'copy', disabled: locked },
{ label: '测试连通性', key: 'test' },
{ type: 'divider', key: 'd2' },
{ label: '删除路由', key: 'delete', disabled: locked },
]
}
function handleRouteAction(key: string | number, route: CfRouteView, index: number) {
if (key === 'up') return void moveRoute(index, -1)
if (key === 'down') return void moveRoute(index, 1)
if (key === 'copy') return void copyRoute(route)
if (key === 'test') return void testRoute(route)
if (key === 'delete') confirmDeleteRoute(route, index)
}
// 删除走对话框二次确认,替代原来包在行内的 n-popconfirm(菜单里无法嵌套 Popconfirm)
function confirmDeleteRoute(route: CfRouteView, index: number) {
dialog.warning({
title: '删除路由',
content: `删除路由 ${route.hostname}?保存后会同步到 Cloudflare。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => deleteRoute(index),
})
}
async function deleteRoute(index: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const next = routeData.value.routes
@@ -515,6 +566,7 @@ function manualHostname() {
async function runAction(action: '启动' | '停止' | '重启') {
if (!tunnel.value) return
actionRunning.value = action
try {
if (action === '启动') await api.startCfTunnel(tunnel.value.id)
if (action === '停止') await api.stopCfTunnel(tunnel.value.id)
@@ -523,6 +575,8 @@ async function runAction(action: '启动' | '停止' | '重启') {
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${action}失败`)
} finally {
actionRunning.value = null
}
}
@@ -554,7 +608,17 @@ onMounted(load)
margin-bottom: var(--havline-space-4);
}
.section-card + .section-card { margin-top: var(--havline-space-4); }
.config-basic-action { display: flex; justify-content: flex-end; }
/* 基础配置:把原来空着的一张卡换成关键字段网格 */
.config-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--havline-space-3) var(--havline-space-4);
}
.config-fact { display: grid; gap: 4px; min-width: 0; }
.config-fact label { font-size: 12px; color: var(--havline-text-secondary); }
.config-fact span { font-size: 13px; color: var(--havline-text); overflow-wrap: anywhere; }
.config-managed-hint { margin-top: var(--havline-space-3); }
.config-hint { margin: var(--havline-space-3) 0 0; font-size: 12px; color: var(--havline-text-muted); }
.topology-canvas {
position: relative;
width: 100%;
@@ -691,10 +755,11 @@ onMounted(load)
.routes-table__head,
.routes-table__row {
display: grid;
grid-template-columns: 64px minmax(180px, 1fr) 90px minmax(220px, 1.2fr) 130px 360px;
/* 操作列从 360px 收到 120px(编辑 + 更多),窄屏不必再横向滚动才能点到操作 */
grid-template-columns: 56px minmax(180px, 1fr) 90px minmax(200px, 1.2fr) 130px 120px;
gap: 12px;
align-items: center;
min-width: 1080px;
min-width: 860px;
padding: 10px 0;
}
.routes-table__actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
+308 -97
View File
@@ -1,27 +1,78 @@
<template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions>
<n-button :loading="preflightLoading" @click="openPreflight">接入向导</n-button>
<!-- 应用配置是常用入口,放在外面直接可见;接入向导与反向代理入口留在「更多」里 -->
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button>
<n-button type="primary" @click="openCreate">
<template #icon><n-icon :component="AddOutline" /></template>
新建隧道
</n-button>
<n-dropdown trigger="click" :options="headerMenuOptions" @select="handleHeaderMenu">
<n-button>
<template #icon><n-icon :component="EllipsisHorizontalOutline" /></template>
更多
</n-button>
</n-dropdown>
</template>
</PageHeader>
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
<div class="cf-global__text">
<strong>全局托管</strong>
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Havline 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
<!-- 状态 Hero:合并原来的「全局托管条 + 二进制状态提示」,首屏直接给出就绪情况与隧道汇总 -->
<section class="cf-hero" aria-label="Cloudflare 隧道概览">
<div class="cf-hero__top">
<div class="cf-hero__status">
<n-icon
:component="CloudOutline"
class="cf-hero__icon"
:class="binaryVersion ? 'cf-hero__icon--ok' : 'cf-hero__icon--warn'"
aria-hidden="true"
/>
<div class="cf-hero__headline">
<div class="cf-hero__title">
<span class="cf-hero__title-label">cloudflared</span>
<StatusBadge v-if="binaryVersion" kind="success" :text="displayBinaryVersion(binaryVersion)" />
<StatusBadge v-else kind="warning" text="未安装" />
</div>
<div class="cf-hero__sub">
{{ binaryVersion ? '已就绪,可用于创建与运行隧道' : '尚未安装:点右侧「下载安装」,装好后再创建隧道' }}
</div>
</div>
</div>
<div class="cf-hero__actions">
<n-button v-if="!binaryVersion" size="small" type="primary" :loading="settingsLoading" @click="openSettings">下载安装</n-button>
<div class="cf-hero__managed">
<span class="cf-hero__managed-label">全局托管</span>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div>
</div>
</div>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
已安装:<span class="mono">{{ displayBinaryVersion(binaryVersion) }}</span>
</n-alert>
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert>
<n-alert type="info" :bordered="false" class="cf-alert">
<div class="cf-hero__metrics">
<div class="cf-hero__metric">
<span class="cf-hero__metric-label">隧道</span>
<span class="cf-hero__metric-value">{{ tunnels.length }}</span>
<span class="cf-hero__metric-sub">{{ managedTunnelCount ? `含 ${managedTunnelCount} 条自动托管` : '暂无自动托管' }}</span>
</div>
<div class="cf-hero__metric">
<span class="cf-hero__metric-label">已连接</span>
<span class="cf-hero__metric-value">{{ connectedTunnelCount }}</span>
<span class="cf-hero__metric-sub">cloudflared 已连到边缘</span>
</div>
<div class="cf-hero__metric">
<span class="cf-hero__metric-label">待修复</span>
<span class="cf-hero__metric-value">{{ driftTunnelCount }}</span>
<span class="cf-hero__metric-sub">{{ driftTunnelCount ? '与 Cloudflare 云端不一致' : '云端与本地一致' }}</span>
</div>
</div>
<div class="cf-hero__foot" :class="{ 'cf-hero__foot--off': !appSettings.enabled }">
{{ appSettings.enabled
? '全局托管已启用:开启时和下次启动 Havline 时都会启动全部隧道。'
: '全局托管已停用:所有隧道已停止,应用重启后也不会自动拉起。' }}
</div>
</section>
<!-- 免费套餐说明属于一次性认知信息,允许关闭(本地记住) -->
<n-alert v-if="showFreeTierHint" type="info" :bordered="false" class="cf-alert" closable @close="dismissFreeTierHint">
Cloudflare Tunnel 适合 NAS 面板、相册和小型 Web 服务。免费套餐不适合持续大文件或视频分发,请不要把它当作大流量媒体出口。
</n-alert>
@@ -157,33 +208,89 @@
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
</n-tab-pane>
<n-tab-pane name="logs" tab="日志">
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
<div class="cf-code-toolbar cf-code-toolbar--split">
<!-- 默认不自动刷新:原来每 3 秒无条件替换内容,读日志时会被打断 -->
<div class="cf-switch-inline">
<n-switch v-model:value="logAutoRefresh" size="small" />
<!-- 文字也可点:不用去精确点那个小开关(switch 自身已绑定,这里只负责文字区) -->
<span @click="logAutoRefresh = !logAutoRefresh">自动刷新(每 3 秒)</span>
</div>
<n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button>
</div>
<p class="cf-hint cf-hint--tight">需要盯实时输出时再打开自动刷新;默认关闭,避免阅读时内容被替换。</p>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</n-tab-pane>
<n-tab-pane name="diagnostics" tab="漂移与诊断">
<div class="cf-code-toolbar">
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
<n-button size="tiny" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
<!-- 原来 5 个按钮平铺 + 3 块结果混在一起,改为按「漂移 / 连通性 / Access / 凭据」分组 -->
<n-tab-pane name="diagnostics" tab="诊断">
<div class="cf-diag-groups">
<section class="cf-diag-group">
<div class="cf-diag-group__head">
<div class="cf-diag-group__text">
<div class="cf-diag-group__title">云端漂移</div>
<p class="cf-diag-group__desc">对比本地 config.yml 与 Cloudflare 上实际生效的路由,不一致时可用本地覆盖。</p>
</div>
<div class="cf-diag-group__actions">
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
<n-button size="tiny" type="primary" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
</div>
</div>
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
<template v-if="detailDrift.has_drift">
发现 {{ detailDrift.items.length }} 项云端漂移:
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
</template>
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
</n-alert>
<p v-else class="cf-diag-group__empty">还没有检测结果,点「检测漂移」开始。</p>
</section>
<section class="cf-diag-group">
<div class="cf-diag-group__head">
<div class="cf-diag-group__text">
<div class="cf-diag-group__title">连通性诊断</div>
<p class="cf-diag-group__desc">检查本机进程、metrics 端口,以及到 Cloudflare 边缘的连通情况。</p>
</div>
<div class="cf-diag-group__actions">
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
</div>
</div>
<div v-if="detailDiagnostics" class="cf-diagnostics">
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
</div>
</div>
<p v-else class="cf-diag-group__empty">还没有诊断结果。</p>
</section>
<section class="cf-diag-group">
<div class="cf-diag-group__head">
<div class="cf-diag-group__text">
<div class="cf-diag-group__title">Access 保护(只读)</div>
<p class="cf-diag-group__desc">检测首个主机名是否被 Cloudflare Access 覆盖;Havline 只读检测,不代配策略。</p>
</div>
<div class="cf-diag-group__actions">
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
</div>
</div>
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。' }}
</n-alert>
<p v-else class="cf-diag-group__empty">还没有检测结果。</p>
</section>
<section class="cf-diag-group">
<div class="cf-diag-group__head">
<div class="cf-diag-group__text">
<div class="cf-diag-group__title">凭据维护</div>
<p class="cf-diag-group__desc">重新从 Cloudflare 下载该隧道的凭据文件;凭据失效或换账号后使用。</p>
</div>
<div class="cf-diag-group__actions">
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
</div>
</div>
</section>
</div>
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
<template v-if="detailDrift.has_drift">
发现 {{ detailDrift.items.length }} 项云端漂移:
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
</template>
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
</n-alert>
<div v-if="detailDiagnostics" class="cf-diagnostics">
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
</div>
</div>
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。Havline 只做只读检测,不代配策略。' }}
</n-alert>
</n-tab-pane>
</n-tabs>
</div>
@@ -195,59 +302,69 @@
</template>
</n-modal>
<n-modal v-model:show="settingsVisible" preset="card" :style="{ width: 'min(640px, 96vw)' }" title="Cloudflare 应用配置">
<n-modal v-model:show="settingsVisible" preset="card" :style="{ width: 'min(680px, 96vw)' }" title="Cloudflare 应用配置">
<div class="cf-modal__body">
<n-divider title-placement="left">程序安装</n-divider>
<div class="cf-binary">
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
</div>
<n-form label-placement="top">
<n-form-item label="下载镜像">
<n-select v-model:value="settingsForm.mirror" :options="mirrorOptions" />
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
<n-button size="small" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
</div>
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
<!-- 三段原是纵向平铺的长表单,改为 Tab:每段各自一屏,不用来回滚动 -->
<n-tabs v-model:value="settingsTab" type="line" animated>
<n-tab-pane name="binary" tab="程序安装">
<div class="cf-binary">
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
</div>
<n-form label-placement="top">
<n-form-item label="下载镜像">
<n-select v-model:value="settingsForm.mirror" :options="mirrorOptions" />
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
<n-button size="small" type="primary" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
</div>
<p class="cf-hint">
下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像;<strong>镜像选择要点右下角「保存配置」才会长期记住</strong>,「下载并安装」会立即使用当前选择。
</p>
</n-tab-pane>
<n-divider title-placement="left">Cloudflare 账号</n-divider>
<n-form label-placement="top">
<n-form-item label="Account ID">
<n-input v-model:value="settingsForm.account_id" :placeholder="appSettings.account_id || '例如 b590ee0f16701d36f2819b0e3c938e81'" />
</n-form-item>
<n-form-item label="API Token">
<n-input
v-model:value="settingsForm.api_token"
type="password"
show-password-on="click"
:placeholder="appSettings.api_token_configured ? `已配置 ${appSettings.api_token_masked},留空保留` : 'cfat_...'"
/>
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
<n-button size="small" tag="a" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener">获取 API Token</n-button>
</div>
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Havline 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
<n-tab-pane name="account" tab="Cloudflare 账号">
<n-form label-placement="top">
<n-form-item label="Account ID">
<n-input v-model:value="settingsForm.account_id" :placeholder="appSettings.account_id || '例如 b590ee0f16701d36f2819b0e3c938e81'" />
</n-form-item>
<n-form-item label="API Token">
<n-input
v-model:value="settingsForm.api_token"
type="password"
show-password-on="click"
:placeholder="appSettings.api_token_configured ? `已配置 ${appSettings.api_token_masked},留空保留` : 'cfat_...'"
/>
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
<n-button size="small" tag="a" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener">获取 API Token</n-button>
</div>
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Havline 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
</n-tab-pane>
<n-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top">
<n-form-item label="传输协议"><n-select v-model:value="settingsForm.network.transport_protocol" :options="protocolOptions" /></n-form-item>
<n-form-item label="边缘地址族"><n-select v-model:value="settingsForm.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
<n-form-item label="HA 连接数"><n-input-number v-model:value="settingsForm.network.ha_connections" :min="1" :max="4" /></n-form-item>
<n-form-item label="代理模式"><n-select v-model:value="settingsForm.network.proxy_mode" :options="proxyOptions" /></n-form-item>
</n-form>
<n-tab-pane name="network" tab="网络设置">
<n-form label-placement="top">
<n-form-item label="传输协议"><n-select v-model:value="settingsForm.network.transport_protocol" :options="protocolOptions" /></n-form-item>
<n-form-item label="边缘地址族"><n-select v-model:value="settingsForm.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
<n-form-item label="HA 连接数"><n-input-number v-model:value="settingsForm.network.ha_connections" :min="1" :max="4" /></n-form-item>
<n-form-item label="代理模式"><n-select v-model:value="settingsForm.network.proxy_mode" :options="proxyOptions" /></n-form-item>
</n-form>
<p class="cf-hint">这些是新建隧道的默认值:保存后只影响之后创建的隧道,已存在的隧道不受影响。</p>
</n-tab-pane>
</n-tabs>
</div>
<template #footer>
<div class="cf-modal__footer">
<span class="cf-modal__foot-hint">程序安装是即时操作,其余两项点「保存配置」生效</span>
<n-button @click="settingsVisible = false">取消</n-button>
<n-button type="primary" :loading="settingsSaving" @click="saveSettings">保存配置</n-button>
</div>
@@ -277,7 +394,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
import { NAlert, NButton, NDivider, NDropdown, NForm, NFormItem, NIcon, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage, type DropdownOption } from 'naive-ui'
import { AddOutline, CloudOutline, EllipsisHorizontalOutline } from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { CfAccessStatus, CfAppSettings, CfAppSettingsInput, CfDiagnosticsResult, CfDriftResult, CfMirror, CfPreflightResult, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfZone } from '../api/types'
import HavlineCard from '../components/HavlineCard.vue'
@@ -305,9 +423,37 @@ const globalLoading = ref(false)
const settingsVisible = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
// 应用配置弹窗的当前 Tab:binary / account / network
const settingsTab = ref<string>('account')
const tokenTesting = ref(false)
const tokenTestResult = ref('')
const tokenTestOk = ref(false)
// Header「更多」:应用配置已移出为常驻按钮,这里保留接入向导与反向代理入口
const headerMenuOptions = computed<DropdownOption[]>(() => [
{ label: '接入向导(环境预检)', key: 'preflight', disabled: preflightLoading.value },
{ type: 'divider', key: 'divider' },
{ label: '前往反向代理页(托管隧道来源)', key: 'proxies' },
])
function handleHeaderMenu(key: string | number) {
if (key === 'preflight') return void openPreflight()
if (key === 'proxies') return void router.push({ name: 'proxies' })
}
// 免费套餐说明可关闭,并记在本地:这是固定不变的认知信息,不该每次都占首屏
const FREE_TIER_HINT_KEY = 'havline.cloudflare.freeTierHintDismissed'
const showFreeTierHint = ref(readFreeTierHintVisible())
function readFreeTierHintVisible() {
try { return localStorage.getItem(FREE_TIER_HINT_KEY) !== '1' } catch { return true }
}
function dismissFreeTierHint() {
showFreeTierHint.value = false
try { localStorage.setItem(FREE_TIER_HINT_KEY, '1') } catch { /* 隐私模式下忽略写入失败 */ }
}
const appSettings = ref<CfAppSettings>({
account_id: '',
enabled: true,
@@ -321,6 +467,9 @@ const appSettings = ref<CfAppSettings>({
const settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
const editorVisible = ref(false)
const detailVisible = ref(false)
// 详情弹窗的日志自动刷新:默认关闭,由日志页签里的开关显式打开
const logAutoRefresh = ref(false)
const logAutoRefreshEnabled = computed(() => detailVisible.value && logAutoRefresh.value)
const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('')
@@ -426,6 +575,11 @@ function runtimeStatus(item: CfTunnel): CfRuntimeStatus {
}
}
// Hero 汇总:隧道总数里的已连接条数、自动托管条数,以及检测到云端漂移的条数
const connectedTunnelCount = computed(() => tunnels.value.filter((item) => runtimeStatus(item).status === 'connected').length)
const managedTunnelCount = computed(() => tunnels.value.filter((item) => item.managed).length)
const driftTunnelCount = computed(() => Object.values(driftByTunnel.value).filter((item) => item.has_drift).length)
function replicaCount(item: CfTunnel): number {
return runtimeStatus(item).connections || 0
}
@@ -615,6 +769,8 @@ async function openDetail(item: CfTunnel) {
detailDrift.value = null
detailDiagnostics.value = null
detailAccess.value = null
// 每次打开都回到「不自动刷新」:否则上次开过会让人以为是默认行为
logAutoRefresh.value = false
detailVisible.value = true
await refreshDetail()
}
@@ -730,6 +886,8 @@ async function copyConfig() {
async function openSettings() {
preflightVisible.value = false
// 未安装 cloudflared 时先落到「程序安装」,否则默认停在最常改的「Cloudflare 账号」
settingsTab.value = binaryVersion.value ? 'account' : 'binary'
settingsVisible.value = true
await loadAppSettings()
}
@@ -862,24 +1020,56 @@ onMounted(() => {
})
useVisibilityPolling(() => load(), 5000)
useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
// 只有用户显式打开「自动刷新」才轮询详情,默认不打扰阅读
useVisibilityPolling(refreshDetail, 3000, { enabled: logAutoRefreshEnabled })
</script>
<style scoped>
.cf-global {
/* 状态 Hero:cloudflared 就绪情况 + 全局托管 + 隧道汇总(原来是两条并列的常驻区块) */
.cf-hero {
display: flex;
flex-direction: column;
gap: var(--havline-space-4);
padding: var(--havline-space-4) var(--havline-space-5);
margin-bottom: var(--havline-space-4);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
}
.cf-hero__top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-4); }
.cf-hero__status { display: flex; align-items: center; gap: var(--havline-space-3); min-width: 0; }
.cf-hero__icon { font-size: 26px; flex-shrink: 0; }
.cf-hero__icon--ok { color: var(--havline-success); }
.cf-hero__icon--warn { color: var(--havline-warning); }
.cf-hero__headline { min-width: 0; }
.cf-hero__title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
.cf-hero__title-label { font-size: 13px; font-weight: 600; color: var(--havline-text-secondary); }
.cf-hero__sub { margin-top: 6px; font-size: 13px; color: var(--havline-text-secondary); }
.cf-hero__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); }
.cf-hero__managed {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--havline-space-4);
padding: 12px var(--havline-space-5);
margin-bottom: var(--havline-space-4);
gap: 10px;
padding: 6px 12px;
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
background: var(--havline-bg);
}
.cf-global--off { border-color: var(--havline-warning); background: var(--havline-bg); }
.cf-global__text { display: grid; gap: 4px; font-size: 13px; }
.cf-global__text span { color: var(--havline-text-secondary); }
.cf-hero__managed-label { font-size: 13px; font-weight: 600; color: var(--havline-text); }
.cf-hero__metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--havline-space-4);
padding-top: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.cf-hero__metric { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.cf-hero__metric-label { font-size: 12px; color: var(--havline-text-muted); }
.cf-hero__metric-value { font-size: 22px; font-weight: 700; line-height: 1.15; color: var(--havline-text); letter-spacing: -0.02em; }
.cf-hero__metric-sub { font-size: 12px; color: var(--havline-text-muted); }
.cf-hero__foot { font-size: 12.5px; color: var(--havline-text-secondary); }
.cf-hero__foot--off { color: var(--havline-warning); }
.cf-alert { margin-bottom: var(--havline-space-4); }
.cf-empty { padding: 24px 0; color: var(--havline-text-muted); }
.cf-table { width: 100%; overflow-x: auto; }
@@ -916,6 +1106,7 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-meta { font-size: 12px; color: var(--havline-text-secondary); }
.cf-modal__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-modal__foot-hint { margin-right: auto; align-self: center; font-size: 12px; color: var(--havline-text-muted); }
.cf-mode-alert { margin-bottom: 4px; }
.cf-preflight { display: grid; gap: 10px; }
.cf-preflight__row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); }
@@ -950,6 +1141,21 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-binary > div { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-bg); }
.cf-binary label { font-size: 12px; color: var(--havline-text-muted); }
.cf-hint { font-size: 12px; color: var(--havline-text-muted); }
.cf-hint--tight { margin: 0 0 8px; }
.cf-code-toolbar--split { justify-content: space-between; align-items: center; }
.cf-switch-inline { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--havline-text-secondary); }
.cf-switch-inline span { cursor: pointer; }
/* 详情弹窗「诊断」页:按检查项分组,每组标题 + 说明 + 动作 + 结果 */
.cf-diag-groups { display: grid; gap: var(--havline-space-3); }
.cf-diag-group { padding: 12px 14px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-surface); }
.cf-diag-group__head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-3); }
.cf-diag-group__text { min-width: 0; }
.cf-diag-group__title { font-size: 13px; font-weight: 600; color: var(--havline-text); }
.cf-diag-group__desc { margin: 4px 0 0; font-size: 12px; line-height: 1.5; color: var(--havline-text-muted); }
.cf-diag-group__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; flex-shrink: 0; }
.cf-diag-group__empty { margin: 10px 0 0; font-size: 12px; color: var(--havline-text-muted); }
.cf-diag-group .cf-mode-alert,
.cf-diag-group .cf-diagnostics { margin-top: 10px; }
.cf-pre {
max-height: 240px;
overflow: auto;
@@ -962,4 +1168,9 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.cf-hero__top { flex-direction: column; align-items: flex-start; }
.cf-hero__actions { width: 100%; }
}
</style>
+30 -6
View File
@@ -131,7 +131,8 @@
<span class="provider-logo">
<img :src="providerIcon(providerDraft.provider)" :alt="providerLabel(providerDraft.provider)" />
</span>
<span class="task-item__name">{{ providerLabel(providerDraft.provider) }}</span>
<span class="task-item__name">{{ providerDraft.name || providerLabel(providerDraft.provider) }}</span>
<span v-if="providerDraft.name" class="task-item__provider">{{ providerLabel(providerDraft.provider) }}</span>
</div>
<span class="task-item__draft-tag">待添加记录</span>
</div>
@@ -166,7 +167,8 @@
<span class="provider-logo">
<img :src="providerIcon(cfg.provider)" :alt="providerLabel(cfg.provider)" />
</span>
<span class="task-item__name">{{ providerLabel(cfg.provider) }}</span>
<span class="task-item__name">{{ cfg.name || providerLabel(cfg.provider) }}</span>
<span v-if="cfg.name" class="task-item__provider">{{ providerLabel(cfg.provider) }}</span>
</div>
<StatusBadge :value="taskStatusKind(cfg)" :text="taskStatusText(cfg)" />
</div>
@@ -207,7 +209,7 @@
<img :src="providerIcon(selectedTask.provider)" :alt="providerLabel(selectedTask.provider)" />
</span>
<div>
<h3 class="task-detail__title">{{ providerLabel(selectedTask.provider) }}</h3>
<h3 class="task-detail__title">{{ selectedTask.name || providerLabel(selectedTask.provider) }}</h3>
<div class="task-detail__meta">
<StatusBadge
v-if="!isDraftSelected"
@@ -567,6 +569,7 @@ const providerMap: Record<string, { label: string; icon: string }> = {
const taskForm = reactive<ProviderForm>({
provider: 'cloudflare',
name: '',
api_token: '',
api_token_id: '',
api_secret: '',
@@ -593,12 +596,15 @@ const draftAsConfig = computed((): DDNSConfig | null => {
return {
id: -1,
provider: providerDraft.value.provider,
name: providerDraft.value.name,
root_domain: '',
record_name: '@',
ipv4_enabled: providerDraft.value.ipv4_enabled,
ipv6_enabled: providerDraft.value.ipv6_enabled,
enabled: providerDraft.value.enabled,
has_token: !!providerDraft.value.api_token,
custom_ipv4: providerDraft.value.custom_ipv4,
custom_ipv6: providerDraft.value.custom_ipv6,
}
})
@@ -643,7 +649,7 @@ const detailError = computed(() => {
}
}
if (cfg.enabled && cfg.last_status === 'error' && cfg.last_error) {
return { title: `${providerLabel(cfg.provider)} 同步失败`, text: cfg.last_error }
return { title: `${cfg.name || providerLabel(cfg.provider)} 同步失败`, text: cfg.last_error }
}
return null
})
@@ -764,6 +770,7 @@ function normalizeRecordDomain(input: string, rootDomain?: string): string {
function resetTaskForm() {
Object.assign(taskForm, {
provider: 'cloudflare',
name: '',
api_token: '',
api_token_id: '',
api_secret: '',
@@ -811,6 +818,7 @@ function startEditTask(cfg: DDNSConfig) {
selectedKey.value = String(cfg.id)
Object.assign(taskForm, {
provider: cfg.provider || 'cloudflare',
name: cfg.name ?? '',
api_token: '',
api_token_id: '',
api_secret: '',
@@ -858,6 +866,7 @@ async function saveCustomIP() {
try {
const updated = await api.updateDDNS(task.id, {
provider: task.provider,
name: task.name ?? '',
domains: domainsOf(task),
enabled: task.enabled,
ipv4_enabled: task.ipv4_enabled,
@@ -949,6 +958,7 @@ async function saveTask() {
try {
const updated = await api.updateDDNS(editingId, {
provider: taskForm.provider,
name: taskForm.name,
domains: domainsOf(existing),
ipv4_enabled: taskForm.ipv4_enabled,
ipv6_enabled: taskForm.ipv6_enabled,
@@ -1045,6 +1055,7 @@ async function saveRecord() {
const domains = [...new Set([...domainsOf(task), domain])]
const updated = await api.updateDDNS(task.id, {
provider: task.provider,
name: task.name ?? '',
domains,
enabled: task.enabled,
ipv4_enabled: task.ipv4_enabled,
@@ -1060,6 +1071,7 @@ async function saveRecord() {
const unique = [...new Set(domains)]
const updated = await api.updateDDNS(task.id, {
provider: task.provider,
name: task.name ?? '',
domains: unique,
enabled: task.enabled,
ipv4_enabled: task.ipv4_enabled,
@@ -1087,6 +1099,7 @@ async function patchTaskFlags(
try {
const updated = await api.updateDDNS(row.id, {
provider: row.provider,
name: row.name ?? '',
domains: domainsOf(row),
enabled: flags.enabled ?? row.enabled,
ipv4_enabled: flags.ipv4_enabled ?? row.ipv4_enabled,
@@ -1177,7 +1190,7 @@ async function saveInterval() {
function confirmDelete(row: DDNSConfig) {
dialog.warning({
title: '删除 DDNS 配置',
content: `确定删除 ${providerLabel(row.provider)} 的 DDNS 配置吗?`,
content: `确定删除 ${row.name || providerLabel(row.provider)} 的 DDNS 配置吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
@@ -1203,6 +1216,7 @@ function confirmDeleteRecord(row: DDNSConfig, record: DDNSDomainRecord) {
} else {
const updated = await api.updateDDNS(row.id, {
provider: row.provider,
name: row.name ?? '',
domains,
enabled: row.enabled,
ipv4_enabled: row.ipv4_enabled,
@@ -1368,12 +1382,15 @@ onMounted(init)
flex-direction: column;
gap: 10px;
padding: var(--havline-space-3);
max-height: 720px;
/* 高度跟着视口走:编辑态表单较长,避免出现「表单被截断」或必须整页滚动才能点到保存 */
max-height: max(420px, calc(100vh - 340px));
overflow: auto;
}
.task-item {
width: 100%;
/* 列表是 max-height 的纵向 flex:不禁止收缩的话,编辑态表单会被压扁并裁掉底部 */
flex-shrink: 0;
text-align: left;
border: 1px solid var(--havline-border);
border-radius: 12px;
@@ -1445,6 +1462,13 @@ onMounted(init)
color: var(--havline-text);
}
/* 设了名称时,旁边补一个灰字服务商名,避免只看名字不知道用的是哪家 DNS */
.task-item__provider {
flex-shrink: 0;
font-size: 12px;
color: var(--havline-text-muted);
}
.task-item__row {
margin-top: 8px;
font-size: 12px;
+525 -181
View File
@@ -2,52 +2,90 @@
<PageHeader title="FRP 穿透" description="管理 NAS 到公网 VPS 的 frpc 客户端连接">
<template #actions>
<n-button @click="showExport = true; loadExport()">查看脱敏配置</n-button>
<n-button @click="openRuntimeModal">
<template #icon><n-icon :component="SettingsOutline" /></template>
应用配置
</n-button>
<n-button @click="showGuide = true">
<template #icon><n-icon :component="DocumentTextOutline" /></template>
使用说明
</n-button>
<n-button v-if="status.status !== 'running'" type="primary" :loading="actionLoading === 'start'" @click="startClient">
<template #icon><n-icon :component="PlayOutline" /></template>
启动 frpc
</n-button>
<n-button v-else type="error" ghost :loading="actionLoading === 'stop'" @click="stopClient">
<template #icon><n-icon :component="StopOutline" /></template>
停止 frpc
</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else>
<section class="stats-row" aria-label="frpc 状态">
<div class="stat-card">
<n-icon :component="GitNetworkOutline" class="stat-card__icon" :class="`stat-card__icon--${status.status}`" aria-hidden="true" />
<div class="stat-card__value stat-card__value--sm"><StatusBadge :kind="statusKind" :text="statusLabel" /></div>
<div class="stat-card__label">客户端状态</div>
<div class="stat-card__sub">frpc 进程状态</div>
<!-- FRP 状态 Hero:全局 frpc 状态 + 关键指标 + 应用配置与启停入口;当前服务端信息在规则区标题展示 -->
<section class="frp-hero" aria-label="FRP 运行状态">
<div class="frp-hero__top">
<div class="frp-hero__status">
<n-icon
:component="GitNetworkOutline"
class="frp-hero__icon"
:class="`frp-hero__icon--${status.status}`"
aria-hidden="true"
/>
<div class="frp-hero__headline">
<div class="frp-hero__title">
<span class="frp-hero__title-label">全局 frpc</span>
<StatusBadge :kind="statusKind" :text="statusLabel" />
</div>
</div>
</div>
<div class="frp-hero__actions">
<n-button @click="openRuntimeModal">
<template #icon><n-icon :component="SettingsOutline" /></template>
应用配置
</n-button>
<n-button
v-if="status.status === 'running'"
type="error"
ghost
:loading="actionLoading === 'stop'"
@click="stopClient"
>
<template #icon><n-icon :component="StopOutline" /></template>
停止 frpc
</n-button>
<n-button
v-else
type="primary"
:loading="actionLoading === 'start'"
:disabled="servers.length === 0"
@click="startClient"
>
<template #icon><n-icon :component="PlayOutline" /></template>
启动 frpc
</n-button>
</div>
</div>
<div class="stat-card">
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
<div class="stat-card__value">{{ status.enabled_proxies }}</div>
<div class="stat-card__label">启用规则</div>
<div class="stat-card__sub">已启用的穿透规则数</div>
</div>
<div class="stat-card">
<n-icon :component="TerminalOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
<div class="stat-card__value stat-card__value--sm">{{ status.frpc_version || '未检测' }}</div>
<div class="stat-card__label">frpc 版本</div>
<div class="stat-card__sub">本机运行的 frpc</div>
</div>
<div class="stat-card">
<n-icon :component="TimeOutline" class="stat-card__icon stat-card__icon--amber" aria-hidden="true" />
<div class="stat-card__value stat-card__value--sm">{{ formatDate(status.last_reloaded_at) || '尚未应用' }}</div>
<div class="stat-card__label">最近应用</div>
<div class="stat-card__sub">配置最近一次生效</div>
<div class="frp-hero__metrics">
<div class="frp-hero__metric">
<span class="frp-hero__metric-label">公网服务端</span>
<span class="frp-hero__metric-value">{{ servers.length }}</span>
<span class="frp-hero__metric-sub">{{ serverSummaryText }}</span>
</div>
<div class="frp-hero__metric">
<span class="frp-hero__metric-label">启用规则</span>
<span class="frp-hero__metric-value">{{ status.enabled_proxies }}</span>
<span class="frp-hero__metric-sub">当前服务端 {{ selectedServerEnabledCount }} 条</span>
</div>
<div class="frp-hero__metric">
<span class="frp-hero__metric-label">
<n-icon :component="PulseOutline" class="frp-hero__metric-icon" :class="healthIconClass" />
健康检查
</span>
<span class="frp-hero__metric-value">{{ healthValue }}</span>
<span class="frp-hero__metric-sub">{{ healthSub }}</span>
</div>
<div class="frp-hero__metric">
<span class="frp-hero__metric-label">frpc 版本</span>
<span class="frp-hero__metric-value">{{ status.frpc_version || '未检测' }}</span>
<span class="frp-hero__metric-sub">本机运行的 frpc</span>
</div>
<div class="frp-hero__metric">
<span class="frp-hero__metric-label">最近应用</span>
<span class="frp-hero__metric-value">{{ formatDate(status.last_reloaded_at) || '尚未应用' }}</span>
<span class="frp-hero__metric-sub">配置最近一次生效</span>
</div>
</div>
</section>
@@ -102,10 +140,16 @@
<div class="rule-pane__head">
<div class="rule-pane__intro">
<h3 class="rule-pane__title">穿透规则</h3>
<span class="rule-pane__sub">{{ selectedServer?.name || '未选择服务端' }} · {{ serverProxies.length }} 条</span>
<span class="rule-pane__sub">
<template v-if="selectedServer">
{{ selectedServer.name }} ·
<span class="rule-pane__state" :class="`rule-pane__state--${selectedServer.boot_status}`">{{ serverBootLabel(selectedServer) }}</span>
<template v-if="selectedServerHealthText"> · {{ selectedServerHealthText }}</template>
</template>
<template v-else>未选择服务端</template>
</span>
</div>
<div class="rule-pane__actions">
<n-button size="small" @click="showExport = true; loadExport()">查看脱敏配置</n-button>
<router-link v-if="selectedServerId" :to="{ name: 'frp-agent', query: { server: String(selectedServerId) } }">
<n-button size="small" quaternary>管理该服务端</n-button>
</router-link>
@@ -125,8 +169,8 @@
:columns="columns"
:data="serverProxies"
:bordered="false"
:single-line="false"
:scroll-x="1890"
size="small"
:scroll-x="1520"
:row-key="(row: FrpProxy) => row.id"
/>
</div>
@@ -158,39 +202,72 @@
<div class="proxy-modal__scroll">
<n-form label-placement="top" class="proxy-modal__pane" @submit.prevent="saveProxy">
<div v-show="proxyFormTab === 'basic'" class="frp-proxy-modal__basic">
<div class="form-grid">
<n-form-item label="公网服务端" required><n-select v-model:value="proxyForm.server_id" :options="proxyServerOptions" placeholder="选择 FRP 服务端" /></n-form-item>
<n-form-item label="规则名称" required><n-input v-model:value="proxyForm.name" placeholder="例如:nas-web" /></n-form-item>
<n-form-item label="类型" required><n-select v-model:value="proxyForm.type" :options="proxyTypeOptions" /></n-form-item>
<n-form-item v-if="!proxyForm.plugin_type" label="内网地址" required><n-input v-model:value="proxyForm.local_ip" placeholder="127.0.0.1" /></n-form-item>
<n-form-item v-if="!proxyForm.plugin_type" label="内网端口" required><n-input-number v-model:value="proxyForm.local_port" :min="1" :max="65535" /></n-form-item>
<n-form-item v-if="['tcp', 'udp'].includes(proxyForm.type)" label="公网远程端口" required><n-input-number v-model:value="proxyForm.remote_port" :min="1" :max="65535" /></n-form-item>
<n-form-item v-if="['http', 'https', 'tcpmux'].includes(proxyForm.type)" label="自定义域名"><n-input v-model:value="proxyForm.domains_text" type="textarea" :rows="2" placeholder="nas.example.com&#10;app.example.com" /></n-form-item>
</div>
<section class="proxy-form-section" v-if="['http', 'https'].includes(proxyForm.type)">
<h4>HTTP / HTTPS 配置</h4>
<div class="form-grid">
<n-form-item label="Subdomain"><n-input v-model:value="proxyForm.options.subdomain" placeholder="例如:nas" /></n-form-item>
<n-form-item label="公网反代选项" :show-feedback="true">
<span class="form-hint">WebSocket / 强制跳转 / IP 白名单 / Basic Auth 已移至「公网反代」页逐条规则的「部署设置」中配置;此处填写的值会保留。已填的 IP 白名单:{{ (proxyForm.options.allow_ips || []).join(', ') || '无' }}</span>
</n-form-item>
<n-form-item label="上游 Host 重写"><n-input v-model:value="proxyForm.host_header_rewrite" placeholder="例如:nas.example.com" /></n-form-item>
<n-form-item label="路径匹配(Locations)"><n-input v-model:value="proxyForm.locations_text" placeholder="多个路径用逗号或换行分隔" /></n-form-item>
<n-form-item label="按 HTTP 用户路由"><n-input v-model:value="proxyForm.options.route_by_http_user" placeholder="可选" /></n-form-item>
<n-form-item label="HTTP 基础认证用户名"><n-input v-model:value="proxyForm.options.http_user" placeholder="可选" /></n-form-item>
<n-form-item label="HTTP 基础认证密码"><n-input v-model:value="proxyForm.options.http_password" type="password" show-password-on="click" placeholder="可选" /></n-form-item>
</div>
</section>
<section class="proxy-form-section" v-if="proxyForm.type === 'tcpmux'">
<h4>TCPMUX 配置</h4>
<div class="form-grid"><n-form-item label="多路复用器" required><n-select v-model:value="proxyForm.options.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" /></n-form-item><n-form-item label="按 HTTP 用户路由"><n-input v-model:value="proxyForm.options.route_by_http_user" placeholder="可选" /></n-form-item></div>
</section>
<section class="proxy-form-section" v-if="['stcp', 'sudp', 'xtcp'].includes(proxyForm.type)">
<h4>访问控制</h4>
<div class="form-grid"><n-form-item label="访问密钥" required><n-input v-model:value="proxyForm.options.secret_key" type="password" show-password-on="click" placeholder="与访客端保持一致" /></n-form-item><n-form-item label="允许的用户"><n-input v-model:value="proxyForm.allow_users_text" placeholder="多个用户用逗号分隔,* 表示全部" /></n-form-item></div>
</section>
<n-form-item label="备注"><n-input v-model:value="proxyForm.remark" placeholder="可选" /></n-form-item>
<div class="switch-row"><span>启用规则</span><n-switch v-model:value="proxyForm.enabled" /></div>
<!-- ① 归属与类型:决定规则写到哪台服务端 -->
<section class="proxy-form-section proxy-form-section--first">
<h4>归属与类型</h4>
<div class="form-grid">
<n-form-item label="公网服务端" required><n-select v-model:value="proxyForm.server_id" :options="proxyServerOptions" placeholder="选择 FRP 服务端" /></n-form-item>
<n-form-item label="规则名称" required><n-input v-model:value="proxyForm.name" placeholder="例如:nas-web" /></n-form-item>
<n-form-item label="类型" required><n-select v-model:value="proxyForm.type" :options="proxyTypeOptions" /></n-form-item>
</div>
</section>
<!-- ② 内网目标:启用插件后由插件接管,地址与端口不生效 -->
<section class="proxy-form-section">
<h4>内网目标</h4>
<div class="form-grid">
<template v-if="!proxyForm.plugin_type">
<n-form-item label="内网地址" required><n-input v-model:value="proxyForm.local_ip" placeholder="127.0.0.1" /></n-form-item>
<n-form-item label="内网端口" required><n-input-number v-model:value="proxyForm.local_port" :min="1" :max="65535" /></n-form-item>
</template>
<n-form-item v-else label="内网目标" class="form-item--full">
<span class="form-hint">已启用插件,内网地址与端口不生效;插件参数在「高级配置 → 插件与 NAT 穿透」中设置。</span>
</n-form-item>
</div>
</section>
<!-- ③ 公网入口:按类型只显示需要的字段 -->
<section class="proxy-form-section">
<h4>公网入口</h4>
<div class="form-grid">
<template v-if="['tcp', 'udp'].includes(proxyForm.type)">
<n-form-item label="公网远程端口" required><n-input-number v-model:value="proxyForm.remote_port" :min="1" :max="65535" /></n-form-item>
</template>
<template v-else-if="['http', 'https', 'tcpmux'].includes(proxyForm.type)">
<n-form-item label="自定义域名" class="form-item--full"><n-input v-model:value="proxyForm.domains_text" type="textarea" :rows="2" placeholder="nas.example.com&#10;app.example.com" /></n-form-item>
<n-form-item label="Subdomain"><n-input v-model:value="proxyForm.options.subdomain" placeholder="例如:nas" /></n-form-item>
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="上游 Host 重写"><n-input v-model:value="proxyForm.host_header_rewrite" placeholder="例如:nas.example.com" /></n-form-item>
</template>
<template v-else>
<n-form-item label="访问密钥" required><n-input v-model:value="proxyForm.options.secret_key" type="password" show-password-on="click" placeholder="与访客端保持一致" /></n-form-item>
<n-form-item label="允许的用户"><n-input v-model:value="proxyForm.allow_users_text" placeholder="多个用户用逗号分隔,* 表示全部" /></n-form-item>
</template>
</div>
<div v-if="['http', 'https'].includes(proxyForm.type)" class="proxy-form-note">
<n-icon :component="InformationCircleOutline" class="proxy-form-note__icon" />
<span>WebSocket / 强制跳转 / IP 白名单 / Basic Auth 在「公网反代」页的「部署设置」里配置;此处已填写的值会保留。已填的 IP 白名单:{{ (proxyForm.options.allow_ips || []).join(', ') || '无' }}</span>
</div>
</section>
<!-- ④ TCPMUX 专属:多路复用器 -->
<section v-if="proxyForm.type === 'tcpmux'" class="proxy-form-section">
<h4>TCPMUX 配置</h4>
<div class="form-grid">
<n-form-item label="多路复用器" required><n-select v-model:value="proxyForm.options.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" /></n-form-item>
</div>
</section>
<!-- ⑤ 备注与启用 -->
<section class="proxy-form-section">
<div class="form-grid">
<n-form-item label="备注" class="form-item--full"><n-input v-model:value="proxyForm.remark" placeholder="可选" /></n-form-item>
</div>
<div class="switch-row proxy-switch-row">
<n-switch v-model:value="proxyForm.enabled" />
<span>启用规则</span>
<span class="form-hint">停用后该规则不会写入 frpc 配置</span>
</div>
</section>
</div>
<div v-show="proxyFormTab === 'advanced'" class="frp-proxy-modal__advanced">
<n-collapse class="advanced-collapse proxy-advanced-collapse">
@@ -210,18 +287,31 @@
<n-collapse-item title="负载均衡与请求头" name="proxy-advanced">
<div class="form-grid"><n-form-item label="负载均衡组"><n-input v-model:value="proxyForm.options.load_balancer.group" placeholder="同组规则共享流量" /></n-form-item><n-form-item label="负载均衡组密钥"><n-input v-model:value="proxyForm.options.load_balancer.group_key" type="password" show-password-on="click" /></n-form-item><n-form-item label="元数据"><n-input v-model:value="proxyForm.metadatas_text" type="textarea" :rows="2" placeholder="每行 key=value" /></n-form-item><n-form-item label="请求头(set)"><n-input v-model:value="proxyForm.request_headers_text" type="textarea" :rows="2" placeholder="每行 key=value" /></n-form-item><n-form-item label="响应头(set)"><n-input v-model:value="proxyForm.response_headers_text" type="textarea" :rows="2" placeholder="每行 key=value" /></n-form-item></div>
</n-collapse-item>
<!-- HTTP 低频项:路径匹配、按用户路由与基础认证从基础页下沉到这里 -->
<n-collapse-item v-if="['http', 'https', 'tcpmux'].includes(proxyForm.type)" title="HTTP 匹配与鉴权" name="proxy-http">
<div class="form-grid">
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="路径匹配(Locations)"><n-input v-model:value="proxyForm.locations_text" placeholder="多个路径用逗号或换行分隔" /></n-form-item>
<n-form-item label="按 HTTP 用户路由"><n-input v-model:value="proxyForm.options.route_by_http_user" placeholder="可选" /></n-form-item>
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="HTTP 基础认证用户名"><n-input v-model:value="proxyForm.options.http_user" placeholder="可选" /></n-form-item>
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="HTTP 基础认证密码"><n-input v-model:value="proxyForm.options.http_password" type="password" show-password-on="click" placeholder="可选" /></n-form-item>
</div>
</n-collapse-item>
<n-collapse-item title="插件与 NAT 穿透" name="proxy-plugin">
<div class="form-grid"><n-form-item label="插件类型"><n-select v-model:value="proxyForm.plugin_type" clearable :options="pluginTypeOptions" /></n-form-item><n-form-item v-if="proxyForm.plugin_type" label="插件本地地址"><n-input v-model:value="proxyForm.plugin_local_addr" placeholder="127.0.0.1:80" /></n-form-item><n-form-item v-if="proxyForm.plugin_type === 'unix_domain_socket'" label="Unix Socket 路径"><n-input v-model:value="proxyForm.plugin_unix_path" /></n-form-item><n-form-item v-if="proxyForm.plugin_type === 'static_file'" label="静态文件目录"><n-input v-model:value="proxyForm.plugin_local_path" /></n-form-item><n-form-item v-if="proxyForm.plugin_type" label="插件用户名"><n-input v-model:value="proxyForm.plugin_username" /></n-form-item><n-form-item v-if="proxyForm.plugin_type" label="插件密码"><n-input v-model:value="proxyForm.plugin_password" type="password" show-password-on="click" /></n-form-item></div>
<div class="switch-row"><span>禁用 NAT 辅助地址</span><n-switch v-model:value="proxyForm.options.nat_traversal.disable_assisted_addrs" /></div>
</n-collapse-item>
</n-collapse>
</div>
<div class="modal-actions"><n-button @click="showProxyModal = false">取消</n-button><n-button type="primary" :loading="savingProxy" @click="saveProxy">保存</n-button></div>
</n-form>
</div>
<!-- 操作栏固定在弹窗底部,长表单滚动时仍可随时保存 -->
<div class="proxy-modal__footer">
<n-button @click="showProxyModal = false">取消</n-button>
<n-button type="primary" :loading="savingProxy" :disabled="savingProxy" @click="saveProxy">保存</n-button>
</div>
</div>
<div class="proxy-modal__help">
<h4>{{ proxyFormTab === 'basic' ? '配置说明' : '高级配置说明' }}</h4>
<h4>{{ proxyFormTab === 'basic' ? `${proxyTypeLabel} 配置说明` : '高级配置说明' }}</h4>
<ol>
<li v-for="item in proxyHelpItems" :key="item.title"><strong>{{ item.title }}</strong>:{{ item.text }}</li>
</ol>
@@ -568,73 +658,109 @@
<h3 class="modal-title">FRP 应用配置</h3>
<n-button size="small" quaternary @click="showRuntimeModal = false">关闭</n-button>
</div>
<div class="proxy-modal__scroll">
<div class="runtime-actions">
<n-button size="small" :loading="runtimeLoading" @click="loadRuntimeConfig">
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新
</n-button>
<n-button size="small" type="primary" :loading="actionLoading === 'reload'" @click="applyRuntimeConfig">
校验并应用配置
</n-button>
</div>
<section class="runtime-section">
<h3>本机运行环境</h3>
<n-descriptions :column="1" label-placement="left" bordered size="small">
<n-descriptions-item label="系统架构">{{ runtime.platform || '检测中' }}</n-descriptions-item>
<n-descriptions-item label="当前 frpc">{{ runtime.active_bin || '未检测到' }}</n-descriptions-item>
<n-descriptions-item label="检测版本">{{ runtime.detected_version || '未检测到' }}</n-descriptions-item>
<n-descriptions-item label="配置文件"><span v-if="runtime.config_paths?.length">{{ runtime.config_paths.join('、') }}</span><span v-else>{{ runtime.config_path }}</span>{{ runtime.config_exists ? '(已生成)' : '(尚未生成)' }}</n-descriptions-item>
</n-descriptions>
</section>
<section class="runtime-section">
<div class="runtime-section__title"><h3>下载加速代理</h3><span>仅代理 FRP 官方 Release 下载地址</span></div>
<div class="download-proxy-form">
<n-select v-model:value="downloadProxy" :options="downloadProxyOptions" placeholder="选择下载方式" />
<n-button type="primary" :loading="savingDownloadProxy" @click="saveDownloadProxy">保存</n-button>
</div>
</section>
<section class="runtime-section">
<div class="runtime-section__title"><h3>已下载客户端</h3><span>{{ binaries.length }} 个版本</span></div>
<n-empty v-if="!runtimeLoading && binaries.length === 0" size="small" description="尚未下载 FRP 客户端" />
<div v-else class="binary-list">
<div v-for="binary in binaries" :key="binary.version" class="binary-row">
<div><strong>{{ binary.version }}</strong><div class="binary-row__meta">{{ formatBinarySize(binary.size) }} · {{ formatDate(binary.installed_at) }}</div></div>
<div class="binary-row__actions">
<n-tag v-if="binary.active" size="small" type="success" :bordered="false">当前使用</n-tag>
<n-button v-else size="small" :loading="activatingVersion === binary.version" @click="confirmActivate(binary.version)">启用</n-button>
<n-button v-if="!binary.active" size="small" quaternary type="error" :loading="deletingVersion === binary.version" @click="confirmDeleteBinary(binary)">删除</n-button>
</div>
<!-- 顶部摘要:不展开折叠项也能确认当前运行状态 -->
<div class="runtime-summary">
<div class="runtime-summary__item">
<span class="runtime-summary__label">当前 frpc</span>
<span class="runtime-summary__value">{{ runtime.detected_version || '未检测到' }}</span>
</div>
<div class="runtime-summary__item">
<span class="runtime-summary__label">运行平台</span>
<span class="runtime-summary__value">{{ runtime.platform || '检测中' }}</span>
</div>
<div class="runtime-summary__item">
<span class="runtime-summary__label">配置文件</span>
<n-tag size="small" :bordered="false" :type="runtime.config_exists ? 'success' : 'warning'">
{{ runtime.config_exists ? '已生成' : '尚未生成' }}
</n-tag>
</div>
</div>
</section>
<section class="runtime-section">
<div class="runtime-section__title"><h3>FRP 官方下载</h3><span>仅显示当前系统 {{ runtime.platform || '' }} 可用的稳定版本</span></div>
<n-empty v-if="!runtimeLoading && releases.length === 0" size="small" description="未获取到官方版本列表" />
<div v-else class="release-list">
<div v-for="release in visibleReleases" :key="release.version" class="release-row">
<div class="release-row__info"><strong>{{ release.version }}</strong><div class="binary-row__meta">{{ release.asset_name }} · {{ formatBinarySize(release.size) }}</div></div>
<div v-if="downloadTask.version === release.version && downloadTask.status !== 'idle'" class="release-progress" :class="`release-progress--${downloadTask.status}`" :title="downloadTask.error || downloadTask.message">
<span class="release-progress__message">{{ downloadTask.error || downloadTask.message }}</span>
<n-progress v-if="downloadTask.status !== 'failed'" class="release-progress__bar" type="line" :percentage="downloadPercent" :status="downloadProgressStatus" :show-indicator="false" />
<span v-if="downloadTask.status !== 'failed'" class="release-progress__value">{{ downloadTask.total > 0 ? `${downloadPercent}%` : '...' }}</span>
</div>
<div v-else class="release-progress-placeholder" />
<n-button size="small" :loading="downloadingVersion === release.version" :disabled="isDownloaded(release.version) || downloadInProgress" @click="confirmDownload(release)">
{{ isDownloaded(release.version) ? '已下载' : '下载' }}
</n-button>
</div>
</div>
<div v-if="releases.length > releaseLimit" class="release-list__more">
<n-button text type="primary" @click="releasesExpanded = !releasesExpanded">
{{ releasesExpanded ? '收起列表' : `显示更多(剩余 ${releases.length - releaseLimit} 条)` }}
<!-- 操作栏固定在滚动区之外,滚动版本列表时仍可刷新 / 应用 -->
<div class="runtime-toolbar">
<n-button size="small" :loading="runtimeLoading" @click="loadRuntimeConfig">
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新
</n-button>
<n-button size="small" type="primary" :loading="actionLoading === 'reload'" @click="applyRuntimeConfig">
校验并应用配置
</n-button>
</div>
</section>
<div class="proxy-modal__scroll">
<!-- 三个折叠分组:运行环境 / 版本管理 / 下载代理,默认只展开版本管理 -->
<n-collapse :default-expanded-names="['versions']" class="runtime-collapse">
<n-collapse-item name="environment">
<template #header>
<span class="runtime-collapse__title"><n-icon :component="TerminalOutline" />运行环境</span>
</template>
<n-descriptions :column="1" label-placement="left" bordered size="small">
<n-descriptions-item label="系统架构">{{ runtime.platform || '检测中' }}</n-descriptions-item>
<n-descriptions-item label="当前 frpc">{{ runtime.active_bin || '未检测到' }}</n-descriptions-item>
<n-descriptions-item label="检测版本">{{ runtime.detected_version || '未检测到' }}</n-descriptions-item>
<n-descriptions-item label="配置文件"><span v-if="runtime.config_paths?.length">{{ runtime.config_paths.join('、') }}</span><span v-else>{{ runtime.config_path }}</span>{{ runtime.config_exists ? '(已生成)' : '(尚未生成)' }}</n-descriptions-item>
</n-descriptions>
</n-collapse-item>
<n-collapse-item name="versions">
<template #header>
<span class="runtime-collapse__title">
<n-icon :component="LayersOutline" />版本管理
<span class="runtime-collapse__meta">{{ binaries.length }} 个已下载</span>
</span>
</template>
<div class="runtime-subsection">
<div class="runtime-section__title"><h3>已下载客户端</h3><span>点击启用可切换当前运行版本</span></div>
<n-empty v-if="!runtimeLoading && binaries.length === 0" size="small" description="尚未下载 FRP 客户端" />
<div v-else class="binary-list">
<div v-for="binary in binaries" :key="binary.version" class="binary-row">
<div><strong>{{ binary.version }}</strong><div class="binary-row__meta">{{ formatBinarySize(binary.size) }} · {{ formatDate(binary.installed_at) }}</div></div>
<div class="binary-row__actions">
<n-tag v-if="binary.active" size="small" type="success" :bordered="false">当前使用</n-tag>
<n-button v-else size="small" :loading="activatingVersion === binary.version" @click="confirmActivate(binary.version)">启用</n-button>
<n-button v-if="!binary.active" size="small" quaternary type="error" :loading="deletingVersion === binary.version" @click="confirmDeleteBinary(binary)">删除</n-button>
</div>
</div>
</div>
</div>
<div class="runtime-subsection">
<div class="runtime-section__title"><h3>FRP 官方下载</h3><span>仅显示当前系统 {{ runtime.platform || '' }} 可用的稳定版本</span></div>
<n-empty v-if="!runtimeLoading && releases.length === 0" size="small" description="未获取到官方版本列表" />
<div v-else class="release-list">
<div v-for="release in visibleReleases" :key="release.version" class="release-row">
<div class="release-row__info"><strong>{{ release.version }}</strong><div class="binary-row__meta">{{ release.asset_name }} · {{ formatBinarySize(release.size) }}</div></div>
<div v-if="downloadTask.version === release.version && downloadTask.status !== 'idle'" class="release-progress" :class="`release-progress--${downloadTask.status}`" :title="downloadTask.error || downloadTask.message">
<span class="release-progress__message">{{ downloadTask.error || downloadTask.message }}</span>
<n-progress v-if="downloadTask.status !== 'failed'" class="release-progress__bar" type="line" :percentage="downloadPercent" :status="downloadProgressStatus" :show-indicator="false" />
<span v-if="downloadTask.status !== 'failed'" class="release-progress__value">{{ downloadTask.total > 0 ? `${downloadPercent}%` : '...' }}</span>
</div>
<div v-else class="release-progress-placeholder" />
<n-button size="small" :loading="downloadingVersion === release.version" :disabled="isDownloaded(release.version) || downloadInProgress" @click="confirmDownload(release)">
{{ isDownloaded(release.version) ? '已下载' : '下载' }}
</n-button>
</div>
</div>
<div v-if="releases.length > releaseLimit" class="release-list__more">
<n-button text type="primary" @click="releasesExpanded = !releasesExpanded">
{{ releasesExpanded ? '收起列表' : `显示更多(剩余 ${releases.length - releaseLimit} 条)` }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item name="proxy">
<template #header>
<span class="runtime-collapse__title"><n-icon :component="FlashOutline" />下载代理</span>
</template>
<div class="runtime-section__title"><h3>下载加速代理</h3><span>仅代理 FRP 官方 Release 下载地址</span></div>
<div class="download-proxy-form">
<n-select v-model:value="downloadProxy" :options="downloadProxyOptions" placeholder="选择下载方式" />
<n-button type="primary" :loading="savingDownloadProxy" @click="saveDownloadProxy">保存</n-button>
</div>
</n-collapse-item>
</n-collapse>
</div>
<div class="modal-footer"><n-button @click="showRuntimeModal = false">关闭</n-button></div>
</div>
@@ -645,17 +771,19 @@
import { computed, h, onMounted, reactive, ref, type VNode } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import type { FrpProxyTraffic, FrpProxiesTraffic } from '../api/types'
import { NButton, NIcon, NSpace, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
import { NButton, NIcon, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
import {
AddOutline, CloseOutline, DocumentTextOutline, FlashOutline, GitNetworkOutline, InformationCircleOutline,
LayersOutline, PeopleOutline, PlayOutline, RefreshOutline, SettingsOutline, StopOutline, TerminalOutline, TimeOutline,
AddOutline, CloseOutline, CopyOutline, DocumentTextOutline, FlashOutline, GitNetworkOutline, InformationCircleOutline,
LayersOutline, OpenOutline, PeopleOutline, PlayOutline, PulseOutline, RefreshOutline, SettingsOutline, StopOutline,
TerminalOutline,
} from '@vicons/ionicons5'
import { RouterLink } from 'vue-router'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import { formatBytes, formatDate, formatRate } from '../utils/format'
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpServerDiagnostics, FrpStatus } from '../api/types'
import HavlineCard from '../components/HavlineCard.vue'
import EmptyState from '../components/EmptyState.vue'
import LoadError from '../components/LoadError.vue'
@@ -667,6 +795,7 @@ const message = useMessage()
const dialog = useDialog()
const status = ref<FrpStatus>({ status: 'unknown', configured: false, enabled_proxies: 0 })
const servers = ref<FrpServer[]>([])
const serverDiagnostics = ref<Record<number, FrpServerDiagnostics>>({})
const proxies = ref<FrpProxy[]>([])
const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({})
const proxyTrafficErrors = ref<Record<number, string>>({})
@@ -732,6 +861,8 @@ const proxyQuickExamples: Record<FrpProxy['type'], {
xtcp: { title: 'XTCP P2P 穿透', description: '通过访客端和服务端建立点对点连接。', lines: ['name = "ssh-xtcp"', 'type = "xtcp"', 'localPort = 22', 'secretKey = "请替换为强密钥"'], name: 'ssh-xtcp', localPort: 22, remotePort: 0, domains: '', remark: 'P2P SSH 服务', secretKey: '请替换为强密钥', multiplexer: '' },
}
const proxyQuickExample = computed(() => proxyQuickExamples[proxyForm.type])
// 右侧说明栏标题带上当前类型名,避免只看“配置说明”不知道在讲哪种规则
const proxyTypeLabel = computed(() => proxyTypeOptions.find((item) => item.value === proxyForm.type)?.label ?? '规则')
const pluginTypeOptions = [
{ label: 'Unix Domain Socket', value: 'unix_domain_socket' }, { label: 'HTTP Proxy', value: 'http_proxy' }, { label: 'Socks5', value: 'socks5' }, { label: 'Static File', value: 'static_file' },
{ label: 'HTTP 转 HTTPS', value: 'http2https' }, { label: 'HTTPS 转 HTTP', value: 'https2http' }, { label: 'HTTPS 转 HTTPS', value: 'https2https' }, { label: 'HTTP 转 HTTP', value: 'http2http' }, { label: 'TLS 转 Raw', value: 'tls2raw' },
@@ -792,46 +923,75 @@ const frpTypeTagType: Record<FrpProxy['type'], 'info' | 'success' | 'warning' |
tcp: 'info', udp: 'default', http: 'success', https: 'success', tcpmux: 'info', stcp: 'warning', sudp: 'warning', xtcp: 'warning',
}
// 穿透规则表格:列宽固定,各行列天然对齐(单元格由 render 创建,样式必须 :deep())
// 穿透规则台账:版式对齐「公网反代 → 规则台账」,列宽固定、逐行对齐(单元格由 render 创建,样式必须 :deep())
const columns = computed<DataTableColumns<FrpProxy>>(() => [
{ title: '名称', key: 'name', minWidth: 150, render: (row) => h('div', [h('strong', row.name), row.remark ? h('div', { class: 'table-muted' }, row.remark) : null]) },
{ title: '类型', key: 'type', width: 92, render: (row) => h(NTag, { size: 'small', type: frpTypeTagType[row.type], bordered: false, round: true }, () => row.type.toUpperCase()) },
{ title: '内网目标', key: 'local', minWidth: 170, render: (row) => h('code', proxyLocalTarget(row)) },
{ title: '公网入口', key: 'public', minWidth: 200, render: (row) => h('code', proxyPublicEndpoint(row)) },
{ title: '传输', key: 'transport', width: 150, render: (row) => {
const tags = proxyTransportTags(row)
return tags.length ? h(NSpace, { size: 4, wrap: false }, () => tags) : h('span', { class: 'table-muted' }, '标准传输')
} },
{ title: '健康检查', key: 'health_check', width: 96, render: (row) => {
const checkType = row.options?.health_check?.type
return checkType
? h(NTag, { size: 'small', type: 'success', bordered: false, round: true }, () => checkType.toUpperCase())
: h('span', { class: 'table-muted' }, '—')
} },
{ title: '负载均衡', key: 'load_balancer', width: 104, render: (row) => {
{
title: '规则', key: 'name', width: 240,
render: (row) => h('div', { class: 'cell-name' }, [
h('div', { class: 'cell-name__head' }, [
h('strong', row.name),
h(NTag, { size: 'small', type: frpTypeTagType[row.type], bordered: false, round: true }, () => row.type.toUpperCase()),
row.enabled ? null : h('span', { class: 'cell-muted' }, '已停用'),
]),
h('div', { class: 'cell-domains' }, proxyEntriesOf(row).map((entry) => h('div', { class: 'cell-domain' }, [
h('span', { class: 'mono cell-wrap' }, entry.text),
h(NButton, { size: 'tiny', quaternary: true, title: `复制 ${entry.text}`, onClick: () => copyEntry(entry.text) },
{ icon: () => h(NIcon, { component: CopyOutline }) }),
entry.url ? h(NButton, { size: 'tiny', quaternary: true, title: `打开 ${entry.text}`, onClick: () => openEntry(entry.url) },
{ icon: () => h(NIcon, { component: OpenOutline }) }) : null,
]))),
row.remark ? h('span', { class: 'cell-muted cell-wrap' }, row.remark) : null,
]),
},
{
title: '内网目标', key: 'local', width: 190,
render: (row) => h('div', { class: 'cell-upstream' }, [
h('span', { class: 'mono cell-wrap' }, `${row.local_ip}:${row.local_port}`),
row.options?.plugin?.type ? h('span', { class: 'mono cell-wrap cell-muted' }, `插件 · ${row.options.plugin.type}`) : null,
]),
},
{
title: '传输 · 健康', key: 'transport', width: 210,
render: (row) => {
const items = [...proxyTransportTags(row)]
const group = row.options?.load_balancer?.group
return group
? h(NTag, { size: 'small', type: 'info', bordered: false, round: true }, () => group)
: h('span', { class: 'table-muted' }, '—')
} },
{ title: '状态', key: 'enabled', width: 100, render: (row) => h('div', { onClick: (event: MouseEvent) => event.stopPropagation() }, h(NSwitch, { value: row.enabled, size: 'small', onUpdateValue: (value: boolean) => toggleProxy(row, value) })) },
{ title: '当前连接', key: 'cur_conns', width: 88, render: (row) => h('span', { class: 'mono' }, String(proxyTraffic.value[row.id]?.cur_conns ?? 0)) },
{ title: '当前上传', key: 'traffic_in_rate', width: 96, render: (row) => h('span', { class: 'mono text-secondary' }, formatRate(proxyTraffic.value[row.id]?.traffic_in_rate ?? 0)) },
{ title: '当前下载', key: 'traffic_out_rate', width: 96, render: (row) => h('span', { class: 'mono text-secondary' }, formatRate(proxyTraffic.value[row.id]?.traffic_out_rate ?? 0)) },
{ title: '总上传', key: 'traffic_in', width: 96, render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_in ?? 0)) },
{ title: '总下载', key: 'traffic_out', width: 96, render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_out ?? 0)) },
{ title: '更新时间', key: 'updated_at', width: 110, render: (row) => h('span', { class: 'table-muted' }, formatDate(row.updated_at)) },
{ title: '操作', key: 'actions', width: 200, render: (row) => h(NSpace, { size: 2, wrap: false }, () => [
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyDetail(row) }, () => '详情'),
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyModal(row) }, () => '编辑'),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDeleteProxy(row) }, () => '删除'),
]) },
if (group) items.push(h(NTag, { size: 'small', type: 'info', bordered: false, round: true }, () => `LB ${group}`))
if (row.options?.health_check?.type) items.push(h(NTag, { size: 'small', type: 'success', bordered: false, round: true }, () => `健康 ${row.options.health_check.type?.toUpperCase()}`))
return items.length ? h('div', { class: 'cell-health' }, items) : h('span', { class: 'cell-muted' }, '标准传输')
},
},
{
title: '状态', key: 'enabled', width: 84, align: 'center',
render: (row) => h('div', { class: 'cell-switch', onClick: (event: MouseEvent) => event.stopPropagation() },
h(NSwitch, { value: row.enabled, size: 'small', onUpdateValue: (value: boolean) => toggleProxy(row, value) })),
},
{ title: '连接', key: 'cur_conns', width: 80, align: 'right', render: (row) => h('span', { class: 'mono' }, String(proxyTraffic.value[row.id]?.cur_conns ?? 0)) },
{ title: '当前上传', key: 'traffic_in_rate', width: 100, align: 'right', render: (row) => h('span', { class: 'mono' }, formatRate(proxyTraffic.value[row.id]?.traffic_in_rate ?? 0)) },
{ title: '当前下载', key: 'traffic_out_rate', width: 100, align: 'right', render: (row) => h('span', { class: 'mono' }, formatRate(proxyTraffic.value[row.id]?.traffic_out_rate ?? 0)) },
{ title: '总上传', key: 'traffic_in', width: 95, align: 'right', render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_in ?? 0)) },
{ title: '总下载', key: 'traffic_out', width: 95, align: 'right', render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_out ?? 0)) },
{
title: '更新时间', key: 'updated_at', width: 155,
render: (row) => h('div', { class: 'cell-time' }, [
h('span', { class: 'mono' }, formatDate(row.updated_at)),
h('span', { class: 'cell-muted' }, formatRelativeTime(row.updated_at)),
]),
},
{
title: '操作', key: 'actions', width: 170, align: 'right',
render: (row) => h('div', { class: 'cell-actions' }, [
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyDetail(row) }, () => '详情'),
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyModal(row) }, () => '编辑'),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDeleteProxy(row) }, () => '删除'),
]),
},
])
// 左侧选中服务端 → 右侧只列该服务端的规则;存 id 而不是对象,避免列表刷新后指向旧快照
const selectedServerId = ref<number | null>(null)
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value) ?? null)
const serverProxies = computed(() => proxies.value.filter((item) => item.server_id === selectedServerId.value))
const selectedServerEnabledCount = computed(() => serverProxies.value.filter((item) => item.enabled).length)
const showProxyDetail = ref(false)
const detailProxy = ref<FrpProxy | null>(null)
@@ -860,6 +1020,56 @@ function serverRuleCount(serverID: number) {
function serverEnabledRuleCount(serverID: number) {
return proxies.value.filter((item) => item.server_id === serverID && item.enabled).length
}
// 全局指标卡:服务端统计
const serverRunningCount = computed(() => servers.value.filter((item) => item.boot_status === 'running').length)
const serverAbnormalCount = computed(() => servers.value.filter((item) => item.boot_status === 'error').length)
const serverSummaryText = computed(() => {
if (servers.value.length === 0) return '尚未添加服务端'
const parts = [`${serverRunningCount.value} 台运行中`]
if (serverAbnormalCount.value > 0) parts.push(`${serverAbnormalCount.value} 台异常`)
return parts.join(' · ')
})
// 全局指标卡:健康检查(取自服务端诊断的可达性与延迟)
const probedDiagnostics = computed(() =>
servers.value.map((item) => serverDiagnostics.value[item.id]).filter((item): item is FrpServerDiagnostics => Boolean(item)))
const reachableServerCount = computed(() => probedDiagnostics.value.filter((item) => item.available).length)
const averageLatency = computed(() => {
const values = probedDiagnostics.value.filter((item) => item.available && typeof item.latency_ms === 'number').map((item) => item.latency_ms as number)
if (values.length === 0) return null
return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)
})
const healthValue = computed(() => {
if (servers.value.length === 0) return '未配置'
if (probedDiagnostics.value.length === 0) return '检测中'
return `${reachableServerCount.value}/${probedDiagnostics.value.length} 可达`
})
const healthSub = computed(() => {
if (servers.value.length === 0) return '添加服务端后可检测'
if (probedDiagnostics.value.length === 0) return '正在探测服务端连通性'
const pending = servers.value.length - probedDiagnostics.value.length
if (pending > 0) return `另有 ${pending} 台待检测`
if (averageLatency.value === null) return '暂无可达服务端'
return `平均延迟 ${averageLatency.value} ms`
})
const healthIconClass = computed(() => {
if (servers.value.length === 0 || probedDiagnostics.value.length === 0) return 'stat-card__icon--blue'
if (reachableServerCount.value === 0) return 'stat-card__icon--red'
if (reachableServerCount.value < probedDiagnostics.value.length) return 'stat-card__icon--amber'
return 'stat-card__icon--green'
})
// 当前选中服务端的探测结果,与全局状态并列展示,避免两级状态脱节
const selectedServerHealthText = computed(() => {
const server = selectedServer.value
if (!server) return ''
const diagnostics = serverDiagnostics.value[server.id]
if (!diagnostics) return ''
if (!diagnostics.available) return diagnostics.error || '不可达'
return diagnostics.latency_ms ? `延迟 ${diagnostics.latency_ms} ms` : '可达'
})
const proxyLogLines = ref<string[]>([])
const proxyLogsLoading = ref(false)
@@ -971,6 +1181,7 @@ async function load() {
selectedServerId.value = servers.value[0]?.id ?? null
}
void loadProxyTraffic()
void loadServerDiagnostics()
} catch (error) {
loadError.value = error instanceof Error ? error.message : '加载 FRP 配置失败'
}
@@ -1148,10 +1359,46 @@ async function loadExport() {
catch (error) { message.error(error instanceof Error ? error.message : '导出失败') }
}
// 公网入口条目:TCP/UDP 补全「服务端地址:远程端口」,域名类逐条给出可跳转的完整地址
function proxyEntriesOf(proxy: FrpProxy): Array<{ text: string; url: string }> {
if (proxy.type === 'tcp' || proxy.type === 'udp') {
const host = servers.value.find((item) => item.id === proxy.server_id)?.server_addr ?? ''
const port = proxy.remote_port
if (!host || !port) return [{ text: '未设置公网入口', url: '' }]
return [{ text: `${host}:${port}`, url: `http://${host}:${port}` }]
}
if (proxy.type === 'stcp' || proxy.type === 'sudp' || proxy.type === 'xtcp') return [{ text: '密钥访问(无公网端口)', url: '' }]
const domains = proxy.custom_domains.length ? proxy.custom_domains : (proxy.options?.subdomain ? [proxy.options.subdomain] : [])
if (domains.length === 0) return [{ text: '未设置公网入口', url: '' }]
const scheme = proxy.type === 'https' ? 'https' : 'http'
return domains.map((domain) => ({ text: domain, url: `${scheme}://${domain}` }))
}
function proxyPublicEndpoint(proxy: FrpProxy) {
if (proxy.type === 'tcp' || proxy.type === 'udp') return `:${proxy.remote_port ?? '-'}`
if (proxy.type === 'stcp' || proxy.type === 'sudp' || proxy.type === 'xtcp') return '密钥访问'
return proxy.custom_domains.join(', ') || proxy.options?.subdomain || '未设置入口'
return proxyEntriesOf(proxy).map((entry) => entry.text).join('、')
}
// 入口快捷操作:复制完整地址 / 新窗口打开(复制文字来自上层 tooltip,见列渲染)
async function copyEntry(text: string) {
if (await copyText(text)) message.success(`已复制 ${text}`)
else message.error('复制失败,请手动选择文本复制')
}
function openEntry(url: string) {
window.open(url, '_blank', 'noopener')
}
// 服务端健康检查:探测各服务端 TCP 可达与延迟,30 秒内不重复探测(与服务端页同一策略)
let diagnosticsFetchedAt = 0
async function loadServerDiagnostics() {
if (servers.value.length === 0) { serverDiagnostics.value = {}; return }
if (Date.now() - diagnosticsFetchedAt < 30_000) return
diagnosticsFetchedAt = Date.now()
const results = await Promise.all(servers.value.map(async (server) => {
try { return await api.getFrpServerDiagnostics(server.id) }
catch { return { server_id: server.id, available: false, error: '不可达' } as FrpServerDiagnostics }
}))
serverDiagnostics.value = Object.fromEntries(results.map((item) => [item.server_id, item]))
}
/** 拉取穿透流量快照并按规则 ID 展开;失败的服务端错误记录到 proxyTrafficErrors */
@@ -1203,7 +1450,7 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
<style scoped>
/* 统计区:对齐标准页(CertificatesView)的 .stats-row / .stat-card 规格 */
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
.stats-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
.stat-card { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); padding: var(--havline-space-4) var(--havline-space-5); min-height: 118px; }
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
.stat-card__icon--blue { color: var(--havline-info); }
@@ -1216,6 +1463,40 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
.stat-card__value--sm { font-size: 18px; }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--havline-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--havline-text-muted); }
/* FRP 状态 Hero:把全局 frpc 状态与当前选中服务端放进同一结论区,解决两级状态脱节 */
.frp-hero {
display: flex;
flex-direction: column;
gap: var(--havline-space-4);
padding: var(--havline-space-4) var(--havline-space-5);
margin-bottom: var(--havline-space-4);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
}
.frp-hero__top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-4); }
.frp-hero__status { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); min-width: 0; }
.frp-hero__icon { font-size: 26px; flex-shrink: 0; color: var(--havline-text-muted); }
.frp-hero__icon--running { color: var(--havline-brand); }
.frp-hero__icon--stopped { color: var(--havline-disabled); }
.frp-hero__icon--unknown { color: var(--havline-warning); }
.frp-hero__headline { min-width: 0; }
.frp-hero__title { display: flex; align-items: center; gap: var(--havline-space-2); }
.frp-hero__title-label { font-size: 13px; font-weight: 600; color: var(--havline-text-secondary); }
.frp-hero__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
.frp-hero__metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--havline-space-4);
padding-top: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.frp-hero__metric { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.frp-hero__metric-label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--havline-text-muted); }
.frp-hero__metric-icon { font-size: 14px; }
.frp-hero__metric-value { font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--havline-text); overflow-wrap: anywhere; }
.frp-hero__metric-sub { font-size: 12px; color: var(--havline-text-muted); }
/* 公网服务端列表:n-data-table(与规则表一致);单元格由 render 创建,样式必须 :deep() */
.rule-detail__logs { max-height: 420px; }
.rule-detail__log-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--havline-space-3); }
@@ -1331,8 +1612,16 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
.frp-proxy-modal .proxy-modal__tab--active { color: var(--havline-brand-text); font-weight: 600; border-bottom-color: var(--havline-brand); }
.frp-proxy-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; }
.frp-proxy-modal .proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--havline-space-4) var(--havline-space-5) 0; }
.frp-proxy-modal .proxy-modal__pane > .modal-actions { padding-bottom: var(--havline-space-5); }
.frp-proxy-modal__basic .form-grid { grid-template-columns: 1fr; }
/* 基础页分组:两列排布,长字段用 .form-item--full 跨列 */
.frp-proxy-modal__basic .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.frp-proxy-modal .form-item--full { grid-column: 1 / -1; }
/* 保存栏固定在弹窗底部(滚动区之外) */
.frp-proxy-modal .proxy-modal__footer { display: flex; justify-content: flex-end; gap: var(--havline-space-3); padding: var(--havline-space-4) var(--havline-space-5); border-top: 1px solid var(--havline-border); flex-shrink: 0; }
/* 公网反代提示:替代原来占一整格的说明项 */
.frp-proxy-modal .proxy-form-note { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--havline-space-2); padding: 10px 12px; border-radius: 8px; background: var(--havline-info-soft); color: var(--havline-info); font-size: 12px; line-height: 1.6; }
.frp-proxy-modal .proxy-form-note__icon { flex-shrink: 0; margin-top: 2px; font-size: 15px; }
.frp-proxy-modal .proxy-switch-row { padding-bottom: 0; }
.frp-proxy-modal .proxy-form-section--first { margin-top: 0; padding-top: 0; border-top: 0; }
.frp-server-modal__basic .form-grid { grid-template-columns: 1fr; }
.frp-proxy-modal .proxy-form-section { margin-top: var(--havline-space-2); padding: var(--havline-space-4) 0 0; border-top: 1px solid var(--havline-border); }
.frp-proxy-modal .proxy-form-section h4 { margin: 0 0 var(--havline-space-4); font-size: 14px; font-weight: 600; color: var(--havline-text); }
@@ -1366,7 +1655,39 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
.frp-card-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--havline-text); }
.frp-card-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; padding: var(--havline-space-4) var(--havline-space-5) 0; }
.frp-card-modal .modal-footer { display: flex; justify-content: flex-end; gap: var(--havline-space-3); padding: var(--havline-space-4) var(--havline-space-5) var(--havline-space-5); border-top: 1px solid var(--havline-border); margin-top: var(--havline-space-2); flex-shrink: 0; }
.frp-runtime-modal { width: min(880px, 92vw); }
.frp-runtime-modal { width: min(780px, 92vw); }
.frp-runtime-modal .proxy-modal__scroll { padding-bottom: var(--havline-space-4); }
/* FRP 应用配置弹窗:顶部摘要 + 固定操作栏 + 折叠分组,替代原来一整列平铺的分段 */
.runtime-summary {
display: flex;
flex-wrap: wrap;
gap: var(--havline-space-5);
padding: var(--havline-space-4) var(--havline-space-5);
border-bottom: 1px solid var(--havline-border);
background: var(--havline-bg-muted);
flex-shrink: 0;
}
.runtime-summary__item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.runtime-summary__label { font-size: 12px; color: var(--havline-text-muted); }
.runtime-summary__value { font-size: 14px; font-weight: 600; color: var(--havline-text); }
.runtime-toolbar {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--havline-space-3);
padding: var(--havline-space-3) var(--havline-space-5);
border-bottom: 1px solid var(--havline-border);
flex-shrink: 0;
}
.runtime-collapse { border: 1px solid var(--havline-border); border-radius: 10px; overflow: hidden; background: var(--havline-surface); }
.runtime-collapse :deep(.n-collapse-item) { margin: 0 !important; border: 0 !important; border-radius: 0 !important; }
.runtime-collapse :deep(.n-collapse-item + .n-collapse-item) { border-top: 1px solid var(--havline-border) !important; }
.runtime-collapse :deep(.n-collapse-item__header) { min-height: 46px; padding: 0 16px !important; background: var(--havline-bg); font-weight: 600; box-sizing: border-box; }
.runtime-collapse :deep(.n-collapse-item__content-inner) { padding: 14px 16px 16px; }
.runtime-collapse__title { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--havline-text); }
.runtime-collapse__meta { margin-left: 6px; font-size: 12px; font-weight: 400; color: var(--havline-text-muted); }
.runtime-collapse h3 { margin: 0; font-size: 15px; }
.runtime-subsection + .runtime-subsection { margin-top: var(--havline-space-5); padding-top: var(--havline-space-4); border-top: 1px dashed var(--havline-border); }
/* 服务端详情弹窗(仍用 naive-ui preset=card):排版对齐 .proxy-modal */
.frp-guide-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--havline-surface); border-radius: var(--havline-radius); overflow: hidden; box-shadow: var(--havline-shadow-md); }
@@ -1465,10 +1786,33 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
.rule-pane__intro { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--havline-space-3); min-width: 0; }
.rule-pane__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--havline-text); }
.rule-pane__sub { font-size: 12.5px; color: var(--havline-text-secondary); }
/* 当前服务端运行状态按状态着色,与左侧列表标签一致 */
.rule-pane__state { font-weight: 600; }
.rule-pane__state--running { color: var(--havline-success); }
.rule-pane__state--starting { color: var(--havline-warning); }
.rule-pane__state--error { color: var(--havline-error); }
.rule-pane__state--stopped { color: var(--havline-text-muted); }
.rule-pane__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
.rule-table-wrap { border: 1px solid var(--havline-border); border-radius: var(--havline-radius); background: var(--havline-surface); box-shadow: var(--havline-shadow); overflow: hidden; padding: var(--havline-space-2) 0; }
.rule-table-wrap { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-surface); box-shadow: var(--havline-shadow); overflow: hidden; }
/* 穿透规则台账:版式对齐「公网反代 → 规则台账」;单元格由 render 创建,必须 :deep() 才能命中 */
.frp-rules-table :deep(.table-muted) { color: var(--havline-text-muted); font-size: 12px; }
.frp-rules-table :deep(.mono) { font-family: var(--havline-mono); font-size: 13px; }
.frp-rules-table :deep(.text-secondary) { color: var(--havline-text-secondary); }
.frp-rules-table :deep(.cell-name) { display: grid; gap: 4px; align-content: center; }
.frp-rules-table :deep(.cell-name__head) { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.frp-rules-table :deep(.cell-name strong) { font-size: 13.5px; color: var(--havline-text); }
.frp-rules-table :deep(.cell-muted) { font-size: 12px; color: var(--havline-text-muted); }
.frp-rules-table :deep(.cell-wrap) { overflow-wrap: anywhere; }
.frp-rules-table :deep(.cell-upstream) { display: grid; gap: 2px; }
.frp-rules-table :deep(.cell-domains) { display: grid; gap: 2px; }
.frp-rules-table :deep(.cell-domain) { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* 地址不撑满整格:复制 / 跳转按钮紧跟地址,不贴近右侧列 */
.frp-rules-table :deep(.cell-domain .mono) { flex: 0 1 auto; min-width: 0; }
.frp-rules-table :deep(.cell-health) { display: flex; align-items: center; gap: var(--havline-space-2); flex-wrap: wrap; }
.frp-rules-table :deep(.cell-switch) { display: flex; justify-content: center; }
.frp-rules-table :deep(.cell-time) { display: grid; gap: 2px; }
.frp-rules-table :deep(.cell-actions) { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
/* 行高被多行内容撑高时,单元格内容保持上下居中 */
.frp-rules-table :deep(.n-data-table-td) { vertical-align: middle; }
@media (max-width: 1100px) { .frp-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
</style>
+310 -119
View File
@@ -8,6 +8,13 @@
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新
</n-button>
<!-- 原「入口行」的跳转与说明收进「更多」,不再占用首屏高度 -->
<n-dropdown trigger="click" :options="headerMenuOptions" @select="handleHeaderMenu">
<n-button>
<template #icon><n-icon :component="EllipsisHorizontalOutline" /></template>
更多
</n-button>
</n-dropdown>
</template>
</PageHeader>
@@ -25,7 +32,7 @@
</EmptyState>
<div v-else-if="server" class="proxy-layout">
<!-- 左:服务端列表;右:该服务端的漂移提示 / 统计 / Nginx 与反代台账 -->
<!-- 左:服务端列表;右:该服务端的行动横幅 / 状态 Hero / Nginx 详情 / 反代台账 -->
<aside class="server-sidebar">
<div class="server-sidebar__head">
<div class="server-sidebar__head-row">
@@ -58,28 +65,38 @@
</aside>
<div class="proxy-detail">
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
<!-- frps 回源端口 80/443:VPS 上由 frps 直接对外,公网反代没有位置(也不需要自动部署) -->
<n-alert v-if="publicProxyInapplicable" type="info" :bordered="false" class="page-alert">
<div class="alert-body__text">
<strong>该服务端的 frps 回源端口是 {{ privilegedVhostPorts.join(' / ') }},公网反代不适用</strong>
<span>80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。</span>
<span>如需使用公网反代,请到「公网服务端」页把回源端口改回 8080/8443,再回本页部署。</span>
<span>若这台 VPS 上还留着以前部署的 Nginx 路由,需先把回源端口改回 8080/8443,才能在本页移除。</span>
</div>
</n-alert>
<!-- 部署漂移:库里的期望与 VPS 上的实际不一致时才出现(P0-2) -->
<n-alert v-if="driftItems.length > 0 && !publicProxyInapplicable" type="warning" :bordered="false" class="page-alert">
<!-- 行动横幅:原先并列的「读取失败 / 回源端口不适用 / 部署漂移 / Nginx 阻塞」合并为一条,按优先级只显示最该处理的那条 -->
<n-alert v-if="actionBanner" :type="actionBanner.type" :bordered="false" class="page-alert">
<div class="alert-body">
<div class="alert-body__text">
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
<span>VPS 上的实际配置与 Havline 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。</span>
<span class="mono alert-body__detail">{{ driftPreview }}</span>
<strong>{{ actionBanner.title }}</strong>
<span>{{ actionBanner.text }}</span>
<span v-if="actionBanner.detail" class="mono alert-body__detail">{{ actionBanner.detail }}</span>
<span v-if="extraIssueCount > 0" class="alert-body__more">另有 {{ extraIssueCount }} 项待处理,处理完当前项后会自动显示</span>
</div>
<n-popconfirm @positive-click="redeployAll">
<n-button
v-if="actionBanner.action === 'reload'"
size="small"
:loading="manualRefreshing"
@click="refreshAll({ silent: false })"
>重新读取</n-button>
<n-button
v-else-if="actionBanner.action === 'retry-deploy'"
size="small"
type="primary"
:loading="busy === 'reload-frp'"
:disabled="busy !== null && busy !== 'reload-frp'"
@click="applyFrpConfigAndRetry"
>应用配置并重试</n-button>
<n-button
v-else-if="actionBanner.action === 'install-nginx'"
size="small"
type="primary"
:loading="busy === 'install-nginx'"
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
@click="installNginx"
>一键安装 Nginx</n-button>
<n-popconfirm v-else-if="actionBanner.action === 'redeploy-all'" @positive-click="redeployAll">
<template #trigger>
<n-button size="small" type="primary" :loading="redeploying" :disabled="busy !== null">
重新部署全部
@@ -87,96 +104,113 @@
</template>
将按 Havline 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续?
</n-popconfirm>
<router-link
v-else-if="actionBanner.action === 'fix-vhost-port' && server"
:to="{ name: 'frp-agent', query: { server: String(server.id) } }"
>
<n-button size="small" type="primary">去改回源端口</n-button>
</router-link>
</div>
</n-alert>
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
<n-alert v-if="nginxBlocking && !publicProxyInapplicable" :type="nginxBlocking.type" :bordered="false" class="page-alert">
<div class="alert-body">
<div class="alert-body__text">
<strong>{{ nginxBlocking.title }}</strong>
<span>{{ nginxBlocking.text }}</span>
<span v-if="nginxBlocking.detail" class="mono alert-body__detail">{{ nginxBlocking.detail }}</span>
<!-- ① 状态 Hero:Nginx 结论 + 台账汇总 + 主操作(原 4 张统计卡与 Nginx 卡头部合并) -->
<section v-if="!publicProxyInapplicable" class="proxy-hero" aria-label="公网反代概览">
<div class="proxy-hero__top">
<div class="proxy-hero__status">
<n-icon :component="ShieldCheckmarkOutline" class="proxy-hero__icon" :class="nginxIconClass" aria-hidden="true" />
<div class="proxy-hero__headline">
<div class="proxy-hero__title">
<span class="proxy-hero__title-label">Nginx</span>
<StatusBadge :kind="nginxKind" :text="nginxText" />
<span v-if="agentStatus?.nginx_version" class="proxy-hero__version mono">{{ agentStatus.nginx_version }}</span>
</div>
<div class="proxy-hero__sub">
<template v-if="statusLoaded">
进程{{ agentStatus?.nginx_running ? '运行中' : '未运行' }} · {{ agentStatus?.nginx_ok ? 'nginx -t 通过' : 'nginx -t 未通过' }}
</template>
<template v-else>正在检测 Nginx 运行状态</template>
</div>
</div>
</div>
<n-button
v-if="agentStatus?.nginx_missing"
size="small"
type="primary"
:loading="busy === 'install-nginx'"
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
@click="installNginx"
>一键安装 Nginx</n-button>
</div>
</n-alert>
<!-- 入口行:agent / frps 生命周期在另一页 -->
<div v-if="!publicProxyInapplicable" class="entrance-row">
<span class="entrance-row__text">
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
</span>
<router-link to="/frp-agent"><n-button size="small" quaternary>前往公网 Agent</n-button></router-link>
</div>
<!-- ① 统计区 -->
<section v-if="!publicProxyInapplicable" class="stats-row" aria-label="反代概览">
<div class="stat-card">
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
<div class="stat-card__value">{{ candidates.length }}</div>
<div class="stat-card__label">可部署规则</div>
<div class="stat-card__sub">来自「内网穿透」的 http 规则</div>
</div>
<div class="stat-card">
<n-icon :component="CheckmarkCircleOutline" class="stat-card__icon" :class="deployedCount ? 'stat-card__icon--green' : 'stat-card__icon--amber'" aria-hidden="true" />
<div class="stat-card__value">{{ deployedCount }}</div>
<div class="stat-card__label">已生效</div>
<div class="stat-card__sub">{{ pendingCount }} 条未部署 / 未加载</div>
</div>
<div class="stat-card">
<n-icon :component="PulseOutline" class="stat-card__icon" :class="healthyCount ? 'stat-card__icon--green' : 'stat-card__icon--amber'" aria-hidden="true" />
<div class="stat-card__value">{{ healthyCount }}<span class="stat-card__value-total">/{{ candidates.length }}</span></div>
<div class="stat-card__label">健康通过</div>
<div class="stat-card__sub">DNS · 证书 · 隧道 · 服务 四段全通过</div>
</div>
<div class="stat-card">
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
<div class="stat-card__value">{{ totalConns }}</div>
<div class="stat-card__label">当前连接</div>
<div class="stat-card__sub">frps 侧实时连接数</div>
</div>
</section>
<!-- ② 环境与 Nginx -->
<HavlineCard v-if="!publicProxyInapplicable" class="section-card nginx-card" title="Nginx(公网反代依赖)">
<div class="section-body">
<div class="field-grid">
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
<div><label>进程</label><span>{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}</span></div>
<div><label>版本</label><span class="mono">{{ agentStatus?.nginx_version || '—' }}</span></div>
<div><label>配置校验</label><span class="field-tags">
<StatusBadge v-if="statusLoaded" :kind="agentStatus?.nginx_ok ? 'success' : 'error'" :text="agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过'" />
<span v-else>—</span>
</span></div>
<div><label>可执行文件</label><span class="mono mono-wrap">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span></div>
<div><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
<div><label>端口监听</label><span class="field-tags">
<StatusBadge :kind="agentStatus?.ports?.['80'] ? 'success' : 'warning'" :text="`80 ${agentStatus?.ports?.['80'] ? '✓' : '✗'}`" />
<StatusBadge :kind="agentStatus?.ports?.['443'] ? 'success' : 'warning'" :text="`443 ${agentStatus?.ports?.['443'] ? '✓' : '✗'}`" />
</span></div>
<div><label>反代配置文件</label><span>{{ routeDetails.length }} 个</span></div>
</div>
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
<div class="agent-actions">
<div class="proxy-hero__actions">
<n-button
v-if="agentStatus?.nginx_missing"
size="small"
type="primary"
:loading="busy === 'install-nginx'"
:disabled="busy !== null && busy !== 'install-nginx'"
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
@click="installNginx"
>一键安装 Nginx</n-button>
<n-button size="small" :loading="manualRefreshing" @click="refreshAll({ silent: false })">重新检测</n-button>
</div>
</div>
<div class="proxy-hero__metrics">
<div class="proxy-hero__metric">
<span class="proxy-hero__metric-label"><n-icon :component="LayersOutline" class="proxy-hero__metric-icon" />可部署规则</span>
<span class="proxy-hero__metric-value">{{ candidates.length }}</span>
<span class="proxy-hero__metric-sub">来自「内网穿透」的 http 规则</span>
</div>
<div class="proxy-hero__metric">
<span class="proxy-hero__metric-label"><n-icon :component="CheckmarkCircleOutline" class="proxy-hero__metric-icon" />已生效</span>
<span class="proxy-hero__metric-value">{{ deployedCount }}</span>
<span class="proxy-hero__metric-sub">{{ pendingCount }} 条未部署 / 未加载</span>
</div>
<div class="proxy-hero__metric">
<span class="proxy-hero__metric-label"><n-icon :component="PulseOutline" class="proxy-hero__metric-icon" />健康通过</span>
<span class="proxy-hero__metric-value">{{ healthyCount }}<span class="proxy-hero__metric-total">/{{ candidates.length }}</span></span>
<span class="proxy-hero__metric-sub">DNS · 证书 · 隧道 · 服务 四段全通过</span>
</div>
<div class="proxy-hero__metric">
<span class="proxy-hero__metric-label"><n-icon :component="SwapHorizontalOutline" class="proxy-hero__metric-icon" />当前连接</span>
<span class="proxy-hero__metric-value">{{ totalConns }}</span>
<span class="proxy-hero__metric-sub">frps 侧实时连接数</span>
</div>
</div>
</section>
<!-- ② Nginx 详情:默认折叠,缺失或 nginx -t 未通过时自动展开 -->
<HavlineCard v-if="!publicProxyInapplicable" class="section-card nginx-card" title="Nginx 环境详情">
<div class="section-body">
<n-collapse
:expanded-names="nginxDetailOpen ? ['nginx-detail'] : []"
@update:expanded-names="setNginxDetailOpen"
>
<n-collapse-item name="nginx-detail">
<template #header>
<span class="nginx-detail__title">路径 / 端口 / 配置文件与一键操作</span>
</template>
<div class="field-grid">
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
<div><label>进程</label><span>{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}</span></div>
<div><label>版本</label><span class="mono">{{ agentStatus?.nginx_version || '—' }}</span></div>
<div><label>配置校验</label><span class="field-tags">
<StatusBadge v-if="statusLoaded" :kind="agentStatus?.nginx_ok ? 'success' : 'error'" :text="agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过'" />
<span v-else>—</span>
</span></div>
<div><label>可执行文件</label><span class="mono mono-wrap">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span></div>
<div><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
<div><label>端口监听</label><span class="field-tags">
<StatusBadge :kind="agentStatus?.ports?.['80'] ? 'success' : 'warning'" :text="`80 ${agentStatus?.ports?.['80'] ? '✓' : '✗'}`" />
<StatusBadge :kind="agentStatus?.ports?.['443'] ? 'success' : 'warning'" :text="`443 ${agentStatus?.ports?.['443'] ? '✓' : '✗'}`" />
</span></div>
<div><label>反代配置文件</label><span>{{ routeDetails.length }} 个</span></div>
</div>
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
<div class="agent-actions">
<n-button
v-if="agentStatus?.nginx_missing"
size="small"
type="primary"
:loading="busy === 'install-nginx'"
:disabled="busy !== null && busy !== 'install-nginx'"
@click="installNginx"
>一键安装 Nginx</n-button>
<n-button size="small" :loading="manualRefreshing" @click="refreshAll({ silent: false })">重新检测</n-button>
</div>
</n-collapse-item>
</n-collapse>
</div>
</HavlineCard>
<!-- ③ 规则台账 -->
@@ -418,9 +452,11 @@
<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import { NButton, NIcon, NPopconfirm, useMessage, type DataTableColumns } from 'naive-ui'
import { useRouter } from 'vue-router'
import { NButton, NDropdown, NIcon, NPopconfirm, useMessage, type DataTableColumns, type DropdownOption } from 'naive-ui'
import {
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
CheckmarkCircleOutline, CopyOutline, EllipsisHorizontalOutline, InformationCircleOutline, LayersOutline, OpenOutline,
PulseOutline, RefreshOutline, ShieldCheckmarkOutline, SwapHorizontalOutline,
} from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue'
import ConfigDiffView from '../components/ConfigDiffView.vue'
@@ -438,15 +474,34 @@ import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../util
import { copyText } from '../utils/clipboard'
type Action =
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx'
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx' | 'reload-frp'
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
type BadgeKind = 'success' | 'warning' | 'error' | 'disabled'
const message = useMessage()
const router = useRouter()
// Header「更多」:原「入口行」的说明与跳转收进菜单,不再占用首屏高度
const headerMenuOptions = computed<DropdownOption[]>(() => [
{ label: '前往公网服务端页(Agent / frps 管理)', key: 'frp-agent' },
{ label: '前往内网穿透页(规则来源)', key: 'frp' },
{ type: 'divider', key: 'divider' },
{ key: 'hint', type: 'render', render: () => h('div', { class: 'header-menu-hint' }, '本页只负责把「内网穿透」的规则发布到 VPS 的 Nginx') } as DropdownOption,
])
function handleHeaderMenu(key: string | number) {
if (key === 'frp-agent') void router.push({ name: 'frp-agent' })
if (key === 'frp') void router.push({ name: 'frp' })
}
const servers = ref<FrpServer[]>([])
const selectedId = ref<number | null>(null)
const loadError = ref('')
const loadErrorMsg = ref('')
// 操作类失败(部署 / 移除 / 保存设置)与「数据读取失败」分开:前者是动作反馈,后者是刷新缺数据
const actionErrorMsg = ref('')
// 部署因「frps 里查不到该域名的隧道」失败时记下规则 ID,横幅据此给出「应用配置并重试」
const lastTunnelMissingProxyId = ref<number | null>(null)
// 两种隧道缺失说法:agent 自检报的「没有找到域名…的隧道注册」,以及部署前预检的「还没有下发给本机 frpc」
const tunnelMissingRe = /没有找到域名|还没有下发给本机 frpc/
const manualRefreshing = ref(false)
const busy = ref<Action | null>(null)
const candidates = ref<FrpProxy[]>([])
@@ -547,6 +602,21 @@ const nginxText = computed(() => {
return agentStatus.value?.nginx_running === false ? '已安装未运行' : '正常'
})
// Hero 里的 Nginx 状态图标:与徽标同口径着色
const nginxIconClass = computed(() => {
if (!statusLoaded.value) return 'proxy-hero__icon--unknown'
if (agentStatus.value?.nginx_missing || !agentStatus.value?.nginx_ok) return 'proxy-hero__icon--error'
if (agentStatus.value?.nginx_running === false) return 'proxy-hero__icon--warning'
return 'proxy-hero__icon--success'
})
// Nginx 详情默认折叠;缺失或 nginx -t 未通过时自动展开,方便直接看到错误与操作
const nginxDetailOpen = ref(false)
function setNginxDetailOpen(names: Array<string | number>) {
nginxDetailOpen.value = names.includes('nginx-detail')
}
watch(nginxBlocking, (blocking) => { if (blocking) nginxDetailOpen.value = true }, { immediate: true })
async function load() {
loadError.value = ''
try {
@@ -574,6 +644,7 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
return
}
if (options.silent === false) manualRefreshing.value = true
if (options.silent === false) actionErrorMsg.value = ''
const failures: string[] = []
const track = async <T>(label: string, fn: () => Promise<T>, assign: (value: T) => void) => {
try { assign(await fn()) } catch (error) {
@@ -604,13 +675,14 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
async function run<T>(action: Action, source: string, fn: () => Promise<T>) {
if (busy.value) return undefined
busy.value = action
actionErrorMsg.value = ''
try {
const result = await fn()
message.success(`${source}完成`)
return result
} catch (error) {
const text = error instanceof Error ? error.message : `${source}失败`
loadErrorMsg.value = `${source}失败 —— ${text}`
actionErrorMsg.value = `${source}失败 —— ${text}`
message.error(`${source}失败`)
return undefined
} finally { busy.value = null }
@@ -694,6 +766,72 @@ const driftPreview = computed(() => {
return driftItems.value.length > 3 ? `${preview.join(';')} …` : preview.join(';')
})
// 首屏行动横幅:把原先并列的 4 条 alert 收敛成按优先级排序的列表,模板只渲染第一条
type BannerAction = '' | 'reload' | 'install-nginx' | 'redeploy-all' | 'fix-vhost-port' | 'retry-deploy'
interface ActionBanner {
type: 'error' | 'warning' | 'info'
title: string
text: string
detail?: string
action: BannerAction
}
const activeIssues = computed<ActionBanner[]>(() => {
const items: ActionBanner[] = []
if (actionErrorMsg.value) {
const tunnelMissing = lastTunnelMissingProxyId.value !== null
items.push(tunnelMissing ? {
type: 'error',
title: '部署反代未生效:frps 里还没有该域名的隧道',
text: 'Nginx 反代配置已经写到 VPS,但 frps 查不到该域名的隧道注册。通常是穿透规则改动后还没应用 frpc 配置,或该服务端的 frpc 没有运行;点右侧按钮会先应用 frpc 配置,再自动重试部署。',
detail: actionErrorMsg.value,
action: 'retry-deploy',
} : {
type: 'error',
title: '上次操作没有成功',
text: '部署 / 移除等操作被中断,按下方原因处理后重试即可;若只是刚改过穿透规则,稍等几秒再试一次通常就能通过。',
detail: actionErrorMsg.value,
action: 'reload',
})
}
if (loadErrorMsg.value) {
items.push({ type: 'error', title: '数据读取失败', text: '部分数据没有取到,页面内容可能不完整。', detail: loadErrorMsg.value, action: 'reload' })
}
// 回源端口 80/443 时不适用公网反代:此时 Nginx 与漂移都不再检查,直接给唯一动作
if (publicProxyInapplicable.value) {
items.push({
type: 'info',
title: `该服务端的 frps 回源端口是 ${privilegedVhostPorts.value.join(' / ')},公网反代不适用`,
text: '80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。',
detail: '需使用公网反代时请把回源端口改回 8080/8443;若 VPS 上还留着以前部署的 Nginx 路由,也要先改回才能在本页移除。',
action: 'fix-vhost-port',
})
return items
}
if (nginxBlocking.value) {
items.push({
type: nginxBlocking.value.type,
title: nginxBlocking.value.title,
text: nginxBlocking.value.text,
detail: nginxBlocking.value.detail,
action: agentStatus.value?.nginx_missing ? 'install-nginx' : '',
})
}
if (driftItems.value.length > 0) {
items.push({
type: 'warning',
title: `检测到 ${driftItems.value.length} 项部署漂移(${driftSummary.value})`,
text: 'VPS 上的实际配置与 Havline 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。',
detail: driftPreview.value,
action: 'redeploy-all',
})
}
return items
})
const actionBanner = computed(() => activeIssues.value[0] ?? null)
const extraIssueCount = computed(() => Math.max(0, activeIssues.value.length - 1))
// 全量重部署:逐条下发,失败不中断(后端已逐条汇总)
async function redeployAll() {
const current = server.value
@@ -882,7 +1020,12 @@ async function deployRoute(candidate: FrpProxy) {
const current = server.value
if (!current) return
const result = await run(`deploy:${candidate.id}`, '部署反代', () => api.deployFrpAgentRoute(current.id, candidate.id))
if (result === undefined) return
if (result === undefined) {
// frps 里还没有该域名的隧道:多半是穿透规则改动后没应用 frpc 配置,横幅据此给出「应用配置并重试」
lastTunnelMissingProxyId.value = tunnelMissingRe.test(actionErrorMsg.value) ? candidate.id : null
return
}
lastTunnelMissingProxyId.value = null
// 自动生成的 Basic Auth 密码只在这一次响应里返回:不再写进 nginx 配置,错过就得在「部署设置」里重设
if (result.basic_auth_password) {
message.warning(`Basic Auth 已生成(仅显示这一次,请立即保存):${result.basic_auth_user} / ${result.basic_auth_password}`, { duration: 15000 })
@@ -890,6 +1033,16 @@ async function deployRoute(candidate: FrpProxy) {
await refreshAll()
}
// 部署失败在「隧道未注册」时:先应用 frpc 配置(等同「内网穿透」页的校验并应用),再自动重试一次部署
async function applyFrpConfigAndRetry() {
const candidate = candidates.value.find((item) => item.id === lastTunnelMissingProxyId.value)
const applied = await run('reload-frp', '应用 frpc 配置', () => api.reloadFrp())
if (applied === undefined || !candidate) return
// frpc 重启后要几秒才重新向 frps 注册隧道;就算 agent 版本较老没有重试,也先等一拍再探测
await new Promise((resolve) => setTimeout(resolve, 2500))
await deployRoute(candidate)
}
async function removeRoute(candidate: FrpProxy) {
const current = server.value
if (!current) return
@@ -1067,24 +1220,52 @@ onMounted(() => { void load() })
</script>
<style scoped>
/* 分区:提示 → 入口行 → ① 统计区 → ② 环境与 Nginx → ③ 规则台账 */
/* 分区:行动横幅 → ① 状态 Hero → ② Nginx 详情 → ③ 规则台账 */
.page-alert { margin-bottom: var(--havline-space-3); }
.alert-body { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); }
.alert-body__text { display: grid; gap: 4px; font-size: 13px; }
.alert-body__detail { font-size: 12px; color: var(--havline-error); overflow-wrap: anywhere; }
.entrance-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); margin-bottom: var(--havline-space-5); padding: 10px var(--havline-space-5); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-info-soft); }
.entrance-row__text { font-size: 13px; color: var(--havline-text); }
.alert-body__more { font-size: 12px; color: var(--havline-text-muted); }
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-5); }
.stat-card { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); padding: var(--havline-space-4) var(--havline-space-5); min-height: 118px; }
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
.stat-card__icon--blue { color: var(--havline-info); }
.stat-card__icon--green { color: var(--havline-brand); }
.stat-card__icon--amber { color: var(--havline-warning); }
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--havline-text); letter-spacing: -0.03em; }
.stat-card__value-total { margin-left: 2px; font-size: 15px; font-weight: 600; color: var(--havline-text-muted); }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--havline-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--havline-text-muted); }
/* 状态 Hero:Nginx 结论 + 台账汇总 + 主操作(原 4 张统计卡与 Nginx 卡头部合并) */
.proxy-hero {
display: flex;
flex-direction: column;
gap: var(--havline-space-4);
padding: var(--havline-space-4) var(--havline-space-5);
margin-bottom: var(--havline-space-5);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
}
.proxy-hero__top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-4); }
.proxy-hero__status { display: flex; align-items: center; gap: var(--havline-space-3); min-width: 0; }
.proxy-hero__icon { font-size: 26px; flex-shrink: 0; color: var(--havline-text-muted); }
.proxy-hero__icon--success { color: var(--havline-success); }
.proxy-hero__icon--warning { color: var(--havline-warning); }
.proxy-hero__icon--error { color: var(--havline-error); }
.proxy-hero__icon--unknown { color: var(--havline-text-muted); }
.proxy-hero__headline { min-width: 0; }
.proxy-hero__title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
.proxy-hero__title-label { font-size: 13px; font-weight: 600; color: var(--havline-text-secondary); }
.proxy-hero__version { font-size: 12px; color: var(--havline-text-muted); }
.proxy-hero__sub { margin-top: 6px; font-size: 13px; color: var(--havline-text-secondary); }
.proxy-hero__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
.proxy-hero__metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--havline-space-4);
padding-top: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.proxy-hero__metric { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.proxy-hero__metric-label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--havline-text-muted); }
.proxy-hero__metric-icon { font-size: 14px; }
.proxy-hero__metric-value { font-size: 22px; font-weight: 700; line-height: 1.15; color: var(--havline-text); letter-spacing: -0.02em; }
.proxy-hero__metric-total { margin-left: 2px; font-size: 14px; font-weight: 600; color: var(--havline-text-muted); }
.proxy-hero__metric-sub { font-size: 12px; color: var(--havline-text-muted); }
.nginx-detail__title { font-size: 13px; font-weight: 500; color: var(--havline-text-secondary); }
/* HavlineCard 自身已有 24px 内边距,这里不再叠加(与 FrpView 的用法一致) */
.section-card { margin-bottom: var(--havline-space-5); }
@@ -1219,13 +1400,12 @@ onMounted(() => { void load() })
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.stats-row, .entrance-row, .section-card { animation: havline-fade-up 160ms ease-out both; }
.proxy-hero, .page-alert, .section-card { animation: havline-fade-up 160ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
.stats-row, .entrance-row, .section-card { animation: none; }
.proxy-hero, .page-alert, .section-card { animation: none; }
}
@media (max-width: 1199px) {
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.nginx-card .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* 台账为表格:窄屏靠 scroll-x 横向滚动,不改列 */
.proxy-modal__help { display: none; }
@@ -1236,11 +1416,22 @@ onMounted(() => { void load() })
.nginx-card .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.stats-row { grid-template-columns: 1fr; }
.nginx-card .field-grid { grid-template-columns: 1fr; }
.entrance-row { flex-direction: column; align-items: flex-start; }
.proxy-hero__top { flex-direction: column; align-items: flex-start; }
.proxy-hero__actions { width: 100%; }
.route-table-wrap :deep(.cell-actions) { width: 100%; justify-content: flex-start; }
.route-table-wrap :deep(.cell-actions .n-button) { min-height: 44px; }
.alert-body { flex-direction: column; align-items: flex-start; }
}
</style>
<!-- 下拉面板由 naive-ui teleport 到 body,scoped 样式命不中,渲染型选项用全局样式约束换行 -->
<style>
.header-menu-hint {
width: 236px;
white-space: normal;
line-height: 1.5;
font-size: 12px;
color: var(--havline-text-muted);
}
</style>
+32
View File
@@ -192,6 +192,7 @@
<p class="field-hint data-note">恢复配置将覆盖当前数据,操作前请确保已备份。</p>
</HavlineCard>
</div>
</n-tab-pane>
@@ -569,6 +570,30 @@
</n-button>
</div>
</HavlineCard>
<HavlineCard title="SSH 终端" subtitle="在浏览器里直接连到公网服务端(默认关闭)">
<p class="field-hint">
开启后「SSH 终端」页面可用「公网服务端」里已保存的 SSH 凭据登录 VPS。凭据不会下发到浏览器,
主机密钥仍按首次信任记录校验;会话空闲 10 分钟自动断开。
</p>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">启用内置终端</div>
<div class="form-switch-row__hint">关闭时终端接口一律返回 403,页面只显示提示</div>
</div>
<n-switch v-model:value="terminalEnabled" />
</div>
<div class="form-switch-row">
<div class="form-switch-row__text">
<div class="form-switch-row__label">录制会话内容</div>
<div class="form-switch-row__hint">
开启后把终端输出写入 logs/terminal(asciinema v2 格式,可在「SSH 终端 → 录像审计」里回放)。
录像包含屏幕上出现的一切,包括在远端提示符下输入的口令,请按需开启;默认关闭,保留 90 天。
</div>
</div>
<n-switch v-model:value="terminalRecordingEnabled" />
</div>
</HavlineCard>
</div>
</n-tab-pane>
@@ -895,6 +920,9 @@ const backupError = ref('')
// 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理)
const statusPageEnabled = ref(false)
// 内置 SSH 终端总开关:默认关闭,开启后才允许从浏览器连 VPS
const terminalEnabled = ref(false)
const terminalRecordingEnabled = ref(false)
const statusPageCode = ref('')
const statusPageConfigured = ref(false)
@@ -1135,6 +1163,8 @@ function applySettingsToForm(settings: Record<string, string>) {
backupAutoEnabled.value = settings.backup_auto_enabled !== '0'
backupKeepCount.value = Number(settings.backup_keep_count || DEFAULT_BACKUP_KEEP_COUNT)
statusPageEnabled.value = settings.status_page_enabled === '1'
terminalEnabled.value = settings.terminal_enabled === '1'
terminalRecordingEnabled.value = settings.terminal_recording_enabled === '1'
statusPageCode.value = settings.status_page_code ?? ''
statusPageConfigured.value = statusPageCode.value === MASKED_SECRET
acmeEmail.value = settings.acme_email ?? ''
@@ -1580,6 +1610,8 @@ function buildSavePayload() {
backup_auto_enabled: backupAutoEnabled.value ? '1' : '0',
backup_keep_count: String(backupKeepCount.value || DEFAULT_BACKUP_KEEP_COUNT),
status_page_enabled: statusPageEnabled.value ? '1' : '0',
terminal_enabled: terminalEnabled.value ? '1' : '0',
terminal_recording_enabled: terminalRecordingEnabled.value ? '1' : '0',
status_page_code: statusPageCode.value.trim(),
notify_cpu_threshold: String(notifyCPUThreshold.value || DEFAULT_CPU_THRESHOLD),
notify_memory_threshold: String(notifyMemoryThreshold.value || DEFAULT_MEMORY_THRESHOLD),
+204
View File
@@ -0,0 +1,204 @@
<template>
<PageHeader
title="终端审计"
description="每次 SSH 会话的发起人、来源、目标、时长与断开原因;记录保留 90 天,不落终端内容"
>
<template #actions>
<n-select
v-model:value="limit"
size="small"
class="audit-limit"
:options="limitOptions"
@update:value="load"
/>
<n-button size="small" :loading="loading" @click="load">
<template #icon><n-icon :component="RefreshOutline" /></template>
刷新
</n-button>
<n-button size="small" type="primary" @click="openTerminal">
<template #icon><n-icon :component="TerminalOutline" /></template>
返回终端
</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<HavlineCard v-else title="会话记录" :subtitle="summary">
<EmptyState
v-if="!loading && entries.length === 0"
title="还没有会话记录"
description="在「SSH 终端」里建立会话后,这里会记录谁、何时、连了哪台、多久、为什么断开。"
>
<template #action>
<n-button type="primary" size="small" @click="openTerminal">去 SSH 终端</n-button>
</template>
</EmptyState>
<div v-else class="audit-table-wrap">
<n-data-table
class="audit-table"
:columns="columns"
:data="entries"
:loading="loading"
:bordered="false"
size="small"
:scroll-x="1120"
:row-key="rowKey"
/>
</div>
</HavlineCard>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NIcon, NSelect, NTag } from 'naive-ui'
import { RefreshOutline, TerminalOutline } from '@vicons/ionicons5'
import { useRouter } from 'vue-router'
import { api, asList } from '../api/client'
import { formatBytes, formatDate, formatDuration, formatRelativeTime } from '../utils/format'
import EmptyState from '../components/EmptyState.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import type { TerminalAuditEntry } from '../api/types'
const router = useRouter()
const loading = ref(false)
const loadError = ref('')
const entries = ref<TerminalAuditEntry[]>([])
const limit = ref(100)
const limitOptions = [
{ label: '最近 50 条', value: 50 },
{ label: '最近 100 条', value: 100 },
{ label: '最近 200 条', value: 200 },
]
const summary = computed(() => {
if (loading.value && entries.value.length === 0) return '正在读取记录…'
if (entries.value.length === 0) return '暂无记录'
const latest = entries.value[0]?.ended_at
return `共 ${entries.value.length} 条 · 最近一次 ${formatRelativeTime(latest)}`
})
async function load() {
loading.value = true
loadError.value = ''
try {
const result = await api.listTerminalAudits(limit.value)
entries.value = asList(result?.entries)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '读取审计记录失败'
} finally {
loading.value = false
}
}
function openTerminal() {
void router.push({ name: 'terminal' })
}
// session_id 已经唯一,id 只是自增主键:优先用 id,避免极端情况下同 id 冲突
function rowKey(row: TerminalAuditEntry): string {
return row.id ? `a-${row.id}` : row.session_id
}
// 断开原因是审计结论,用颜色区分「主动关闭」和「被动断开」
function reasonTagType(reason?: string): 'info' | 'warning' | 'default' {
switch (reason) {
case '用户关闭':
return 'info'
case '空闲超时':
case '超过最长会话时长':
case '前端断开':
case '服务停止':
return 'warning'
default:
return 'default'
}
}
const columns = computed<DataTableColumns<TerminalAuditEntry>>(() => [
{
title: '结束时间',
key: 'ended_at',
width: 170,
render: (row) => h('div', { class: 'cell-time' }, [
h('span', { class: 'cell-time__main' }, formatDate(row.ended_at)),
h('span', { class: 'cell-muted' }, formatRelativeTime(row.ended_at)),
]),
},
{
title: '操作人',
key: 'user',
width: 170,
render: (row) => h('div', { class: 'cell-stack' }, [
h('strong', { class: 'cell-strong' }, row.user || '未知'),
h('span', { class: 'mono cell-muted cell-wrap' }, row.client_ip || '-'),
]),
},
{
title: '目标服务端',
key: 'target',
width: 250,
render: (row) => h('div', { class: 'cell-stack' }, [
h('span', { class: 'cell-wrap' }, row.target || `服务端 #${row.server_id}`),
h('span', { class: 'mono cell-muted' }, `#${row.server_id} · ${row.session_id}`),
]),
},
{
title: '时长',
key: 'duration_ms',
width: 120,
align: 'right',
render: (row) => h('span', { class: 'mono' }, formatDuration(row.duration_ms)),
},
{
title: '断开原因',
key: 'reason',
width: 160,
render: (row) =>
h(
NTag,
{ size: 'small', bordered: false, round: true, type: reasonTagType(row.reason) },
() => row.reason || '未知',
),
},
{
title: '流量',
key: 'traffic',
width: 170,
render: (row) => h('div', { class: 'cell-stack cell-traffic' }, [
h('span', { class: 'mono' }, `输入 ${formatBytes(row.bytes_in)}`),
h('span', { class: 'mono cell-muted' }, `输出 ${formatBytes(row.bytes_out)}`),
]),
},
])
onMounted(() => {
void load()
})
</script>
<style scoped>
.audit-limit { width: 140px; }
/* 台账外框对齐「穿透规则台账」的版式 */
.audit-table-wrap {
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
box-shadow: var(--havline-shadow);
overflow: hidden;
}
/* 单元格由 render 创建,作用域样式必须 :deep() 才能命中表格内部节点 */
.audit-table :deep(td) { vertical-align: middle; }
.audit-table :deep(.mono) { font-family: var(--havline-mono); font-size: 13px; }
.audit-table :deep(.cell-time),
.audit-table :deep(.cell-stack) { display: grid; gap: 2px; }
.audit-table :deep(.cell-time__main),
.audit-table :deep(.cell-strong) { font-size: 13px; color: var(--havline-text); }
.audit-table :deep(.cell-muted) { font-size: 12px; color: var(--havline-text-muted); }
.audit-table :deep(.cell-wrap) { overflow-wrap: anywhere; }
</style>
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
proxy: {
'/api': {
target: 'http://localhost:6893',
// 内置 SSH 终端走 WebSocket:开发代理必须开 ws,否则升级请求会一直挂起
ws: true,
timeout: 300_000,
proxyTimeout: 300_000,
configure(proxy) {