This commit is contained in:
+146
-42
@@ -19,14 +19,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<n-menu
|
<!-- 导航按业务域分组:12 个平铺项不容易扫读,分组后能按「概览 / 网络 / 运维 / 系统」定位 -->
|
||||||
class="app-menu"
|
<nav class="nav" aria-label="主导航">
|
||||||
:value="activeKey"
|
<template v-for="group in menuGroups" :key="group.label">
|
||||||
:collapsed="collapsed"
|
<div v-if="!collapsed" class="nav__group">{{ group.label }}</div>
|
||||||
:collapsed-width="72"
|
<div v-else class="nav__divider" aria-hidden="true"></div>
|
||||||
:options="menuOptions"
|
<button
|
||||||
@update:value="handleMenu"
|
v-for="item in group.items"
|
||||||
/>
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="nav__item"
|
||||||
|
:class="{ 'nav__item--active': activeKey === item.key, 'nav__item--icon-only': collapsed }"
|
||||||
|
:title="collapsed ? item.label : undefined"
|
||||||
|
:aria-current="activeKey === item.key ? 'page' : undefined"
|
||||||
|
@click="handleMenu(item.key)"
|
||||||
|
>
|
||||||
|
<n-icon class="nav__icon" :component="item.icon" />
|
||||||
|
<span v-if="!collapsed" class="nav__label">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div v-if="!collapsed" class="sider-footer">
|
<div v-if="!collapsed" class="sider-footer">
|
||||||
<div class="sider-footer__version-row">
|
<div class="sider-footer__version-row">
|
||||||
@@ -103,7 +115,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, type Component } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
@@ -112,9 +124,7 @@ import {
|
|||||||
NLayout,
|
NLayout,
|
||||||
NLayoutContent,
|
NLayoutContent,
|
||||||
NLayoutSider,
|
NLayoutSider,
|
||||||
NMenu,
|
|
||||||
useMessage,
|
useMessage,
|
||||||
type MenuOption,
|
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
ArrowUpOutline,
|
ArrowUpOutline,
|
||||||
@@ -124,18 +134,19 @@ import {
|
|||||||
DocumentTextOutline,
|
DocumentTextOutline,
|
||||||
Ellipse,
|
Ellipse,
|
||||||
GitNetworkOutline,
|
GitNetworkOutline,
|
||||||
|
GlobeOutline,
|
||||||
GridOutline,
|
GridOutline,
|
||||||
|
HardwareChipOutline,
|
||||||
InformationCircleOutline,
|
InformationCircleOutline,
|
||||||
LockClosedOutline,
|
LockClosedOutline,
|
||||||
LogoGithub,
|
LogoGithub,
|
||||||
MoonOutline,
|
MoonOutline,
|
||||||
OpenOutline,
|
OpenOutline,
|
||||||
PersonOutline,
|
PersonOutline,
|
||||||
|
ShieldCheckmarkOutline,
|
||||||
SunnyOutline,
|
SunnyOutline,
|
||||||
SwapHorizontalOutline,
|
SwapHorizontalOutline,
|
||||||
TerminalOutline,
|
TerminalOutline,
|
||||||
HardwareChipOutline,
|
|
||||||
ShieldCheckmarkOutline,
|
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
|
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
|
||||||
@@ -195,29 +206,44 @@ async function loadUpdateStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuDefs = [
|
type NavItem = { label: string; key: string; icon: Component }
|
||||||
{ label: '仪表盘', key: 'dashboard', icon: GridOutline },
|
type NavGroup = { label: string; items: NavItem[] }
|
||||||
{ label: 'DDNS', key: 'ddns', icon: CloudOutline },
|
|
||||||
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
|
// 分组与原型一致:概览 / 网络 / 运维 / 系统。
|
||||||
|
// 顺序按「先看状态、再管流量、最后进设置」的使用路径排,而不是按模块实现顺序。
|
||||||
|
const menuGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: '概览',
|
||||||
|
items: [{ label: '仪表盘', key: 'dashboard', icon: GridOutline }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '网络',
|
||||||
|
items: [
|
||||||
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
|
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
|
||||||
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
|
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
|
||||||
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
|
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
|
||||||
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
|
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
|
||||||
|
{ label: 'DDNS', key: 'ddns', icon: GlobeOutline },
|
||||||
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
|
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '运维',
|
||||||
|
items: [
|
||||||
|
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
|
||||||
{ label: 'SSH 终端', key: 'terminal', icon: TerminalOutline },
|
{ label: 'SSH 终端', key: 'terminal', icon: TerminalOutline },
|
||||||
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '系统',
|
||||||
|
items: [
|
||||||
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
||||||
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
|
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const menuOptions = computed<MenuOption[]>(() =>
|
|
||||||
menuDefs.map((item) => ({
|
|
||||||
label: item.label,
|
|
||||||
key: item.key,
|
|
||||||
icon: () => h(NIcon, null, { default: () => h(item.icon) }),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeKey = computed(() => {
|
const activeKey = computed(() => {
|
||||||
const name = route.name
|
const name = route.name
|
||||||
// 终端审计是终端页的子页面:菜单里只有「SSH 终端」,这里让它保持高亮
|
// 终端审计是终端页的子页面:菜单里只有「SSH 终端」,这里让它保持高亮
|
||||||
@@ -306,6 +332,8 @@ onUnmounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ 品牌区 ------------------------------ */
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -347,39 +375,113 @@ onUnmounted(() => {
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu {
|
/* ------------------------------ 导航 ------------------------------ */
|
||||||
|
|
||||||
|
.nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--havline-space-3);
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.n-menu-item) {
|
.nav__group {
|
||||||
|
padding: 16px 12px 6px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav__group:first-child {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态没有文字标题,用细分隔线保留分组感 */
|
||||||
|
.nav__divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 10px 8px;
|
||||||
|
background: var(--havline-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav__divider:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav__item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: none;
|
||||||
|
color: var(--havline-text-secondary);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.16s ease, color 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.n-menu-item-content) {
|
.nav__item--icon-only {
|
||||||
border-radius: var(--havline-radius-sm) !important;
|
justify-content: center;
|
||||||
padding-left: 14px !important;
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.n-menu-item-content--selected) {
|
.nav__icon {
|
||||||
background: var(--havline-brand-soft) !important;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
flex: none;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
transition: color 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header),
|
.nav__item:hover {
|
||||||
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content-header *) {
|
background: var(--havline-bg-muted);
|
||||||
color: var(--havline-brand-text) !important;
|
color: var(--havline-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.n-menu-item-content--selected .n-icon),
|
.nav__item:hover .nav__icon {
|
||||||
.app-menu :deep(.n-menu-item-content--selected .n-menu-item-content__icon) {
|
color: var(--havline-text-secondary);
|
||||||
color: var(--havline-brand) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.n-menu-item-content--selected::before) {
|
.nav__item--active {
|
||||||
display: none !important;
|
background: linear-gradient(90deg, var(--havline-brand-soft), rgba(16, 185, 129, 0.03));
|
||||||
|
color: var(--havline-brand-text);
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav__item--active .nav__icon {
|
||||||
|
color: var(--havline-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中态左侧竖条:比整块底色更容易在长列表里定位当前页 */
|
||||||
|
.nav__item--active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--havline-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav__label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------- 底部状态区 --------------------------- */
|
||||||
|
|
||||||
.sider-footer {
|
.sider-footer {
|
||||||
padding: var(--havline-space-4);
|
padding: var(--havline-space-4);
|
||||||
border-top: 1px solid var(--havline-border);
|
border-top: 1px solid var(--havline-border);
|
||||||
@@ -469,6 +571,8 @@ onUnmounted(() => {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ 顶栏与内容 ------------------------------ */
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
--havline-banner-border: rgba(16, 185, 129, 0.2);
|
--havline-banner-border: rgba(16, 185, 129, 0.2);
|
||||||
--havline-surface: #ffffff;
|
--havline-surface: #ffffff;
|
||||||
--havline-border: #e5e7eb;
|
--havline-border: #e5e7eb;
|
||||||
|
/* 比 --havline-border 更淡的分隔线:用于分组标题、卡片内部分隔等弱边界 */
|
||||||
|
--havline-border-soft: #eef2f6;
|
||||||
--havline-text: #111827;
|
--havline-text: #111827;
|
||||||
--havline-text-secondary: #6b7280;
|
--havline-text-secondary: #6b7280;
|
||||||
--havline-text-muted: #9ca3af;
|
--havline-text-muted: #9ca3af;
|
||||||
@@ -54,6 +56,7 @@ html.dark,
|
|||||||
--havline-bg-muted: #1e293b;
|
--havline-bg-muted: #1e293b;
|
||||||
--havline-surface: #1e293b;
|
--havline-surface: #1e293b;
|
||||||
--havline-border: #334155;
|
--havline-border: #334155;
|
||||||
|
--havline-border-soft: #263449;
|
||||||
--havline-text: #f1f5f9;
|
--havline-text: #f1f5f9;
|
||||||
--havline-text-secondary: #94a3b8;
|
--havline-text-secondary: #94a3b8;
|
||||||
--havline-text-muted: #64748b;
|
--havline-text-muted: #64748b;
|
||||||
|
|||||||
+600
-95
@@ -2,28 +2,84 @@
|
|||||||
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="hero-banner">
|
<!-- 顶部不写问候语,直接给结论:一眼看到「几项正常 / 哪条链路有问题」,
|
||||||
<div class="hero-content">
|
右侧把公网访问链路按 入口 → DDNS → 证书 → Nginx → 穿透 排出来 -->
|
||||||
<h1 class="hero-banner__title">{{ greetingText }}</h1>
|
<section class="hero">
|
||||||
<p class="hero-banner__desc">Havline 正在为你的 NAS 提供稳定、安全的访问服务。</p>
|
<div class="hero__main">
|
||||||
<div class="hero-banner__badges">
|
<div class="hero__lead">
|
||||||
<span class="hero-banner__badge"><n-icon :component="CheckmarkCircleOutline" /> 简单易用</span>
|
<div class="hero__topline">
|
||||||
<span class="hero-banner__badge"><n-icon :component="FlashOutline" /> 安全稳定</span>
|
<span class="hero__medal" :class="{ 'hero__medal--warn': !coreHealthy }">
|
||||||
<span class="hero-banner__badge"><n-icon :component="GlobeOutline" /> 随时随地访问</span>
|
<n-icon :component="coreHealthy ? CheckmarkOutline : AlertCircleOutline" />
|
||||||
|
</span>
|
||||||
|
<span class="hero__kicker">
|
||||||
|
<i :class="{ 'is-warn': !coreHealthy }"></i>
|
||||||
|
{{ coreHealthy ? 'LIVE · 核心链路正常' : 'DEGRADED · 核心链路异常' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero__title">{{ heroTitle }}</h1>
|
||||||
|
<p class="hero__sub">{{ heroSub }}</p>
|
||||||
|
<div class="hero__actions">
|
||||||
|
<button
|
||||||
|
v-if="attentionCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="hero__alert"
|
||||||
|
:title="attentionHint"
|
||||||
|
@click="goToAttention"
|
||||||
|
>
|
||||||
|
<n-icon :component="AlertCircleOutline" />
|
||||||
|
处理 {{ attentionCount }} 项提醒
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hero__ghost" @click="load">
|
||||||
|
<n-icon :component="PulseOutline" />
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
<button type="button" class="hero__primary" @click="router.push({ name: 'proxies' })">
|
||||||
|
<n-icon :component="AddOutline" />
|
||||||
|
新建反代
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero__edge">
|
||||||
|
<div class="hero__edge-head">
|
||||||
|
<span>PUBLIC EDGE / 公网访问链路</span>
|
||||||
|
<span>{{ linkWarningCount }} 项提醒 · {{ linkNodes.length }} 个节点</span>
|
||||||
|
</div>
|
||||||
|
<div class="edge">
|
||||||
|
<div
|
||||||
|
v-for="node in linkNodes"
|
||||||
|
:key="node.name"
|
||||||
|
class="edge__node"
|
||||||
|
:class="{ 'edge__node--warn': node.warn }"
|
||||||
|
>
|
||||||
|
<span class="edge__dot"></span>
|
||||||
|
<span class="edge__name">{{ node.name }}</span>
|
||||||
|
<span class="edge__value mono">{{ node.value }}</span>
|
||||||
|
<span class="edge__state">{{ node.state }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img class="hero-art" :src="bannerImg" alt="" aria-hidden="true" />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<n-spin :show="loading && !status">
|
<n-spin :show="loading && !status">
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatCard label="公网 IP" tone="blue">
|
<StatCard label="公网 IP" tone="blue">
|
||||||
<template #icon><n-icon :component="GlobeOutline" /></template>
|
<template #icon><n-icon :component="GlobeOutline" /></template>
|
||||||
<template #extra><StatusBadge v-if="publicIPv4Label !== '-'" value="ok" text="正常" /></template>
|
<template #extra><StatusBadge v-if="publicIPEntries.length > 0" value="ok" text="正常" /></template>
|
||||||
<template #value>
|
<template #value>
|
||||||
<span class="stat-primary mono">{{ publicIPv4Label }}</span>
|
<!-- 多个 DDNS 服务商可能对应不同的出口 IP(家宽/多线/VPS),
|
||||||
|
只显示「最后更新的那条」会让人以为只配了一个。
|
||||||
|
这里按配置逐条列出,并标出来源名称。 -->
|
||||||
|
<ul v-if="publicIPEntries.length > 0" class="ip-list">
|
||||||
|
<li v-for="item in publicIPEntries" :key="item.key" class="ip-list__row">
|
||||||
|
<span class="ip-list__addr mono">{{ item.address }}</span>
|
||||||
|
<span class="ip-list__tag">{{ item.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<span v-else class="stat-primary mono">-</span>
|
||||||
</template>
|
</template>
|
||||||
<p class="stat-desc mono">{{ publicIPv6Label }}</p>
|
<p class="stat-desc">{{ publicIPSecondaryLabel }}</p>
|
||||||
<div class="stat-foot">
|
<div class="stat-foot">
|
||||||
<div class="stat-foot__line">{{ publicIPSourceLabel }}</div>
|
<div class="stat-foot__line">{{ publicIPSourceLabel }}</div>
|
||||||
<div class="stat-foot__muted">上次更新 {{ formatRelativeTime(status?.ddns_last_updated) || '刚刚' }}</div>
|
<div class="stat-foot__muted">上次更新 {{ formatRelativeTime(status?.ddns_last_updated) || '刚刚' }}</div>
|
||||||
@@ -354,11 +410,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, ref } from 'vue'
|
import { computed, h, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
|
AddOutline,
|
||||||
AlertCircleOutline,
|
AlertCircleOutline,
|
||||||
AppsOutline,
|
AppsOutline,
|
||||||
CheckmarkCircleOutline,
|
CheckmarkCircleOutline,
|
||||||
|
CheckmarkOutline,
|
||||||
ChevronForwardOutline,
|
ChevronForwardOutline,
|
||||||
CloudOutline,
|
CloudOutline,
|
||||||
DocumentTextOutline,
|
DocumentTextOutline,
|
||||||
@@ -373,7 +432,6 @@ import {
|
|||||||
SwapVerticalOutline,
|
SwapVerticalOutline,
|
||||||
WifiOutline,
|
WifiOutline,
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
import bannerImg from '../assets/brand/banner.png'
|
|
||||||
import { api, asList } from '../api/client'
|
import { api, asList } from '../api/client'
|
||||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||||
import type {
|
import type {
|
||||||
@@ -422,6 +480,7 @@ import { displaySystemLog } from '../utils/logDisplay'
|
|||||||
import { httpStatusKind, statusLabel } from '../utils/status'
|
import { httpStatusKind, statusLabel } from '../utils/status'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
const status = ref<DashboardStatus | null>(null)
|
const status = ref<DashboardStatus | null>(null)
|
||||||
const ddnsConfigs = ref<DDNSConfig[]>([])
|
const ddnsConfigs = ref<DDNSConfig[]>([])
|
||||||
const certificates = ref<CertificateRecord[]>([])
|
const certificates = ref<CertificateRecord[]>([])
|
||||||
@@ -469,13 +528,155 @@ const activeTrafficRows = computed<TrafficRow[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function timeGreeting(hour: number): string {
|
// 顶部主视觉的三段结论:
|
||||||
if (hour >= 5 && hour < 12) return '上午好'
|
// 1) 核心服务几项在线(标题)
|
||||||
if (hour >= 12 && hour < 18) return '下午好'
|
// 2) 一句话说清「哪条链路有问题」或「一切正常」(副标题)
|
||||||
return '晚上好'
|
// 3) 需要处理的提醒数量(按钮)
|
||||||
|
//
|
||||||
|
// 「未配置」不算故障:新装用户一点 DDNS/证书/隧道都没配时,
|
||||||
|
// 如果按「非 running 即异常」判定,顶部会直接显示降级,看起来像系统坏了。
|
||||||
|
function isProblemStatus(status?: string): boolean {
|
||||||
|
if (!status) return false
|
||||||
|
return status !== 'running' && status !== 'ok' && status !== 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
const greetingText = computed(() => `${timeGreeting(new Date().getHours())},管理员`)
|
const badHealthItems = computed(() => healthItems.value.filter(
|
||||||
|
(item) => isProblemStatus(item.status),
|
||||||
|
))
|
||||||
|
|
||||||
|
const coreHealthy = computed(() => badHealthItems.value.length === 0)
|
||||||
|
|
||||||
|
const heroTitle = computed(() => {
|
||||||
|
const items = healthItems.value
|
||||||
|
const bad = badHealthItems.value
|
||||||
|
if (bad.length === 0) return `${items.length} 个核心服务全部在线。`
|
||||||
|
return `${items.length - bad.length}/${items.length} 个核心服务在线。`
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroSub = computed(() => {
|
||||||
|
const bad = badHealthItems.value
|
||||||
|
const proxyCount = status.value?.proxy_count ?? proxyRules.value.length
|
||||||
|
const certDays = status.value?.certificate_days ?? primaryCert.value?.days_left ?? 0
|
||||||
|
const certClause = certDays > 0 && certDays <= 14
|
||||||
|
? `有 1 张证书将在 ${certDays} 天后到期。`
|
||||||
|
: ''
|
||||||
|
if (bad.length === 0) {
|
||||||
|
return `Havline、Nginx、DDNS 与证书服务运行正常;${proxyCount} 条反代规则已生效。${certClause}`
|
||||||
|
}
|
||||||
|
return `${bad.map((item) => item.name).join('、')} 状态异常;${proxyCount} 条反代规则。${certClause}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 需要提醒的项:服务异常 + 证书临期,作为顶部「处理 N 项提醒」的来源
|
||||||
|
const attentionCount = computed(() => attentionTargets.value.length)
|
||||||
|
|
||||||
|
// 提醒各自的跳转目标。
|
||||||
|
//
|
||||||
|
// 之前这里写死跳证书页,但提醒可能来自 Nginx 停止、DDNS 未同步、公网反代健康下降、
|
||||||
|
// Cloudflare 隧道异常——统一跳证书页等于把用户送到一个和问题无关的页面。
|
||||||
|
// 这里按异常项推导出「去哪修」,按钮文案也带上第一个问题的名字。
|
||||||
|
const attentionTargets = computed(() => {
|
||||||
|
const out: { label: string; routeName: string }[] = []
|
||||||
|
for (const item of badHealthItems.value) {
|
||||||
|
const routeName = attentionRouteFor(item.name)
|
||||||
|
if (routeName) out.push({ label: item.name, routeName })
|
||||||
|
}
|
||||||
|
const certDays = status.value?.certificate_days ?? primaryCert.value?.days_left ?? 0
|
||||||
|
if (certDays > 0 && certDays <= 14) {
|
||||||
|
out.push({ label: `证书 ${certDays} 天后到期`, routeName: 'certificates' })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
function attentionRouteFor(name: string): string {
|
||||||
|
switch (name) {
|
||||||
|
case 'Nginx':
|
||||||
|
return 'proxies'
|
||||||
|
case 'DDNS':
|
||||||
|
return 'ddns'
|
||||||
|
case 'HTTPS 证书':
|
||||||
|
return 'certificates'
|
||||||
|
case '公网反代':
|
||||||
|
case '公网服务端':
|
||||||
|
return 'public-proxy'
|
||||||
|
case 'Cloudflare 隧道':
|
||||||
|
return 'cloudflare'
|
||||||
|
default:
|
||||||
|
// Havline 自身异常没有可跳转的页面,就不提供入口
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attentionHint = computed(() => {
|
||||||
|
const first = attentionTargets.value[0]
|
||||||
|
return first ? `处理「${first.label}」` : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToAttention() {
|
||||||
|
const first = attentionTargets.value[0]
|
||||||
|
if (first) {
|
||||||
|
router.push({ name: first.routeName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「未配置」不算提醒:新装用户一点 DDNS/证书都没配时,
|
||||||
|
// 链路图会全程黄点,看起来像系统坏了。这里只统计「已配置但状态不对」的段落。
|
||||||
|
const linkWarningCount = computed(() => linkNodes.value.filter((n) => n.warn && !n.unconfigured).length)
|
||||||
|
|
||||||
|
// 公网访问链路:入口 → DDNS → 证书 → Nginx → 穿透,每段给出实际值,异常段标黄
|
||||||
|
const linkNodes = computed(() => {
|
||||||
|
const ip = status.value?.public_ipv4 || '-'
|
||||||
|
const ddnsCount = status.value?.ddns_count ?? ddnsConfigs.value.length
|
||||||
|
const recordCount = ddnsConfigs.value.reduce(
|
||||||
|
(sum, cfg) => sum + (cfg.record_names?.length || (cfg.record_name ? 1 : 0)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const certCount = status.value?.certificate_count ?? certificates.value.length
|
||||||
|
const certDays = status.value?.certificate_days ?? primaryCert.value?.days_left ?? 0
|
||||||
|
const proxyCount = status.value?.proxy_count ?? proxyRules.value.length
|
||||||
|
const enabledCount = proxyRules.value.filter((rule) => rule.enabled).length
|
||||||
|
const frpCount = Object.keys(frpProxyNames.value).length
|
||||||
|
const ddnsOk = status.value?.ddns_status === 'ok'
|
||||||
|
const nginxOk = status.value?.nginx_status === 'running'
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: '公网入口',
|
||||||
|
value: ip,
|
||||||
|
state: ip === '-' ? '未检测到' : '可达',
|
||||||
|
warn: ip === '-',
|
||||||
|
unconfigured: ip === '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DDNS',
|
||||||
|
value: ddnsCount > 0 ? `${ddnsCount} 条配置 · ${recordCount} 条记录` : '未配置',
|
||||||
|
state: ddnsOk ? '已同步' : ddnsCount > 0 ? '待同步' : '未配置',
|
||||||
|
warn: !ddnsOk && ddnsCount > 0,
|
||||||
|
unconfigured: ddnsCount === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HTTPS 证书',
|
||||||
|
value: certCount > 0 ? `${certCount} 张` : '未申请',
|
||||||
|
state: certDays > 0 ? `${certDays} 天到期` : certCount > 0 ? '已过期' : '未申请',
|
||||||
|
// 已配置但临期/过期才算提醒;一张证书都没有时不标黄
|
||||||
|
warn: certCount > 0 && certDays <= 14,
|
||||||
|
unconfigured: certCount === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Nginx',
|
||||||
|
value: `${proxyCount} 条规则 · ${enabledCount} 启用`,
|
||||||
|
state: nginxOk ? '运行中' : '已停止',
|
||||||
|
warn: !nginxOk,
|
||||||
|
unconfigured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FRP 穿透',
|
||||||
|
value: frpCount > 0 ? `${frpCount} 条隧道` : '未配置',
|
||||||
|
state: frpCount > 0 ? '已连接' : '未配置',
|
||||||
|
warn: false,
|
||||||
|
unconfigured: frpCount === 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const primaryCert = computed(() => {
|
const primaryCert = computed(() => {
|
||||||
if (certificates.value.length === 0) return null
|
if (certificates.value.length === 0) return null
|
||||||
@@ -487,14 +688,64 @@ const certWildcard = computed(() => {
|
|||||||
})
|
})
|
||||||
const certDisplayName = computed(() => certWildcard.value || primaryCert.value?.domain || '')
|
const certDisplayName = computed(() => certWildcard.value || primaryCert.value?.domain || '')
|
||||||
|
|
||||||
const publicIPv4Label = computed(() => status.value?.public_ipv4 || '-')
|
// 公网 IP 逐条列出。
|
||||||
const publicIPv6Label = computed(() => {
|
//
|
||||||
if (status.value?.public_ip_source === 'ddns' && !status.value?.public_ipv6) return '未获取'
|
// 后端 /api/status 只给一条 public_ipv4(取「最后更新的那条 DDNS 配置」),
|
||||||
return status.value?.public_ipv6 || '-'
|
// 多条配置各自的 IP 在 ddnsConfigs 里。这里按配置聚合去重:
|
||||||
|
// 同一出口 IP 被多个域名共用时只显示一次,标签合并成「名称1 / 名称2」,
|
||||||
|
// 避免卡片被重复的同一串 IP 撑满。
|
||||||
|
type PublicIPEntry = { key: string; address: string; label: string }
|
||||||
|
|
||||||
|
const publicIPEntries = computed<PublicIPEntry[]>(() => {
|
||||||
|
const byAddress = new Map<string, string[]>()
|
||||||
|
for (const cfg of ddnsConfigs.value) {
|
||||||
|
if (!cfg.enabled) continue
|
||||||
|
// 自定义 IP 优先生效(配置了就按它同步),其次才是上次同步到的 IP
|
||||||
|
const v4 = (cfg.custom_ipv4 || cfg.last_ipv4 || '').trim()
|
||||||
|
const v6 = (cfg.custom_ipv6 || cfg.last_ipv6 || '').trim()
|
||||||
|
const label = cfg.name?.trim() || cfg.root_domain || `配置 #${cfg.id}`
|
||||||
|
for (const address of [v4, v6]) {
|
||||||
|
if (!address) continue
|
||||||
|
const labels = byAddress.get(address) ?? []
|
||||||
|
if (!labels.includes(label)) labels.push(label)
|
||||||
|
byAddress.set(address, labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有任何 DDNS 配置时,退回后端探测到的出口 IP(未配置 DDNS 的场景)
|
||||||
|
if (byAddress.size === 0) {
|
||||||
|
const fallback = (status.value?.public_ipv4 || status.value?.public_ipv6 || '').trim()
|
||||||
|
if (fallback) {
|
||||||
|
const how = status.value?.public_ip_source === 'detect' ? '出口探测' : 'DDNS'
|
||||||
|
return [{ key: fallback, address: fallback, label: how }]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byAddress.entries()].map(([address, labels]) => ({
|
||||||
|
key: address,
|
||||||
|
address,
|
||||||
|
label: labels.join(' / '),
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 副行:配置了多个 IP 时说明总数,只有 IPv6 时提示未获取 IPv4
|
||||||
|
const publicIPSecondaryLabel = computed(() => {
|
||||||
|
const entries = publicIPEntries.value
|
||||||
|
if (entries.length === 0) return '暂无公网 IP'
|
||||||
|
if (entries.length > 1) return `共 ${entries.length} 个出口地址`
|
||||||
|
const hasIPv6 = entries.some((item) => item.address.includes(':'))
|
||||||
|
if (status.value?.public_ip_source === 'ddns' && !hasIPv6 && !status.value?.public_ipv6) {
|
||||||
|
return '未获取 IPv6'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
const publicIPSourceLabel = computed(() => {
|
const publicIPSourceLabel = computed(() => {
|
||||||
|
const enabledCount = ddnsConfigs.value.filter((cfg) => cfg.enabled).length
|
||||||
switch (status.value?.public_ip_source) {
|
switch (status.value?.public_ip_source) {
|
||||||
case 'ddns':
|
case 'ddns':
|
||||||
|
if (enabledCount > 1) return `来源:${enabledCount} 条 DDNS 配置`
|
||||||
return status.value?.public_ipv4 || status.value?.public_ipv6
|
return status.value?.public_ipv4 || status.value?.public_ipv6
|
||||||
? '来源:DDNS 已同步记录'
|
? '来源:DDNS 已同步记录'
|
||||||
: '来源:DDNS 已配置,等待同步'
|
: '来源:DDNS 已配置,等待同步'
|
||||||
@@ -1162,122 +1413,336 @@ useVisibilityPolling(pollCIDR, 2000, { enabled: cidrPolling })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.hero-banner {
|
/* 顶部主视觉:左侧状态结论 + 右侧公网访问链路(两条栏在 1080px 以下堆叠) */
|
||||||
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: var(--havline-space-4);
|
margin-bottom: var(--havline-space-4);
|
||||||
min-height: 208px;
|
padding: 24px 26px;
|
||||||
height: 220px;
|
border: 1px solid var(--havline-border);
|
||||||
border-radius: 16px;
|
border-radius: 24px;
|
||||||
box-shadow: var(--havline-shadow);
|
background:
|
||||||
border: 1px solid rgba(16, 185, 129, 0.08);
|
radial-gradient(720px 320px at 90% -30%, rgba(16, 185, 129, 0.1), transparent 60%),
|
||||||
background: linear-gradient(100deg, #f4fffc 0%, #f1fbff 55%, #eef9ff 100%);
|
var(--havline-surface);
|
||||||
|
box-shadow: 0 26px 60px -50px rgba(6, 78, 59, 0.45), var(--havline-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'] .hero-banner,
|
.hero__main {
|
||||||
html.dark .hero-banner {
|
display: grid;
|
||||||
background: linear-gradient(100deg, #0f172a 0%, #134e4a 100%);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
|
||||||
border-color: rgba(16, 185, 129, 0.15);
|
gap: 34px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero__lead {
|
||||||
position: relative;
|
min-width: 0;
|
||||||
z-index: 2;
|
}
|
||||||
|
|
||||||
|
.hero__topline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 14px;
|
||||||
width: 46%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 28px 32px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-art {
|
.hero__medal {
|
||||||
position: absolute;
|
display: grid;
|
||||||
z-index: 1;
|
place-items: center;
|
||||||
right: 0;
|
width: 46px;
|
||||||
bottom: 0;
|
height: 46px;
|
||||||
height: 100%;
|
flex: none;
|
||||||
width: auto;
|
border: 1px solid rgba(16, 185, 129, 0.28);
|
||||||
max-width: none;
|
border-radius: 50%;
|
||||||
object-fit: contain;
|
background: var(--havline-brand-soft);
|
||||||
object-position: right bottom;
|
color: var(--havline-brand-text);
|
||||||
pointer-events: none;
|
box-shadow: inset 0 0 0 5px var(--havline-surface);
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'] .hero-art,
|
.hero__medal .n-icon {
|
||||||
html.dark .hero-art {
|
font-size: 20px;
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__title {
|
.hero__medal--warn {
|
||||||
margin: 0;
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
font-size: 30px;
|
background: rgba(245, 158, 11, 0.12);
|
||||||
font-weight: 700;
|
color: var(--havline-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__kicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-family: var(--havline-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__kicker i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--havline-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__kicker i.is-warn {
|
||||||
|
background: var(--havline-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 1.1;
|
||||||
color: var(--havline-text);
|
color: var(--havline-text);
|
||||||
letter-spacing: -0.03em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__desc {
|
.hero__sub {
|
||||||
margin: 10px 0 0;
|
margin: 9px 0 0;
|
||||||
font-size: 14px;
|
max-width: 46ch;
|
||||||
color: var(--havline-text-secondary);
|
font-size: 12.5px;
|
||||||
max-width: 520px;
|
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__badges {
|
.hero__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
margin-top: 20px;
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__badge {
|
.hero__alert,
|
||||||
|
.hero__ghost,
|
||||||
|
.hero__primary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 8px 13px;
|
||||||
border-radius: 999px;
|
border-radius: 9px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--havline-text-secondary);
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__alert {
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
color: var(--havline-warning);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__alert:hover {
|
||||||
|
border-color: var(--havline-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__ghost {
|
||||||
|
border: 1px solid var(--havline-border);
|
||||||
background: var(--havline-surface);
|
background: var(--havline-surface);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.14);
|
color: var(--havline-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__badge .n-icon {
|
.hero__ghost:hover {
|
||||||
font-size: 16px;
|
border-color: var(--havline-brand);
|
||||||
color: var(--havline-brand);
|
color: var(--havline-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.hero__primary {
|
||||||
.hero-content {
|
border: 1px solid transparent;
|
||||||
width: 100%;
|
background: var(--havline-brand);
|
||||||
padding: 22px 20px;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner {
|
.hero__primary:hover {
|
||||||
height: auto;
|
background: var(--havline-brand-hover);
|
||||||
min-height: 180px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-art {
|
.hero__alert .n-icon,
|
||||||
height: 100%;
|
.hero__ghost .n-icon,
|
||||||
opacity: 0.28;
|
.hero__primary .n-icon {
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'] .hero-art,
|
/* ------------------------- 右侧公网访问链路 ------------------------- */
|
||||||
html.dark .hero-art {
|
|
||||||
opacity: 0.22;
|
.hero__edge {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
border: 1px solid var(--havline-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--havline-bg-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__title {
|
/* 顶部元信息行:左标题 + 右侧统计,两端分别贴住容器左右边界。
|
||||||
|
容器宽度与下方的 flex 链路一致,所以文字与首尾圆点自然对齐。 */
|
||||||
|
.hero__edge-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: var(--havline-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__edge-head > :first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__edge-head > :last-child {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 链路节点:5 个节点用 flex 等距分布,首尾贴住容器两端。
|
||||||
|
不能用 5 等分 grid —— 那样第一个圆点在 0%、最后一个在 80%,
|
||||||
|
右端会空出近一整列,看起来左边顶满、右边悬空。 */
|
||||||
|
.edge {
|
||||||
|
--edge-gap: 12px;
|
||||||
|
--edge-dot: 9px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--edge-gap);
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__node {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
/* 等宽分配剩余空间:连接线要靠这个宽度精确接到下一个圆点 */
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 最后一个节点不参与等宽拉伸,贴住右边界,两端留白才对称。
|
||||||
|
注意圆点仍要左对齐:它前面还要接上一段的连接线,右对齐会让线断开。 */
|
||||||
|
.edge__node:last-child {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 连接线:每个节点各画一小段,从自己的圆点连到下一个圆点。
|
||||||
|
- left 取圆点半径,从圆心起步(圆点 z-index 更高,会盖住重合部分)
|
||||||
|
- right 取负的「gap + 圆点半径」,跨过 flex 间隙接到下一个圆心;
|
||||||
|
只写 -半径 的话线会在间隙处断开,看起来像几段悬空的短线。 */
|
||||||
|
.edge__node:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: calc(var(--edge-dot) / 2 - 0.5px);
|
||||||
|
left: calc(var(--edge-dot) / 2);
|
||||||
|
right: calc(-1 * (var(--edge-gap) + var(--edge-dot) / 2));
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: var(--edge-dot);
|
||||||
|
height: var(--edge-dot);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--havline-brand);
|
||||||
|
box-shadow: 0 0 0 4px var(--havline-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__node--warn .edge__dot {
|
||||||
|
background: var(--havline-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__name {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--havline-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__value {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 9.5px;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__state {
|
||||||
|
font-size: 9.5px;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__node--warn .edge__state {
|
||||||
|
color: var(--havline-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.hero__main {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__edge {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.hero {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-banner__badges {
|
.hero__medal {
|
||||||
gap: 8px;
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__medal .n-icon {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__actions > * {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏改成两列换行:节点不再「一行串起来」,连线会指向错的地方,直接去掉 */
|
||||||
|
.edge {
|
||||||
|
--edge-gap: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px var(--edge-gap);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__node,
|
||||||
|
.edge__node:last-child {
|
||||||
|
flex: initial;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge__node:not(:last-child)::after {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,6 +1760,46 @@ html.dark .hero-art {
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 公网 IP 逐条列表:地址与来源标签同一行,地址过长时省略而不是换行,
|
||||||
|
保证多条 IP 时卡片高度可控。 */
|
||||||
|
.ip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list__addr {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--havline-text);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list__tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--havline-bg-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--havline-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-desc {
|
.stat-desc {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
Reference in New Issue
Block a user