feat: rename project from Fonu to Havline
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-10-01 16:54:35 +08:00
parent c377405696
commit 8fa5d922ac
250 changed files with 6724 additions and 5070 deletions
+13 -13
View File
@@ -23,21 +23,21 @@ const { naiveTheme, themeOverrides } = useTheme()
body {
margin: 0;
font-family: var(--fonu-font);
background: var(--fonu-bg);
color: var(--fonu-text);
font-family: var(--havline-font);
background: var(--havline-bg);
color: var(--havline-text);
}
.mono {
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
}
.text-secondary {
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.text-muted {
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.page-shell,
@@ -47,27 +47,27 @@ body {
.page-narrow {
width: 100%;
max-width: var(--fonu-form-max);
max-width: var(--havline-form-max);
margin-inline: auto;
}
.form-narrow {
max-width: var(--fonu-form-max);
max-width: var(--havline-form-max);
}
.toolbar {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
flex-wrap: wrap;
margin-bottom: var(--fonu-space-4);
padding-top: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
padding-top: var(--havline-space-4);
}
.n-data-table .n-data-table-th {
background: var(--fonu-bg-muted) !important;
background: var(--havline-bg-muted) !important;
font-weight: 500;
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
</style>
+2 -2
View File
@@ -69,7 +69,7 @@ async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_
throw new Error(
timeoutMs > REQUEST_TIMEOUT_MS
? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出'
: '请求超时,请检查 Fonu 服务或 Nginx 状态',
: '请求超时,请检查 Havline 服务或 Nginx 状态',
)
}
throw error
@@ -440,7 +440,7 @@ export const api = {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `fonu-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`
link.download = `havline-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`
link.click()
URL.revokeObjectURL(url)
},
+1 -1
View File
@@ -470,7 +470,7 @@ export interface FrpServerOptions {
frps_log_to_file?: boolean
/** 文件日志保留天数,默认 3 */
frps_log_max_days?: number
/** frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息 */
/** frps 管理接口(webServer),仅用于 Havline 拉取穿透流量等只读信息 */
dashboard_addr?: string
dashboard_port?: number
dashboard_user?: string
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Havline logo">
<defs>
<linearGradient id="havline-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0e4b68"/>
<stop offset="1" stop-color="#061827"/>
</linearGradient>
<linearGradient id="havline-line" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#2fa9ff"/>
<stop offset="1" stop-color="#63f0d0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#havline-bg)"/>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.13)" stroke-width="1.6"/>
<path d="M31 25.5V70.5" stroke="#f2fffc" stroke-width="11" stroke-linecap="round"/>
<path d="M65 25.5V70.5" stroke="#f2fffc" stroke-width="11" stroke-linecap="round"/>
<path d="M31 59C39 59 42.5 49 49.5 49C56.5 49 57.5 37 65 37" stroke="url(#havline-line)" stroke-width="7" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="31" cy="59" r="5" fill="#2fa9ff" stroke="#061827" stroke-width="2.2"/>
<circle cx="65" cy="37" r="5" fill="#63f0d0" stroke="#061827" stroke-width="2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Havline logo v2">
<defs>
<linearGradient id="havline-v2-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0f506d"/>
<stop offset="1" stop-color="#061827"/>
</linearGradient>
<linearGradient id="havline-v2-ink" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6fffd"/>
<stop offset="1" stop-color="#c9f5ed"/>
</linearGradient>
<linearGradient id="havline-v2-line" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#299cff"/>
<stop offset="1" stop-color="#68f3d2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="88" height="88" rx="22" fill="url(#havline-v2-bg)"/>
<rect x="4.8" y="4.8" width="86.4" height="86.4" rx="21.2" fill="none" stroke="rgba(242,255,252,.14)" stroke-width="1.6"/>
<path d="M17 62C29 52 37 68 47 58C57 48 66 52 79 43" stroke="url(#havline-v2-line)" stroke-width="6.5" fill="none" stroke-linecap="round"/>
<rect x="28" y="24" width="11" height="48" rx="5.5" fill="url(#havline-v2-ink)"/>
<rect x="57" y="24" width="11" height="48" rx="5.5" fill="url(#havline-v2-ink)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 92 KiB

+14 -14
View File
@@ -46,24 +46,24 @@ function sign(kind: DiffLine['kind']): string {
</script>
<style scoped>
.config-diff { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
.config-diff { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); overflow: hidden; }
.config-diff__head {
display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3);
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
background: var(--fonu-bg-muted); font-size: 12px; color: var(--fonu-text-secondary);
display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3);
padding: 6px var(--havline-space-3); border-bottom: 1px solid var(--havline-border);
background: var(--havline-bg-muted); font-size: 12px; color: var(--havline-text-secondary);
}
.config-diff__stats { display: flex; gap: 8px; font-family: var(--fonu-mono); }
.config-diff__add { color: var(--fonu-brand-text); }
.config-diff__del { color: var(--fonu-error); }
.config-diff__body { max-height: 420px; overflow: auto; background: var(--fonu-surface); }
.config-diff__stats { display: flex; gap: 8px; font-family: var(--havline-mono); }
.config-diff__add { color: var(--havline-brand-text); }
.config-diff__del { color: var(--havline-error); }
.config-diff__body { max-height: 420px; overflow: auto; background: var(--havline-surface); }
.config-diff__row {
display: flex; gap: 8px; padding: 1px 10px;
font-family: var(--fonu-mono); font-size: 12px; line-height: 1.6;
font-family: var(--havline-mono); font-size: 12px; line-height: 1.6;
}
.config-diff__row--add { background: var(--fonu-brand-soft); }
.config-diff__row--del { background: var(--fonu-error-soft); }
.config-diff__sign { flex: none; width: 10px; color: var(--fonu-text-muted); }
.config-diff__row--add .config-diff__sign { color: var(--fonu-brand-text); }
.config-diff__row--del .config-diff__sign { color: var(--fonu-error); }
.config-diff__row--add { background: var(--havline-brand-soft); }
.config-diff__row--del { background: var(--havline-error-soft); }
.config-diff__sign { flex: none; width: 10px; color: var(--havline-text-muted); }
.config-diff__row--add .config-diff__sign { color: var(--havline-brand-text); }
.config-diff__row--del .config-diff__sign { color: var(--havline-error); }
.config-diff__text { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
+8 -8
View File
@@ -22,17 +22,17 @@ withDefaults(
<style scoped>
.empty-state {
padding: var(--fonu-space-6) var(--fonu-space-5);
padding: var(--havline-space-6) var(--havline-space-5);
text-align: center;
}
.empty-state__icon {
width: 48px;
height: 48px;
margin: 0 auto var(--fonu-space-4);
margin: 0 auto var(--havline-space-4);
border-radius: 50%;
background: var(--fonu-brand-soft);
color: var(--fonu-brand);
background: var(--havline-brand-soft);
color: var(--havline-brand);
display: flex;
align-items: center;
justify-content: center;
@@ -41,10 +41,10 @@ withDefaults(
}
.empty-state__title {
margin: 0 0 var(--fonu-space-2);
margin: 0 0 var(--havline-space-2);
font-size: 16px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.empty-state__desc {
@@ -52,10 +52,10 @@ withDefaults(
max-width: 360px;
font-size: 14px;
line-height: 1.6;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.empty-state__action {
margin-top: var(--fonu-space-5);
margin-top: var(--havline-space-5);
}
</style>
-72
View File
@@ -1,72 +0,0 @@
<template>
<div class="fonu-card" :class="{ 'fonu-card--flush': flush }">
<div v-if="title || $slots.title || $slots.header" class="fonu-card__header">
<div>
<h3 v-if="title" class="fonu-card__title">{{ title }}</h3>
<div v-else-if="$slots.title" class="fonu-card__title fonu-card__title-row">
<slot name="title" />
</div>
<p v-if="subtitle" class="fonu-card__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.header" class="fonu-card__header-extra">
<slot name="header" />
</div>
</div>
<div class="fonu-card__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
subtitle?: string
flush?: boolean
}>()
</script>
<style scoped>
.fonu-card {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
overflow: hidden;
}
.fonu-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
}
.fonu-card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--fonu-text);
}
.fonu-card__title-row {
display: flex;
align-items: center;
gap: var(--fonu-space-2);
}
.fonu-card__subtitle {
margin: var(--fonu-space-1) 0 0;
font-size: 13px;
color: var(--fonu-text-secondary);
}
.fonu-card__body {
padding: var(--fonu-space-5);
}
.fonu-card--flush .fonu-card__body {
padding-top: 0;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div class="havline-card" :class="{ 'havline-card--flush': flush }">
<div v-if="title || $slots.title || $slots.header" class="havline-card__header">
<div>
<h3 v-if="title" class="havline-card__title">{{ title }}</h3>
<div v-else-if="$slots.title" class="havline-card__title havline-card__title-row">
<slot name="title" />
</div>
<p v-if="subtitle" class="havline-card__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.header" class="havline-card__header-extra">
<slot name="header" />
</div>
</div>
<div class="havline-card__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
subtitle?: string
flush?: boolean
}>()
</script>
<style scoped>
.havline-card {
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
overflow: hidden;
}
.havline-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--havline-space-4);
padding: var(--havline-space-5) var(--havline-space-5) 0;
}
.havline-card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--havline-text);
}
.havline-card__title-row {
display: flex;
align-items: center;
gap: var(--havline-space-2);
}
.havline-card__subtitle {
margin: var(--havline-space-1) 0 0;
font-size: 13px;
color: var(--havline-text-secondary);
}
.havline-card__body {
padding: var(--havline-space-5);
}
.havline-card--flush .havline-card__body {
padding-top: 0;
}
</style>
+2 -2
View File
@@ -15,7 +15,7 @@ withDefaults(
defineProps<{
message?: string
}>(),
{ message: '请检查 Fonu 服务是否正常运行' },
{ message: '请检查 Havline 服务是否正常运行' },
)
defineEmits<{ retry: [] }>()
@@ -23,6 +23,6 @@ defineEmits<{ retry: [] }>()
<style scoped>
.load-error {
padding: var(--fonu-space-6) 0;
padding: var(--havline-space-6) 0;
}
</style>
+4 -4
View File
@@ -131,11 +131,11 @@ watch(() => props.hours, load)
</script>
<style scoped>
.trend-panel { display: flex; flex-direction: column; gap: var(--fonu-space-4); }
.trend-panel { display: flex; flex-direction: column; gap: var(--havline-space-4); }
.trend-panel--loading { opacity: 0.6; }
/* 每块自带小标题,卡片被同行撑高时也不会显得内容散掉 */
.trend-panel__section { display: flex; flex-direction: column; gap: 6px; }
.trend-panel__label { font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
.trend-panel__hint { margin: 0; font-size: 12.5px; color: var(--fonu-text-muted); }
.trend-panel__hint--error { color: var(--fonu-error); }
.trend-panel__label { font-size: 12px; font-weight: 600; color: var(--havline-text-secondary); }
.trend-panel__hint { margin: 0; font-size: 12.5px; color: var(--havline-text-muted); }
.trend-panel__hint--error { color: var(--havline-error); }
</style>
+19 -19
View File
@@ -56,32 +56,32 @@ const nginxLanguage = StreamLanguage.define(nginx)
const lightTheme = EditorView.theme(
{
'&': {
backgroundColor: 'var(--fonu-surface)',
color: 'var(--fonu-text)',
backgroundColor: 'var(--havline-surface)',
color: 'var(--havline-text)',
},
'.cm-content': {
caretColor: 'var(--fonu-brand)',
fontFamily: 'var(--fonu-mono)',
caretColor: 'var(--havline-brand)',
fontFamily: 'var(--havline-mono)',
fontSize: '12px',
lineHeight: '1.7',
},
'.cm-gutters': {
backgroundColor: 'var(--fonu-bg-muted)',
color: 'var(--fonu-text-muted)',
borderRight: '1px solid var(--fonu-border)',
backgroundColor: 'var(--havline-bg-muted)',
color: 'var(--havline-text-muted)',
borderRight: '1px solid var(--havline-border)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--fonu-bg)',
color: 'var(--fonu-text-secondary)',
backgroundColor: 'var(--havline-bg)',
color: 'var(--havline-text-secondary)',
},
'.cm-activeLine': {
backgroundColor: 'var(--fonu-brand-soft)',
backgroundColor: 'var(--havline-brand-soft)',
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'color-mix(in srgb, var(--fonu-brand) 22%, transparent) !important',
backgroundColor: 'color-mix(in srgb, var(--havline-brand) 22%, transparent) !important',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--fonu-brand)',
borderLeftColor: 'var(--havline-brand)',
},
'&.cm-focused': {
outline: 'none',
@@ -128,10 +128,10 @@ function onUpdate(value: string) {
<style scoped>
.nginx-code-editor {
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
overflow: hidden;
background: var(--fonu-surface);
background: var(--havline-surface);
}
.nginx-code-editor--embedded {
@@ -146,11 +146,11 @@ function onUpdate(value: string) {
.nginx-code-editor :deep(.cm-scroller) {
overflow: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 35%, transparent) transparent;
scrollbar-color: color-mix(in srgb, var(--havline-text-muted) 35%, transparent) transparent;
}
.nginx-code-editor:hover :deep(.cm-scroller) {
scrollbar-color: color-mix(in srgb, var(--fonu-text-muted) 65%, transparent) var(--fonu-bg-muted);
scrollbar-color: color-mix(in srgb, var(--havline-text-muted) 65%, transparent) var(--havline-bg-muted);
}
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar {
@@ -163,12 +163,12 @@ function onUpdate(value: string) {
}
.nginx-code-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fonu-text-muted) 30%, transparent);
background: color-mix(in srgb, var(--havline-text-muted) 30%, transparent);
border-radius: 4px;
}
.nginx-code-editor:hover :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fonu-text-muted) 55%, transparent);
background: color-mix(in srgb, var(--havline-text-muted) 55%, transparent);
}
.nginx-code-editor--readonly :deep(.cm-cursor) {
+6 -6
View File
@@ -22,8 +22,8 @@ defineProps<{
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-5);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-5);
flex-wrap: wrap;
}
@@ -32,21 +32,21 @@ defineProps<{
font-size: 24px;
font-weight: 700;
line-height: 1.3;
color: var(--fonu-text);
color: var(--havline-text);
letter-spacing: -0.02em;
}
.page-header__desc {
margin: var(--fonu-space-2) 0 0;
margin: var(--havline-space-2) 0 0;
font-size: 14px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
line-height: 1.5;
}
.page-header__actions {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
flex-wrap: wrap;
}
</style>
+6 -6
View File
@@ -150,11 +150,11 @@ defineExpose({ scrollToBottom })
color: #e2e8f0;
overflow-y: auto;
overflow-x: auto;
padding: var(--fonu-space-3);
font-family: var(--fonu-mono);
padding: var(--havline-space-3);
font-family: var(--havline-mono);
font-size: 12px;
line-height: 1.7;
border-radius: var(--fonu-radius-sm);
border-radius: var(--havline-radius-sm);
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.2) transparent;
}
@@ -195,7 +195,7 @@ defineExpose({ scrollToBottom })
.proxy-log-box--fullscreen {
flex: 1;
min-height: 0;
border-radius: var(--fonu-radius);
border-radius: var(--havline-radius);
}
.proxy-log-entry {
@@ -297,11 +297,11 @@ defineExpose({ scrollToBottom })
}
.proxy-log-empty {
padding: var(--fonu-space-5);
padding: var(--havline-space-5);
text-align: center;
color: #94a3b8;
white-space: normal;
}
.mono { font-family: var(--fonu-mono); }
.mono { font-family: var(--havline-mono); }
</style>
+15 -15
View File
@@ -35,11 +35,11 @@ withDefaults(
<style scoped>
.stat-card {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
padding: var(--fonu-space-5);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
padding: var(--havline-space-5);
min-height: 140px;
display: flex;
flex-direction: column;
@@ -47,21 +47,21 @@ withDefaults(
}
.stat-card:hover {
box-shadow: var(--fonu-shadow-md);
box-shadow: var(--havline-shadow-md);
}
.stat-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-2);
margin-bottom: var(--fonu-space-3);
gap: var(--havline-space-2);
margin-bottom: var(--havline-space-3);
}
.stat-card__title-row {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.stat-card__icon {
@@ -76,8 +76,8 @@ withDefaults(
}
.stat-card__icon--brand {
background: var(--fonu-brand-soft);
color: var(--fonu-brand);
background: var(--havline-brand-soft);
color: var(--havline-brand);
}
.stat-card__icon--blue {
@@ -98,7 +98,7 @@ withDefaults(
.stat-card__label {
font-size: 13px;
font-weight: 500;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.stat-card__value,
@@ -106,7 +106,7 @@ withDefaults(
font-size: 26px;
font-weight: 700;
line-height: 1.25;
color: var(--fonu-text);
color: var(--havline-text);
word-break: break-all;
letter-spacing: -0.02em;
}
@@ -116,9 +116,9 @@ withDefaults(
}
.stat-card__sub {
margin-top: var(--fonu-space-2);
margin-top: var(--havline-space-2);
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.5;
}
</style>
+15 -15
View File
@@ -36,28 +36,28 @@ const label = computed(() => props.text ?? statusLabel(props.value))
.status-badge.pill {
padding: 4px 10px;
border-radius: 999px;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.status-badge.pill.success {
background: rgba(16, 185, 129, 0.12);
color: var(--fonu-success);
color: var(--havline-success);
}
.status-badge.pill.warning {
background: rgba(245, 158, 11, 0.12);
color: var(--fonu-warning);
color: var(--havline-warning);
}
.status-badge.pill.error {
background: rgba(239, 68, 68, 0.12);
color: var(--fonu-error);
color: var(--havline-error);
}
.status-badge.pill.disabled,
.status-badge.pill.unknown {
background: var(--fonu-bg-muted);
color: var(--fonu-text-secondary);
background: var(--havline-bg-muted);
color: var(--havline-text-secondary);
}
.dot {
@@ -67,11 +67,11 @@ const label = computed(() => props.text ?? statusLabel(props.value))
flex-shrink: 0;
}
.success .dot { background: var(--fonu-success); }
.warning .dot { background: var(--fonu-warning); }
.error .dot { background: var(--fonu-error); }
.disabled .dot { background: var(--fonu-disabled); }
.unknown .dot { background: var(--fonu-text-muted); }
.success .dot { background: var(--havline-success); }
.warning .dot { background: var(--havline-warning); }
.error .dot { background: var(--havline-error); }
.disabled .dot { background: var(--havline-disabled); }
.unknown .dot { background: var(--havline-text-muted); }
.success .label { color: inherit; }
.warning .label { color: inherit; }
@@ -79,9 +79,9 @@ const label = computed(() => props.text ?? statusLabel(props.value))
.disabled .label { color: inherit; }
.unknown .label { color: inherit; }
.status-badge:not(.pill) .success .label { color: var(--fonu-success); }
.status-badge:not(.pill) .warning .label { color: var(--fonu-warning); }
.status-badge:not(.pill) .error .label { color: var(--fonu-error); }
.status-badge:not(.pill) .success .label { color: var(--havline-success); }
.status-badge:not(.pill) .warning .label { color: var(--havline-warning); }
.status-badge:not(.pill) .error .label { color: var(--havline-error); }
.status-badge:not(.pill) .disabled .label,
.status-badge:not(.pill) .unknown .label { color: var(--fonu-text-secondary); }
.status-badge:not(.pill) .unknown .label { color: var(--havline-text-secondary); }
</style>
+4 -4
View File
@@ -174,7 +174,7 @@ function providerIcon(v: string) {
.provider-form__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.provider-form__field {
@@ -185,7 +185,7 @@ function providerIcon(v: string) {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.provider-select {
@@ -210,7 +210,7 @@ function providerIcon(v: string) {
overflow: hidden;
flex-shrink: 0;
background: #fff;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
}
.provider-logo--md {
@@ -237,7 +237,7 @@ function providerIcon(v: string) {
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--fonu-text);
color: var(--havline-text);
}
.provider-form__actions {
+25 -25
View File
@@ -3,7 +3,7 @@
<div class="update-modal">
<div class="update-modal__header">
<div>
<h3 class="update-modal__title">升级 Fonu</h3>
<h3 class="update-modal__title">升级 Havline</h3>
<p class="update-modal__subtitle">升级完成后容器会重启,数据目录不会被删除</p>
</div>
<n-button quaternary size="small" :disabled="applying" @click="show = false">关闭</n-button>
@@ -34,7 +34,7 @@
</div>
<p class="update-modal__hint">
升级会重新构建应用镜像并只重建 <span class="mono">fonu</span> 容器。构建期间管理页面可能短暂无法访问,
升级会重新构建应用镜像并只重建 <span class="mono">havline</span> 容器。构建期间管理页面可能短暂无法访问,
浏览器会自动等待服务恢复。
</p>
@@ -127,7 +127,7 @@ async function pollStatus() {
localMessage.value = ''
if (status.value.phase === 'success') {
applying.value = false
message.success('Fonu 已升级完成')
message.success('Havline 已升级完成')
emit('updated')
window.setTimeout(() => window.location.reload(), 1500)
return
@@ -153,9 +153,9 @@ useVisibilityPolling(pollStatus, 2000, { enabled: applying })
.update-modal {
width: 560px;
max-width: 94vw;
background: var(--fonu-surface);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow-md);
background: var(--havline-surface);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow-md);
overflow: hidden;
}
@@ -163,54 +163,54 @@ useVisibilityPolling(pollStatus, 2000, { enabled: applying })
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-3);
padding: var(--fonu-space-5) var(--fonu-space-5) var(--fonu-space-3);
gap: var(--havline-space-3);
padding: var(--havline-space-5) var(--havline-space-5) var(--havline-space-3);
}
.update-modal__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.update-modal__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.update-modal__body {
display: grid;
gap: var(--fonu-space-4);
padding: 0 var(--fonu-space-5) var(--fonu-space-4);
gap: var(--havline-space-4);
padding: 0 var(--havline-space-5) var(--havline-space-4);
}
.update-modal__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.update-modal__grid > div {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.update-modal__label {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.update-modal__hint {
margin: 0;
font-size: 12px;
line-height: 1.6;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.update-modal__log {
@@ -218,10 +218,10 @@ useVisibilityPolling(pollStatus, 2000, { enabled: applying })
margin: 0;
padding: 12px;
overflow: auto;
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg-muted);
color: var(--fonu-text-secondary);
font-family: var(--fonu-mono);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg-muted);
color: var(--havline-text-secondary);
font-family: var(--havline-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
@@ -230,9 +230,9 @@ useVisibilityPolling(pollStatus, 2000, { enabled: applying })
.update-modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--fonu-space-3);
padding: var(--fonu-space-4) var(--fonu-space-5);
border-top: 1px solid var(--fonu-border);
gap: var(--havline-space-3);
padding: var(--havline-space-4) var(--havline-space-5);
border-top: 1px solid var(--havline-border);
}
@media (max-width: 640px) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { computed, ref, watch } from 'vue'
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
const themeKey = 'fonu-theme'
const themeKey = 'havline-theme'
const themeMode = ref(localStorage.getItem(themeKey) ?? 'system')
function systemDark() {
@@ -14,7 +14,7 @@ const shared = {
primaryColorPressed: '#047857',
primaryColorSuppl: '#34D399',
borderRadius: '8px',
fontFamily: 'var(--fonu-font)',
fontFamily: 'var(--havline-font)',
}
const lightOverrides: GlobalThemeOverrides = {
+1 -1
View File
@@ -1,4 +1,4 @@
export const GITHUB_REPO = 'O96u/Fonu'
export const GITHUB_REPO = 'O96u/Havline'
export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
+2 -2
View File
@@ -100,14 +100,14 @@ export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
{
key: 'on_agent_unreachable',
label: '公网 agent 不可达',
hint: 'VPS 上的 fonu-agent 连续无响应(重启后没起来最常见),恢复时再通知一次',
hint: 'VPS 上的 havline-agent 连续无响应(重启后没起来最常见),恢复时再通知一次',
hasThreshold: true,
thresholdKey: 'agent_fail',
},
{
key: 'on_route_drift',
label: '配置漂移',
hint: 'VPS 上的 vhost 与 Fonu 记录不一致(被手工改过或重装后未重部署),同一台按提醒间隔重复提醒',
hint: 'VPS 上的 vhost 与 Havline 记录不一致(被手工改过或重装后未重部署),同一台按提醒间隔重复提醒',
hasThreshold: true,
thresholdKey: 'drift_cooldown',
},
+44 -47
View File
@@ -12,9 +12,9 @@
@expand="collapsed = false"
>
<router-link class="brand" :class="{ 'brand--collapsed': collapsed }" :to="{ name: 'dashboard' }">
<img class="brand__logo" :src="logoImg" alt="Fonu" />
<img class="brand__logo" :src="brandLogo" alt="Havline" />
<div v-if="!collapsed" class="brand__text">
<div class="brand__name">Fonu</div>
<div class="brand__name">Havline</div>
<div class="brand__tagline">让 NAS 访问更简单</div>
</div>
</router-link>
@@ -31,7 +31,7 @@
<div v-if="!collapsed" class="sider-footer">
<div class="sider-footer__version-row">
<div class="sider-footer__title">
<span class="sider-footer__brand">Fonu</span>
<span class="sider-footer__brand">Havline</span>
<span class="sider-footer__version">v{{ appVersion }}</span>
</div>
<a
@@ -141,7 +141,7 @@ import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
import { useTheme } from '../composables/useTheme'
import { fetchLatestRelease, isNewerVersion } from '../utils/version'
import type { UpdateStatus } from '../api/types'
import logoImg from '../assets/brand/logo.png'
import brandLogo from '../assets/brand/havline-logo.svg'
import UpdateModal from '../components/UpdateModal.vue'
const route = useRoute()
@@ -262,7 +262,7 @@ onUnmounted(() => {
.app-layout {
min-height: 100vh;
height: 100vh;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.app-layout :deep(.n-layout-scroll-container) {
@@ -270,8 +270,8 @@ onUnmounted(() => {
}
.app-sider {
background: var(--fonu-surface) !important;
border-right: 1px solid var(--fonu-border) !important;
background: var(--havline-surface) !important;
border-right: 1px solid var(--havline-border) !important;
min-height: 100vh;
}
@@ -283,9 +283,9 @@ onUnmounted(() => {
.brand {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
padding: var(--fonu-space-5) var(--fonu-space-4);
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-3);
padding: var(--havline-space-5) var(--havline-space-4);
border-bottom: 1px solid var(--havline-border);
color: inherit;
text-decoration: none;
cursor: pointer;
@@ -293,40 +293,37 @@ onUnmounted(() => {
}
.brand:hover {
background: var(--fonu-bg-muted);
background: var(--havline-bg-muted);
}
.brand--collapsed {
justify-content: center;
padding: var(--fonu-space-4) 0;
padding: var(--havline-space-4) 0;
}
.brand__logo {
width: 40px;
height: 40px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
.brand__name {
font-size: 18px;
font-weight: 700;
color: var(--fonu-text);
color: var(--havline-text);
line-height: 1.2;
}
.brand__tagline {
font-size: 11px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.app-menu {
flex: 1;
padding: var(--fonu-space-3);
padding: var(--havline-space-3);
}
.app-menu :deep(.n-menu-item) {
@@ -334,23 +331,23 @@ onUnmounted(() => {
}
.app-menu :deep(.n-menu-item-content) {
border-radius: var(--fonu-radius-sm) !important;
border-radius: var(--havline-radius-sm) !important;
padding-left: 14px !important;
}
.app-menu :deep(.n-menu-item-content--selected) {
background: var(--fonu-brand-soft) !important;
background: var(--havline-brand-soft) !important;
font-weight: 600;
}
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header),
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header *) {
color: var(--fonu-brand-text) !important;
color: var(--havline-brand-text) !important;
}
.app-menu :deep(.n-menu-item-content--selected .n-icon),
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content__icon) {
color: var(--fonu-brand) !important;
color: var(--havline-brand) !important;
}
.app-menu :deep(.n-menu-item-content--selected::before) {
@@ -358,8 +355,8 @@ onUnmounted(() => {
}
.sider-footer {
padding: var(--fonu-space-4);
border-top: 1px solid var(--fonu-border);
padding: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.sider-footer__version-row {
@@ -367,7 +364,7 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
}
.sider-footer__title {
@@ -380,12 +377,12 @@ onUnmounted(() => {
.sider-footer__brand {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.sider-footer__version {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.sider-footer__update {
@@ -398,7 +395,7 @@ onUnmounted(() => {
font-size: 11px;
font-weight: 500;
font-family: inherit;
color: var(--fonu-brand);
color: var(--havline-brand);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
@@ -417,15 +414,15 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
font-size: 12px;
color: var(--fonu-success);
color: var(--havline-success);
font-weight: 500;
}
.sider-footer__status-icon {
font-size: 8px;
color: var(--fonu-success);
color: var(--havline-success);
}
.sider-footer__github {
@@ -433,13 +430,13 @@ onUnmounted(() => {
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
text-decoration: none;
transition: color 0.15s;
}
.sider-footer__github:hover {
color: var(--fonu-text);
color: var(--havline-text);
}
.sider-footer__github :deep(.n-icon) {
@@ -450,53 +447,53 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.topbar {
flex-shrink: 0;
height: var(--fonu-topbar-height);
padding: 0 var(--fonu-space-5);
height: var(--havline-topbar-height);
padding: 0 var(--havline-space-5);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
background: var(--fonu-surface);
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-4);
background: var(--havline-surface);
border-bottom: 1px solid var(--havline-border);
}
.topbar__datetime {
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.topbar__right {
display: flex;
align-items: center;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
}
.user-btn {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
padding: 0 var(--fonu-space-2);
gap: var(--havline-space-3);
padding: 0 var(--havline-space-2);
}
.user-btn__icon,
.user-btn__chevron {
font-size: 18px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.user-name {
font-size: 14px;
color: var(--fonu-text);
color: var(--havline-text);
}
.content {
flex: 1;
padding: var(--fonu-space-5);
padding: var(--havline-space-5);
overflow: auto;
}
@@ -507,7 +504,7 @@ onUnmounted(() => {
@media (max-width: 768px) {
.topbar,
.content {
padding-inline: var(--fonu-space-4);
padding-inline: var(--havline-space-4);
}
.user-name,
+1 -1
View File
@@ -6,7 +6,7 @@ import router from './router'
const app = createApp(App)
app.config.errorHandler = (err, _instance, info) => {
console.error('[fonu]', info, err)
console.error('[havline]', info, err)
}
app.use(router).use(naive).mount('#app')
+3 -3
View File
@@ -33,7 +33,7 @@ const router = createRouter({
path: 'dashboard',
name: 'dashboard',
component: DashboardView,
meta: { title: '仪表盘', description: '查看 Fonu 运行状态与关键指标' },
meta: { title: '仪表盘', description: '查看 Havline 运行状态与关键指标' },
},
{
path: 'ddns',
@@ -57,13 +57,13 @@ const router = createRouter({
path: 'frp',
name: 'frp',
component: FrpView,
meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Fonu Nginx' },
meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Havline Nginx' },
},
{
path: 'frp-agent',
name: 'frp-agent',
component: AgentView,
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' },
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 havline-agent 与 frps' },
},
{
path: 'cloudflare',
+51 -51
View File
@@ -1,65 +1,65 @@
:root {
--fonu-brand: #10b981;
--fonu-brand-hover: #059669;
--fonu-brand-soft: rgba(16, 185, 129, 0.12);
--fonu-brand-text: #047857;
--fonu-brand-sidebar: #10b981;
--havline-brand: #10b981;
--havline-brand-hover: #059669;
--havline-brand-soft: rgba(16, 185, 129, 0.12);
--havline-brand-text: #047857;
--havline-brand-sidebar: #10b981;
--fonu-success: #10b981;
--fonu-warning: #f59e0b;
--fonu-error: #ef4444;
--fonu-disabled: #94a3b8;
--fonu-info: #3b82f6;
--fonu-info-soft: rgba(59, 130, 246, 0.1);
--fonu-error-soft: rgba(239, 68, 68, 0.1);
--havline-success: #10b981;
--havline-warning: #f59e0b;
--havline-error: #ef4444;
--havline-disabled: #94a3b8;
--havline-info: #3b82f6;
--havline-info-soft: rgba(59, 130, 246, 0.1);
--havline-error-soft: rgba(239, 68, 68, 0.1);
--fonu-bg: #f8fafc;
--fonu-bg-muted: #f3f4f6;
--fonu-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%);
--fonu-banner-border: rgba(16, 185, 129, 0.2);
--fonu-surface: #ffffff;
--fonu-border: #e5e7eb;
--fonu-text: #111827;
--fonu-text-secondary: #6b7280;
--fonu-text-muted: #9ca3af;
--havline-bg: #f8fafc;
--havline-bg-muted: #f3f4f6;
--havline-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%);
--havline-banner-border: rgba(16, 185, 129, 0.2);
--havline-surface: #ffffff;
--havline-border: #e5e7eb;
--havline-text: #111827;
--havline-text-secondary: #6b7280;
--havline-text-muted: #9ca3af;
--fonu-radius: 12px;
--fonu-radius-sm: 8px;
--fonu-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--fonu-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06);
--havline-radius: 12px;
--havline-radius-sm: 8px;
--havline-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--havline-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06);
--fonu-sidebar-width: 240px;
--fonu-topbar-height: 56px;
--fonu-form-max: 720px;
--havline-sidebar-width: 240px;
--havline-topbar-height: 56px;
--havline-form-max: 720px;
--fonu-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--fonu-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace;
--havline-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--havline-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace;
--fonu-space-1: 4px;
--fonu-space-2: 8px;
--fonu-space-3: 12px;
--fonu-space-4: 16px;
--fonu-space-5: 24px;
--fonu-space-6: 32px;
--havline-space-1: 4px;
--havline-space-2: 8px;
--havline-space-3: 12px;
--havline-space-4: 16px;
--havline-space-5: 24px;
--havline-space-6: 32px;
}
html.dark,
[data-theme='dark'] {
--fonu-brand-soft: rgba(16, 185, 129, 0.18);
--fonu-brand-text: #6ee7b7;
--fonu-info-soft: rgba(59, 130, 246, 0.16);
--fonu-error-soft: rgba(239, 68, 68, 0.16);
--havline-brand-soft: rgba(16, 185, 129, 0.18);
--havline-brand-text: #6ee7b7;
--havline-info-soft: rgba(59, 130, 246, 0.16);
--havline-error-soft: rgba(239, 68, 68, 0.16);
--fonu-bg: #0f172a;
--fonu-bg-muted: #1e293b;
--fonu-surface: #1e293b;
--fonu-border: #334155;
--fonu-text: #f1f5f9;
--fonu-text-secondary: #94a3b8;
--fonu-text-muted: #64748b;
--fonu-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%);
--fonu-banner-border: rgba(52, 211, 153, 0.25);
--havline-bg: #0f172a;
--havline-bg-muted: #1e293b;
--havline-surface: #1e293b;
--havline-border: #334155;
--havline-text: #f1f5f9;
--havline-text-secondary: #94a3b8;
--havline-text-muted: #64748b;
--havline-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%);
--havline-banner-border: rgba(52, 211, 153, 0.25);
--fonu-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--fonu-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--havline-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--havline-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
}
+1 -1
View File
@@ -1,6 +1,6 @@
// 剪贴板复制。
//
// Fonu 常以 http://内网IP:6893 访问,属于**非安全上下文**,此时 navigator.clipboard 为 undefined,
// Havline 常以 http://内网IP:6893 访问,属于**非安全上下文**,此时 navigator.clipboard 为 undefined,
// 直接调用会导致复制必然失败。这里统一收口:优先用异步剪贴板 API,不可用时回退到 execCommand。
// 返回是否复制成功,由调用方决定提示文案。
export async function copyText(text: string): Promise<boolean> {
+214 -214
View File
@@ -137,7 +137,7 @@
<div class="stat-card__label">端口监听</div>
<div class="stat-card__sub">frps 运行所需端口</div>
</div>
<FonuCard class="section-card vps-resource-card" title="VPS 资源">
<HavlineCard class="section-card vps-resource-card" title="VPS 资源">
<div class="section-body">
<p v-if="metricsError" class="form-hint">{{ metricsError }}</p>
<div v-else-if="agentMetrics" class="res-strip">
@@ -156,16 +156,16 @@
</div>
<p v-else class="form-hint">{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}</p>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="section-card vps-resource-card" title="VPS 证书">
<HavlineCard class="section-card vps-resource-card" title="VPS 证书">
<div class="section-body">
<p v-if="certsError" class="form-hint">{{ certsError }}</p>
<div v-else-if="agentCerts.length > 0" class="cert-list">
<div class="cert-row cert-row--head">
<span>域名</span>
<span>剩余天数</span>
<span>Fonu 证书库</span>
<span>Havline 证书库</span>
<span>到期时间</span>
</div>
<div v-for="item in agentCerts" :key="item.domain" class="cert-row">
@@ -173,19 +173,19 @@
<span :class="{ 'cert-days--warn': item.days_left < 30, 'cert-days--error': item.days_left < 0 }">
{{ item.days_left < 0 ? '已过期' : `${item.days_left} 天` }}
</span>
<span>{{ fonuCertDomains.has(item.domain) ? '有' : '无' }}</span>
<span>{{ havlineCertDomains.has(item.domain) ? '有' : '无' }}</span>
<span class="cert-muted">{{ item.not_after ? formatDate(item.not_after) : '未解析' }}</span>
</div>
</div>
<p v-else class="form-hint">{{ certsLoading ? '正在读取 VPS 证书…' : 'VPS 证书目录为空' }}</p>
<p v-if="certWarnings.length > 0" class="form-hint">{{ certWarnings.join(';') }}</p>
</div>
</FonuCard>
</HavlineCard>
</section>
<!-- ② 环境与依赖 -->
<section class="dep-grid" aria-label="环境与依赖">
<FonuCard class="section-card" title="Fonu Agent">
<HavlineCard class="section-card" title="Havline Agent">
<div class="section-body">
<div class="field-grid">
<div><label>Agent 版本</label><span class="mono">{{ agentVersionText(agentStatus?.agent_version) }}</span></div>
@@ -201,9 +201,9 @@
<n-button size="small" :loading="busy === 'probe'" :disabled="busy !== null && busy !== 'probe'" @click="probe(server)">探测环境</n-button>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="section-card" title="Nginx(公网反代依赖)">
<HavlineCard class="section-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>
@@ -219,9 +219,9 @@
<router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="section-card" title="frps 运行时">
<HavlineCard class="section-card" title="frps 运行时">
<div class="section-body">
<div class="field-grid">
<div><label>已安装版本</label><span class="field-tags"><StatusBadge :kind="agentStatus?.frps_version ? 'success' : 'warning'" :text="installedFrpsVersion || '未安装'" /></span></div>
@@ -233,12 +233,12 @@
</div>
<p v-if="!frpsConsistency.ok && frpsConsistency.detail" class="form-hint">{{ frpsConsistency.detail }}</p>
</div>
</FonuCard>
</HavlineCard>
</section>
<!-- ④ Agent 生命周期(SSH) + ③ frps 控制:两列并排(生命周期在左) -->
<div class="control-grid">
<FonuCard class="section-card" title="Agent 生命周期(SSH)">
<HavlineCard class="section-card" title="Agent 生命周期(SSH)">
<div class="section-body">
<p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。
@@ -256,7 +256,7 @@
当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。
</n-alert>
<n-alert v-if="server.agent_transport === 'https-pin'" type="info" :bordered="false" class="agent-transport-warning">
HTTPS 直连使用证书指纹校验,证书到期前 30 天会自动轮换。请确认云安全组已放行 {{ agentHTTPSPort(server) || 'HTTPS' }} 端口,Fonu 不会自动修改云安全组。
HTTPS 直连使用证书指纹校验,证书到期前 30 天会自动轮换。请确认云安全组已放行 {{ agentHTTPSPort(server) || 'HTTPS' }} 端口,Havline 不会自动修改云安全组。
</n-alert>
<p v-if="server.agent_transport === 'https-pin' && server.agent_firewall_state" class="form-hint">主机防火墙:{{ server.agent_firewall_state }}</p>
<p v-if="server.agent_transport_error" class="form-hint agent-transport-error">{{ server.agent_transport_error }}</p>
@@ -289,13 +289,13 @@
<template #trigger>
<n-button size="small" type="warning" :disabled="busy !== null">卸载(含数据)</n-button>
</template>
将删除 VPS 上的 fonu-agent、frps 单元与 /var/lib/fonu-agent(已注册的路由与证书一并丢失),确认继续?
将删除 VPS 上的 havline-agent、frps 单元与 /var/lib/havline-agent(已注册的路由与证书一并丢失),确认继续?
</n-popconfirm>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="section-card" title="frps 控制">
<HavlineCard class="section-card" title="frps 控制">
<div class="section-body">
<div class="agent-actions">
<n-button v-if="agentStatus?.frps_active" size="small" type="warning" :loading="busy === 'frps-stop'" :disabled="busy !== null && busy !== 'frps-stop'" @click="frpsAction(server, 'stop')">停止 frps</n-button>
@@ -326,12 +326,12 @@
<span v-else class="form-hint">VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置</span>
</div>
</div>
</FonuCard>
</HavlineCard>
</div>
<!-- ⑤ 输出与日志 -->
<FonuCard class="section-card" title="输出与日志">
<HavlineCard class="section-card" title="输出与日志">
<template #header>
<n-button size="small" quaternary :disabled="!opOutput.length" @click="opOutput = []">清空操作输出</n-button>
</template>
@@ -356,7 +356,7 @@
</n-tab-pane>
</n-tabs>
</div>
</FonuCard>
</HavlineCard>
</div>
</div>
@@ -371,7 +371,7 @@
</div>
<div class="config-modal__body">
<p class="form-hint">
由 Fonu 本地生成;点「下发 frps 配置」后写入 VPS 的
由 Havline 本地生成;点「下发 frps 配置」后写入 VPS 的
<span class="mono">{{ agentStatus?.frps_config_path || '/etc/frp/frps.toml' }}</span>,重启 frps 后生效。
<br />
密钥类字段(如 <span class="mono">auth.token</span>)在这里显示为掩码,下发时仍使用真实值。
@@ -487,7 +487,7 @@
<section class="form-section">
<h3><n-icon :component="ServerOutline" /> frps 管理接口</h3>
<p class="form-hint">用于 Fonu 拉取穿透规则的实时连接与流量信息;需在 frps 的 frps.toml 中开启 webServer(dashboard),与 frpc 连接用的令牌无关。</p>
<p class="form-hint">用于 Havline 拉取穿透规则的实时连接与流量信息;需在 frps 的 frps.toml 中开启 webServer(dashboard),与 frpc 连接用的令牌无关。</p>
<div class="form-grid">
<n-form-item label="管理地址"><n-input v-model:value="serverForm.options.dashboard_addr" placeholder="frps 机器的 IP 或域名,留空则不采集流量" /></n-form-item>
<n-form-item label="管理端口"><n-input-number v-model:value="serverForm.options.dashboard_port" :min="0" :max="65535" placeholder="例如:7500" /></n-form-item>
@@ -693,7 +693,7 @@ import {
} from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue'
import ConfiguredSecretField from '../components/ConfiguredSecretField.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -759,8 +759,8 @@ function portMeaning(port: string) {
return `${portMeanings[port] ?? '端口'}:${portOn(port) ? '正在监听' : '未监听'}`
}
// frps 运行一致性(P0-3):systemd 实际执行的启动命令,是否用的是 Fonu 下发的二进制与配置文件。
// 不一致意味着:手工改过 unit,或者 Fonu 写入的 frps.toml 并不是 frps 实际加载的那一份。
// frps 运行一致性(P0-3):systemd 实际执行的启动命令,是否用的是 Havline 下发的二进制与配置文件。
// 不一致意味着:手工改过 unit,或者 Havline 写入的 frps.toml 并不是 frps 实际加载的那一份。
const frpsConsistency = computed(() => {
const exec = agentStatus.value?.frps_unit_exec ?? ''
if (!exec) return { ok: false, kind: 'warning' as const, text: '未获取', detail: '' }
@@ -768,7 +768,7 @@ const frpsConsistency = computed(() => {
const config = agentStatus.value?.frps_config_path ?? ''
const problems: string[] = []
if (binary && !exec.includes(binary)) problems.push(`启动命令未使用配置的二进制(${binary})`)
if (config && !exec.includes(config)) problems.push(`启动命令未使用 Fonu 下发的配置文件(${config})`)
if (config && !exec.includes(config)) problems.push(`启动命令未使用 Havline 下发的配置文件(${config})`)
if (problems.length > 0) {
return { ok: false, kind: 'warning' as const, text: '不一致', detail: problems.join(';') }
}
@@ -782,10 +782,10 @@ const frpsVersionOptions = computed(() => releases.value.map((release) => ({ lab
// O:最后检查时间(PageHeader 副标题,判断数据新鲜度)
const headerDescription = computed(() => {
if (!server.value) return '管理公网 VPS 服务端(frps)与其上的 fonu-agent'
if (!server.value) return '管理公网 VPS 服务端(frps)与其上的 havline-agent'
if (!statusLoaded.value) return '正在读取 VPS 状态…'
const checkedAt = agentStatus.value?.checked_at
return checkedAt ? `管理部署在公网 VPS 上的 fonu-agent 与 frps · 检查于 ${formatRelativeTime(checkedAt)}` : '管理部署在公网 VPS 上的 fonu-agent 与 frps 服务'
return checkedAt ? `管理部署在公网 VPS 上的 havline-agent 与 frps · 检查于 ${formatRelativeTime(checkedAt)}` : '管理部署在公网 VPS 上的 havline-agent 与 frps 服务'
})
// 三态:未加载 / 加载中 / 失败——避免把"没拉到"显示成"未连接"
@@ -821,7 +821,7 @@ const statusAlert = computed<{ type: 'error' | 'warning'; text: string; action?:
if (!agentStatusOk.value) {
return {
type: 'error',
text: 'Agent 未连接:请确认 VPS 上 fonu-agent 正在运行,且 Agent 地址与 Token 与页面一致。',
text: 'Agent 未连接:请确认 VPS 上 havline-agent 正在运行,且 Agent 地址与 Token 与页面一致。',
action: { label: '探测环境', run: () => probe(current) },
}
}
@@ -891,7 +891,7 @@ async function load() {
void loadServerDiagnostics(servers.value)
void loadRuntimeVersion()
if (server.value) await refreshAll({ silent: false })
void loadFonuCerts()
void loadHavlineCerts()
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
@@ -949,12 +949,12 @@ const metricsUptimeText = computed(() =>
agentMetrics.value?.uptime_seconds ? formatUptime(agentMetrics.value.uptime_seconds) : '—',
)
// VPS 证书清单(需 agent ≥ 0.11.0):与 Fonu 证书库交叉核对,宝塔自签的证书也能看到
// VPS 证书清单(需 agent ≥ 0.11.0):与 Havline 证书库交叉核对,宝塔自签的证书也能看到
const agentCerts = ref<import('../api/types').FrpAgentCertificate[]>([])
const certWarnings = ref<string[]>([])
const certsError = ref('')
const certsLoading = ref(false)
const fonuCertDomains = ref<Set<string>>(new Set())
const havlineCertDomains = ref<Set<string>>(new Set())
async function loadAgentCerts(serverID: number) {
certsLoading.value = true
@@ -972,8 +972,8 @@ async function loadAgentCerts(serverID: number) {
}
}
// Fonu 证书库里的域名(含通配符),用于判断「VPS 上有但 Fonu 没有」
async function loadFonuCerts() {
// Havline 证书库里的域名(含通配符),用于判断「VPS 上有但 Havline 没有」
async function loadHavlineCerts() {
try {
const records = await api.listCertificates()
const domains = new Set<string>()
@@ -981,9 +981,9 @@ async function loadFonuCerts() {
domains.add(record.domain)
for (const item of record.domains ?? []) domains.add(item)
}
fonuCertDomains.value = domains
havlineCertDomains.value = domains
} catch {
fonuCertDomains.value = new Set()
havlineCertDomains.value = new Set()
}
}
@@ -1074,7 +1074,7 @@ function pushAgentConfig(target: FrpServer) {
() => ({ text: 'frps 同源配置已下发到 VPS,点「重启 frps」后生效', ok: true }))
}
// 配置查看:只读,展示 Fonu 本地生成的 frps.toml(即「下发」会写入 VPS 的内容)
// 配置查看:只读,展示 Havline 本地生成的 frps.toml(即「下发」会写入 VPS 的内容)
async function viewConfig(target: FrpServer) {
configLoading.value = true
try {
@@ -1241,8 +1241,8 @@ const serverHelpItems = computed(() => {
const common = [
{ title: '基本信息', text: '主机地址和端口对应 frps 的 bindAddr / bindPort,多个服务端可并行配置,规则保存时选择归属。' },
{ title: '认证方式', text: '与 frps 的 auth 配置保持一致:无认证、令牌(auth.token)或 OIDC;令牌与密钥加密存储,编辑时留空保持不变。' },
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Fonu 借此拉取穿透规则的实时连接与流量;不写入 frpc.toml,与连接认证无关。' },
{ title: '描述', text: '仅用于在 Fonu 中区分多台服务端的备注信息。' },
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;不写入 frpc.toml,与连接认证无关。' },
{ title: '描述', text: '仅用于在 Havline 中区分多台服务端的备注信息。' },
]
if (serverForm.options.auth_method === 'oidc') return [...common, { title: 'OIDC', text: '客户端 ID、密钥和令牌端点由身份提供商提供;受众与权限范围需与 frps 的 OIDC 校验配置匹配。' }]
return common
@@ -1675,90 +1675,90 @@ useVisibilityPolling(() => {
<style scoped>
/* 分区:① 运行状态 → ② 环境与依赖 → ③ frps 控制 → ④ 生命周期 → ⑤ 输出与日志 */
.page-alert { margin-bottom: var(--fonu-space-3); }
.page-alert__body { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); }
.entrance-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); padding: 10px var(--fonu-space-5); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-info-soft); }
.entrance-row__text { font-size: 13px; color: var(--fonu-text); }
.page-alert { margin-bottom: var(--havline-space-3); }
.page-alert__body { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); }
.entrance-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); 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); }
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.stat-card { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); padding: var(--fonu-space-4) var(--fonu-space-5); min-height: 118px; }
.stats-row { display: grid; grid-template-columns: repeat(4, 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(--fonu-info); }
.stat-card__icon--green { color: var(--fonu-brand); }
.stat-card__icon--amber { color: var(--fonu-warning); }
.stat-card__icon--red { color: var(--fonu-error); }
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--fonu-text); letter-spacing: -0.03em; }
.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__icon--red { color: var(--havline-error); }
.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--sm { font-size: 18px; }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.port-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.port-item { font-family: var(--fonu-mono); font-size: 12.5px; padding: 1px 6px; border-radius: 999px; background: var(--fonu-bg-muted); color: var(--fonu-text-muted); }
.port-item--on { background: var(--fonu-brand-soft); color: var(--fonu-brand-text); }
.port-item { font-family: var(--havline-mono); font-size: 12.5px; padding: 1px 6px; border-radius: 999px; background: var(--havline-bg-muted); color: var(--havline-text-muted); }
.port-item--on { background: var(--havline-brand-soft); color: var(--havline-brand-text); }
.dep-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.dep-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
/* 前四张卡每行三张(各占 2/6),VPS 资源与 VPS 证书各占 3/6 并排 */
.dep-grid > .section-card { grid-column: span 2; }
/* 三列下卡片较窄:短字段(版本/徽标)双列并排,长路径与命令用 .field-grid__wide 整行,控制卡片高度 */
.dep-grid .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-2) var(--fonu-space-3); }
.dep-grid .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-2) var(--havline-space-3); }
/* frps 控制与 Agent 生命周期两列并排,卡片等高 */
.control-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.control-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
.control-grid .section-card { margin-bottom: 0; }
.section-card { margin-bottom: var(--fonu-space-4); }
.section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-3) var(--fonu-space-4); }
.section-card { margin-bottom: var(--havline-space-4); }
.section-body { padding: var(--havline-space-2) var(--havline-space-5) var(--havline-space-4); }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-3) var(--havline-space-4); }
.field-grid > div { display: grid; gap: 4px; min-width: 0; }
.field-grid__wide { grid-column: 1 / -1; }
.field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.field-grid label { color: var(--havline-text-secondary); font-size: 12px; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-3); }
.agent-transport-grid { margin-top: var(--fonu-space-3); }
.agent-transport-warning { margin-top: var(--fonu-space-3); }
.agent-transport-error { color: var(--fonu-error); }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--havline-space-3); }
.agent-transport-grid { margin-top: var(--havline-space-3); }
.agent-transport-warning { margin-top: var(--havline-space-3); }
.agent-transport-error { color: var(--havline-error); }
.tooltip-trigger { display: inline-flex; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-3); flex-wrap: wrap; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--havline-space-3); flex-wrap: wrap; }
.frps-version-select { width: 200px; }
.nginx-error { margin-top: var(--fonu-space-2); color: var(--fonu-error); overflow-wrap: anywhere; }
.nginx-error { margin-top: var(--havline-space-2); color: var(--havline-error); overflow-wrap: anywhere; }
/* 配置查看弹窗(只读) */
.config-modal { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow-md); padding: var(--fonu-space-4) var(--fonu-space-5); }
.config-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.config-modal__body { margin-top: var(--fonu-space-3); }
.config-modal { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow-md); padding: var(--havline-space-4) var(--havline-space-5); }
.config-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.config-modal__body { margin-top: var(--havline-space-3); }
.config-modal__code { max-height: 380px; }
.config-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-4); }
.modal-title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); }
.config-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--havline-space-4); }
.modal-title { margin: 0; font-size: 15px; font-weight: 600; color: var(--havline-text); }
.op-list { display: grid; gap: var(--fonu-space-3); }
.op-item { padding: var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
.op-item--fail { border-color: var(--fonu-error); }
.op-list { display: grid; gap: var(--havline-space-3); }
.op-item { padding: var(--havline-space-3); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-surface); }
.op-item--fail { border-color: var(--havline-error); }
.op-item__head { display: flex; align-items: center; gap: 8px; }
.op-item__source { font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.op-item__time { margin-left: auto; font-size: 12px; color: var(--fonu-text-muted); }
.op-item__source { font-size: 13px; font-weight: 600; color: var(--havline-text); }
.op-item__time { margin-left: auto; font-size: 12px; color: var(--havline-text-muted); }
.code-block {
margin: var(--fonu-space-2) 0 0;
padding: var(--fonu-space-3) var(--fonu-space-4);
background: var(--fonu-bg-muted);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
font-family: var(--fonu-mono);
margin: var(--havline-space-2) 0 0;
padding: var(--havline-space-3) var(--havline-space-4);
background: var(--havline-bg-muted);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
font-family: var(--havline-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--fonu-text);
color: var(--havline-text);
overflow: auto;
max-height: 240px;
white-space: pre-wrap;
word-break: break-all;
}
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; }
.form-hint { color: var(--havline-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--havline-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; }
/* 首屏一次性入场;用户偏好减少动效时关闭 */
@keyframes fonu-fade-up {
@keyframes havline-fade-up {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.stats-row, .dep-grid, .section-card, .entrance-row { animation: fonu-fade-up 160ms ease-out both; }
.stats-row, .dep-grid, .section-card, .entrance-row { animation: havline-fade-up 160ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
.stats-row, .dep-grid, .section-card, .entrance-row { animation: none; }
}
@@ -1790,7 +1790,7 @@ useVisibilityPolling(() => {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
}
.res-row {
@@ -1801,13 +1801,13 @@ useVisibilityPolling(() => {
'detail detail'
'bar bar';
align-items: center;
gap: var(--fonu-space-2);
gap: var(--havline-space-2);
font-size: 13px;
}
.res-row__label {
grid-area: label;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.res-row__detail {
@@ -1816,13 +1816,13 @@ useVisibilityPolling(() => {
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.res-row__value {
grid-area: value;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
font-variant-numeric: tabular-nums;
}
@@ -1831,7 +1831,7 @@ useVisibilityPolling(() => {
display: block;
height: 4px;
border-radius: 2px;
background: var(--fonu-border);
background: var(--havline-border);
overflow: hidden;
}
@@ -1839,26 +1839,26 @@ useVisibilityPolling(() => {
display: block;
height: 100%;
border-radius: 2px;
background: var(--fonu-brand);
background: var(--havline-brand);
transition: width 0.3s ease;
}
.res-bar--warn .res-bar__fill {
background: var(--fonu-warning);
background: var(--havline-warning);
}
.res-bar--danger .res-bar__fill {
background: var(--fonu-error);
background: var(--havline-error);
}
.res-meta {
grid-column: 1 / -1;
display: flex;
gap: var(--fonu-space-4);
padding-top: var(--fonu-space-2);
border-top: 1px solid var(--fonu-border);
gap: var(--havline-space-4);
padding-top: var(--havline-space-2);
border-top: 1px solid var(--havline-border);
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
@media (max-width: 900px) {
@@ -1875,13 +1875,13 @@ useVisibilityPolling(() => {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 76px 76px 92px;
align-items: center;
gap: var(--fonu-space-2);
gap: var(--havline-space-2);
font-size: 13px;
}
.cert-row--head {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.cert-domain {
@@ -1892,15 +1892,15 @@ useVisibilityPolling(() => {
.cert-muted {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.cert-days--warn {
color: var(--fonu-warning);
color: var(--havline-warning);
}
.cert-days--error {
color: var(--fonu-error);
color: var(--havline-error);
font-weight: 600;
}
@@ -1913,178 +1913,178 @@ useVisibilityPolling(() => {
/* ── 以下样式随「公网服务端」列表与弹窗从内网穿透页迁入(2026-09-24)── */
.section-actions, .switch-row, .modal-actions, .logs-toolbar { display: flex; align-items: center; gap: var(--fonu-space-2); flex-wrap: wrap; }
.section-actions, .switch-row, .modal-actions, .logs-toolbar { display: flex; align-items: center; gap: var(--havline-space-2); flex-wrap: wrap; }
.logs-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: var(--fonu-space-3); }
.logs-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: var(--havline-space-3); }
.frp-proxy-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.frp-proxy-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); }
.frp-proxy-modal .proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; }
.frp-proxy-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; }
.frp-proxy-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); padding: var(--havline-space-5) var(--havline-space-5) 0; }
.frp-proxy-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: var(--fonu-space-3) var(--fonu-space-5) 0; border-bottom: 1px solid var(--fonu-border); flex-shrink: 0; }
.frp-proxy-modal .proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--fonu-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
.frp-proxy-modal .proxy-modal__tab:hover { color: var(--fonu-text); }
.frp-proxy-modal .proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
.frp-proxy-modal .proxy-modal__tabbar { display: flex; gap: var(--havline-space-5); padding: var(--havline-space-3) var(--havline-space-5) 0; border-bottom: 1px solid var(--havline-border); flex-shrink: 0; }
.frp-proxy-modal .proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--havline-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
.frp-proxy-modal .proxy-modal__tab:hover { color: var(--havline-text); }
.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(--fonu-space-4) var(--fonu-space-5) 0; }
.frp-proxy-modal .proxy-modal__pane > .modal-actions { padding-bottom: var(--fonu-space-5); }
.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; }
.frp-server-modal__basic .form-grid { grid-template-columns: 1fr; }
.form-section { padding: var(--fonu-space-4) 0; border-bottom: 1px solid var(--fonu-border); }
.form-section { padding: var(--havline-space-4) 0; border-bottom: 1px solid var(--havline-border); }
.form-section:first-child { padding-top: 0; }
.form-section:last-of-type { border-bottom: 0; }
.form-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 var(--fonu-space-4); font-size: 16px; }
.form-section h3 :deep(.n-icon) { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--fonu-radius-sm); background: var(--fonu-brand); color: #fff; font-size: 15px; }
.form-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 var(--havline-space-4); font-size: 16px; }
.form-section h3 :deep(.n-icon) { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--havline-radius-sm); background: var(--havline-brand); color: #fff; font-size: 15px; }
.narrow-input { max-width: 200px; }
/* 统一卡片式弹窗外壳(运行日志 / 导出配置 / frps 同源配置):与 .proxy-modal 体系一致的头部 + 滚动区 + 页脚 */
.frp-card-modal { display: flex; flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.frp-card-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; flex-shrink: 0; }
.frp-card-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--fonu-text); }
.frp-card-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.frp-card-modal .modal-footer { display: flex; justify-content: flex-end; gap: var(--fonu-space-3); padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); border-top: 1px solid var(--fonu-border); margin-top: var(--fonu-space-2); flex-shrink: 0; }
.frp-card-modal { display: flex; flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: 90vh; background: var(--havline-surface); border-radius: var(--havline-radius); overflow: hidden; box-shadow: var(--havline-shadow-md); }
.frp-card-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); padding: var(--havline-space-5) var(--havline-space-5) 0; flex-shrink: 0; }
.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-detail-card :deep(.n-card-header) { padding: var(--fonu-space-5) var(--fonu-space-5) 0; }
.frp-detail-card :deep(.n-card-header__main) { font-size: 18px; font-weight: 600; color: var(--fonu-text); }
.frp-detail-card :deep(.n-card__content) { padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); }
.frp-detail-card :deep(.n-card-header) { padding: var(--havline-space-5) var(--havline-space-5) 0; }
.frp-detail-card :deep(.n-card-header__main) { font-size: 18px; font-weight: 600; color: var(--havline-text); }
.frp-detail-card :deep(.n-card__content) { padding: var(--havline-space-4) var(--havline-space-5) var(--havline-space-5); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--fonu-space-4); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--havline-space-4); }
.form-grid--wide > :first-child { grid-column: 1 / -1; }
.tls-panel { padding: var(--fonu-space-4); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg-muted); }
.tls-file-field { margin-bottom: var(--fonu-space-4); }
.tls-file-field__head { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); margin-bottom: var(--fonu-space-2); font-size: 14px; font-weight: 500; }
.tls-panel { padding: var(--havline-space-4); border-radius: var(--havline-radius-sm); background: var(--havline-bg-muted); }
.tls-file-field { margin-bottom: var(--havline-space-4); }
.tls-file-field__head { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); margin-bottom: var(--havline-space-2); font-size: 14px; font-weight: 500; }
.file-input { display: none; }
.asset-actions { display: flex; gap: var(--fonu-space-2); margin-top: calc(-1 * var(--fonu-space-2)); }
.advanced-collapse { margin-top: var(--fonu-space-3); }
.switch-row { padding: 4px 0 var(--fonu-space-5); }
.switch-row span:nth-of-type(2) { margin-left: var(--fonu-space-4); }
.asset-actions { display: flex; gap: var(--havline-space-2); margin-top: calc(-1 * var(--havline-space-2)); }
.advanced-collapse { margin-top: var(--havline-space-3); }
.switch-row { padding: 4px 0 var(--havline-space-5); }
.switch-row span:nth-of-type(2) { margin-left: var(--havline-space-4); }
.modal-actions { justify-content: flex-end; }
.logs-box { margin: var(--fonu-space-3) 0 0; padding: var(--fonu-space-4); min-height: 260px; max-height: 480px; overflow: auto; border: 1px solid var(--fonu-border); background: var(--fonu-bg-muted); color: var(--fonu-text); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--fonu-mono); }
.logs-box { margin: var(--havline-space-3) 0 0; padding: var(--havline-space-4); min-height: 260px; max-height: 480px; overflow: auto; border: 1px solid var(--havline-border); background: var(--havline-bg-muted); color: var(--havline-text); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--havline-mono); }
.table-muted { margin-top: 4px; color: var(--fonu-text-muted); font-size: 12px; }
.type-chip { font-family: var(--fonu-mono); font-size: 12px; color: var(--fonu-brand-text); }
.table-muted { margin-top: 4px; color: var(--havline-text-muted); font-size: 12px; }
.type-chip { font-family: var(--havline-mono); font-size: 12px; color: var(--havline-brand-text); }
.server-detail { display: grid; gap: var(--fonu-space-4); }
.server-detail__top, .server-detail__section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.server-detail { display: grid; gap: var(--havline-space-4); }
.server-detail__top, .server-detail__section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.server-detail__top h2 { margin: 0; font-size: 24px; }
.server-detail__top p { margin: 4px 0 0; color: var(--fonu-text-secondary); font-size: 13px; }
.server-detail__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
.server-detail__panel { padding: var(--fonu-space-4); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
.server-detail__panel h3 { margin: 0 0 var(--fonu-space-4); font-size: 16px; }
.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
.server-detail__top p { margin: 4px 0 0; color: var(--havline-text-secondary); font-size: 13px; }
.server-detail__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-4); }
.server-detail__panel { padding: var(--havline-space-4); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-bg); }
.server-detail__panel h3 { margin: 0 0 var(--havline-space-4); font-size: 16px; }
.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-4); }
.detail-fields--single { grid-template-columns: 1fr; }
.detail-fields > div { display: grid; gap: 5px; min-width: 0; }
.detail-fields label, .detail-stats label { color: var(--fonu-text-secondary); font-size: 12px; }
.detail-fields label, .detail-stats label { color: var(--havline-text-secondary); font-size: 12px; }
.detail-fields strong, .detail-fields span { overflow-wrap: anywhere; }
.detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); }
.detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
.detail-muted { color: var(--fonu-text-secondary); font-size: 12px; }
.detail-subgroup { margin-top: var(--havline-space-3); padding-top: var(--havline-space-3); border-top: 1px solid var(--havline-border); }
.detail-subgroup__title { margin-bottom: var(--havline-space-2); font-size: 12px; font-weight: 600; color: var(--havline-text-secondary); }
.detail-muted { color: var(--havline-text-secondary); font-size: 12px; }
.diag-checks { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.server-detail__location { display: flex; align-items: center; gap: 5px; }
.detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
.detail-stats > div { display: grid; gap: 5px; }
.detail-stats strong { font-size: 20px; color: var(--fonu-success); }
.server-status-summary { display: grid; grid-template-columns: minmax(150px, 1.3fr) repeat(3, minmax(110px, 1fr)); align-items: center; gap: var(--fonu-space-4); }
.detail-stats strong { font-size: 20px; color: var(--havline-success); }
.server-status-summary { display: grid; grid-template-columns: minmax(150px, 1.3fr) repeat(3, minmax(110px, 1fr)); align-items: center; gap: var(--havline-space-4); }
.server-status-summary__item { display: grid; gap: 6px; min-width: 0; }
.server-status-summary__item label { color: var(--fonu-text-secondary); font-size: 12px; }
.server-status-summary__item strong { color: var(--fonu-success); font-size: 18px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.server-status-summary__item--state { display: flex; align-items: center; min-height: 28px; padding-right: var(--fonu-space-4); border-right: 1px solid var(--fonu-border); }
.status-bars { display: grid; grid-template-columns: repeat(30, minmax(0, 1fr)); gap: 4px; margin-top: var(--fonu-space-5); min-height: 36px; }
.status-bar { position: relative; min-width: 0; height: 36px; border-radius: 4px; background: var(--fonu-bg-muted); cursor: help; outline: none; transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease; }
.status-bar--ok { background: linear-gradient(180deg, color-mix(in srgb, var(--fonu-success) 75%, white), color-mix(in srgb, var(--fonu-success) 48%, white)); }
.status-bar--failed { background: color-mix(in srgb, var(--fonu-error) 68%, white); }
.status-bar:hover, .status-bar:focus-visible { z-index: 2; transform: translateY(-2px); filter: brightness(1.1); box-shadow: var(--fonu-shadow-md); }
.status-bar::after { content: attr(data-tooltip); position: absolute; left: 50%; bottom: calc(100% + 9px); z-index: 3; max-width: 240px; padding: 6px 8px; border-radius: 6px; background: var(--fonu-text); color: var(--fonu-surface); font-size: 12px; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 4px); transition: opacity 160ms ease, transform 160ms ease; }
.status-bar::before { content: ''; position: absolute; left: 50%; bottom: calc(100% + 4px); z-index: 3; border: 5px solid transparent; border-top-color: var(--fonu-text); pointer-events: none; opacity: 0; transform: translate(-50%, 4px); transition: opacity 160ms ease, transform 160ms ease; }
.server-status-summary__item label { color: var(--havline-text-secondary); font-size: 12px; }
.server-status-summary__item strong { color: var(--havline-success); font-size: 18px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.server-status-summary__item--state { display: flex; align-items: center; min-height: 28px; padding-right: var(--havline-space-4); border-right: 1px solid var(--havline-border); }
.status-bars { display: grid; grid-template-columns: repeat(30, minmax(0, 1fr)); gap: 4px; margin-top: var(--havline-space-5); min-height: 36px; }
.status-bar { position: relative; min-width: 0; height: 36px; border-radius: 4px; background: var(--havline-bg-muted); cursor: help; outline: none; transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease; }
.status-bar--ok { background: linear-gradient(180deg, color-mix(in srgb, var(--havline-success) 75%, white), color-mix(in srgb, var(--havline-success) 48%, white)); }
.status-bar--failed { background: color-mix(in srgb, var(--havline-error) 68%, white); }
.status-bar:hover, .status-bar:focus-visible { z-index: 2; transform: translateY(-2px); filter: brightness(1.1); box-shadow: var(--havline-shadow-md); }
.status-bar::after { content: attr(data-tooltip); position: absolute; left: 50%; bottom: calc(100% + 9px); z-index: 3; max-width: 240px; padding: 6px 8px; border-radius: 6px; background: var(--havline-text); color: var(--havline-surface); font-size: 12px; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 4px); transition: opacity 160ms ease, transform 160ms ease; }
.status-bar::before { content: ''; position: absolute; left: 50%; bottom: calc(100% + 4px); z-index: 3; border: 5px solid transparent; border-top-color: var(--havline-text); pointer-events: none; opacity: 0; transform: translate(-50%, 4px); transition: opacity 160ms ease, transform 160ms ease; }
.status-bar:hover::after, .status-bar:hover::before, .status-bar:focus-visible::after, .status-bar:focus-visible::before { opacity: 1; transform: translate(-50%, 0); }
.status-bars-empty { display: grid; place-items: center; min-height: 36px; margin-top: var(--fonu-space-5); border: 1px dashed var(--fonu-border); color: var(--fonu-text-muted); font-size: 12px; }
.status-bars-footer { display: flex; justify-content: space-between; margin-top: var(--fonu-space-3); color: var(--fonu-text-muted); font-size: 12px; }
.status-bars-empty { display: grid; place-items: center; min-height: 36px; margin-top: var(--havline-space-5); border: 1px dashed var(--havline-border); color: var(--havline-text-muted); font-size: 12px; }
.status-bars-footer { display: flex; justify-content: space-between; margin-top: var(--havline-space-3); color: var(--havline-text-muted); font-size: 12px; }
.status-bars-footer span:last-child { display: inline-flex; align-items: center; gap: 5px; }
.status-bars-footer i { width: 6px; height: 6px; border-radius: 50%; background: color-mix(in srgb, var(--fonu-success) 35%, white); }
.latency-panel__hint { color: var(--fonu-text-muted); font-size: 12px; }
.detail-status-meta { display: flex; justify-content: space-between; gap: var(--fonu-space-3); margin: -4px 0 var(--fonu-space-3); color: var(--fonu-text-muted); font-size: 12px; }
.detail-status-error { color: var(--fonu-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-status-panel .detail-status-meta { margin: var(--fonu-space-4) 0 0; }
.status-bars-footer i { width: 6px; height: 6px; border-radius: 50%; background: color-mix(in srgb, var(--havline-success) 35%, white); }
.latency-panel__hint { color: var(--havline-text-muted); font-size: 12px; }
.detail-status-meta { display: flex; justify-content: space-between; gap: var(--havline-space-3); margin: -4px 0 var(--havline-space-3); color: var(--havline-text-muted); font-size: 12px; }
.detail-status-error { color: var(--havline-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-status-panel .detail-status-meta { margin: var(--havline-space-4) 0 0; }
.latency-chart { width: 100%; height: 240px; }
.detail-empty { display: grid; place-items: center; height: 240px; color: var(--fonu-text-muted); border: 1px dashed var(--fonu-border); }
.detail-legend-failed { display: inline-block; width: 6px; height: 6px; margin: 0 4px 1px 12px; border-radius: 50%; background: var(--fonu-error); }
.latency-bars { display: flex; align-items: end; gap: 4px; height: 92px; padding: 8px 0; border-bottom: 1px solid var(--fonu-border); }
.latency-bars span { flex: 1; min-width: 5px; max-width: 24px; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, var(--fonu-success), color-mix(in srgb, var(--fonu-success) 55%, white)); }
.latency-bars span.latency-bars__item--bad { background: var(--fonu-warning); }
.detail-chart-footer { display: flex; justify-content: space-between; margin-top: 8px; color: var(--fonu-text-muted); font-size: 12px; }
.detail-chart-footer i { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: var(--fonu-success); }
.detail-status--on { color: var(--fonu-success); }
.detail-status--off { color: var(--fonu-text-muted); }
.detail-logs { margin: 0; max-height: 220px; overflow: auto; padding: var(--fonu-space-3); background: var(--fonu-bg-muted); color: var(--fonu-text-secondary); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--fonu-mono); }
.detail-empty { display: grid; place-items: center; height: 240px; color: var(--havline-text-muted); border: 1px dashed var(--havline-border); }
.detail-legend-failed { display: inline-block; width: 6px; height: 6px; margin: 0 4px 1px 12px; border-radius: 50%; background: var(--havline-error); }
.latency-bars { display: flex; align-items: end; gap: 4px; height: 92px; padding: 8px 0; border-bottom: 1px solid var(--havline-border); }
.latency-bars span { flex: 1; min-width: 5px; max-width: 24px; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, var(--havline-success), color-mix(in srgb, var(--havline-success) 55%, white)); }
.latency-bars span.latency-bars__item--bad { background: var(--havline-warning); }
.detail-chart-footer { display: flex; justify-content: space-between; margin-top: 8px; color: var(--havline-text-muted); font-size: 12px; }
.detail-chart-footer i { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: var(--havline-success); }
.detail-status--on { color: var(--havline-success); }
.detail-status--off { color: var(--havline-text-muted); }
.detail-logs { margin: 0; max-height: 220px; overflow: auto; padding: var(--havline-space-3); background: var(--havline-bg-muted); color: var(--havline-text-secondary); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--havline-mono); }
.frps-compare--warn {
color: var(--fonu-warning);
color: var(--havline-warning);
}
/* 服务端表单沿用内网穿透页的提示排版;本页既有的 .form-hint 服务于详情卡,保持不动 */
.frp-proxy-modal .form-hint { margin: 0 0 var(--fonu-space-4); font-size: 13px; line-height: 1.55; }
.frp-proxy-modal .form-hint { margin: 0 0 var(--havline-space-4); font-size: 13px; line-height: 1.55; }
@media (max-width: 760px) {
.frp-proxy-modal { display: block; max-height: 92vh; overflow-y: auto; }
.frp-proxy-modal .proxy-modal__form { max-height: none; }
.frp-proxy-modal .proxy-modal__scroll { overflow: visible; }
.frp-proxy-modal .proxy-modal__help { width: auto; border-top: 1px solid var(--fonu-border); border-left: 0; }
.frp-proxy-modal .proxy-modal__help { width: auto; border-top: 1px solid var(--havline-border); border-left: 0; }
}
@media (max-width: 700px) {
.server-detail__grid, .detail-fields, .form-grid { grid-template-columns: 1fr; }
.server-status-summary { grid-template-columns: 1fr; }
.server-status-summary__item--state { min-height: 0; padding-right: 0; padding-bottom: var(--fonu-space-3); border-right: 0; border-bottom: 1px solid var(--fonu-border); }
.server-status-summary__item--state { min-height: 0; padding-right: 0; padding-bottom: var(--havline-space-3); border-right: 0; border-bottom: 1px solid var(--havline-border); }
.status-bars { gap: 3px; }
.status-bar { height: 28px; }
.switch-row span:nth-of-type(2) { margin-left: 0; }
}
/* 服务端表单弹窗的说明栏 / 高级折叠 / 服务端选项区:与内网穿透页同源,随弹窗一并迁入 */
.logs-toolbar__filter { width: 200px; }
.frp-proxy-modal .proxy-form-section { margin-top: var(--fonu-space-2); padding: var(--fonu-space-4) 0 0; border-top: 1px solid var(--fonu-border); }
.frp-proxy-modal .proxy-form-section h4 { margin: 0 0 var(--fonu-space-4); font-size: 14px; font-weight: 600; color: var(--fonu-text); }
.frp-proxy-modal__advanced { padding-bottom: var(--fonu-space-4); }
.frp-proxy-modal .proxy-advanced-collapse { width: 100%; border: 1px solid var(--fonu-border); border-radius: 10px; overflow: hidden; background: var(--fonu-surface); }
.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); }
.frp-proxy-modal__advanced { padding-bottom: var(--havline-space-4); }
.frp-proxy-modal .proxy-advanced-collapse { width: 100%; border: 1px solid var(--havline-border); border-radius: 10px; overflow: hidden; background: var(--havline-surface); }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item) { margin: 0 !important; border: 0 !important; border-radius: 0 !important; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item + .n-collapse-item) { border-top: 1px solid var(--fonu-border) !important; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item__header) { min-height: 44px; padding: 12px 14px !important; background: var(--fonu-bg); font-weight: 600; box-sizing: border-box; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item + .n-collapse-item) { border-top: 1px solid var(--havline-border) !important; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item__header) { min-height: 44px; padding: 12px 14px !important; background: var(--havline-bg); font-weight: 600; box-sizing: border-box; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item__content-inner) { padding: 0 14px 14px; }
.frp-proxy-modal .proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--fonu-space-5); background: var(--fonu-bg-muted); border-left: 1px solid var(--fonu-border); color: var(--fonu-text-secondary); font-size: 13px; line-height: 1.65; overflow: auto; }
.frp-proxy-modal .proxy-modal__help h4 { margin: 0 0 var(--fonu-space-3); color: var(--fonu-text); font-size: 14px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--havline-space-5); background: var(--havline-bg-muted); border-left: 1px solid var(--havline-border); color: var(--havline-text-secondary); font-size: 13px; line-height: 1.65; overflow: auto; }
.frp-proxy-modal .proxy-modal__help h4 { margin: 0 0 var(--havline-space-3); color: var(--havline-text); font-size: 14px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__help ol { margin: 0; padding-left: 18px; }
.frp-proxy-modal .proxy-modal__help li + li { margin-top: 10px; }
.frp-proxy-modal .proxy-modal__help strong { color: var(--fonu-text); font-weight: 600; }
.frp-proxy-modal .proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--fonu-space-5); padding: 12px; border-radius: 8px; background: var(--fonu-info-soft); color: var(--fonu-info); font-size: 12px; line-height: 1.6; }
.frp-proxy-modal .proxy-modal__help strong { color: var(--havline-text); font-weight: 600; }
.frp-proxy-modal .proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--havline-space-5); padding: 12px; border-radius: 8px; background: var(--havline-info-soft); color: var(--havline-info); font-size: 12px; line-height: 1.6; }
.frp-proxy-modal .proxy-modal__tip-icon { flex-shrink: 0; margin-top: 1px; font-size: 16px; }
.frp-proxy-modal .proxy-modal__example { margin-top: var(--fonu-space-5); padding-top: var(--fonu-space-4); border-top: 1px solid var(--fonu-border); }
.frp-proxy-modal .proxy-modal__example-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--fonu-space-3); }
.frp-proxy-modal .proxy-modal__example h5 { margin: 0; color: var(--fonu-text); font-size: 13px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__example { margin-top: var(--havline-space-5); padding-top: var(--havline-space-4); border-top: 1px solid var(--havline-border); }
.frp-proxy-modal .proxy-modal__example-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--havline-space-3); }
.frp-proxy-modal .proxy-modal__example h5 { margin: 0; color: var(--havline-text); font-size: 13px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__example p { margin: 4px 0 0; font-size: 12px; line-height: 1.55; }
.frp-proxy-modal .proxy-modal__example pre { margin: var(--fonu-space-3) 0 0; padding: 10px; overflow-x: auto; border-radius: 6px; background: var(--fonu-bg-muted); color: var(--fonu-text); font: 12px/1.6 var(--fonu-mono); white-space: pre; }
.frp-proxy-modal .proxy-modal__example pre { margin: var(--havline-space-3) 0 0; padding: 10px; overflow-x: auto; border-radius: 6px; background: var(--havline-bg-muted); color: var(--havline-text); font: 12px/1.6 var(--havline-mono); white-space: pre; }
/* 左列表 + 右详情:与 DDNS 页(.ddns-layout / .task-sidebar)同构 */
.agent-layout { display: grid; grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); gap: var(--fonu-space-4); align-items: start; }
.agent-layout { display: grid; grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); gap: var(--havline-space-4); align-items: start; }
.agent-detail { min-width: 0; }
.server-sidebar { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--fonu-space-4); border-bottom: 1px solid var(--fonu-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--fonu-space-3); max-height: 720px; overflow: auto; }
.server-sidebar { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--havline-space-4); border-bottom: 1px solid var(--havline-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--havline-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--havline-space-3); max-height: 720px; overflow: auto; }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--fonu-brand-hover); }
.server-item--active { border-color: var(--fonu-brand); box-shadow: 0 0 0 1px var(--fonu-brand-soft); }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius); background: var(--havline-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--havline-brand-hover); }
.server-item--active { border-color: var(--havline-brand); box-shadow: 0 0 0 1px var(--havline-brand-soft); }
.server-item__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--fonu-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--fonu-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-3); margin-top: 6px; font-size: 12px; color: var(--fonu-text-muted); }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--havline-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--havline-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); margin-top: 6px; font-size: 12px; color: var(--havline-text-muted); }
.server-item__meta span { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
.server-item__meta :deep(.n-icon) { flex: 0 0 auto; font-size: 14px; }
.server-item__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.server-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--fonu-space-3); margin-bottom: var(--fonu-space-4); padding: var(--fonu-space-3) var(--fonu-space-4); background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); }
.server-toolbar__info { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fonu-space-3); min-width: 0; }
.server-toolbar__name { font-size: 15px; font-weight: 600; color: var(--fonu-text); }
.server-toolbar__endpoint { font-size: 12.5px; color: var(--fonu-text-secondary); }
.server-toolbar__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-2); }
.server-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-3); margin-bottom: var(--havline-space-4); padding: var(--havline-space-3) var(--havline-space-4); background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); }
.server-toolbar__info { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--havline-space-3); min-width: 0; }
.server-toolbar__name { font-size: 15px; font-weight: 600; color: var(--havline-text); }
.server-toolbar__endpoint { font-size: 12.5px; color: var(--havline-text-secondary); }
.server-toolbar__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
@media (max-width: 1100px) {
.agent-layout { grid-template-columns: 1fr; }
+80 -80
View File
@@ -45,7 +45,7 @@
</div>
</div>
<FonuCard flush class="cert-panel">
<HavlineCard flush class="cert-panel">
<div class="cert-toolbar">
<n-input
v-model:value="searchQuery"
@@ -117,7 +117,7 @@
title="没有匹配的证书"
description="试试调整搜索关键词或筛选条件。"
/>
</FonuCard>
</HavlineCard>
<div class="tip-box">
<n-icon :component="InformationCircleOutline" class="tip-box__icon" />
@@ -402,7 +402,7 @@
</n-form-item>
<n-form-item
label="私钥文件路径"
feedback="填写 Fonu 服务可访问的路径,导入后会复制到证书目录"
feedback="填写 Havline 服务可访问的路径,导入后会复制到证书目录"
:show-feedback="true"
>
<n-input v-model:value="importForm.key_path" placeholder=".data/certs/example.com/privatekey.pem" />
@@ -459,7 +459,7 @@ import type {
DDNSConfig,
} from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import buypassLogo from '../assets/brand/ca/buypass.png'
@@ -987,7 +987,7 @@ async function load() {
try {
records.value = asList(await api.listCertificates())
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
loadError.value = error instanceof Error ? error.message : '请检查 Havline 服务是否正常运行'
} finally {
loading.value = false
}
@@ -1190,13 +1190,13 @@ onUnmounted(stopApplyStream)
min-height: 220px;
max-height: 320px;
overflow: auto;
padding: var(--fonu-space-4);
border-radius: var(--fonu-radius);
border: 1px solid var(--fonu-border);
font-family: var(--fonu-mono);
padding: var(--havline-space-4);
border-radius: var(--havline-radius);
border: 1px solid var(--havline-border);
font-family: var(--havline-mono);
font-size: 12px;
line-height: 1.7;
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
}
html[data-theme='dark'] .apply-log-box,
@@ -1215,11 +1215,11 @@ html.dark .apply-log-box {
.apply-log-empty {
text-align: center;
color: #94a3b8;
padding: var(--fonu-space-5) 0;
padding: var(--havline-space-5) 0;
}
.apply-result {
margin-top: var(--fonu-space-2);
margin-top: var(--havline-space-2);
}
.apply-result-detail {
@@ -1238,23 +1238,23 @@ html.dark .apply-log-box {
.apply-result-k {
display: inline-block;
min-width: 72px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
margin-right: 8px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.stat-card {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
padding: var(--fonu-space-4) var(--fonu-space-5);
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;
}
@@ -1269,7 +1269,7 @@ html.dark .apply-log-box {
font-size: 30px;
font-weight: 700;
line-height: 1.1;
color: var(--fonu-text);
color: var(--havline-text);
letter-spacing: -0.03em;
}
@@ -1281,20 +1281,20 @@ html.dark .apply-log-box {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.stat-card__sub {
margin-top: 4px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.cert-panel {
min-width: 0;
}
.cert-panel :deep(.fonu-card__body) {
.cert-panel :deep(.havline-card__body) {
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
@@ -1303,9 +1303,9 @@ html.dark .apply-log-box {
.cert-toolbar {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
padding: var(--fonu-space-4);
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-3);
padding: var(--havline-space-4);
border-bottom: 1px solid var(--havline-border);
flex-wrap: wrap;
}
@@ -1328,7 +1328,7 @@ html.dark .apply-log-box {
.cert-loading {
display: flex;
justify-content: center;
padding: var(--fonu-space-6) 0;
padding: var(--havline-space-6) 0;
}
.cert-empty-actions {
@@ -1352,31 +1352,31 @@ html.dark .apply-log-box {
.cert-table :deep(.cert-name-cell__title) {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.cert-table :deep(.cert-name-cell__sub) {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.cert-pagination {
display: flex;
justify-content: flex-end;
padding: var(--fonu-space-3) var(--fonu-space-4) var(--fonu-space-4);
padding: var(--havline-space-3) var(--havline-space-4) var(--havline-space-4);
}
.cert-name-cell__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.cert-name-cell__sub {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.cert-table :deep(.issuer-cell) {
@@ -1401,7 +1401,7 @@ html.dark .apply-log-box {
.cert-table :deep(.issuer-badge--zero) { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
.cert-table :deep(.issuer-badge--buypass) { background: rgba(37, 99, 235, 0.15); color: #1d4ed8; }
.cert-table :deep(.issuer-badge--imported) { background: rgba(100, 116, 139, 0.15); color: #64748b; }
.cert-table :deep(.issuer-badge--default) { background: var(--fonu-bg); color: var(--fonu-text-secondary); }
.cert-table :deep(.issuer-badge--default) { background: var(--havline-bg); color: var(--havline-text-secondary); }
.cert-table :deep(.issuer-logo) {
display: inline-flex;
@@ -1427,27 +1427,27 @@ html.dark .apply-log-box {
.cert-table :deep(.issuer-cell__label) {
font-size: 13px;
color: var(--fonu-text);
color: var(--havline-text);
white-space: nowrap;
}
.expire-cell__date {
font-size: 13px;
color: var(--fonu-text);
color: var(--havline-text);
}
.expire-cell__days {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.text-muted { color: var(--fonu-text-muted); }
.text-muted { color: var(--havline-text-muted); }
.tip-box {
display: flex;
gap: 10px;
margin-top: var(--fonu-space-4);
margin-top: var(--havline-space-4);
padding: 14px 16px;
border-radius: 12px;
background: rgba(59, 130, 246, 0.08);
@@ -1466,7 +1466,7 @@ html.dark .apply-log-box {
.modal-header__title {
font-size: 18px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
line-height: 1.3;
}
@@ -1474,7 +1474,7 @@ html.dark .apply-log-box {
margin-top: 4px;
font-size: 13px;
font-weight: 400;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
line-height: 1.5;
}
@@ -1499,7 +1499,7 @@ html.dark .apply-log-box {
.ca-field {
display: flex;
flex-direction: column;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
width: 100%;
}
@@ -1515,7 +1515,7 @@ html.dark .apply-log-box {
.domains-input :deep(textarea),
.pem-input :deep(textarea) {
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
font-size: 13px;
line-height: 1.6;
}
@@ -1540,7 +1540,7 @@ html.dark .apply-log-box {
width: 100%;
font-size: 12px;
line-height: 1.5;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.ca-hint--warn {
@@ -1551,7 +1551,7 @@ html.dark .apply-log-box {
display: flex;
align-items: center;
gap: 0;
margin-bottom: var(--fonu-space-5);
margin-bottom: var(--havline-space-5);
padding: 0 4px;
}
@@ -1559,13 +1559,13 @@ html.dark .apply-log-box {
display: flex;
align-items: center;
gap: 8px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
font-size: 13px;
white-space: nowrap;
}
.apply-step--active {
color: var(--fonu-text);
color: var(--havline-text);
font-weight: 600;
}
@@ -1596,7 +1596,7 @@ html.dark .apply-log-box {
flex: 1;
height: 2px;
margin: 0 12px;
background: var(--fonu-border);
background: var(--havline-border);
min-width: 24px;
}
@@ -1618,9 +1618,9 @@ html.dark .apply-log-box {
align-items: center;
gap: 10px;
padding: 10px 10px 12px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 12px;
background: var(--fonu-surface);
background: var(--havline-surface);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, box-shadow 0.15s;
@@ -1688,68 +1688,68 @@ html.dark .apply-log-box {
.ca-card__fallback {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.ca-card__sub {
width: 100%;
font-size: 12px;
line-height: 1.4;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.apply-auto-renew {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
padding: 14px 16px;
margin-top: 4px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 12px;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.apply-auto-renew__title {
font-size: 14px;
font-weight: 500;
color: var(--fonu-text);
color: var(--havline-text);
}
.apply-auto-renew__sub {
margin-top: 4px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.5;
}
.verify-step__intro {
margin: 0 0 var(--fonu-space-4);
margin: 0 0 var(--havline-space-4);
font-size: 13px;
line-height: 1.6;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.verify-empty {
padding: var(--fonu-space-5);
padding: var(--havline-space-5);
text-align: center;
border: 1px dashed var(--fonu-border);
border: 1px dashed var(--havline-border);
border-radius: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
font-size: 13px;
line-height: 1.6;
}
.verify-empty__sub {
margin-top: 8px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
font-size: 12px;
}
.verify-options {
display: flex;
flex-direction: column;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.verify-option {
@@ -1758,9 +1758,9 @@ html.dark .apply-log-box {
gap: 14px;
width: 100%;
padding: 14px 16px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 12px;
background: var(--fonu-surface);
background: var(--havline-surface);
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
@@ -1783,7 +1783,7 @@ html.dark .apply-log-box {
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: var(--fonu-bg);
background: var(--havline-bg);
overflow: hidden;
}
@@ -1801,26 +1801,26 @@ html.dark .apply-log-box {
.verify-option__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.verify-option__sub {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.verify-option__zones {
margin-top: 6px;
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.verify-option__check {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--fonu-border);
border: 2px solid var(--havline-border);
flex-shrink: 0;
}
@@ -1831,29 +1831,29 @@ html.dark .apply-log-box {
}
.apply-done-step__hint {
margin: 0 0 var(--fonu-space-3);
margin: 0 0 var(--havline-space-3);
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.file-btn {
margin-top: var(--fonu-space-2);
margin-top: var(--havline-space-2);
}
.domain-preview {
display: flex;
flex-wrap: wrap;
gap: var(--fonu-space-2);
gap: var(--havline-space-2);
min-height: 28px;
padding: var(--fonu-space-3);
border: 1px dashed var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
padding: var(--havline-space-3);
border: 1px dashed var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.domain-preview__empty {
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.file-input {
+39 -39
View File
@@ -27,7 +27,7 @@
</StatCard>
</div>
<FonuCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
<HavlineCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
<div class="section-body">
<n-alert v-if="tunnel.managed" type="info" :bordered="false">
这是自动托管隧道。暴露域名与回源请到
@@ -38,9 +38,9 @@
<n-button type="primary" @click="basicVisible = true">编辑基础配置</n-button>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="section-card" title="路由" subtitle="流量通过以下路由到应用程序和服务">
<HavlineCard class="section-card" title="路由" subtitle="流量通过以下路由到应用程序和服务">
<div class="section-body">
<div class="topology-canvas" :style="{ height: `${topologyHeight}px` }">
<div class="topology-graph">
@@ -142,9 +142,9 @@
<strong class="mono">{{ routeData?.catch_all || 'http_status:404' }}</strong>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
<HavlineCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
<div class="section-body">
<n-space>
<n-button type="success" :disabled="runtimeState === 'connected'" @click="runAction('启动')">启动</n-button>
@@ -152,7 +152,7 @@
<n-button @click="runAction('重启')">重启</n-button>
</n-space>
</div>
</FonuCard>
</HavlineCard>
</template>
<n-modal v-model:show="basicVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="基础配置">
@@ -187,7 +187,7 @@
</n-form-item>
<div class="config-form__footer">
<n-switch v-model:value="form.autoStart" />
<span>随 Fonu 自动启动</span>
<span>随 Havline 自动启动</span>
</div>
</n-form>
<template #footer>
@@ -242,7 +242,7 @@ import { NAlert, NButton, NForm, NFormItem, NIcon, NInput, NModal, NPopconfirm,
import { AddOutline, ArrowBackOutline, CloudOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline, TimeOutline } from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { CfRouteView, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfTunnelRoutes, CfZone } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import EmptyState from '../components/EmptyState.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -550,20 +550,20 @@ onMounted(load)
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.section-card + .section-card { margin-top: var(--fonu-space-4); }
.section-card + .section-card { margin-top: var(--havline-space-4); }
.config-basic-action { display: flex; justify-content: flex-end; }
.topology-canvas {
position: relative;
width: 100%;
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
overflow-x: auto;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background-color: var(--fonu-bg);
background-image: radial-gradient(var(--fonu-border) 0.8px, transparent 0.8px);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background-color: var(--havline-bg);
background-image: radial-gradient(var(--havline-border) 0.8px, transparent 0.8px);
background-size: 10px 10px;
}
.topology-graph {
@@ -584,7 +584,7 @@ onMounted(load)
}
.topology-domain__name {
white-space: nowrap;
color: var(--fonu-brand-text);
color: var(--havline-brand-text);
text-decoration: underline;
}
.topology-node-copy {
@@ -602,7 +602,7 @@ onMounted(load)
}
.topology-lines path {
fill: none;
stroke: var(--fonu-border);
stroke: var(--havline-border);
stroke-width: 1.5;
}
.topology-add {
@@ -619,10 +619,10 @@ onMounted(load)
align-items: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 999px;
background: var(--fonu-surface);
box-shadow: var(--fonu-shadow);
background: var(--havline-surface);
box-shadow: var(--havline-shadow);
white-space: nowrap;
}
.topology-exit { left: 320px; min-width: 100px; }
@@ -638,15 +638,15 @@ onMounted(load)
min-width: 260px;
height: 44px;
padding: 7px 8px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-surface);
color: var(--fonu-text-secondary);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
color: var(--havline-text-secondary);
font-size: 12px;
white-space: nowrap;
}
.topology-service .mono { white-space: nowrap; }
.topology-service :deep(.n-icon) { flex: none; color: var(--fonu-brand); }
.topology-service :deep(.n-icon) { flex: none; color: var(--havline-brand); }
.config-form { max-width: 780px; }
.config-form__grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.config-origin {
@@ -662,7 +662,7 @@ onMounted(load)
align-items: center;
justify-content: center;
min-width: 22px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
white-space: nowrap;
}
.config-form__footer { display: flex; align-items: center; gap: 10px; font-size: 13px; }
@@ -672,21 +672,21 @@ onMounted(load)
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--fonu-space-4);
margin: 0 0 var(--havline-space-4);
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.hostname-preview span { color: var(--fonu-text-muted); font-size: 12px; }
.hostname-preview strong { color: var(--fonu-text); }
.hostname-preview span { color: var(--havline-text-muted); font-size: 12px; }
.hostname-preview strong { color: var(--havline-text); }
.routes-table { overflow-x: auto; }
.routes-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-3);
gap: var(--havline-space-3);
margin-bottom: var(--havline-space-3);
}
.routes-table__head,
.routes-table__row {
@@ -698,11 +698,11 @@ onMounted(load)
padding: 10px 0;
}
.routes-table__actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.routes-table__head { color: var(--fonu-text-muted); font-size: 12px; border-bottom: 1px solid var(--fonu-border); }
.routes-table__row { border-bottom: 1px solid var(--fonu-border); }
.routes-table__head { color: var(--havline-text-muted); font-size: 12px; border-bottom: 1px solid var(--havline-border); }
.routes-table__row { border-bottom: 1px solid var(--havline-border); }
.routes-table__row:last-child { border-bottom: none; }
.routes-empty { padding: 20px 0; color: var(--fonu-text-muted); }
.routes-catchall { margin-top: 12px; padding: 10px 12px; background: var(--fonu-bg); border-radius: var(--fonu-radius-sm); font-size: 13px; }
.routes-empty { padding: 20px 0; color: var(--havline-text-muted); }
.routes-catchall { margin-top: 12px; padding: 10px 12px; background: var(--havline-bg); border-radius: var(--havline-radius-sm); font-size: 13px; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.config-form__grid { grid-template-columns: 1fr; }
+42 -42
View File
@@ -10,7 +10,7 @@
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
<div class="cf-global__text">
<strong>全局托管</strong>
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Fonu 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Havline 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
</div>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div>
@@ -25,7 +25,7 @@
Cloudflare Tunnel 适合 NAS 面板、相册和小型 Web 服务。免费套餐不适合持续大文件或视频分发,请不要把它当作大流量媒体出口。
</n-alert>
<FonuCard title="隧道列表" subtitle="托管隧道从「反向代理」规则的 Cloudflare 出口自动派生;手工隧道仍在 Cloudflare 面板管理">
<HavlineCard title="隧道列表" subtitle="托管隧道从「反向代理」规则的 Cloudflare 出口自动派生;手工隧道仍在 Cloudflare 面板管理">
<div v-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-else class="cf-table">
<div class="cf-table__head">
@@ -73,12 +73,12 @@
</div>
</div>
</div>
</FonuCard>
</HavlineCard>
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="新建隧道">
<div class="cf-modal__body">
<n-alert type="info" :bordered="false" class="cf-mode-alert">
隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。
隧道由 Cloudflare API 创建;Havline 负责本地配置、进程、日志与健康。
</n-alert>
<n-alert v-if="!appSettings.account_id || !appSettings.api_token_configured" type="warning" :bordered="false" class="cf-mode-alert">
请先在「应用配置」中填写 Account ID 与 API Token,再创建隧道。
@@ -129,7 +129,7 @@
</n-alert>
<n-divider title-placement="left">运行</n-divider>
<n-form label-placement="top">
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
<n-form-item label="随 Havline 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
</n-form>
<p class="cf-hint">网络调优统一在「应用配置」中管理,保存后会应用到新建隧道。</p>
</div>
@@ -182,7 +182,7 @@
</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 保护。Fonu 只做只读检测,不代配策略。' }}
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。Havline 只做只读检测,不代配策略。' }}
</n-alert>
</n-tab-pane>
</n-tabs>
@@ -236,7 +236,7 @@
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
<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-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top">
@@ -280,7 +280,7 @@ import { useRouter } from 'vue-router'
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
import { api, asList } from '../api/client'
import type { CfAccessStatus, CfAppSettings, CfAppSettingsInput, CfDiagnosticsResult, CfDriftResult, CfMirror, CfPreflightResult, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfZone } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
@@ -870,18 +870,18 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: 12px var(--fonu-space-5);
margin-bottom: var(--fonu-space-4);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-surface);
gap: var(--havline-space-4);
padding: 12px var(--havline-space-5);
margin-bottom: var(--havline-space-4);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-surface);
}
.cf-global--off { border-color: var(--fonu-warning); background: var(--fonu-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(--fonu-text-secondary); }
.cf-alert { margin-bottom: var(--fonu-space-4); }
.cf-empty { padding: 24px 0; color: var(--fonu-text-muted); }
.cf-global__text span { color: var(--havline-text-secondary); }
.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; }
.cf-table__head,
.cf-table__row {
@@ -893,11 +893,11 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
padding: 10px 0;
}
.cf-table__head {
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
font-size: 12px;
border-bottom: 1px solid var(--fonu-border);
border-bottom: 1px solid var(--havline-border);
}
.cf-table__row { border-bottom: 1px solid var(--fonu-border); }
.cf-table__row { border-bottom: 1px solid var(--havline-border); }
.cf-table__row:last-child { border-bottom: none; }
.cf-table__tunnel { min-width: 0; }
.cf-table__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
@@ -906,21 +906,21 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
padding: 12px 0;
border-bottom: 1px solid var(--fonu-border);
border-bottom: 1px solid var(--havline-border);
}
.cf-row:last-child { border-bottom: none; }
.cf-row__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cf-row__actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cf-meta { font-size: 12px; color: var(--fonu-text-secondary); }
.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-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(--fonu-border); border-radius: var(--fonu-radius-sm); }
.cf-preflight__row span { flex: 1; min-width: 0; color: var(--fonu-text-secondary); font-size: 12.5px; }
.cf-managed-field { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--fonu-text-muted); }
.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); }
.cf-preflight__row span { flex: 1; min-width: 0; color: var(--havline-text-secondary); font-size: 12.5px; }
.cf-managed-field { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--havline-text-muted); }
.cf-rule-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.cf-rule-row__subdomain,
.cf-rule-row__domain { min-width: 0; }
@@ -928,35 +928,35 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--fonu-space-4);
margin: 0 0 var(--havline-space-4);
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.cf-hostname-preview span { color: var(--fonu-text-muted); font-size: 12px; }
.cf-hostname-preview strong { color: var(--fonu-text); }
.cf-hostname-preview span { color: var(--havline-text-muted); font-size: 12px; }
.cf-hostname-preview strong { color: var(--havline-text); }
.cf-origin-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.cf-origin-row__type { width: 126px; flex: none; }
.cf-origin-row__scheme { color: var(--fonu-text-muted); flex: none; }
.cf-origin-row__scheme { color: var(--havline-text-muted); flex: none; }
.cf-diagnostics { display: grid; gap: 8px; margin-top: 10px; }
.cf-diagnostic-row { display: flex; align-items: flex-start; gap: 10px; font-size: 12.5px; line-height: 1.5; }
.cf-detail__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 12px; }
.cf-stat { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
.cf-stat label { font-size: 12px; color: var(--fonu-text-muted); }
.cf-stat { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-bg); }
.cf-stat label { font-size: 12px; color: var(--havline-text-muted); }
.cf-stat strong { font-size: 15px; }
.cf-code-toolbar { display: flex; justify-content: flex-end; margin-bottom: 6px; }
.cf-binary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }
.cf-binary > div { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
.cf-binary label { font-size: 12px; color: var(--fonu-text-muted); }
.cf-hint { font-size: 12px; color: var(--fonu-text-muted); }
.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-pre {
max-height: 240px;
overflow: auto;
padding: 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
+97 -97
View File
@@ -5,7 +5,7 @@
<section class="hero-banner">
<div class="hero-content">
<h1 class="hero-banner__title">{{ greetingText }}</h1>
<p class="hero-banner__desc">Fonu 正在为你的 NAS 提供稳定、安全的访问服务。</p>
<p class="hero-banner__desc">Havline 正在为你的 NAS 提供稳定、安全的访问服务。</p>
<div class="hero-banner__badges">
<span class="hero-banner__badge"><n-icon :component="CheckmarkCircleOutline" /> 简单易用</span>
<span class="hero-banner__badge"><n-icon :component="FlashOutline" /> 安全稳定</span>
@@ -60,7 +60,7 @@
<template #value>{{ serviceHealth.value }}</template>
<p class="stat-desc">{{ serviceHealth.desc }}</p>
<div class="stat-foot">
<div class="stat-foot__line">Fonu · Nginx · DDNS · 证书</div>
<div class="stat-foot__line">Havline · Nginx · DDNS · 证书</div>
<div class="stat-foot__muted">
运行 {{ formatUptime(status?.uptime_seconds ?? 0) }} · 上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}
</div>
@@ -80,7 +80,7 @@
</div>
<div class="metrics-row">
<FonuCard class="panel requests-panel">
<HavlineCard class="panel requests-panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
@@ -98,9 +98,9 @@
<MiniBarChart :values="hourlyBars.values" :labels="hourlyBars.labels" />
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="panel traffic-panel">
<HavlineCard class="panel traffic-panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="SwapVerticalOutline" class="panel-title__icon panel-title__icon--teal" />
@@ -154,9 +154,9 @@
</ul>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="panel trend-card">
<HavlineCard class="panel trend-card">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="StatsChartOutline" class="panel-title__icon panel-title__icon--teal" />
@@ -178,12 +178,12 @@
<div class="panel__body">
<MetricsTrendPanel :hours="trendHours" />
</div>
</FonuCard>
</HavlineCard>
</div>
<div class="ops-row">
<FonuCard class="panel perf-panel">
<HavlineCard class="panel perf-panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
@@ -219,9 +219,9 @@
<span class="perf-value" :class="{ 'perf-value--error': errorToday > 0 }">{{ errorTodayLabel }}</span>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="panel">
<HavlineCard class="panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="ServerOutline" class="panel-title__icon panel-title__icon--teal" />
@@ -243,9 +243,9 @@
<span class="perf-value">{{ item.value }}</span>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="panel">
<HavlineCard class="panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="GlobeOutline" class="panel-title__icon panel-title__icon--teal" />
@@ -268,9 +268,9 @@
<span class="perf-value">{{ item.value }}</span>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard class="panel">
<HavlineCard class="panel">
<div class="panel__head">
<div class="panel-title">
<n-icon :component="ShieldCheckmarkOutline" class="panel-title__icon panel-title__icon--teal" />
@@ -296,11 +296,11 @@
<span class="perf-value">{{ row.value }}</span>
</div>
</div>
</FonuCard>
</HavlineCard>
</div>
<div class="bottom-row">
<FonuCard flush class="bottom-card">
<HavlineCard flush class="bottom-card">
<template #title>
<n-icon :component="ListOutline" class="card-title-icon" />
<span>最近访问</span>
@@ -322,9 +322,9 @@
/>
<EmptyState v-else title="暂无访问记录" description="产生访问后这里会显示最近请求。" />
</div>
</FonuCard>
</HavlineCard>
<FonuCard flush class="bottom-card">
<HavlineCard flush class="bottom-card">
<template #title>
<n-icon :component="DocumentTextOutline" class="card-title-icon" />
<span>最新日志</span>
@@ -346,7 +346,7 @@
/>
<EmptyState v-else title="暂无系统日志" description="应用运行后会产生日志。" />
</div>
</FonuCard>
</HavlineCard>
</div>
</n-spin>
</template>
@@ -394,7 +394,7 @@ import type {
SystemLogEntry,
} from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import MiniBarChart from '../components/MiniBarChart.vue'
import MiniTrafficChart from '../components/MiniTrafficChart.vue'
@@ -442,7 +442,7 @@ type RatePoint = { at: number; upload: number; download: number }
const rateHistory = ref<RatePoint[]>([])
const RATE_HISTORY_MS = 5 * 60 * 1000
// 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数)
// 实时流量的数据源:本机(Havline 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数)
type TrafficSource = 'local' | 'public'
type TrafficRow = { name: string; uploadRate: number; downloadRate: number }
const trafficSource = ref<TrafficSource>('local')
@@ -702,7 +702,7 @@ const requestTrend = computed(() => {
const healthItems = computed(() => [
{
name: 'Fonu',
name: 'Havline',
desc: '应用核心服务',
meta: `运行 ${formatUptime(status.value?.uptime_seconds ?? 0)}`,
status: 'running',
@@ -1145,7 +1145,7 @@ async function load() {
})
.catch(() => {})
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
loadError.value = error instanceof Error ? error.message : '请检查 Havline 服务是否正常运行'
message.error('加载仪表盘失败')
} finally {
loading.value = false
@@ -1165,11 +1165,11 @@ useVisibilityPolling(pollCIDR, 2000, { enabled: cidrPolling })
.hero-banner {
position: relative;
overflow: hidden;
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
min-height: 208px;
height: 220px;
border-radius: 16px;
box-shadow: var(--fonu-shadow);
box-shadow: var(--havline-shadow);
border: 1px solid rgba(16, 185, 129, 0.08);
background: linear-gradient(100deg, #f4fffc 0%, #f1fbff 55%, #eef9ff 100%);
}
@@ -1215,14 +1215,14 @@ html.dark .hero-art {
margin: 0;
font-size: 30px;
font-weight: 700;
color: var(--fonu-text);
color: var(--havline-text);
letter-spacing: -0.03em;
}
.hero-banner__desc {
margin: 10px 0 0;
font-size: 14px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
max-width: 520px;
line-height: 1.65;
}
@@ -1241,14 +1241,14 @@ html.dark .hero-art {
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
color: var(--fonu-text-secondary);
background: var(--fonu-surface);
color: var(--havline-text-secondary);
background: var(--havline-surface);
border: 1px solid rgba(16, 185, 129, 0.14);
}
.hero-banner__badge .n-icon {
font-size: 16px;
color: var(--fonu-brand);
color: var(--havline-brand);
}
@media (max-width: 768px) {
@@ -1284,28 +1284,28 @@ html.dark .hero-art {
.stats-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.stat-primary {
font-size: 22px;
font-weight: 700;
color: var(--fonu-text);
color: var(--havline-text);
line-height: 1.3;
}
.stat-desc {
margin: 8px 0 0;
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
line-height: 1.5;
}
.stat-foot {
margin-top: var(--fonu-space-3);
padding-top: var(--fonu-space-3);
border-top: 1px solid var(--fonu-border);
margin-top: var(--havline-space-3);
padding-top: var(--havline-space-3);
border-top: 1px solid var(--havline-border);
display: flex;
flex-direction: column;
gap: 4px;
@@ -1316,12 +1316,12 @@ html.dark .hero-art {
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--fonu-text);
color: var(--havline-text);
}
.stat-foot__muted {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.traffic-panel {
@@ -1329,7 +1329,7 @@ html.dark .hero-art {
flex-direction: column;
}
.traffic-panel :deep(.fonu-card__body) {
.traffic-panel :deep(.havline-card__body) {
flex: 1;
display: flex;
flex-direction: column;
@@ -1356,8 +1356,8 @@ html.dark .hero-art {
.traffic-rates {
display: flex;
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-3);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-3);
}
.traffic-rate {
@@ -1384,25 +1384,25 @@ html.dark .hero-art {
.traffic-rate__label {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.traffic-rate__value {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
font-variant-numeric: tabular-nums;
}
.traffic-top {
margin-top: var(--fonu-space-3);
padding-top: var(--fonu-space-3);
border-top: 1px solid var(--fonu-border);
margin-top: var(--havline-space-3);
padding-top: var(--havline-space-3);
border-top: 1px solid var(--havline-border);
}
.traffic-top__title {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.traffic-top__list {
@@ -1416,7 +1416,7 @@ html.dark .hero-art {
.traffic-top__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
font-size: 13px;
}
@@ -1424,37 +1424,37 @@ html.dark .hero-art {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fonu-text);
color: var(--havline-text);
}
.traffic-top__rate {
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
font-variant-numeric: tabular-nums;
}
.ops-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.metrics-row {
display: grid;
grid-template-columns: 3fr 3fr 2fr 2fr;
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
align-items: stretch;
}
/* 趋势卡片占两列:这样「性能」才能跟「系统资源」等一起排到下一行的四列里 */
.trend-card { grid-column: span 2; }
.metrics-row > .fonu-card {
.metrics-row > .havline-card {
height: 100%;
}
.panel :deep(.fonu-card__body) {
.panel :deep(.havline-card__body) {
padding-top: 0;
}
@@ -1462,11 +1462,11 @@ html.dark .hero-art {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
padding: var(--havline-space-5) var(--havline-space-5) 0;
}
.panel__body {
padding: var(--fonu-space-3) var(--fonu-space-5) var(--fonu-space-5);
padding: var(--havline-space-3) var(--havline-space-5) var(--havline-space-5);
}
.panel-title {
@@ -1475,12 +1475,12 @@ html.dark .hero-art {
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.panel-title__icon {
font-size: 18px;
color: var(--fonu-brand);
color: var(--havline-brand);
}
.panel-title__icon--amber {
@@ -1497,14 +1497,14 @@ html.dark .hero-art {
.trend-range { display: flex; gap: 2px; }
.range-tag {
background: var(--fonu-bg) !important;
color: var(--fonu-text-secondary) !important;
background: var(--havline-bg) !important;
color: var(--havline-text-secondary) !important;
}
.metric-hero {
display: flex;
align-items: baseline;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
margin-bottom: 4px;
}
@@ -1512,7 +1512,7 @@ html.dark .hero-art {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--fonu-text);
color: var(--havline-text);
line-height: 1.1;
}
@@ -1521,7 +1521,7 @@ html.dark .hero-art {
flex-direction: column;
}
.requests-panel :deep(.fonu-card__body) {
.requests-panel :deep(.havline-card__body) {
flex: 1;
display: flex;
flex-direction: column;
@@ -1532,7 +1532,7 @@ html.dark .hero-art {
flex-direction: column;
}
.health-panel :deep(.fonu-card__body) {
.health-panel :deep(.havline-card__body) {
flex: 1;
display: flex;
flex-direction: column;
@@ -1556,7 +1556,7 @@ html.dark .hero-art {
justify-self: end;
font-size: 16px;
font-weight: 700;
color: var(--fonu-text);
color: var(--havline-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -1565,17 +1565,17 @@ html.dark .hero-art {
margin-left: 2px;
font-size: 12px;
font-weight: 500;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.perf-value--error {
color: var(--fonu-error);
color: var(--havline-error);
}
.head-actions {
display: flex;
align-items: center;
gap: var(--fonu-space-2);
gap: var(--havline-space-2);
}
.res-bar {
@@ -1583,7 +1583,7 @@ html.dark .hero-art {
height: 4px;
margin-top: 6px;
border-radius: 2px;
background: var(--fonu-border);
background: var(--havline-border);
overflow: hidden;
}
@@ -1591,25 +1591,25 @@ html.dark .hero-art {
display: block;
height: 100%;
border-radius: 2px;
background: var(--fonu-brand);
background: var(--havline-brand);
transition: width 0.3s ease;
}
.res-bar--warn .res-bar__fill {
background: var(--fonu-warning);
background: var(--havline-warning);
}
.res-bar--danger .res-bar__fill {
background: var(--fonu-error);
background: var(--havline-error);
}
.health-panel .panel__head {
padding: var(--fonu-space-3) var(--fonu-space-4) 0;
padding: var(--havline-space-3) var(--havline-space-4) 0;
}
.health-panel .panel__body {
flex: 1;
padding: var(--fonu-space-2) var(--fonu-space-4) var(--fonu-space-3);
padding: var(--havline-space-2) var(--havline-space-4) var(--havline-space-3);
}
.health-list {
@@ -1629,8 +1629,8 @@ html.dark .hero-art {
flex: 1;
min-height: 64px;
padding: 12px 14px;
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
min-width: 0;
}
@@ -1646,8 +1646,8 @@ html.dark .hero-art {
}
.health-item__icon--brand {
background: var(--fonu-brand-soft);
color: var(--fonu-brand);
background: var(--havline-brand-soft);
color: var(--havline-brand);
}
.health-item__icon--blue {
@@ -1666,8 +1666,8 @@ html.dark .hero-art {
}
.health-item__icon--error {
background: color-mix(in srgb, var(--fonu-error) 12%, transparent);
color: var(--fonu-error);
background: color-mix(in srgb, var(--havline-error) 12%, transparent);
color: var(--havline-error);
}
.health-item__main {
@@ -1687,19 +1687,19 @@ html.dark .hero-art {
.health-item__name {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
line-height: 1.2;
}
.health-item__meta {
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
line-height: 1.3;
}
.health-item__detail {
font-size: 11px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.3;
}
@@ -1712,7 +1712,7 @@ html.dark .hero-art {
.bottom-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
align-items: stretch;
}
@@ -1722,7 +1722,7 @@ html.dark .hero-art {
height: 100%;
}
.bottom-card :deep(.fonu-card__body) {
.bottom-card :deep(.havline-card__body) {
flex: 1;
display: flex;
flex-direction: column;
@@ -1732,8 +1732,8 @@ html.dark .hero-art {
padding-bottom: 0;
}
.bottom-card :deep(.fonu-card__header) {
padding-bottom: var(--fonu-space-3);
.bottom-card :deep(.havline-card__header) {
padding-bottom: var(--havline-space-3);
}
.bottom-card__body {
@@ -1764,12 +1764,12 @@ html.dark .hero-art {
.bottom-card__body :deep(.n-data-table-th:first-child),
.bottom-card__body :deep(.n-data-table-td:first-child) {
padding-left: var(--fonu-space-5);
padding-left: var(--havline-space-5);
}
.bottom-card__body :deep(.n-data-table-th:last-child),
.bottom-card__body :deep(.n-data-table-td:last-child) {
padding-right: var(--fonu-space-5);
padding-right: var(--havline-space-5);
}
.nowrap-cell {
@@ -1778,7 +1778,7 @@ html.dark .hero-art {
.card-title-icon {
font-size: 18px;
color: var(--fonu-brand);
color: var(--havline-brand);
}
.card-link {
@@ -1786,17 +1786,17 @@ html.dark .hero-art {
align-items: center;
gap: 2px;
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
text-decoration: none;
font-weight: 500;
}
.card-link:hover {
color: var(--fonu-brand);
color: var(--havline-brand);
}
.mono {
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
}
@media (max-width: 1199px) {
+77 -77
View File
@@ -237,7 +237,7 @@
</div>
</div>
<FonuCard class="detail-section">
<HavlineCard class="detail-section">
<h4 class="detail-section__title">基本设置</h4>
<div class="settings-grid">
<div class="settings-field">
@@ -281,9 +281,9 @@
</div>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard flush class="detail-section">
<HavlineCard flush class="detail-section">
<div class="records-head">
<h4 class="detail-section__title">解析记录({{ displayRecords.length }})</h4>
<n-button
@@ -420,7 +420,7 @@
</tbody>
</table>
</div>
</FonuCard>
</HavlineCard>
<div v-if="detailError" class="alert alert--error">
<n-icon :component="AlertCircleOutline" class="alert__icon" />
@@ -436,7 +436,7 @@
<div class="alert__body">
<div class="alert__title">提示</div>
<div class="alert__text">
每条记录请填写完整域名(如 s.example.com、www.sub.example.com)。同一服务商任务可管理多个不同域名,Fonu 会按同步周期自动检测公网 IP 并更新对应解析记录。
每条记录请填写完整域名(如 s.example.com、www.sub.example.com)。同一服务商任务可管理多个不同域名,Havline 会按同步周期自动检测公网 IP 并更新对应解析记录。
</div>
</div>
</div>
@@ -512,7 +512,7 @@ import volcengineIcon from '../assets/brand/dns/volcengine.png'
import { api, asList } from '../api/client'
import type { DDNSConfig, DDNSDomainRecord } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -923,7 +923,7 @@ async function init() {
try {
await loadCached()
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
loadError.value = error instanceof Error ? error.message : '请检查 Havline 服务是否正常运行'
} finally {
loading.value = false
}
@@ -1222,22 +1222,22 @@ onMounted(init)
<style scoped>
.frp-ddns-alert {
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
}
.stats-row {
display: grid;
grid-template-columns: minmax(0, 1.6fr) repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.stat-card {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
padding: var(--fonu-space-4) var(--fonu-space-5);
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;
}
@@ -1247,8 +1247,8 @@ onMounted(init)
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-3);
gap: var(--havline-space-3);
margin-bottom: var(--havline-space-3);
}
.stat-card__title {
@@ -1257,7 +1257,7 @@ onMounted(init)
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.stat-card__icon { font-size: 18px; }
@@ -1271,7 +1271,7 @@ onMounted(init)
font-size: 30px;
font-weight: 700;
line-height: 1.1;
color: var(--fonu-text);
color: var(--havline-text);
letter-spacing: -0.03em;
}
@@ -1283,14 +1283,14 @@ onMounted(init)
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.stat-card__sub,
.stat-card__foot {
margin-top: 4px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.ip-lines { display: flex; flex-direction: column; gap: 8px; }
@@ -1306,15 +1306,15 @@ onMounted(init)
width: 36px;
flex-shrink: 0;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.ip-line__value {
flex: 1;
min-width: 0;
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
font-size: 13px;
color: var(--fonu-text);
color: var(--havline-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1323,41 +1323,41 @@ onMounted(init)
.loading-wrap {
display: flex;
justify-content: center;
padding: var(--fonu-space-6);
padding: var(--havline-space-6);
}
.ddns-layout {
display: grid;
grid-template-columns: minmax(280px, 34%) minmax(0, 1fr);
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
align-items: start;
}
.task-sidebar {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
overflow: hidden;
}
.task-sidebar__head {
padding: var(--fonu-space-4);
border-bottom: 1px solid var(--fonu-border);
padding: var(--havline-space-4);
border-bottom: 1px solid var(--havline-border);
}
.task-sidebar__head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.task-sidebar__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
flex-shrink: 0;
}
@@ -1365,7 +1365,7 @@ onMounted(init)
display: flex;
flex-direction: column;
gap: 10px;
padding: var(--fonu-space-3);
padding: var(--havline-space-3);
max-height: 720px;
overflow: auto;
}
@@ -1373,9 +1373,9 @@ onMounted(init)
.task-item {
width: 100%;
text-align: left;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 12px;
background: var(--fonu-surface);
background: var(--havline-surface);
padding: 14px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
@@ -1384,7 +1384,7 @@ onMounted(init)
.task-item:hover { border-color: rgba(16, 185, 129, 0.25); }
.task-item--active {
border-color: var(--fonu-brand);
border-color: var(--havline-brand);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.15);
}
@@ -1398,7 +1398,7 @@ onMounted(init)
}
.task-item--editing {
border-color: var(--fonu-brand);
border-color: var(--havline-brand);
padding: 12px;
overflow: hidden;
}
@@ -1425,7 +1425,7 @@ onMounted(init)
overflow: hidden;
flex-shrink: 0;
background: #fff;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
}
.provider-logo--md { width: 32px; height: 32px; }
@@ -1440,17 +1440,17 @@ onMounted(init)
.task-item__name {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.task-item__row {
margin-top: 8px;
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.task-item__domain { font-weight: 600; color: var(--fonu-text); }
.task-item__muted { color: var(--fonu-text-muted); }
.task-item__domain { font-weight: 600; color: var(--havline-text); }
.task-item__muted { color: var(--havline-text-muted); }
.task-item__draft-tag {
font-size: 11px;
@@ -1486,11 +1486,11 @@ onMounted(init)
max-width: 100%;
padding: 2px 8px;
border-radius: 999px;
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
font-family: var(--fonu-mono);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
font-family: var(--havline-mono);
font-size: 11px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1499,7 +1499,7 @@ onMounted(init)
.task-detail {
display: flex;
flex-direction: column;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
min-width: 0;
}
@@ -1507,13 +1507,13 @@ onMounted(init)
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
flex-wrap: wrap;
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
padding: var(--fonu-space-4) var(--fonu-space-5);
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);
}
.task-detail__title-wrap {
@@ -1526,7 +1526,7 @@ onMounted(init)
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--fonu-text);
color: var(--havline-text);
}
.task-detail__meta {
@@ -1536,7 +1536,7 @@ onMounted(init)
gap: 10px;
margin-top: 6px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.task-detail__actions {
@@ -1551,21 +1551,21 @@ onMounted(init)
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-top: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-top: var(--havline-space-4);
}
.settings-field__label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.settings-row {
@@ -1573,14 +1573,14 @@ onMounted(init)
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
flex-wrap: wrap;
}
.settings-switches {
display: flex;
flex-wrap: wrap;
gap: var(--fonu-space-5);
gap: var(--havline-space-5);
align-items: center;
}
@@ -1593,7 +1593,7 @@ onMounted(init)
.settings-interval__label {
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
white-space: nowrap;
}
@@ -1606,25 +1606,25 @@ onMounted(init)
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--fonu-text);
color: var(--havline-text);
}
.interval-unit {
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.records-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
gap: var(--havline-space-3);
padding: var(--havline-space-4) var(--havline-space-5) 0;
}
.record-table-wrap {
overflow-x: auto;
padding: 0 var(--fonu-space-5) var(--fonu-space-4);
padding: 0 var(--havline-space-5) var(--havline-space-4);
}
.record-table {
@@ -1638,23 +1638,23 @@ onMounted(init)
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--fonu-text-secondary);
border-bottom: 1px solid var(--fonu-border);
color: var(--havline-text-secondary);
border-bottom: 1px solid var(--havline-border);
white-space: nowrap;
}
.record-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--fonu-border);
border-bottom: 1px solid var(--havline-border);
vertical-align: middle;
}
.record-table__mono {
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
font-size: 12px;
}
.record-table__muted { color: var(--fonu-text-muted); }
.record-table__muted { color: var(--havline-text-muted); }
.record-table__result {
max-width: 220px;
@@ -1665,7 +1665,7 @@ onMounted(init)
.record-table__empty {
text-align: center;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
padding: 28px 12px !important;
}
@@ -1739,8 +1739,8 @@ onMounted(init)
}
.custom-ip-form { display: flex; flex-direction: column; gap: 10px; }
.custom-ip-field__label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--fonu-text-secondary); }
.custom-ip-hint { margin: 2px 0 0; font-size: 12px; line-height: 1.6; color: var(--fonu-text-secondary); }
.custom-ip-field__label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--havline-text-secondary); }
.custom-ip-hint { margin: 2px 0 0; font-size: 12px; line-height: 1.6; color: var(--havline-text-secondary); }
.custom-ip-actions { display: flex; justify-content: flex-end; gap: 8px; }
.custom-ip-actions--secondary { justify-content: flex-start; margin-top: 6px; }
</style>
+26 -26
View File
@@ -38,24 +38,24 @@
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<HavlineCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.9.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.9.0。</li>
<li>验证标识更新为 <span class="mono">v1.9.0-example-page</span>。</li>
</ul>
</FonuCard>
</HavlineCard>
<FonuCard title="验证步骤" subtitle="用这个页面确认线上实例已经更新到目标版本">
<HavlineCard title="验证步骤" subtitle="用这个页面确认线上实例已经更新到目标版本">
<ol class="example-steps">
<li>在旧版本页面点击侧栏的「一键升级」。</li>
<li>等待容器重建完成,页面自动刷新或恢复访问。</li>
<li>打开侧栏「示例页面」,确认本页可访问。</li>
<li>确认页面上的版本号为 <span class="mono">v{{ version }}</span>。</li>
</ol>
</FonuCard>
</HavlineCard>
<FonuCard title="验证结果" subtitle="用于确认当前前端资源与后端版本一致">
<HavlineCard title="验证结果" subtitle="用于确认当前前端资源与后端版本一致">
<div class="example-result">
<div><label>前端路由</label><span class="mono">/example</span></div>
<div><label>侧栏菜单</label><span>已加载「示例页面」</span></div>
@@ -64,7 +64,7 @@
<div><label>验证状态</label><StatusBadge kind="success" text="通过" /></div>
<div><label>最近检查</label><span>{{ checkedAt || '刚刚' }}</span></div>
</div>
</FonuCard>
</HavlineCard>
</template>
<script setup lang="ts">
@@ -77,7 +77,7 @@ import {
RefreshOutline,
} from '@vicons/ionicons5'
import { api } from '../api/client'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -110,25 +110,25 @@ onMounted(() => {
<style scoped>
.example-alert {
margin-bottom: var(--fonu-space-5);
margin-bottom: var(--havline-space-5);
}
.example-proof {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-5);
margin-bottom: var(--fonu-space-5);
padding: var(--fonu-space-4) var(--fonu-space-5);
border: 1px solid var(--fonu-brand-soft);
border-radius: var(--fonu-radius);
background: var(--fonu-brand-soft);
gap: var(--havline-space-5);
margin-bottom: var(--havline-space-5);
padding: var(--havline-space-4) var(--havline-space-5);
border: 1px solid var(--havline-brand-soft);
border-radius: var(--havline-radius);
background: var(--havline-brand-soft);
}
.example-proof__label {
display: block;
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.example-proof__value {
@@ -136,21 +136,21 @@ onMounted(() => {
margin-top: 2px;
font-size: 28px;
line-height: 1.2;
color: var(--fonu-brand-text);
color: var(--havline-brand-text);
}
.example-proof__hint {
max-width: 360px;
font-size: 13px;
line-height: 1.6;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-5);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-5);
}
.example-steps {
@@ -158,7 +158,7 @@ onMounted(() => {
gap: 10px;
margin: 0;
padding-left: 22px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
line-height: 1.7;
}
@@ -167,28 +167,28 @@ onMounted(() => {
gap: 10px;
margin: 0;
padding-left: 22px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
line-height: 1.7;
}
.example-result {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.example-result > div {
display: grid;
gap: 4px;
padding: 12px 14px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
}
.example-result label {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
@media (max-width: 640px) {
File diff suppressed because it is too large Load Diff
+159 -159
View File
@@ -133,9 +133,9 @@
</div>
</div>
<FonuCard v-if="status.last_error" class="error-card" title="最近错误">
<HavlineCard v-if="status.last_error" class="error-card" title="最近错误">
<div class="error-card__content">{{ status.last_error }}</div>
</FonuCard>
</HavlineCard>
</template>
<n-modal
@@ -391,14 +391,14 @@
<ul>
<li><strong>IP 来源留空(默认)</strong>:每次同步自动检测本机公网 IP——家庭宽带 NAS 场景,域名指向家里出口。</li>
<li><strong>IP 来源自定义</strong>:DDNS 页选中任务 → 「自定义 IP」填 VPS 的 IP,域名直接解析到 VPS(Web 服务经 VPS 对外时使用)。须为公网地址,内网 IP 会被拒绝。</li>
<li><strong>VPS 动态 IP</strong>:DDNS 自定义 IP 填当前 VPS IP,FRP 服务端「主机地址」填 DDNS 域名而非裸 IP——VPS 换 IP 后域名自动跟随,frpc 重连无需改 Fonu 配置。</li>
<li><strong>VPS 动态 IP</strong>:DDNS 自定义 IP 填当前 VPS IP,FRP 服务端「主机地址」填 DDNS 域名而非裸 IP——VPS 换 IP 后域名自动跟随,frpc 重连无需改 Havline 配置。</li>
</ul>
</div>
<div class="guide-section">
<h4>三、与反向代理的组合(按需求选型)</h4>
<ul>
<li><strong>多 Web 应用统一入口(推荐)</strong>:FRP 一条 tcp 规则,远程端口 443,内网目标 127.0.0.1:443(Fonu Nginx 的 HTTPS 监听);反向代理页按域名分流到各内网应用,TLS 与证书都在本地 Nginx 终止。加规则只改反代,不动 FRP。</li>
<li><strong>多 Web 应用统一入口(推荐)</strong>:FRP 一条 tcp 规则,远程端口 443,内网目标 127.0.0.1:443(Havline Nginx 的 HTTPS 监听);反向代理页按域名分流到各内网应用,TLS 与证书都在本地 Nginx 终止。加规则只改反代,不动 FRP。</li>
<li><strong>HTTP/HTTPS 直通</strong>:frps 按域名分流,不需要本地反代。存在 http/https 规则时,本页生成的 frps.toml 会自动带上 vhostHTTPPort / vhostHTTPSPort;FRP 规则选 http / https 类型并填自定义域名。TLS 终止在 VPS 侧或上游。</li>
<li><strong>端口映射</strong>:SSH、远程桌面、数据库等非 HTTP 服务——tcp / udp 规则 + 远程端口,访问 <code>VPS_IP:远程端口</code> 即达内网。</li>
<li><strong>STCP 安全穿透</strong>:管理入口不想暴露公网端口时用 stcp,只放行 frps 的 bindPort;访问端运行同 secretKey 的 visitor 配置连回内网。</li>
@@ -440,13 +440,13 @@
→ DNS 解析到 VPS IP
→ VPS:443 → frps → FRP TCP 隧道(远程端口 443)
→ NAS frpc → 127.0.0.1:443
→ Fonu Nginx(本地证书终止 TLS,按域名分流)→ 内网应用</pre>
→ Havline Nginx(本地证书终止 TLS,按域名分流)→ 内网应用</pre>
<ol class="guide-steps">
<li>VPS 部署 frps:服务端「详情 → frps.toml」生成同源配置,放行 bindPort 与 443;</li>
<li>DDNS:解析 example.com / *.example.com 到 VPS IP(「自定义 IP」填 VPS IP);</li>
<li>证书:为 *.example.com 申请通配符证书(DNS-01 复用 DDNS 凭证);</li>
<li>FRP 服务端:添加 VPS,Token 与 frps 一致;</li>
<li>FRP 规则:类型 tcp,远程端口 443,内网目标 127.0.0.1:443(Fonu Nginx 的 HTTPS 监听);</li>
<li>FRP 规则:类型 tcp,远程端口 443,内网目标 127.0.0.1:443(Havline Nginx 的 HTTPS 监听);</li>
<li>反向代理:按域名建规则(HTTPS + 强制跳转 + 选证书),目标为各内网应用;以后加应用只改反代,不动 FRP;</li>
<li>「应用配置」校验并应用 → 服务端「详情」看诊断与日志验证。</li>
</ol>
@@ -457,7 +457,7 @@
<ol class="guide-steps">
<li>DDNS「自定义 IP」填当前 VPS IP(注意:留空检测的是 NAS 本机出口 IP,两者语义不同);</li>
<li>FRP 服务端「主机地址」填 DDNS 域名(如 vps.example.com)而非裸 IP;</li>
<li>VPS IP 变化后 DDNS 定时任务自动跟随,frpc 重连无需改 Fonu 配置;</li>
<li>VPS IP 变化后 DDNS 定时任务自动跟随,frpc 重连无需改 Havline 配置;</li>
<li>服务端「日志」确认重新出现 login to server success。</li>
</ol>
<p class="guide-note">通常与场景 A / C 叠加:DDNS 保证「地址跟着 VPS 走」,FRP 保证「隧道通」。</p>
@@ -499,7 +499,7 @@
<pre class="guide-topo">用户 → https://app.example.com
→ DNS(DDNS 自动更新到宽带出口 IP)
→ 家庭宽带路由器端口映射 :443
→ NAS Fonu Nginx(本地证书)→ 内网应用</pre>
→ NAS Havline Nginx(本地证书)→ 内网应用</pre>
<ol class="guide-steps">
<li>确认宽带有公网 IP(没有则只能走 FRP 中转或 IPv6 直连);</li>
<li>DDNS IP 来源留空(自动检测本机出口);</li>
@@ -534,10 +534,10 @@
<p class="guide-note">适用:域名挂在 Cloudflare 等 CDN 后面,隐藏源站 IP、边缘加速。</p>
<pre class="guide-topo">用户 → CDN(边缘 TLS)
→ 回源 → VPS frps :远程端口(FRP TCP 隧道)
→ NAS Fonu Nginx(HTTP 或本地证书)→ 内网应用</pre>
→ NAS Havline Nginx(HTTP 或本地证书)→ 内网应用</pre>
<ol class="guide-steps">
<li>CDN 侧添加域名,DNS 由 CDN 托管(此时可不建 DDNS,或 DDNS 只用于 FRP 服务端地址,见场景 B);</li>
<li>FRP tcp 规则:远程端口如 8443,内网目标指向 Fonu Nginx 的对应监听;</li>
<li>FRP tcp 规则:远程端口如 8443,内网目标指向 Havline Nginx 的对应监听;</li>
<li>反代 HTTPS 按回源模式决定(Flexible → HTTP 回源可关 443;Full → 需本地证书);</li>
<li>真实客户端 IP 来自 CDN,需在设置中配置信任代理网段,IP 黑白名单 / 限流才基于真实 IP 生效。</li>
</ol>
@@ -656,7 +656,7 @@ import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import { formatBytes, formatDate, formatRate } from '../utils/format'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import EmptyState from '../components/EmptyState.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -1203,89 +1203,89 @@ 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(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.stat-card { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); padding: var(--fonu-space-4) var(--fonu-space-5); min-height: 118px; }
.stats-row { display: grid; grid-template-columns: repeat(4, 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(--fonu-info); }
.stat-card__icon--amber { color: var(--fonu-warning); }
.stat-card__icon--green, .stat-card__icon--running { color: var(--fonu-brand); }
.stat-card__icon--red { color: var(--fonu-error); }
.stat-card__icon--stopped { color: var(--fonu-disabled); }
.stat-card__icon--unknown { color: var(--fonu-warning); }
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--fonu-text); letter-spacing: -0.03em; }
.stat-card__icon--blue { color: var(--havline-info); }
.stat-card__icon--amber { color: var(--havline-warning); }
.stat-card__icon--green, .stat-card__icon--running { color: var(--havline-brand); }
.stat-card__icon--red { color: var(--havline-error); }
.stat-card__icon--stopped { color: var(--havline-disabled); }
.stat-card__icon--unknown { 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--sm { font-size: 18px; }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-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); }
/* 公网服务端列表: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(--fonu-space-3); }
.rule-detail__log-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--havline-space-3); }
/* 详情弹窗概览区:与反代详情同版式 */
.overview-strip {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.8fr) minmax(0, 1.2fr) minmax(0, 1.4fr);
gap: 12px 16px;
padding: 12px 14px;
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
border-radius: 10px;
flex-shrink: 0;
}
.overview-strip__item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.overview-strip__item--wide { grid-column: span 1; }
.overview-strip__label { font-size: 11px; color: var(--fonu-text-muted); letter-spacing: 0.02em; }
.overview-strip__value { font-size: 13px; font-weight: 500; color: var(--fonu-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.overview-security { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 10px 14px; background: var(--fonu-bg); border: 1px solid var(--fonu-border); border-radius: 10px; flex-shrink: 0; }
.overview-security__label { font-size: 11px; color: var(--fonu-text-muted); letter-spacing: 0.02em; flex-shrink: 0; }
.overview-security__empty { font-size: 13px; color: var(--fonu-text-muted); }
.overview-main { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr); gap: var(--fonu-space-4); flex: 1 1 0; min-height: 0; align-items: stretch; }
.overview-strip__label { font-size: 11px; color: var(--havline-text-muted); letter-spacing: 0.02em; }
.overview-strip__value { font-size: 13px; font-weight: 500; color: var(--havline-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.overview-security { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 10px 14px; background: var(--havline-bg); border: 1px solid var(--havline-border); border-radius: 10px; flex-shrink: 0; }
.overview-security__label { font-size: 11px; color: var(--havline-text-muted); letter-spacing: 0.02em; flex-shrink: 0; }
.overview-security__empty { font-size: 13px; color: var(--havline-text-muted); }
.overview-main { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr); gap: var(--havline-space-4); flex: 1 1 0; min-height: 0; align-items: stretch; }
.overview-side { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.overview-card { border: 1px solid var(--fonu-border); border-radius: 10px; background: var(--fonu-surface); padding: 14px 16px; }
.overview-card__head { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); margin-bottom: 10px; }
.overview-card { border: 1px solid var(--havline-border); border-radius: 10px; background: var(--havline-surface); padding: 14px 16px; }
.overview-card__head { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); margin-bottom: 10px; }
.overview-card__head h4 { margin: 0; font-size: 14px; font-weight: 600; }
.overview-card__sub { margin-left: 6px; font-size: 12px; font-weight: 400; color: var(--fonu-text-muted); }
.overview-card__sub { margin-left: 6px; font-size: 12px; font-weight: 400; color: var(--havline-text-muted); }
.overview-card--chart { display: flex; flex-direction: column; min-height: 0; }
.overview-chart { flex: 1; min-height: 180px; }
.overview-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: 1fr 1fr; gap: 10px; flex-shrink: 0; }
.overview-metric { display: flex; flex-direction: column; justify-content: center; padding: 12px 14px; border-radius: 10px; background: var(--fonu-bg); border: 1px solid var(--fonu-border); }
.overview-metric__label { font-size: 12px; color: var(--fonu-text-muted); }
.overview-metric__value { margin-top: 4px; font-size: 18px; font-weight: 600; line-height: 1.2; color: var(--fonu-text); }
.live-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--fonu-brand); font-weight: 500; }
.live-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fonu-brand); animation: live-pulse 1.5s ease-in-out infinite; }
.overview-metric { display: flex; flex-direction: column; justify-content: center; padding: 12px 14px; border-radius: 10px; background: var(--havline-bg); border: 1px solid var(--havline-border); }
.overview-metric__label { font-size: 12px; color: var(--havline-text-muted); }
.overview-metric__value { margin-top: 4px; font-size: 18px; font-weight: 600; line-height: 1.2; color: var(--havline-text); }
.live-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--havline-brand); font-weight: 500; }
.live-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--havline-brand); animation: live-pulse 1.5s ease-in-out infinite; }
@keyframes live-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.traffic-live-legend { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 8px; font-size: 12px; color: var(--fonu-text-secondary); }
.traffic-live-legend { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 8px; font-size: 12px; color: var(--havline-text-secondary); }
.traffic-live-legend--compact { margin-bottom: 6px; }
.traffic-live-legend__item { display: inline-flex; align-items: center; gap: 6px; }
.traffic-live-legend__dot { width: 8px; height: 8px; border-radius: 50%; }
.traffic-live-legend__item--up .traffic-live-legend__dot { background: var(--fonu-brand); }
.traffic-live-legend__item--down .traffic-live-legend__dot { background: var(--fonu-info); }
.proxy-detail-modal__conn { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--fonu-text-muted); background: var(--fonu-bg); border: 1px solid var(--fonu-border); }
.proxy-detail-modal__conn.is-active { color: var(--fonu-brand-text); background: var(--fonu-brand-soft); border-color: var(--fonu-brand-soft); }
.traffic-live-legend__item--up .traffic-live-legend__dot { background: var(--havline-brand); }
.traffic-live-legend__item--down .traffic-live-legend__dot { background: var(--havline-info); }
.proxy-detail-modal__conn { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--havline-text-muted); background: var(--havline-bg); border: 1px solid var(--havline-border); }
.proxy-detail-modal__conn.is-active { color: var(--havline-brand-text); background: var(--havline-brand-soft); border-color: var(--havline-brand-soft); }
/* 穿透规则详情弹窗:反代详情的样式类定义在 ProxyView 的 scoped 样式中,对 FrpView 不生效,此处本地定义 */
.frp-rule-detail-modal {
width: min(920px, 96vw);
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--fonu-surface);
border-radius: var(--fonu-radius);
background: var(--havline-surface);
border-radius: var(--havline-radius);
overflow: hidden;
box-shadow: var(--fonu-shadow-md);
box-shadow: var(--havline-shadow-md);
}
.frp-rule-detail-modal .proxy-detail-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
gap: var(--havline-space-4);
padding: var(--havline-space-5) var(--havline-space-5) 0;
flex-shrink: 0;
}
.frp-rule-detail-modal .proxy-detail-modal__intro { display: flex; align-items: center; gap: var(--fonu-space-3); min-width: 0; }
.frp-rule-detail-modal .proxy-detail-modal__intro { display: flex; align-items: center; gap: var(--havline-space-3); min-width: 0; }
.frp-rule-detail-modal .proxy-detail-modal__title { margin: 0; font-size: 18px; font-weight: 600; }
.frp-rule-detail-modal .proxy-detail__tabbar {
display: flex;
gap: var(--fonu-space-5);
padding: var(--fonu-space-3) var(--fonu-space-5) 0;
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-5);
padding: var(--havline-space-3) var(--havline-space-5) 0;
border-bottom: 1px solid var(--havline-border);
flex-shrink: 0;
}
.frp-rule-detail-modal .proxy-detail__tab {
@@ -1295,12 +1295,12 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
background: none;
font: inherit;
font-size: 14px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.frp-rule-detail-modal .proxy-detail__tab:hover { color: var(--fonu-text); }
.frp-rule-detail-modal .proxy-detail__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
.frp-rule-detail-modal .proxy-detail__tab:hover { color: var(--havline-text); }
.frp-rule-detail-modal .proxy-detail__tab--active { color: var(--havline-brand-text); font-weight: 600; border-bottom-color: var(--havline-brand); }
.frp-rule-detail-modal .proxy-detail__scroll {
flex: 1;
min-height: min(480px, 60vh);
@@ -1311,7 +1311,7 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
.frp-rule-detail-modal .proxy-detail__pane {
width: 100%;
box-sizing: border-box;
padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5);
padding: var(--havline-space-4) var(--havline-space-5) var(--havline-space-5);
}
.frp-rule-detail-modal .proxy-detail__pane:first-of-type {
flex: 1;
@@ -1319,156 +1319,156 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
}
.frp-proxy-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.frp-proxy-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); }
.frp-proxy-modal .proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; }
.frp-proxy-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; }
.frp-proxy-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); padding: var(--havline-space-5) var(--havline-space-5) 0; }
.frp-proxy-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: var(--fonu-space-3) var(--fonu-space-5) 0; border-bottom: 1px solid var(--fonu-border); flex-shrink: 0; }
.frp-proxy-modal .proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--fonu-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
.frp-proxy-modal .proxy-modal__tab:hover { color: var(--fonu-text); }
.frp-proxy-modal .proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
.frp-proxy-modal .proxy-modal__tabbar { display: flex; gap: var(--havline-space-5); padding: var(--havline-space-3) var(--havline-space-5) 0; border-bottom: 1px solid var(--havline-border); flex-shrink: 0; }
.frp-proxy-modal .proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--havline-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
.frp-proxy-modal .proxy-modal__tab:hover { color: var(--havline-text); }
.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(--fonu-space-4) var(--fonu-space-5) 0; }
.frp-proxy-modal .proxy-modal__pane > .modal-actions { padding-bottom: var(--fonu-space-5); }
.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; }
.frp-server-modal__basic .form-grid { grid-template-columns: 1fr; }
.frp-proxy-modal .proxy-form-section { margin-top: var(--fonu-space-2); padding: var(--fonu-space-4) 0 0; border-top: 1px solid var(--fonu-border); }
.frp-proxy-modal .proxy-form-section h4 { margin: 0 0 var(--fonu-space-4); font-size: 14px; font-weight: 600; color: var(--fonu-text); }
.frp-proxy-modal__advanced { padding-bottom: var(--fonu-space-4); }
.frp-proxy-modal .proxy-advanced-collapse { width: 100%; border: 1px solid var(--fonu-border); border-radius: 10px; overflow: hidden; background: var(--fonu-surface); }
.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); }
.frp-proxy-modal__advanced { padding-bottom: var(--havline-space-4); }
.frp-proxy-modal .proxy-advanced-collapse { width: 100%; border: 1px solid var(--havline-border); border-radius: 10px; overflow: hidden; background: var(--havline-surface); }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item) { margin: 0 !important; border: 0 !important; border-radius: 0 !important; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item + .n-collapse-item) { border-top: 1px solid var(--fonu-border) !important; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item__header) { min-height: 44px; padding: 12px 14px !important; background: var(--fonu-bg); font-weight: 600; box-sizing: border-box; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item + .n-collapse-item) { border-top: 1px solid var(--havline-border) !important; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item__header) { min-height: 44px; padding: 12px 14px !important; background: var(--havline-bg); font-weight: 600; box-sizing: border-box; }
.frp-proxy-modal .proxy-advanced-collapse :deep(.n-collapse-item__content-inner) { padding: 0 14px 14px; }
.frp-proxy-modal .proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--fonu-space-5); background: var(--fonu-bg-muted); border-left: 1px solid var(--fonu-border); color: var(--fonu-text-secondary); font-size: 13px; line-height: 1.65; overflow: auto; }
.frp-proxy-modal .proxy-modal__help h4 { margin: 0 0 var(--fonu-space-3); color: var(--fonu-text); font-size: 14px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--havline-space-5); background: var(--havline-bg-muted); border-left: 1px solid var(--havline-border); color: var(--havline-text-secondary); font-size: 13px; line-height: 1.65; overflow: auto; }
.frp-proxy-modal .proxy-modal__help h4 { margin: 0 0 var(--havline-space-3); color: var(--havline-text); font-size: 14px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__help ol { margin: 0; padding-left: 18px; }
.frp-proxy-modal .proxy-modal__help li + li { margin-top: 10px; }
.frp-proxy-modal .proxy-modal__help strong { color: var(--fonu-text); font-weight: 600; }
.frp-proxy-modal .proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--fonu-space-5); padding: 12px; border-radius: 8px; background: var(--fonu-info-soft); color: var(--fonu-info); font-size: 12px; line-height: 1.6; }
.frp-proxy-modal .proxy-modal__help strong { color: var(--havline-text); font-weight: 600; }
.frp-proxy-modal .proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--havline-space-5); padding: 12px; border-radius: 8px; background: var(--havline-info-soft); color: var(--havline-info); font-size: 12px; line-height: 1.6; }
.frp-proxy-modal .proxy-modal__tip-icon { flex-shrink: 0; margin-top: 1px; font-size: 16px; }
.frp-proxy-modal .proxy-modal__example { margin-top: var(--fonu-space-5); padding-top: var(--fonu-space-4); border-top: 1px solid var(--fonu-border); }
.frp-proxy-modal .proxy-modal__example-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--fonu-space-3); }
.frp-proxy-modal .proxy-modal__example h5 { margin: 0; color: var(--fonu-text); font-size: 13px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__example { margin-top: var(--havline-space-5); padding-top: var(--havline-space-4); border-top: 1px solid var(--havline-border); }
.frp-proxy-modal .proxy-modal__example-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--havline-space-3); }
.frp-proxy-modal .proxy-modal__example h5 { margin: 0; color: var(--havline-text); font-size: 13px; font-weight: 600; }
.frp-proxy-modal .proxy-modal__example p { margin: 4px 0 0; font-size: 12px; line-height: 1.55; }
.frp-proxy-modal .proxy-modal__example pre { margin: var(--fonu-space-3) 0 0; padding: 10px; overflow-x: auto; border-radius: 6px; background: var(--fonu-bg-muted); color: var(--fonu-text); font: 12px/1.6 var(--fonu-mono); white-space: pre; }
.form-section { padding: var(--fonu-space-4) 0; border-bottom: 1px solid var(--fonu-border); }
.frp-proxy-modal .proxy-modal__example pre { margin: var(--havline-space-3) 0 0; padding: 10px; overflow-x: auto; border-radius: 6px; background: var(--havline-bg-muted); color: var(--havline-text); font: 12px/1.6 var(--havline-mono); white-space: pre; }
.form-section { padding: var(--havline-space-4) 0; border-bottom: 1px solid var(--havline-border); }
.form-section:first-child { padding-top: 0; }
.form-section:last-of-type { border-bottom: 0; }
.form-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 var(--fonu-space-4); font-size: 16px; }
.form-section h3 :deep(.n-icon) { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--fonu-radius-sm); background: var(--fonu-brand); color: #fff; font-size: 15px; }
.form-hint { margin: 0 0 var(--fonu-space-4); color: var(--fonu-text-secondary); font-size: 13px; line-height: 1.55; }
.form-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 var(--havline-space-4); font-size: 16px; }
.form-section h3 :deep(.n-icon) { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--havline-radius-sm); background: var(--havline-brand); color: #fff; font-size: 15px; }
.form-hint { margin: 0 0 var(--havline-space-4); color: var(--havline-text-secondary); font-size: 13px; line-height: 1.55; }
/* 统一卡片式弹窗外壳(运行日志 / 导出配置 / frps 同源配置):与 .proxy-modal 体系一致的头部 + 滚动区 + 页脚 */
.frp-card-modal { display: flex; flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.frp-card-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; flex-shrink: 0; }
.frp-card-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--fonu-text); }
.frp-card-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.frp-card-modal .modal-footer { display: flex; justify-content: flex-end; gap: var(--fonu-space-3); padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); border-top: 1px solid var(--fonu-border); margin-top: var(--fonu-space-2); flex-shrink: 0; }
.frp-card-modal { display: flex; flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: 90vh; background: var(--havline-surface); border-radius: var(--havline-radius); overflow: hidden; box-shadow: var(--havline-shadow-md); }
.frp-card-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); padding: var(--havline-space-5) var(--havline-space-5) 0; flex-shrink: 0; }
.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); }
/* 服务端详情弹窗(仍用 naive-ui preset=card):排版对齐 .proxy-modal */
.frp-guide-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.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); }
.frp-guide-modal .proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; }
.frp-guide-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; }
.frp-guide-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); padding: var(--havline-space-5) var(--havline-space-5) 0; }
.frp-guide-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; }
.frp-guide-modal .proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: var(--fonu-space-3) var(--fonu-space-5) 0; border-bottom: 1px solid var(--fonu-border); flex-shrink: 0; }
.frp-guide-modal .proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--fonu-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
.frp-guide-modal .proxy-modal__tab:hover { color: var(--fonu-text); }
.frp-guide-modal .proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
.frp-guide-modal .proxy-modal__tabbar { display: flex; gap: var(--havline-space-5); padding: var(--havline-space-3) var(--havline-space-5) 0; border-bottom: 1px solid var(--havline-border); flex-shrink: 0; }
.frp-guide-modal .proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--havline-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
.frp-guide-modal .proxy-modal__tab:hover { color: var(--havline-text); }
.frp-guide-modal .proxy-modal__tab--active { color: var(--havline-brand-text); font-weight: 600; border-bottom-color: var(--havline-brand); }
.frp-guide-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; }
.frp-guide-modal .proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); }
.frp-guide-modal .proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--havline-space-4) var(--havline-space-5) var(--havline-space-5); }
@media (max-width: 760px) { .frp-guide-modal { display: block; max-height: 92vh; overflow-y: auto; } }
.guide-section { margin-bottom: 18px; }
.guide-section h4 { margin: 0 0 8px; font-size: 14px; }
.guide-section ol, .guide-section ul { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; line-height: 1.7; }
.guide-section code { padding: 1px 5px; border-radius: 4px; background: var(--fonu-bg-muted); font-size: 12px; }
.guide-section code { padding: 1px 5px; border-radius: 4px; background: var(--havline-bg-muted); font-size: 12px; }
.guide-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.guide-table th, .guide-table td { border: 1px solid var(--fonu-border); padding: 6px 10px; text-align: left; line-height: 1.6; }
.guide-table th, .guide-table td { border: 1px solid var(--havline-border); padding: 6px 10px; text-align: left; line-height: 1.6; }
.guide-table th { font-weight: 600; }
.guide-note { margin: 6px 0; font-size: 12.5px; color: var(--fonu-text-secondary); line-height: 1.6; }
.guide-topo { margin: 8px 0; padding: 10px 12px; border-radius: 6px; background: var(--fonu-bg-muted); font-size: 12px; line-height: 1.6; overflow-x: auto; }
.guide-note { margin: 6px 0; font-size: 12.5px; color: var(--havline-text-secondary); line-height: 1.6; }
.guide-topo { margin: 8px 0; padding: 10px 12px; border-radius: 6px; background: var(--havline-bg-muted); font-size: 12px; line-height: 1.6; overflow-x: auto; }
.guide-steps { margin: 8px 0; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; font-size: 13px; line-height: 1.7; }
.guide-collapse :deep(.n-collapse-item__header) { font-size: 13px; font-weight: 500; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--fonu-space-4); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--havline-space-4); }
.form-grid--wide > :first-child { grid-column: 1 / -1; }
.advanced-collapse { margin-top: var(--fonu-space-3); }
.switch-row, .modal-actions { display: flex; align-items: center; gap: var(--fonu-space-2); flex-wrap: wrap; }
.switch-row { padding: 4px 0 var(--fonu-space-5); }
.switch-row span:nth-of-type(2) { margin-left: var(--fonu-space-4); }
.advanced-collapse { margin-top: var(--havline-space-3); }
.switch-row, .modal-actions { display: flex; align-items: center; gap: var(--havline-space-2); flex-wrap: wrap; }
.switch-row { padding: 4px 0 var(--havline-space-5); }
.switch-row span:nth-of-type(2) { margin-left: var(--havline-space-4); }
.modal-actions { justify-content: flex-end; }
.logs-box { margin: var(--fonu-space-3) 0 0; padding: var(--fonu-space-4); min-height: 260px; max-height: 480px; overflow: auto; border: 1px solid var(--fonu-border); background: var(--fonu-bg-muted); color: var(--fonu-text); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--fonu-mono); }
.error-card__content { color: var(--fonu-error); white-space: pre-wrap; }
.table-muted { margin-top: 4px; color: var(--fonu-text-muted); font-size: 12px; }
.logs-box { margin: var(--havline-space-3) 0 0; padding: var(--havline-space-4); min-height: 260px; max-height: 480px; overflow: auto; border: 1px solid var(--havline-border); background: var(--havline-bg-muted); color: var(--havline-text); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--havline-mono); }
.error-card__content { color: var(--havline-error); white-space: pre-wrap; }
.table-muted { margin-top: 4px; color: var(--havline-text-muted); font-size: 12px; }
/* 规则表单元格由 render 函数的 h() 创建,不带 scoped 属性,必须 :deep() 才能命中 */
.runtime-actions, .runtime-section__title, .binary-row, .binary-row__actions { display: flex; align-items: center; gap: var(--fonu-space-3); }
.runtime-actions { justify-content: flex-end; margin-bottom: var(--fonu-space-4); }
.runtime-section { padding: var(--fonu-space-4) 0; border-top: 1px solid var(--fonu-border); }
.runtime-actions, .runtime-section__title, .binary-row, .binary-row__actions { display: flex; align-items: center; gap: var(--havline-space-3); }
.runtime-actions { justify-content: flex-end; margin-bottom: var(--havline-space-4); }
.runtime-section { padding: var(--havline-space-4) 0; border-top: 1px solid var(--havline-border); }
.runtime-section:first-of-type { border-top: 0; padding-top: 0; }
.runtime-section h3 { margin: 0; font-size: 15px; }
.runtime-section__title { justify-content: space-between; margin-bottom: var(--fonu-space-3); }
.runtime-section__title span, .binary-row__meta { color: var(--fonu-text-secondary); font-size: 12px; word-break: break-all; }
.download-proxy-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--fonu-space-3); align-items: center; }
.binary-list, .release-list { display: grid; gap: var(--fonu-space-2); }
.release-list__more { display: flex; justify-content: center; margin-top: var(--fonu-space-3); }
.binary-row { justify-content: space-between; padding: var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
.release-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(240px, 320px) auto; align-items: center; gap: var(--fonu-space-3); padding: var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
.runtime-section__title { justify-content: space-between; margin-bottom: var(--havline-space-3); }
.runtime-section__title span, .binary-row__meta { color: var(--havline-text-secondary); font-size: 12px; word-break: break-all; }
.download-proxy-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--havline-space-3); align-items: center; }
.binary-list, .release-list { display: grid; gap: var(--havline-space-2); }
.release-list__more { display: flex; justify-content: center; margin-top: var(--havline-space-3); }
.binary-row { justify-content: space-between; padding: var(--havline-space-3); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); }
.release-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(240px, 320px) auto; align-items: center; gap: var(--havline-space-3); padding: var(--havline-space-3); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); }
.release-row__info { min-width: 0; }
.binary-row__actions { flex-shrink: 0; }
.release-progress, .release-progress-placeholder { min-width: 0; }
.release-progress { display: grid; grid-template-columns: minmax(0, 110px) minmax(80px, 1fr) auto; align-items: center; gap: var(--fonu-space-2); color: var(--fonu-text-secondary); font-size: 12px; white-space: nowrap; }
.release-progress { display: grid; grid-template-columns: minmax(0, 110px) minmax(80px, 1fr) auto; align-items: center; gap: var(--havline-space-2); color: var(--havline-text-secondary); font-size: 12px; white-space: nowrap; }
.release-progress__message { overflow: hidden; text-overflow: ellipsis; }
.release-progress__bar { min-width: 0; }
.release-progress__value { font-variant-numeric: tabular-nums; }
.release-progress--failed { color: var(--fonu-error); grid-template-columns: minmax(0, 1fr); }
.latency-panel__hint { color: var(--fonu-text-muted); font-size: 12px; }
.detail-status-meta { display: flex; justify-content: space-between; gap: var(--fonu-space-3); margin: -4px 0 var(--fonu-space-3); color: var(--fonu-text-muted); font-size: 12px; }
.detail-status-error { color: var(--fonu-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-status-panel .detail-status-meta { margin: var(--fonu-space-4) 0 0; }
.release-progress--failed { color: var(--havline-error); grid-template-columns: minmax(0, 1fr); }
.latency-panel__hint { color: var(--havline-text-muted); font-size: 12px; }
.detail-status-meta { display: flex; justify-content: space-between; gap: var(--havline-space-3); margin: -4px 0 var(--havline-space-3); color: var(--havline-text-muted); font-size: 12px; }
.detail-status-error { color: var(--havline-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-status-panel .detail-status-meta { margin: var(--havline-space-4) 0 0; }
.latency-chart { width: 100%; height: 240px; }
.detail-empty { display: grid; place-items: center; height: 240px; color: var(--fonu-text-muted); border: 1px dashed var(--fonu-border); }
.detail-legend-failed { display: inline-block; width: 6px; height: 6px; margin: 0 4px 1px 12px; border-radius: 50%; background: var(--fonu-error); }
.latency-bars { display: flex; align-items: end; gap: 4px; height: 92px; padding: 8px 0; border-bottom: 1px solid var(--fonu-border); }
.latency-bars span { flex: 1; min-width: 5px; max-width: 24px; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, var(--fonu-success), color-mix(in srgb, var(--fonu-success) 55%, white)); }
.latency-bars span.latency-bars__item--bad { background: var(--fonu-warning); }
.detail-chart-footer { display: flex; justify-content: space-between; margin-top: 8px; color: var(--fonu-text-muted); font-size: 12px; }
.detail-chart-footer i { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: var(--fonu-success); }
.detail-status--on { color: var(--fonu-success); }
.detail-status--off { color: var(--fonu-text-muted); }
.detail-logs { margin: 0; max-height: 220px; overflow: auto; padding: var(--fonu-space-3); background: var(--fonu-bg-muted); color: var(--fonu-text-secondary); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--fonu-mono); }
.detail-empty { display: grid; place-items: center; height: 240px; color: var(--havline-text-muted); border: 1px dashed var(--havline-border); }
.detail-legend-failed { display: inline-block; width: 6px; height: 6px; margin: 0 4px 1px 12px; border-radius: 50%; background: var(--havline-error); }
.latency-bars { display: flex; align-items: end; gap: 4px; height: 92px; padding: 8px 0; border-bottom: 1px solid var(--havline-border); }
.latency-bars span { flex: 1; min-width: 5px; max-width: 24px; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, var(--havline-success), color-mix(in srgb, var(--havline-success) 55%, white)); }
.latency-bars span.latency-bars__item--bad { background: var(--havline-warning); }
.detail-chart-footer { display: flex; justify-content: space-between; margin-top: 8px; color: var(--havline-text-muted); font-size: 12px; }
.detail-chart-footer i { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: var(--havline-success); }
.detail-status--on { color: var(--havline-success); }
.detail-status--off { color: var(--havline-text-muted); }
.detail-logs { margin: 0; max-height: 220px; overflow: auto; padding: var(--havline-space-3); background: var(--havline-bg-muted); color: var(--havline-text-secondary); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--havline-mono); }
@media (max-width: 1100px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 700px) { .stats-row, .form-grid, .download-proxy-form, .server-detail__grid, .detail-fields, .detail-stats, .server-status-summary { grid-template-columns: 1fr; } .server-status-summary__item--state { min-height: 0; padding-right: 0; padding-bottom: var(--fonu-space-3); border-right: 0; border-bottom: 1px solid var(--fonu-border); } .status-bars { gap: 3px; } .status-bar { height: 28px; } .binary-row { align-items: flex-start; flex-direction: column; } .release-row { grid-template-columns: 1fr; } .release-progress-placeholder { display: none; } .switch-row span:nth-of-type(2) { margin-left: 0; } }
@media (max-width: 760px) { .frp-proxy-modal { display: block; max-height: 92vh; overflow-y: auto; } .frp-proxy-modal .proxy-modal__form { max-height: none; } .frp-proxy-modal .proxy-modal__scroll { overflow: visible; } .frp-proxy-modal .proxy-modal__help { width: auto; border-top: 1px solid var(--fonu-border); border-left: 0; } }
@media (max-width: 700px) { .stats-row, .form-grid, .download-proxy-form, .server-detail__grid, .detail-fields, .detail-stats, .server-status-summary { grid-template-columns: 1fr; } .server-status-summary__item--state { min-height: 0; padding-right: 0; padding-bottom: var(--havline-space-3); border-right: 0; border-bottom: 1px solid var(--havline-border); } .status-bars { gap: 3px; } .status-bar { height: 28px; } .binary-row { align-items: flex-start; flex-direction: column; } .release-row { grid-template-columns: 1fr; } .release-progress-placeholder { display: none; } .switch-row span:nth-of-type(2) { margin-left: 0; } }
@media (max-width: 760px) { .frp-proxy-modal { display: block; max-height: 92vh; overflow-y: auto; } .frp-proxy-modal .proxy-modal__form { max-height: none; } .frp-proxy-modal .proxy-modal__scroll { overflow: visible; } .frp-proxy-modal .proxy-modal__help { width: auto; border-top: 1px solid var(--havline-border); border-left: 0; } }
.frps-compare--warn {
color: var(--fonu-warning);
color: var(--havline-warning);
}
/* 左:服务端列表;右:该服务端的穿透规则(与「公网服务端」页 .agent-layout 同构) */
.frp-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: var(--fonu-space-4); align-items: start; }
.server-sidebar { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--fonu-space-4); border-bottom: 1px solid var(--fonu-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--fonu-space-3); max-height: 720px; overflow: auto; }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--fonu-brand-hover); }
.server-item--active { border-color: var(--fonu-brand); box-shadow: 0 0 0 1px var(--fonu-brand-soft); }
.frp-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: var(--havline-space-4); align-items: start; }
.server-sidebar { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--havline-space-4); border-bottom: 1px solid var(--havline-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--havline-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--havline-space-3); max-height: 720px; overflow: auto; }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius); background: var(--havline-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--havline-brand-hover); }
.server-item--active { border-color: var(--havline-brand); box-shadow: 0 0 0 1px var(--havline-brand-soft); }
.server-item__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--fonu-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--fonu-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-3); margin-top: 6px; font-size: 12px; color: var(--fonu-text-muted); }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--havline-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--havline-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); margin-top: 6px; font-size: 12px; color: var(--havline-text-muted); }
.server-item__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.rule-pane { min-width: 0; display: flex; flex-direction: column; gap: var(--fonu-space-4); }
.rule-pane__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--fonu-space-3); padding: var(--fonu-space-3) var(--fonu-space-4); background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); }
.rule-pane__intro { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fonu-space-3); min-width: 0; }
.rule-pane__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); }
.rule-pane__sub { font-size: 12.5px; color: var(--fonu-text-secondary); }
.rule-pane__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-2); }
.rule-table-wrap { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); box-shadow: var(--fonu-shadow); overflow: hidden; padding: var(--fonu-space-2) 0; }
.frp-rules-table :deep(.table-muted) { color: var(--fonu-text-muted); font-size: 12px; }
.frp-rules-table :deep(.mono) { font-family: var(--fonu-mono); font-size: 13px; }
.frp-rules-table :deep(.text-secondary) { color: var(--fonu-text-secondary); }
.rule-pane { min-width: 0; display: flex; flex-direction: column; gap: var(--havline-space-4); }
.rule-pane__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-3); padding: var(--havline-space-3) var(--havline-space-4); background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); }
.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__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; }
.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); }
@media (max-width: 1100px) { .frp-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
</style>
+17 -18
View File
@@ -6,11 +6,11 @@
<div class="auth-brand">
<img class="auth-brand__logo" :src="logoImg" alt="Fonu" />
<img class="auth-brand__logo" :src="logoImg" alt="Havline" />
<div>
<h1>Fonu</h1>
<h1>Havline</h1>
<p>让 NAS 访问更简单</p>
@@ -57,7 +57,7 @@ import { useRouter } from 'vue-router'
import { NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
import { api } from '../api/client'
import logoImg from '../assets/brand/logo.png'
import logoImg from '../assets/brand/havline-logo.svg'
@@ -111,9 +111,9 @@ async function submit() {
place-items: center;
padding: var(--fonu-space-5);
padding: var(--havline-space-5);
background: var(--fonu-bg);
background: var(--havline-bg);
}
@@ -125,15 +125,15 @@ async function submit() {
max-width: 420px;
background: var(--fonu-surface);
background: var(--havline-surface);
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: var(--fonu-radius);
border-radius: var(--havline-radius);
box-shadow: var(--fonu-shadow-md);
box-shadow: var(--havline-shadow-md);
padding: var(--fonu-space-6) var(--fonu-space-5);
padding: var(--havline-space-6) var(--havline-space-5);
}
@@ -145,9 +145,9 @@ async function submit() {
align-items: center;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--fonu-space-5);
margin-bottom: var(--havline-space-5);
}
@@ -187,7 +187,7 @@ async function submit() {
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
@@ -195,23 +195,22 @@ async function submit() {
.auth-title {
margin: 0 0 var(--fonu-space-5);
margin: 0 0 var(--havline-space-5);
font-size: 16px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.auth-hint {
margin: var(--fonu-space-4) 0 0;
margin: var(--havline-space-4) 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
</style>
+25 -25
View File
@@ -1,12 +1,12 @@
<template>
<PageHeader
title="日志中心"
description="系统日志记录 Fonu 全部运行输出;访问日志与 Nginx 错误日志来自反向代理"
description="系统日志记录 Havline 全部运行输出;访问日志与 Nginx 错误日志来自反向代理"
/>
<LoadError v-if="pageError" :message="pageError" @retry="loadAll" />
<FonuCard v-else flush class="logs-card">
<HavlineCard v-else flush class="logs-card">
<n-tabs v-model:value="tab" type="line" animated class="logs-tabs">
<n-tab-pane name="system" tab="系统日志">
<LogToolbar
@@ -15,7 +15,7 @@
:show-level="true"
@refresh="loadSystemLogs"
/>
<p class="tab-hint">Fonu 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。</p>
<p class="tab-hint">Havline 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。</p>
<n-data-table
v-if="filteredSystemLogs.length > 0"
class="log-table"
@@ -150,7 +150,7 @@
<EmptyState
v-if="!loadingAccess && accessLogs.length === 0"
title="暂无访问日志"
description="此处记录经 Nginx 反向代理的访问(非 Fonu 管理界面本身)。请通过代理域名访问后刷新。"
description="此处记录经 Nginx 反向代理的访问(非 Havline 管理界面本身)。请通过代理域名访问后刷新。"
/>
</n-tab-pane>
@@ -178,7 +178,7 @@
/>
</n-tab-pane>
</n-tabs>
</FonuCard>
</HavlineCard>
</template>
<script setup lang="ts">
@@ -200,7 +200,7 @@ import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { AccessLogEntry, CfTunnel, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import {
@@ -863,7 +863,7 @@ async function loadAll() {
try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs(), loadCfTunnels()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
pageError.value = error instanceof Error ? error.message : '请检查 Havline 服务是否正常运行'
}
}
@@ -909,7 +909,7 @@ onMounted(() => {
<style scoped>
.logs-card :deep(.n-tabs-nav) {
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
padding: var(--havline-space-4) var(--havline-space-5) 0;
}
.logs-card :deep(.n-tabs-tab) {
@@ -917,26 +917,26 @@ onMounted(() => {
}
.logs-card :deep(.n-tab-pane) {
padding-top: var(--fonu-space-2);
padding-top: var(--havline-space-2);
}
.tab-hint {
margin: 0 var(--fonu-space-5) var(--fonu-space-3);
margin: 0 var(--havline-space-5) var(--havline-space-3);
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.log-pagination {
display: flex;
justify-content: flex-end;
padding: var(--fonu-space-4) var(--fonu-space-5);
border-top: 1px solid var(--fonu-border);
padding: var(--havline-space-4) var(--havline-space-5);
border-top: 1px solid var(--havline-border);
}
.logs-card :deep(.log-table .n-data-table-th) {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.logs-card :deep(.log-table .n-data-table-base-table) {
@@ -962,12 +962,12 @@ onMounted(() => {
}
.logs-card :deep(.log-table .n-data-table-tr.log-row:hover .n-data-table-td) {
background: color-mix(in srgb, var(--fonu-brand) 5%, var(--fonu-surface));
background: color-mix(in srgb, var(--havline-brand) 5%, var(--havline-surface));
}
.log-time {
white-space: nowrap;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
font-variant-numeric: tabular-nums;
}
@@ -976,7 +976,7 @@ onMounted(() => {
}
.log-path {
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.log-message {
@@ -984,7 +984,7 @@ onMounted(() => {
}
.mono {
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
font-size: 12px;
}
@@ -1009,18 +1009,18 @@ onMounted(() => {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-2);
gap: var(--havline-space-3);
margin-bottom: var(--havline-space-2);
}
.cf-log-pre {
max-height: 620px;
margin: 0 var(--fonu-space-5);
padding: var(--fonu-space-4);
margin: 0 var(--havline-space-5);
padding: var(--havline-space-4);
overflow: auto;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
background: var(--havline-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
+97 -97
View File
@@ -66,7 +66,7 @@
<div class="alert-body">
<div class="alert-body__text">
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
<span>VPS 上的实际配置与 Fonu 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。</span>
<span>VPS 上的实际配置与 Havline 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。</span>
<span class="mono alert-body__detail">{{ driftPreview }}</span>
</div>
<n-popconfirm @positive-click="redeployAll">
@@ -75,7 +75,7 @@
重新部署全部
</n-button>
</template>
将按 Fonu 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续?
将按 Havline 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续?
</n-popconfirm>
</div>
</n-alert>
@@ -136,7 +136,7 @@
</section>
<!-- ② 环境与 Nginx -->
<FonuCard class="section-card nginx-card" title="Nginx(公网反代依赖)">
<HavlineCard 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>
@@ -167,10 +167,10 @@
<n-button size="small" :loading="manualRefreshing" @click="refreshAll({ silent: false })">重新检测</n-button>
</div>
</div>
</FonuCard>
</HavlineCard>
<!-- ③ 规则台账 -->
<FonuCard class="section-card" title="规则台账">
<HavlineCard class="section-card" title="规则台账">
<div class="section-body">
<p class="form-hint">穿透规则保存后不会自动改 VPS:点「部署反代」才会发布到 Nginx(自动推送证书并做回源自检)</p>
<div v-if="candidates.length" class="route-table-wrap">
@@ -204,7 +204,7 @@
</div>
</details>
</div>
</FonuCard>
</HavlineCard>
</div>
</div>
@@ -349,8 +349,8 @@
</div>
<div class="proxy-modal__scroll conf-scroll">
<p class="field-hint">
由 VPS 上的 fonu-agent 生成,位于 Nginx 的 vhost 配置目录(文件名
<span class="mono">fonu-{{ confDomain }}.conf</span>)。请勿在 VPS 上手工修改:
由 VPS 上的 havline-agent 生成,位于 Nginx 的 vhost 配置目录(文件名
<span class="mono">havline-{{ confDomain }}.conf</span>)。请勿在 VPS 上手工修改:
下次部署会被重新生成覆盖,改动请到「部署设置」或穿透规则里调整。
</p>
<div class="conf-layout">
@@ -415,7 +415,7 @@ import {
import EmptyState from '../components/EmptyState.vue'
import ConfigDiffView from '../components/ConfigDiffView.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -606,11 +606,11 @@ function stateOf(candidate: FrpProxy): { state: RouteState; label: string; kind:
return { state: 'none', label: '未部署', kind: 'disabled' }
}
// 证书来源(P0-4):Fonu 证书库里是否有该域名的证书(决定重新部署时能否推送证书、公网 HTTPS 能否终结)
// 证书来源(P0-4):Havline 证书库里是否有该域名的证书(决定重新部署时能否推送证书、公网 HTTPS 能否终结)
function certSummary(domain: string) {
const record = certificates.value.find((item) => item.domain === domain)
if (!record) return 'Fonu 证书库:无(需先在「证书管理」申请)'
return `Fonu 证书库:有(剩余 ${record.days_left} 天)`
if (!record) return 'Havline 证书库:无(需先在「证书管理」申请)'
return `Havline 证书库:有(剩余 ${record.days_left} 天)`
}
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务。
@@ -1036,47 +1036,47 @@ onMounted(() => { void load() })
<style scoped>
/* 分区:提示 → 入口行 → ① 统计区 → ② 环境与 Nginx → ③ 规则台账 */
.page-alert { margin-bottom: var(--fonu-space-3); }
.alert-body { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); }
.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(--fonu-error); overflow-wrap: anywhere; }
.entrance-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-5); padding: 10px var(--fonu-space-5); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-info-soft); }
.entrance-row__text { font-size: 13px; color: var(--fonu-text); }
.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); }
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-5); }
.stat-card { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); padding: var(--fonu-space-4) var(--fonu-space-5); min-height: 118px; }
.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(--fonu-info); }
.stat-card__icon--green { color: var(--fonu-brand); }
.stat-card__icon--amber { color: var(--fonu-warning); }
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--fonu-text); letter-spacing: -0.03em; }
.stat-card__value-total { margin-left: 2px; font-size: 15px; font-weight: 600; color: var(--fonu-text-muted); }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); }
.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); }
/* FonuCard 自身已有 24px 内边距,这里不再叠加(与 FrpView 的用法一致) */
.section-card { margin-bottom: var(--fonu-space-5); }
/* HavlineCard 自身已有 24px 内边距,这里不再叠加(与 FrpView 的用法一致) */
.section-card { margin-bottom: var(--havline-space-5); }
.section-body { padding: 0; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-3) var(--fonu-space-4); }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--havline-space-3) var(--havline-space-4); }
/* Nginx 卡的 label/value 双列:只在本卡生效,避免影响弹窗里 n-form-item 的内部布局 */
.nginx-card .field-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.nginx-card .field-grid > div { display: grid; gap: 4px; min-width: 0; }
.nginx-card .field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.nginx-card .field-grid label { color: var(--havline-text-secondary); font-size: 12px; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.field-grid--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.full-width { width: 100%; }
.agent-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-3); }
.nginx-error { margin-top: var(--fonu-space-2); color: var(--fonu-error); overflow-wrap: anywhere; }
.agent-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: var(--havline-space-3); }
.nginx-error { margin-top: var(--havline-space-2); color: var(--havline-error); overflow-wrap: anywhere; }
/* 规则台账:与本地反向代理页一致,用 n-data-table 呈现(列宽固定,各行列天然对齐) */
/* 单元格元素由 render 函数的 h() 创建,不带 scoped 的 data-v 属性,必须用 :deep() 才能命中 */
.route-table-wrap { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
.route-table-wrap { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); overflow: hidden; }
.route-table-wrap :deep(.cell-name) { display: grid; gap: 4px; }
.route-table-wrap :deep(.cell-name__head) { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.route-table-wrap :deep(.cell-name strong) { font-size: 13.5px; color: var(--fonu-text); }
.route-table-wrap :deep(.cell-muted) { font-size: 12px; color: var(--fonu-text-muted); }
.route-table-wrap :deep(.cell-name strong) { font-size: 13.5px; color: var(--havline-text); }
.route-table-wrap :deep(.cell-muted) { font-size: 12px; color: var(--havline-text-muted); }
.route-table-wrap :deep(.cell-wrap) { overflow-wrap: anywhere; }
.route-table-wrap :deep(.cell-health) { display: flex; gap: var(--fonu-space-2); flex-wrap: wrap; }
.route-table-wrap :deep(.cell-health) { display: flex; gap: var(--havline-space-2); flex-wrap: wrap; }
.route-table-wrap :deep(.cell-upstream) { display: grid; gap: 2px; }
.route-table-wrap :deep(.cell-time) { display: grid; gap: 2px; }
.route-table-wrap :deep(.cell-domains) { display: grid; gap: 2px; }
@@ -1087,107 +1087,107 @@ onMounted(() => { void load() })
/* 域名不撑满整格:按钮紧跟在域名后面(不贴近右侧的协议列) */
.route-table-wrap :deep(.cell-domain .mono) { flex: 0 1 auto; min-width: 0; }
.route-table-wrap :deep(.cell-actions) { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
.route-table-wrap :deep(.mono) { font-family: var(--fonu-mono); font-size: 13px; }
.route-table-wrap :deep(.mono) { font-family: var(--havline-mono); font-size: 13px; }
.raw-routes { margin-top: var(--fonu-space-4); font-size: 12px; color: var(--fonu-text-secondary); }
.raw-routes summary { cursor: pointer; color: var(--fonu-text-secondary); }
.raw-routes summary:hover { color: var(--fonu-text); }
.raw-routes__list { display: grid; gap: 6px; margin-top: var(--fonu-space-3); }
.raw-routes { margin-top: var(--havline-space-4); font-size: 12px; color: var(--havline-text-secondary); }
.raw-routes summary { cursor: pointer; color: var(--havline-text-secondary); }
.raw-routes summary:hover { color: var(--havline-text); }
.raw-routes__list { display: grid; gap: 6px; margin-top: var(--havline-space-3); }
.raw-route-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.raw-route-row .mono { color: var(--fonu-text); }
.raw-route-row .mono { color: var(--havline-text); }
/* 弹窗样式对齐本地反向代理页(ProxyView)的规则弹窗:900px 宽、无外层内边距、头部/tabbar/页脚各自带内边距 */
.proxy-modal { display: flex; flex-direction: column; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.proxy-modal { display: flex; flex-direction: column; 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); }
/* 带右侧说明栏的弹窗:整体改为横向,左表单列 + 右说明栏 */
.proxy-modal--with-help { flex-direction: row; }
.proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; }
.proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--fonu-space-5); background: var(--fonu-bg-muted); border-left: 1px solid var(--fonu-border); font-size: 13px; color: var(--fonu-text-secondary); overflow: auto; }
.proxy-modal__help h4 { margin: 0 0 var(--fonu-space-3); font-size: 14px; font-weight: 600; color: var(--fonu-text); }
.proxy-modal__help { width: 300px; flex-shrink: 0; padding: var(--havline-space-5); background: var(--havline-bg-muted); border-left: 1px solid var(--havline-border); font-size: 13px; color: var(--havline-text-secondary); overflow: auto; }
.proxy-modal__help h4 { margin: 0 0 var(--havline-space-3); font-size: 14px; font-weight: 600; color: var(--havline-text); }
.proxy-modal__help ol { margin: 0; padding-left: 18px; line-height: 1.75; }
.proxy-modal__help li + li { margin-top: 10px; }
.proxy-modal__help code { font-size: 12px; background: var(--fonu-surface); padding: 1px 4px; border-radius: 4px; }
.proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--fonu-space-5); padding: 12px; border-radius: 8px; background: var(--fonu-info-soft); color: var(--fonu-info); font-size: 12px; line-height: 1.6; }
.proxy-modal__help code { font-size: 12px; background: var(--havline-surface); padding: 1px 4px; border-radius: 4px; }
.proxy-modal__tip { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--havline-space-5); padding: 12px; border-radius: 8px; background: var(--havline-info-soft); color: var(--havline-info); font-size: 12px; line-height: 1.6; }
.proxy-modal__tip-icon { flex-shrink: 0; margin-top: 1px; font-size: 16px; }
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; flex-shrink: 0; }
.proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: var(--fonu-space-3) var(--fonu-space-5) 0; border-bottom: 1px solid var(--fonu-border); flex-shrink: 0; }
.proxy-modal__tab { margin: 0; padding: 8px 2px 10px; border: none; background: none; font: inherit; font-size: 14px; color: var(--fonu-text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.proxy-modal__tab:hover { color: var(--fonu-text); }
.proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); padding: var(--havline-space-5) var(--havline-space-5) 0; flex-shrink: 0; }
.proxy-modal__tabbar { display: flex; gap: var(--havline-space-5); padding: var(--havline-space-3) var(--havline-space-5) 0; border-bottom: 1px solid var(--havline-border); flex-shrink: 0; }
.proxy-modal__tab { margin: 0; padding: 8px 2px 10px; border: none; background: none; font: inherit; font-size: 14px; color: var(--havline-text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.proxy-modal__tab:hover { color: var(--havline-text); }
.proxy-modal__tab--active { color: var(--havline-brand-text); font-weight: 600; border-bottom-color: var(--havline-brand); }
.proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; }
.proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--havline-space-4) var(--havline-space-5) 0; }
.proxy-modal__pane :deep(.n-form) { width: 100%; }
.proxy-modal__pane :deep(.n-form-item .n-form-item-blank) { display: block; width: 100%; }
.modal-footer { display: flex; justify-content: flex-end; gap: var(--fonu-space-3); padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); border-top: 1px solid var(--fonu-border); margin-top: var(--fonu-space-2); flex-shrink: 0; }
.form-switch-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: var(--fonu-space-4); }
.form-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); padding: 12px 14px; border: 1px solid var(--fonu-border); border-radius: 10px; background: var(--fonu-bg); }
.form-switch-row__label { font-size: 14px; font-weight: 600; color: var(--fonu-text); }
.form-switch-row__hint { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); line-height: 1.5; }
.field-hint { margin: 0; font-size: 12px; color: var(--fonu-text-muted); line-height: 1.5; }
.panel-title { margin: var(--fonu-space-4) 0 var(--fonu-space-3); font-size: 14px; font-weight: 600; color: var(--fonu-text); }
.modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--fonu-text); }
.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; }
.form-switch-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: var(--havline-space-4); }
.form-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); padding: 12px 14px; border: 1px solid var(--havline-border); border-radius: 10px; background: var(--havline-bg); }
.form-switch-row__label { font-size: 14px; font-weight: 600; color: var(--havline-text); }
.form-switch-row__hint { margin-top: 4px; font-size: 12px; color: var(--havline-text-muted); line-height: 1.5; }
.field-hint { margin: 0; font-size: 12px; color: var(--havline-text-muted); line-height: 1.5; }
.panel-title { margin: var(--havline-space-4) 0 var(--havline-space-3); font-size: 14px; font-weight: 600; color: var(--havline-text); }
.modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--havline-text); }
.code-block {
margin: 0; padding: var(--fonu-space-3) var(--fonu-space-4); background: var(--fonu-bg-muted);
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
font-family: var(--fonu-mono); font-size: 12.5px; line-height: 1.65; color: var(--fonu-text);
margin: 0; padding: var(--havline-space-3) var(--havline-space-4); background: var(--havline-bg-muted);
border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm);
font-family: var(--havline-mono); font-size: 12.5px; line-height: 1.65; color: var(--havline-text);
overflow: auto; white-space: pre-wrap; word-break: break-all;
}
/* 反代配置查看弹窗:内容区与头部同样的 24px 内边距,代码块自身滚动 */
.conf-scroll { padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.conf-scroll .field-hint { margin-bottom: var(--fonu-space-3); line-height: 1.65; }
.conf-scroll { padding: var(--havline-space-4) var(--havline-space-5) 0; }
.conf-scroll .field-hint { margin-bottom: var(--havline-space-3); line-height: 1.65; }
.conf-view { max-height: 62vh; }
/* 版本面板 + 差异区:左侧固定宽度列表,右侧自适应(与弹窗 900px 宽度对齐) */
.conf-layout { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--fonu-space-4); }
.conf-versions { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
.conf-layout { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--havline-space-4); }
.conf-versions { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); overflow: hidden; }
.conf-versions__head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
background: var(--fonu-bg-muted); font-size: 12px; color: var(--fonu-text-secondary);
padding: 6px var(--havline-space-3); border-bottom: 1px solid var(--havline-border);
background: var(--havline-bg-muted); font-size: 12px; color: var(--havline-text-secondary);
}
.conf-versions__count { color: var(--fonu-text-muted); }
.conf-versions__empty { padding: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
.conf-versions__empty--error { color: var(--fonu-error); }
.conf-versions__count { color: var(--havline-text-muted); }
.conf-versions__empty { padding: var(--havline-space-3); font-size: 12px; color: var(--havline-text-muted); line-height: 1.6; }
.conf-versions__empty--error { color: var(--havline-error); }
.conf-versions__list { max-height: 52vh; overflow: auto; margin: 0; padding: 0; list-style: none; }
.conf-versions__item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
font-size: 12px; color: var(--fonu-text-secondary); cursor: pointer;
padding: 6px var(--havline-space-3); border-bottom: 1px solid var(--havline-border);
font-size: 12px; color: var(--havline-text-secondary); cursor: pointer;
}
.conf-versions__item:hover { background: var(--fonu-bg-muted); }
.conf-versions__item--active { background: var(--fonu-info-soft); color: var(--fonu-text); }
.conf-versions__item:hover { background: var(--havline-bg-muted); }
.conf-versions__item--active { background: var(--havline-info-soft); color: var(--havline-text); }
.conf-versions__time { font-size: 12px; }
.conf-versions__size { flex: none; color: var(--fonu-text-muted); }
.conf-body { min-width: 0; display: grid; gap: var(--fonu-space-3); align-content: start; }
.conf-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.conf-actions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; }
.conf-versions__size { flex: none; color: var(--havline-text-muted); }
.conf-body { min-width: 0; display: grid; gap: var(--havline-space-3); align-content: start; }
.conf-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.conf-actions__hint { font-size: 12px; color: var(--havline-text-muted); line-height: 1.6; }
.form-hint { color: var(--havline-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--havline-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; }
/* 左:服务端列表 / 右:该服务端的反代台账(与「内网穿透」「公网服务端」页同构) */
.proxy-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: var(--fonu-space-4); align-items: start; }
.proxy-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: var(--havline-space-4); align-items: start; }
.proxy-layout .section-card { min-width: 0; }
.proxy-detail { min-width: 0; }
.server-sidebar { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); box-shadow: var(--fonu-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--fonu-space-4); border-bottom: 1px solid var(--fonu-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--fonu-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--fonu-space-3); max-height: 720px; overflow: auto; }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--fonu-brand-hover); }
.server-item--active { border-color: var(--fonu-brand); box-shadow: 0 0 0 1px var(--fonu-brand-soft); }
.server-sidebar { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); overflow: hidden; }
.server-sidebar__head { padding: var(--havline-space-4); border-bottom: 1px solid var(--havline-border); }
.server-sidebar__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.server-sidebar__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--havline-text); flex-shrink: 0; }
.server-sidebar__list { display: flex; flex-direction: column; gap: 10px; padding: var(--havline-space-3); max-height: 720px; overflow: auto; }
.server-item { width: 100%; font: inherit; text-align: left; padding: 12px 14px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius); background: var(--havline-surface); cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.server-item:hover { border-color: var(--havline-brand-hover); }
.server-item--active { border-color: var(--havline-brand); box-shadow: 0 0 0 1px var(--havline-brand-soft); }
.server-item__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--fonu-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--fonu-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-3); margin-top: 6px; font-size: 12px; color: var(--fonu-text-muted); }
.server-item__name { font-size: 14px; font-weight: 600; color: var(--havline-text); overflow-wrap: anywhere; }
.server-item__endpoint { margin-top: 6px; font-size: 12px; color: var(--havline-text-secondary); overflow-wrap: anywhere; }
.server-item__meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); margin-top: 6px; font-size: 12px; color: var(--havline-text-muted); }
.server-item__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
@media (max-width: 1100px) { .proxy-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
/* 首屏一次性入场;用户偏好减少动效时关闭 */
@keyframes fonu-fade-up {
@keyframes havline-fade-up {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.stats-row, .entrance-row, .section-card { animation: fonu-fade-up 160ms ease-out both; }
.stats-row, .entrance-row, .section-card { animation: havline-fade-up 160ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
.stats-row, .entrance-row, .section-card { animation: none; }
}
+133 -133
View File
@@ -58,7 +58,7 @@
</div>
<div class="proxy-layout">
<FonuCard flush class="proxy-panel">
<HavlineCard flush class="proxy-panel">
<div class="proxy-toolbar">
<n-input
v-model:value="search"
@@ -126,7 +126,7 @@
title="没有匹配的规则"
description="试试调整搜索关键词或筛选条件。"
/>
</FonuCard>
</HavlineCard>
</div>
</template>
@@ -502,7 +502,7 @@
新建并开启「自动托管」。
</p>
<p class="field-hint">域名的根 Zone 必须已加入当前 Cloudflare 账号,且 API Token 的 Zone Resources 包含该域名。</p>
<p v-if="!form.exit_local" class="field-hint">仅走 Cloudflare 出口时,HTTPS 由 Cloudflare 边缘提供,不需要在 Fonu 申请本机证书。</p>
<p v-if="!form.exit_local" class="field-hint">仅走 Cloudflare 出口时,HTTPS 由 Cloudflare 边缘提供,不需要在 Havline 申请本机证书。</p>
</div>
</n-form-item>
@@ -915,7 +915,7 @@ import { api, asList } from '../api/client'
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import ProxyAccessLogBox from '../components/ProxyAccessLogBox.vue'
import NginxCodeEditor from '../components/NginxCodeEditor.vue'
import LoadError from '../components/LoadError.vue'
@@ -1943,7 +1943,7 @@ async function load() {
cfTunnels.value = []
}
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
loadError.value = error instanceof Error ? error.message : '请检查 Havline 服务是否正常运行'
} finally {
loading.value = false
}
@@ -2331,16 +2331,16 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.stats-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
gap: var(--havline-space-4);
margin-bottom: var(--havline-space-4);
}
.stat-card {
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
padding: var(--fonu-space-4);
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);
min-height: 118px;
}
@@ -2355,7 +2355,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
font-size: 28px;
font-weight: 700;
line-height: 1.1;
color: var(--fonu-text);
color: var(--havline-text);
}
.stat-card__value--sm { font-size: 22px; }
@@ -2369,13 +2369,13 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.stat-card__sub {
margin-top: 4px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.proxy-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
align-items: start;
}
@@ -2383,7 +2383,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
min-width: 0;
}
.proxy-panel :deep(.fonu-card__body) {
.proxy-panel :deep(.havline-card__body) {
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
@@ -2392,9 +2392,9 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-toolbar {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
padding: var(--fonu-space-4);
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-3);
padding: var(--havline-space-4);
border-bottom: 1px solid var(--havline-border);
flex-wrap: wrap;
}
@@ -2417,7 +2417,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-loading {
display: flex;
justify-content: center;
padding: var(--fonu-space-6) 0;
padding: var(--havline-space-6) 0;
}
.proxy-drag-handle {
@@ -2426,7 +2426,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
justify-content: center;
width: 24px;
height: 24px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
cursor: grab;
}
@@ -2453,7 +2453,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-table :deep(.domain-cell__main) {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.proxy-table :deep(.domain-cell__links) {
@@ -2474,7 +2474,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
flex: 1;
min-width: 0;
font-size: 12px;
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
color: #2563eb;
text-decoration: none;
overflow: hidden;
@@ -2547,14 +2547,14 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
}
.domain-cell__popover-links :deep(.domain-cell__link-row:hover) {
background: var(--fonu-bg);
background: var(--havline-bg);
}
.domain-cell__popover-links :deep(.domain-cell__link) {
flex: 1;
min-width: 0;
font-size: 12px;
font-family: var(--fonu-mono);
font-family: var(--havline-mono);
color: #2563eb;
text-decoration: none;
overflow: hidden;
@@ -2600,25 +2600,25 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--fonu-surface);
border-radius: var(--fonu-radius);
background: var(--havline-surface);
border-radius: var(--havline-radius);
overflow: hidden;
box-shadow: var(--fonu-shadow-md);
box-shadow: var(--havline-shadow-md);
}
.proxy-detail-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
gap: var(--havline-space-4);
padding: var(--havline-space-5) var(--havline-space-5) 0;
flex-shrink: 0;
}
.proxy-detail-modal__intro {
display: flex;
align-items: center;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
min-width: 0;
}
@@ -2636,22 +2636,22 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
border-radius: 999px;
font-size: 12px;
font-weight: 500;
color: var(--fonu-text-muted);
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
color: var(--havline-text-muted);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
}
.proxy-detail-modal__conn.is-active {
color: var(--fonu-brand-text);
background: var(--fonu-brand-soft);
color: var(--havline-brand-text);
background: var(--havline-brand-soft);
border-color: rgba(16, 185, 129, 0.25);
}
.proxy-detail__tabbar {
display: flex;
gap: var(--fonu-space-5);
padding: var(--fonu-space-3) var(--fonu-space-5) 0;
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-5);
padding: var(--havline-space-3) var(--havline-space-5) 0;
border-bottom: 1px solid var(--havline-border);
flex-shrink: 0;
}
@@ -2662,20 +2662,20 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
background: none;
font: inherit;
font-size: 14px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.proxy-detail__tab:hover {
color: var(--fonu-text);
color: var(--havline-text);
}
.proxy-detail__tab--active {
color: var(--fonu-brand-text);
color: var(--havline-brand-text);
font-weight: 600;
border-bottom-color: var(--fonu-brand);
border-bottom-color: var(--havline-brand);
}
.proxy-detail__scroll {
@@ -2689,7 +2689,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-detail__pane {
width: 100%;
box-sizing: border-box;
padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5);
padding: var(--havline-space-4) var(--havline-space-5) var(--havline-space-5);
}
.proxy-detail__pane--overview {
@@ -2698,7 +2698,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
}
.overview-strip {
@@ -2706,8 +2706,8 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.8fr) minmax(0, 1.4fr) auto;
gap: 12px 16px;
padding: 12px 14px;
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
border-radius: 10px;
flex-shrink: 0;
}
@@ -2718,15 +2718,15 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
flex-wrap: wrap;
gap: 8px 12px;
padding: 10px 14px;
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
border-radius: 10px;
flex-shrink: 0;
}
.overview-security__label {
font-size: 11px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
letter-spacing: 0.02em;
flex-shrink: 0;
}
@@ -2740,7 +2740,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.overview-security__empty {
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.overview-strip__item {
@@ -2752,14 +2752,14 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.overview-strip__label {
font-size: 11px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
letter-spacing: 0.02em;
}
.overview-strip__value {
font-size: 13px;
font-weight: 500;
color: var(--fonu-text);
color: var(--havline-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2768,7 +2768,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.overview-main {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
flex: 1 1 0;
min-height: 0;
align-items: stretch;
@@ -2782,9 +2782,9 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
}
.overview-card {
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
background: var(--fonu-surface);
background: var(--havline-surface);
padding: 14px 16px;
}
@@ -2792,7 +2792,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
margin-bottom: 10px;
}
@@ -2806,7 +2806,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
margin-left: 6px;
font-size: 12px;
font-weight: 400;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.overview-card--chart {
@@ -2834,13 +2834,13 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
justify-content: center;
padding: 12px 14px;
border-radius: 10px;
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
}
.overview-metric__label {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.overview-metric__value {
@@ -2848,7 +2848,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
font-size: 18px;
font-weight: 600;
line-height: 1.2;
color: var(--fonu-text);
color: var(--havline-text);
}
.overview-card--clients {
@@ -2871,7 +2871,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
}
.overview-client-list:hover {
scrollbar-color: rgba(148, 163, 184, 0.55) var(--fonu-bg-muted);
scrollbar-color: rgba(148, 163, 184, 0.55) var(--havline-bg-muted);
}
.overview-client-list::-webkit-scrollbar {
@@ -2891,7 +2891,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
padding: 7px 10px;
border-radius: 8px;
font-size: 12px;
@@ -2902,12 +2902,12 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
}
.overview-client-row:hover {
background: var(--fonu-bg);
background: var(--havline-bg);
}
.overview-client-row__time {
flex-shrink: 0;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
font-size: 11px;
}
@@ -2919,7 +2919,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.traffic-live-legend--compact {
@@ -2933,11 +2933,11 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
flex-direction: column;
overflow: hidden;
padding-top: var(--fonu-space-3);
padding-top: var(--havline-space-3);
}
.nginx-pane-alert {
margin-bottom: var(--fonu-space-2);
margin-bottom: var(--havline-space-2);
flex-shrink: 0;
}
@@ -2993,7 +2993,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
gap: 14px;
margin-bottom: 8px;
font-size: 12px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.traffic-live-legend__item {
@@ -3072,7 +3072,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.listen-types {
display: flex;
align-items: center;
gap: var(--fonu-space-5);
gap: var(--havline-space-5);
}
.proxy-modal {
@@ -3080,10 +3080,10 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
width: 900px;
max-width: 95vw;
max-height: 90vh;
background: var(--fonu-surface);
border-radius: var(--fonu-radius);
background: var(--havline-surface);
border-radius: var(--havline-radius);
overflow: hidden;
box-shadow: var(--fonu-shadow-md);
box-shadow: var(--havline-shadow-md);
}
.proxy-modal__form {
@@ -3098,15 +3098,15 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
gap: var(--havline-space-3);
padding: var(--havline-space-5) var(--havline-space-5) 0;
}
.proxy-modal__tabbar {
display: flex;
gap: var(--fonu-space-5);
padding: var(--fonu-space-3) var(--fonu-space-5) 0;
border-bottom: 1px solid var(--fonu-border);
gap: var(--havline-space-5);
padding: var(--havline-space-3) var(--havline-space-5) 0;
border-bottom: 1px solid var(--havline-border);
flex-shrink: 0;
}
@@ -3117,20 +3117,20 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
background: none;
font: inherit;
font-size: 14px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.proxy-modal__tab:hover {
color: var(--fonu-text);
color: var(--havline-text);
}
.proxy-modal__tab--active {
color: var(--fonu-brand-text);
color: var(--havline-brand-text);
font-weight: 600;
border-bottom-color: var(--fonu-brand);
border-bottom-color: var(--havline-brand);
}
.proxy-modal__tab-label {
@@ -3148,7 +3148,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-modal__pane {
width: 100%;
box-sizing: border-box;
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
padding: var(--havline-space-4) var(--havline-space-5) 0;
}
.proxy-modal__pane--nginx {
@@ -3175,15 +3175,15 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.security-header {
padding: 14px 16px 16px;
border-radius: 10px;
background: var(--fonu-bg);
border: 1px solid var(--fonu-border);
background: var(--havline-bg);
border: 1px solid var(--havline-border);
}
.security-header__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.security-header__preset {
@@ -3203,7 +3203,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.security-header__status-label {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
flex-shrink: 0;
}
@@ -3215,24 +3215,24 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.security-header__empty {
font-size: 13px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
}
.security-header__note {
margin: 14px 0 0;
padding-top: 14px;
border-top: 1px solid var(--fonu-border);
border-top: 1px solid var(--havline-border);
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.6;
}
.security-collapse {
width: 100%;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
overflow: hidden;
background: var(--fonu-surface);
background: var(--havline-surface);
}
.security-collapse :deep(.n-collapse-item) {
@@ -3242,7 +3242,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
}
.security-collapse :deep(.n-collapse-item + .n-collapse-item) {
border-top: 1px solid var(--fonu-border) !important;
border-top: 1px solid var(--havline-border) !important;
}
/* naive-ui 首个 collapse-item 默认 padding-top: 0,导致首项偏矮 */
@@ -3251,7 +3251,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
min-height: 44px;
box-sizing: border-box;
font-weight: 600;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.security-collapse :deep(.n-collapse-item:first-child > .n-collapse-item__header) {
@@ -3269,7 +3269,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.security-panel__fields {
padding: 8px 0 16px;
margin-bottom: 4px;
border-bottom: 1px solid var(--fonu-border);
border-bottom: 1px solid var(--havline-border);
}
.security-panel__fields :deep(.n-form-item) {
@@ -3289,40 +3289,40 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.security-panel .security-fields-grid {
padding: 8px 0 16px;
margin-bottom: 4px;
border-bottom: 1px solid var(--fonu-border);
border-bottom: 1px solid var(--havline-border);
}
.security-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
padding: 12px 0;
}
.security-panel .security-option + .security-option,
.security-panel__fields + .security-option,
.security-fields-grid + .security-option {
border-top: 1px solid var(--fonu-border);
border-top: 1px solid var(--havline-border);
}
.security-option__label {
font-size: 14px;
font-weight: 500;
color: var(--fonu-text);
color: var(--havline-text);
}
.security-option__hint {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.45;
}
.security-fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
padding: 4px 0 8px;
}
@@ -3347,19 +3347,19 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-modal__help {
width: 300px;
flex-shrink: 0;
padding: var(--fonu-space-5);
padding: var(--havline-space-5);
background: #f8fafc;
border-left: 1px solid var(--fonu-border);
border-left: 1px solid var(--havline-border);
font-size: 13px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
overflow: auto;
}
.proxy-modal__help h4 {
margin: 0 0 var(--fonu-space-3);
margin: 0 0 var(--havline-space-3);
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.proxy-modal__help ol {
@@ -3379,7 +3379,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-modal__help code {
font-size: 12px;
background: var(--fonu-surface);
background: var(--havline-surface);
padding: 1px 4px;
border-radius: 4px;
}
@@ -3388,7 +3388,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: var(--fonu-space-5);
margin-top: var(--havline-space-5);
padding: 12px;
border-radius: 8px;
background: #eff6ff;
@@ -3416,7 +3416,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.form-label__help {
font-size: 14px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
cursor: help;
}
@@ -3436,11 +3436,11 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.field-hint {
margin: 0;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.5;
}
.field-hint--warning { color: var(--fonu-warning); }
.field-hint--warning { color: var(--havline-warning); }
.exit-options {
display: flex;
@@ -3459,8 +3459,8 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius-sm);
}
.discovery-item__main {
@@ -3480,7 +3480,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
align-items: flex-start;
gap: 24px;
margin-bottom: var(--fonu-space-5);
margin-bottom: var(--havline-space-5);
}
.listen-col {
@@ -3501,7 +3501,7 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.listen-col__label {
font-size: 14px;
line-height: 1.25;
color: var(--fonu-text);
color: var(--havline-text);
}
.required-mark {
@@ -3522,30 +3522,30 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
}
.form-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
padding: 12px 14px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.form-switch-row__label {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.form-switch-row__hint {
margin-top: 4px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.5;
}
@@ -3558,10 +3558,10 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--fonu-space-3);
padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5);
border-top: 1px solid var(--fonu-border);
margin-top: var(--fonu-space-2);
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);
}
.clients-list {
@@ -3579,15 +3579,15 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
padding: 8px 10px;
border-radius: 8px;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.mono { font-family: var(--fonu-mono); }
.text-muted { color: var(--fonu-text-muted); }
.text-secondary { color: var(--fonu-text-secondary); }
.mono { font-family: var(--havline-mono); }
.text-muted { color: var(--havline-text-muted); }
.text-secondary { color: var(--havline-text-secondary); }
@media (max-width: 1199px) {
.stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@@ -3619,12 +3619,12 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
.proxy-modal__help {
width: 100%;
border-left: none;
border-top: 1px solid var(--fonu-border);
border-top: 1px solid var(--havline-border);
}
.listen-row {
flex-direction: column;
align-items: stretch;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
}
.listen-col--port {
+87 -87
View File
@@ -7,7 +7,7 @@
<n-tabs v-model:value="settingsTab" type="line" animated class="settings-tabs">
<n-tab-pane name="general" tab="常规">
<div class="settings-grid">
<FonuCard title="外观" subtitle="界面主题与时区">
<HavlineCard title="外观" subtitle="界面主题与时区">
<div class="settings-block">
<div class="settings-block__label">主题模式</div>
<div class="theme-cards">
@@ -36,9 +36,9 @@
<n-select v-model:value="form.timezone" :options="timezoneOptions" />
<p class="field-hint">影响日志、证书到期时间等显示</p>
</div>
</FonuCard>
</HavlineCard>
<FonuCard title="自动任务" subtitle="DDNS 与证书维护策略">
<HavlineCard title="自动任务" subtitle="DDNS 与证书维护策略">
<div class="settings-fields">
<div class="settings-field">
<div class="settings-field__row">
@@ -62,9 +62,9 @@
<p class="field-hint">超过保留天数的日志将被自动清理</p>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard title="ACME 证书" subtitle="申请与续签所需账户信息">
<HavlineCard title="ACME 证书" subtitle="申请与续签所需账户信息">
<div class="settings-fields">
<div class="settings-field">
<div class="settings-field__label">ACME 邮箱</div>
@@ -103,9 +103,9 @@
placeholder="用 LiteSSL 申请证书时填写"
/>
</div>
</FonuCard>
</HavlineCard>
<FonuCard title="数据" subtitle="备份与恢复">
<HavlineCard title="数据" subtitle="备份与恢复">
<div class="data-actions">
<button type="button" class="data-action-card" :disabled="exporting" @click="exportBackup">
<n-icon :component="DownloadOutline" class="data-action-card__icon" />
@@ -191,13 +191,13 @@
</div>
<p class="field-hint data-note">恢复配置将覆盖当前数据,操作前请确保已备份。</p>
</FonuCard>
</HavlineCard>
</div>
</n-tab-pane>
<n-tab-pane name="notify" tab="通知">
<div class="settings-grid settings-grid--single">
<FonuCard title="通知" subtitle="告警通道与事件(仅可启用一种方式)">
<HavlineCard title="通知" subtitle="告警通道与事件(仅可启用一种方式)">
<div class="security-form notify-form">
<div class="settings-field">
<div class="settings-field__label">通知方式</div>
@@ -231,7 +231,7 @@
/>
<div class="settings-field">
<div class="settings-field__label">发件人</div>
<n-input v-model:value="notifyEmail.from" placeholder="fonu@example.com" />
<n-input v-model:value="notifyEmail.from" placeholder="havline@example.com" />
</div>
<div class="settings-field">
<div class="settings-field__label">收件人(每行一个)</div>
@@ -273,7 +273,7 @@
</div>
<div class="settings-field">
<div class="settings-field__label">Topic</div>
<n-input v-model:value="notifyWebhook.topic" placeholder="fonu-alerts" />
<n-input v-model:value="notifyWebhook.topic" placeholder="havline-alerts" />
</div>
<ConfiguredSecretField
v-model="notifyWebhookKey"
@@ -319,7 +319,7 @@
placeholder="access_token 参数值"
/>
<p class="field-hint">
安全设置选「自定义关键词」并填 <span class="mono">Fonu</span>:消息正文固定以「Fonu · 」开头。加签方式暂不支持。
安全设置选「自定义关键词」并填 <span class="mono">Havline</span>:消息正文固定以「Havline · 」开头。加签方式暂不支持。
</p>
</template>
<template v-else-if="notifyWebhook.provider === 'feishu'">
@@ -447,13 +447,13 @@
<p class="field-hint">使用当前表单配置发送测试消息,无需先保存</p>
</div>
</div>
</FonuCard>
</HavlineCard>
</div>
</n-tab-pane>
<n-tab-pane name="security" tab="安全">
<div class="settings-grid">
<FonuCard title="反代安全" subtitle="全局 IP 策略与真实客户端 IP">
<HavlineCard title="反代安全" subtitle="全局 IP 策略与真实客户端 IP">
<div class="security-form proxy-security-form">
<p class="field-hint proxy-security-form__intro">
IP 黑白名单、仅中国大陆等策略依赖正确识别客户端 IP。若域名经 CDN/多层反代,请在此配置信任代理。
@@ -546,9 +546,9 @@
<p class="field-hint">自动检查并更新中国 IP 段库的间隔</p>
</div>
</div>
</FonuCard>
</HavlineCard>
<FonuCard title="管理员账户" subtitle="修改登录密码" class="settings-card settings-card--security">
<HavlineCard title="管理员账户" subtitle="修改登录密码" class="settings-card settings-card--security">
<div class="security-form">
<div class="settings-fields">
<div class="settings-field">
@@ -568,13 +568,13 @@
修改管理员密码
</n-button>
</div>
</FonuCard>
</HavlineCard>
</div>
</n-tab-pane>
<n-tab-pane name="advanced" tab="高级">
<div class="settings-grid settings-grid--single">
<FonuCard title="Nginx 全局配置" subtitle="http 块内自定义片段">
<HavlineCard title="Nginx 全局配置" subtitle="http 块内自定义片段">
<p class="field-hint">
仅作用于 <code>http {}</code> 内部、各反代 <code>server {}</code> 之前。单条规则的 server 块请在反代详情 → Nginx 中编辑。
</p>
@@ -697,8 +697,8 @@
<n-alert type="warning" :bordered="false" class="global-nginx-warn">
小白勿碰。若保存后 Nginx 启动失败,请点「恢复自动生成」或「回滚」还原配置。
</n-alert>
</FonuCard>
<FonuCard title="API Token" subtitle="给脚本、HomeAssistant、手机快捷指令调用">
</HavlineCard>
<HavlineCard title="API Token" subtitle="给脚本、HomeAssistant、手机快捷指令调用">
<p class="field-hint">
浏览器之外的调用方拿不到会话 cookie,可在这里签发令牌,用
<code>Authorization: Bearer &lt;token&gt;</code> 调用接口。明文只在创建时显示一次。
@@ -759,9 +759,9 @@
</div>
</div>
<p v-else-if="!tokenError" class="field-hint">还没有令牌。</p>
</FonuCard>
</HavlineCard>
<FonuCard title="公开状态页" subtitle="给外部看的只读页面(默认关闭)">
<HavlineCard title="公开状态页" subtitle="给外部看的只读页面(默认关闭)">
<p class="field-hint">
开启后 <code>/status</code> 任何人都能访问,只展示域名与 24 小时可用率——不含内网地址、端口与证书路径。
建议设一个访问码,它会让页面需要带码才能打开。
@@ -787,7 +787,7 @@
</n-button>
<span class="field-hint">访问码只存 SHA-256,忘了只能重设。</span>
</div>
</FonuCard>
</HavlineCard>
</div>
</n-tab-pane>
</n-tabs>
@@ -841,7 +841,7 @@ import type {
} from '../api/types'
import ConfiguredSecretField from '../components/ConfiguredSecretField.vue'
import ConfigDiffView from '../components/ConfigDiffView.vue'
import FonuCard from '../components/FonuCard.vue'
import HavlineCard from '../components/HavlineCard.vue'
import NginxCodeEditor from '../components/NginxCodeEditor.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -1621,7 +1621,7 @@ async function load() {
applySettingsToForm(await api.getSettings())
await Promise.all([loadChinaCIDRStatus(), loadGlobalNginx()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
pageError.value = error instanceof Error ? error.message : '请检查 Havline 服务是否正常运行'
} finally {
loading.value = false
}
@@ -1699,7 +1699,7 @@ async function restoreBackupArchive(archive: BackupArchive) {
backupBusy.value = `restore:${archive.name}`
try {
const result = await api.restoreBackupArchive(archive.name)
message.success(result.message || '备份已恢复,建议重启 Fonu')
message.success(result.message || '备份已恢复,建议重启 Havline')
} catch (error) {
message.error(error instanceof Error ? error.message : '恢复失败')
} finally { backupBusy.value = null }
@@ -1757,17 +1757,17 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
<style scoped>
.settings-tabs {
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
}
.settings-tabs :deep(.n-tabs-nav) {
margin-bottom: var(--fonu-space-4);
margin-bottom: var(--havline-space-4);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-5);
gap: var(--havline-space-5);
align-items: stretch;
}
@@ -1775,27 +1775,27 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
grid-template-columns: minmax(0, 1fr);
}
.settings-grid :deep(.fonu-card) {
.settings-grid :deep(.havline-card) {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-grid :deep(.fonu-card__body) {
.settings-grid :deep(.havline-card__body) {
flex: 1;
display: flex;
flex-direction: column;
}
.settings-block--spaced {
margin-top: var(--fonu-space-5);
margin-top: var(--havline-space-5);
}
.settings-block__label,
.settings-field__label {
font-size: 14px;
font-weight: 500;
color: var(--fonu-text);
color: var(--havline-text);
}
.settings-block__label,
@@ -1806,7 +1806,7 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
.theme-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
}
.theme-card {
@@ -1818,25 +1818,25 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
gap: 8px;
min-height: 80px;
padding: 12px 8px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
background: var(--fonu-bg);
background: var(--havline-bg);
cursor: pointer;
transition: border-color 0.15s;
}
.theme-card:hover {
border-color: color-mix(in srgb, #10b981 40%, var(--fonu-border));
border-color: color-mix(in srgb, #10b981 40%, var(--havline-border));
}
.theme-card--active {
border-color: #10b981;
background: color-mix(in srgb, #10b981 6%, var(--fonu-bg));
background: color-mix(in srgb, #10b981 6%, var(--havline-bg));
}
.theme-card__icon {
font-size: 20px;
color: var(--fonu-text-secondary);
color: var(--havline-text-secondary);
}
.theme-card--active .theme-card__icon {
@@ -1863,7 +1863,7 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
.field-hint {
margin: 6px 0 0;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.5;
}
@@ -1876,42 +1876,42 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
}
/* 主配置版本:左侧版本列表 + 右侧当前内容 / 差异 */
.config-versions { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--fonu-space-4); margin-top: 8px; }
.config-versions__list { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: auto; max-height: 320px; }
.config-versions { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--havline-space-4); margin-top: 8px; }
.config-versions__list { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); overflow: auto; max-height: 320px; }
.config-versions__item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
font-size: 12px; color: var(--fonu-text-secondary); cursor: pointer;
padding: 6px var(--havline-space-3); border-bottom: 1px solid var(--havline-border);
font-size: 12px; color: var(--havline-text-secondary); cursor: pointer;
}
.config-versions__item:hover { background: var(--fonu-bg-muted); }
.config-versions__item--active { background: var(--fonu-info-soft); color: var(--fonu-text); }
.config-versions__size { flex: none; color: var(--fonu-text-muted); }
.config-versions__empty { padding: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
.config-versions__body { min-width: 0; display: grid; gap: var(--fonu-space-3); align-content: start; }
.config-versions__actions { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.config-versions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
.config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--fonu-error); line-height: 1.6; }
.config-versions__item:hover { background: var(--havline-bg-muted); }
.config-versions__item--active { background: var(--havline-info-soft); color: var(--havline-text); }
.config-versions__size { flex: none; color: var(--havline-text-muted); }
.config-versions__empty { padding: var(--havline-space-3); font-size: 12px; color: var(--havline-text-muted); }
.config-versions__body { min-width: 0; display: grid; gap: var(--havline-space-3); align-content: start; }
.config-versions__actions { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.config-versions__hint { font-size: 12px; color: var(--havline-text-muted); line-height: 1.6; }
.config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--havline-error); line-height: 1.6; }
/* 自动备份:开关 + 保留份数 + 备份列表 */
.backup-auto {
display: grid; gap: var(--fonu-space-3);
margin-top: var(--fonu-space-4); padding-top: var(--fonu-space-4);
border-top: 1px solid var(--fonu-border);
display: grid; gap: var(--havline-space-3);
margin-top: var(--havline-space-4); padding-top: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.backup-auto__actions { display: flex; align-items: end; gap: var(--fonu-space-3); }
.backup-auto__actions { display: flex; align-items: end; gap: var(--havline-space-3); }
.backup-auto__keep { max-width: 160px; }
.backup-list { display: grid; gap: 6px; margin-top: var(--fonu-space-3); }
.backup-list { display: grid; gap: 6px; margin-top: var(--havline-space-3); }
.backup-row {
display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3);
padding: 6px var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3);
padding: 6px var(--havline-space-3); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm);
}
.backup-row__main { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.backup-row__size { font-size: 12px; color: var(--fonu-text-muted); }
.backup-row__size { font-size: 12px; color: var(--havline-text-muted); }
.backup-row__actions { display: flex; align-items: center; gap: 4px; flex: none; }
/* 公开状态页:访问码 + 打开链接 */
.status-page-field { margin-top: var(--fonu-space-3); }
.status-page-actions { display: flex; align-items: center; gap: var(--fonu-space-3); margin-top: var(--fonu-space-3); flex-wrap: wrap; }
.status-page-field { margin-top: var(--havline-space-3); }
.status-page-actions { display: flex; align-items: center; gap: var(--havline-space-3); margin-top: var(--havline-space-3); flex-wrap: wrap; }
/* API Token:创建区一行放四项,列表一行一项 */
/* 必须带上 .settings-fields 提高优先级:那条纵向 flex 规则在样式表里位置更靠后,
@@ -1925,12 +1925,12 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
.token-created { margin: 12px 0; }
.token-created__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: 13px; }
.token-created__row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.token-created__value { flex: 1; min-width: 0; font-family: var(--fonu-mono); font-size: 12px; overflow-wrap: anywhere; }
.token-created__value { flex: 1; min-width: 0; font-family: var(--havline-mono); font-size: 12px; overflow-wrap: anywhere; }
.token-list { display: grid; gap: 8px; margin-top: 12px; }
.token-row { display: flex; align-items: center; gap: var(--fonu-space-3); padding: 10px var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
.token-row { display: flex; align-items: center; gap: var(--havline-space-3); padding: 10px var(--havline-space-3); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); }
.token-row__main { display: flex; align-items: center; gap: 8px; min-width: 0; }
.token-row__name { font-weight: 600; color: var(--fonu-text); }
.token-row__meta { flex: 1; display: flex; gap: var(--fonu-space-3); flex-wrap: wrap; font-size: 12px; color: var(--fonu-text-muted); }
.token-row__name { font-weight: 600; color: var(--havline-text); }
.token-row__meta { flex: 1; display: flex; gap: var(--havline-space-3); flex-wrap: wrap; font-size: 12px; color: var(--havline-text-muted); }
.global-nginx-head {
margin-top: 12px;
@@ -2062,9 +2062,9 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.china-cidr-panel__head {
@@ -2098,13 +2098,13 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
}
.notify-events {
margin-top: var(--fonu-space-4);
margin-top: var(--havline-space-4);
}
.notify-events__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
margin-bottom: 12px;
}
@@ -2115,7 +2115,7 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
.notify-event-group__title {
font-size: 12px;
font-weight: 600;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
@@ -2153,7 +2153,7 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
}
.password-btn {
margin-top: var(--fonu-space-4);
margin-top: var(--havline-space-4);
align-self: flex-start;
flex-shrink: 0;
}
@@ -2162,37 +2162,37 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
display: flex;
flex-direction: column;
gap: 10px;
margin-top: var(--fonu-space-4);
margin-top: var(--havline-space-4);
}
.form-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
gap: var(--havline-space-4);
padding: 12px 14px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
background: var(--fonu-bg);
background: var(--havline-bg);
}
.form-switch-row__label {
font-size: 14px;
font-weight: 500;
color: var(--fonu-text);
color: var(--havline-text);
}
.form-switch-row__hint {
margin-top: 2px;
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.5;
}
.data-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-3);
gap: var(--havline-space-3);
align-items: stretch;
}
@@ -2221,9 +2221,9 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
min-height: 96px;
height: 100%;
padding: 14px;
border: 1px solid var(--fonu-border);
border: 1px solid var(--havline-border);
border-radius: 10px;
background: var(--fonu-bg);
background: var(--havline-bg);
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
@@ -2233,7 +2233,7 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
}
.data-action-card:hover:not(:disabled):not(.data-action-card--disabled) {
border-color: color-mix(in srgb, #10b981 35%, var(--fonu-border));
border-color: color-mix(in srgb, #10b981 35%, var(--havline-border));
}
.data-action-card:disabled,
@@ -2251,25 +2251,25 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
.data-action-card__title {
font-size: 14px;
font-weight: 600;
color: var(--fonu-text);
color: var(--havline-text);
}
.data-action-card__hint {
font-size: 12px;
color: var(--fonu-text-muted);
color: var(--havline-text-muted);
line-height: 1.45;
}
.data-note {
margin-top: auto;
padding-top: var(--fonu-space-3);
padding-top: var(--havline-space-3);
}
.save-bar {
display: flex;
justify-content: flex-end;
margin-top: var(--fonu-space-5);
padding-bottom: var(--fonu-space-2);
margin-top: var(--havline-space-5);
padding-bottom: var(--havline-space-2);
}
@media (max-width: 960px) {
+25 -25
View File
@@ -38,7 +38,7 @@
</div>
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p>
<p class="status-page__hint status-page__hint--foot">本页只展示域名与可用率,由 Fonu 生成。</p>
<p class="status-page__hint status-page__hint--foot">本页只展示域名与可用率,由 Havline 生成。</p>
</div>
</div>
</template>
@@ -94,37 +94,37 @@ onMounted(load)
min-height: 100vh;
display: flex;
justify-content: center;
padding: var(--fonu-space-6) var(--fonu-space-4);
background: var(--fonu-bg);
padding: var(--havline-space-6) var(--havline-space-4);
background: var(--havline-bg);
}
.status-page__card {
width: min(680px, 100%);
align-self: flex-start;
padding: var(--fonu-space-5);
background: var(--fonu-surface);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow);
padding: var(--havline-space-5);
background: var(--havline-surface);
border: 1px solid var(--havline-border);
border-radius: var(--havline-radius);
box-shadow: var(--havline-shadow);
}
.status-page__head { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.status-page__title { margin: 0; font-size: 18px; color: var(--fonu-text); }
.status-page__meta { margin: 8px 0 0; font-size: 12.5px; color: var(--fonu-text-secondary); }
.status-page__code { display: flex; gap: 8px; margin-top: var(--fonu-space-4); }
.status-page__error { margin: var(--fonu-space-4) 0 0; font-size: 13px; color: var(--fonu-error); }
.status-page__list { display: grid; gap: 6px; margin-top: var(--fonu-space-4); }
.status-page__head { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-3); }
.status-page__title { margin: 0; font-size: 18px; color: var(--havline-text); }
.status-page__meta { margin: 8px 0 0; font-size: 12.5px; color: var(--havline-text-secondary); }
.status-page__code { display: flex; gap: 8px; margin-top: var(--havline-space-4); }
.status-page__error { margin: var(--havline-space-4) 0 0; font-size: 13px; color: var(--havline-error); }
.status-page__list { display: grid; gap: 6px; margin-top: var(--havline-space-4); }
.status-row {
display: flex; align-items: center; gap: var(--fonu-space-3);
padding: 8px var(--fonu-space-3);
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
display: flex; align-items: center; gap: var(--havline-space-3);
padding: 8px var(--havline-space-3);
border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm);
}
.status-row__domain { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.status-row__source { flex: none; font-size: 11.5px; color: var(--fonu-text-muted); }
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--fonu-text-secondary); }
.status-route { border-bottom: 1px solid var(--fonu-border); }
.status-row__source { flex: none; font-size: 11.5px; color: var(--havline-text-muted); }
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--havline-text-secondary); }
.status-route { border-bottom: 1px solid var(--havline-border); }
.status-route:last-child { border-bottom: none; }
.incident-list { display: grid; gap: 5px; padding: 0 var(--fonu-space-3) 10px; }
.incident-row { display: flex; align-items: center; gap: 10px; color: var(--fonu-text-muted); font-size: 12px; flex-wrap: wrap; }
.incident-row__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fonu-error); }
.status-page__hint { margin: var(--fonu-space-3) 0 0; font-size: 12.5px; color: var(--fonu-text-muted); }
.status-page__hint--foot { margin-top: var(--fonu-space-5); }
.incident-list { display: grid; gap: 5px; padding: 0 var(--havline-space-3) 10px; }
.incident-row { display: flex; align-items: center; gap: 10px; color: var(--havline-text-muted); font-size: 12px; flex-wrap: wrap; }
.incident-row__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--havline-error); }
.status-page__hint { margin: var(--havline-space-3) 0 0; font-size: 12.5px; color: var(--havline-text-muted); }
.status-page__hint--foot { margin-top: var(--havline-space-5); }
</style>