Files
Havline/web/src/layouts/AppLayout.vue
T

646 lines
16 KiB
Vue
Raw Normal View History

2026-09-23 09:40:59 +08:00
<template>
<n-layout has-sider class="app-layout">
<n-layout-sider
class="app-sider"
bordered
collapse-mode="width"
:collapsed-width="72"
:width="240"
:collapsed="collapsed"
show-trigger="bar"
@collapse="collapsed = true"
@expand="collapsed = false"
>
<router-link class="brand" :class="{ 'brand--collapsed': collapsed }" :to="{ name: 'dashboard' }">
2026-10-01 16:54:35 +08:00
<img class="brand__logo" :src="brandLogo" alt="Havline" />
2026-09-23 09:40:59 +08:00
<div v-if="!collapsed" class="brand__text">
2026-10-01 16:54:35 +08:00
<div class="brand__name">Havline</div>
2026-09-23 09:40:59 +08:00
<div class="brand__tagline">让 NAS 访问更简单</div>
</div>
</router-link>
<!-- 导航按业务域分组:12 个平铺项不容易扫读,分组后能按「概览 / 网络 / 运维 / 系统」定位 -->
<nav class="nav" aria-label="主导航">
<template v-for="group in menuGroups" :key="group.label">
<div v-if="!collapsed" class="nav__group">{{ group.label }}</div>
<div v-else class="nav__divider" aria-hidden="true"></div>
<button
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>
2026-09-23 09:40:59 +08:00
<div v-if="!collapsed" class="sider-footer">
<div class="sider-footer__version-row">
<div class="sider-footer__title">
2026-10-01 16:54:35 +08:00
<span class="sider-footer__brand">Havline</span>
2026-09-23 09:40:59 +08:00
<span class="sider-footer__version">v{{ appVersion }}</span>
</div>
<a
v-if="canOneClickUpdate"
class="sider-footer__update"
href="#"
@click.prevent="showUpdateModal = true"
>
<n-icon :component="ArrowUpOutline" />
一键升级
</a>
<a
v-else-if="hasUpdate"
2026-09-23 09:40:59 +08:00
class="sider-footer__update"
:href="latestReleaseUrl"
target="_blank"
rel="noopener noreferrer"
>
<n-icon :component="ArrowUpOutline" />
有新版本
<n-icon :component="OpenOutline" />
</a>
</div>
<div class="sider-footer__status">
<n-icon :component="Ellipse" class="sider-footer__status-icon" />
<span>运行中</span>
</div>
<a
class="sider-footer__github"
:href="GITHUB_REPO_URL"
target="_blank"
rel="noopener noreferrer"
>
<n-icon :component="LogoGithub" />
<span>{{ GITHUB_REPO }}</span>
</a>
</div>
</n-layout-sider>
<n-layout class="app-main">
<header class="topbar">
<span class="topbar__datetime">{{ datetime }}</span>
<div class="topbar__right">
<n-button quaternary circle @click="handleToggleTheme">
2026-09-23 09:40:59 +08:00
<template #icon>
<n-icon :component="isDark ? MoonOutline : SunnyOutline" />
</template>
</n-button>
<n-dropdown :options="userMenuOptions" @select="handleUserMenu">
<n-button quaternary class="user-btn">
<n-icon :component="PersonOutline" class="user-btn__icon" />
<span class="user-name">管理员</span>
<n-icon :component="ChevronDownOutline" class="user-btn__chevron" />
</n-button>
</n-dropdown>
</div>
</header>
<n-layout-content class="content">
<div class="page-shell page-body">
<router-view />
</div>
</n-layout-content>
</n-layout>
</n-layout>
<UpdateModal v-model:show="showUpdateModal" @updated="checkForUpdate" />
2026-09-23 09:40:59 +08:00
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, type Component } from 'vue'
2026-09-23 09:40:59 +08:00
import { useRoute, useRouter } from 'vue-router'
import {
NButton,
NDropdown,
NIcon,
NLayout,
NLayoutContent,
NLayoutSider,
useMessage,
} from 'naive-ui'
import {
ArrowUpOutline,
ChevronDownOutline,
CloudOutline,
CogOutline,
DocumentTextOutline,
Ellipse,
GitNetworkOutline,
GlobeOutline,
2026-09-23 09:40:59 +08:00
GridOutline,
HardwareChipOutline,
InformationCircleOutline,
2026-09-23 09:40:59 +08:00
LockClosedOutline,
LogoGithub,
MoonOutline,
OpenOutline,
PersonOutline,
ShieldCheckmarkOutline,
2026-09-23 09:40:59 +08:00
SunnyOutline,
SwapHorizontalOutline,
2026-10-16 20:18:04 +08:00
TerminalOutline,
2026-09-23 09:40:59 +08:00
} from '@vicons/ionicons5'
import { api } from '../api/client'
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
import { useTheme } from '../composables/useTheme'
import { fetchLatestRelease, isNewerVersion } from '../utils/version'
import type { UpdateStatus } from '../api/types'
2026-10-01 16:54:35 +08:00
import brandLogo from '../assets/brand/havline-logo.svg'
import UpdateModal from '../components/UpdateModal.vue'
2026-09-23 09:40:59 +08:00
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { isDark, themeMode, toggleTheme, syncFromSettings } = useTheme()
2026-09-23 09:40:59 +08:00
const collapsed = ref(false)
const datetime = ref('')
const appVersion = ref(__APP_VERSION__)
const latestVersion = ref('')
const latestReleaseUrl = ref('')
const hasUpdate = ref(false)
const showUpdateModal = ref(false)
const updateStatus = ref<UpdateStatus | null>(null)
2026-09-23 09:40:59 +08:00
let clockTimer: number | undefined
const canOneClickUpdate = computed(() => Boolean(
updateStatus.value?.enabled && updateStatus.value.update_available
))
2026-09-23 09:40:59 +08:00
async function checkForUpdate() {
const release = await fetchLatestRelease()
if (!release) {
return
}
latestVersion.value = release.version
latestReleaseUrl.value = release.url
hasUpdate.value = isNewerVersion(release.version, appVersion.value)
}
async function loadVersion() {
try {
const { version } = await api.getVersion()
if (version) {
appVersion.value = version
}
} catch {
// keep build-time fallback
}
await Promise.all([checkForUpdate(), loadUpdateStatus()])
}
async function loadUpdateStatus() {
try {
updateStatus.value = await api.getUpdateStatus()
} catch {
updateStatus.value = null
}
2026-09-23 09:40:59 +08:00
}
type NavItem = { label: string; key: string; icon: Component }
type NavGroup = { label: string; items: NavItem[] }
2026-09-23 09:40:59 +08:00
// 分组与原型一致:概览 / 网络 / 运维 / 系统。
// 顺序按「先看状态、再管流量、最后进设置」的使用路径排,而不是按模块实现顺序。
const menuGroups: NavGroup[] = [
{
label: '概览',
items: [{ label: '仪表盘', key: 'dashboard', icon: GridOutline }],
},
{
label: '网络',
items: [
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: 'DDNS', key: 'ddns', icon: GlobeOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
],
},
{
label: '运维',
items: [
{ label: '证书管理', key: 'certificates', icon: LockClosedOutline },
{ label: 'SSH 终端', key: 'terminal', icon: TerminalOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
],
},
{
label: '系统',
items: [
{ label: '系统设置', key: 'settings', icon: CogOutline },
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
],
},
]
2026-09-23 09:40:59 +08:00
const activeKey = computed(() => {
const name = route.name
2026-10-16 20:18:04 +08:00
// 终端审计是终端页的子页面:菜单里只有「SSH 终端」,这里让它保持高亮
if (name === 'terminal-audits') return 'terminal'
2026-09-23 09:40:59 +08:00
return typeof name === 'string' ? name : 'dashboard'
})
const userMenuOptions = [{ label: '退出登录', key: 'logout' }]
function updateClock() {
const now = new Date()
const date = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
const time = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
const weekday = now.toLocaleDateString('zh-CN', { weekday: 'long' })
datetime.value = `${date} ${time} · ${weekday}`
}
function handleMenu(key: string) {
router.push({ name: key })
}
// handleToggleTheme 顶栏的快捷切换:本地立即生效,同时回写后端设置。
// 不回写的话会出现「这里切了、设置页显示的还是旧值」,下次任何一次保存设置
// 又会把主题改回去。
async function handleToggleTheme() {
toggleTheme()
try {
await api.saveSettings({ theme: themeMode.value })
} catch {
// 回写失败不影响当前会话的外观,下次进设置页仍能看到服务端的值
}
}
2026-09-23 09:40:59 +08:00
async function handleUserMenu(key: string) {
if (key === 'logout') {
await api.logout()
message.success('已退出登录')
router.push({ name: 'login' })
}
}
onMounted(() => {
loadVersion()
updateClock()
clockTimer = window.setInterval(updateClock, 30_000)
// 主题以后端设置为准:localStorage 只是首屏回显,登录后要用服务端的值覆盖,
// 否则换设备/换浏览器两个人看到的是不同主题(而设置页显示的是服务端那个)。
api
.getSettings()
.then((settings) => {
syncFromSettings(settings.theme)
})
.catch(() => {
// 读不到设置就沿用本地偏好,不影响使用
})
2026-09-23 09:40:59 +08:00
})
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer)
})
</script>
<style scoped>
.app-layout {
min-height: 100vh;
height: 100vh;
2026-10-01 16:54:35 +08:00
background: var(--havline-bg);
2026-09-23 09:40:59 +08:00
}
.app-layout :deep(.n-layout-scroll-container) {
min-height: 100%;
}
.app-sider {
2026-10-01 16:54:35 +08:00
background: var(--havline-surface) !important;
border-right: 1px solid var(--havline-border) !important;
2026-09-23 09:40:59 +08:00
min-height: 100vh;
}
.app-sider :deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
}
/* ------------------------------ 品牌区 ------------------------------ */
2026-09-23 09:40:59 +08:00
.brand {
display: flex;
align-items: center;
2026-10-01 16:54:35 +08:00
gap: var(--havline-space-3);
padding: var(--havline-space-5) var(--havline-space-4);
border-bottom: 1px solid var(--havline-border);
2026-09-23 09:40:59 +08:00
color: inherit;
text-decoration: none;
cursor: pointer;
transition: background 0.15s ease;
}
.brand:hover {
2026-10-01 16:54:35 +08:00
background: var(--havline-bg-muted);
2026-09-23 09:40:59 +08:00
}
.brand--collapsed {
justify-content: center;
2026-10-01 16:54:35 +08:00
padding: var(--havline-space-4) 0;
2026-09-23 09:40:59 +08:00
}
.brand__logo {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.brand__name {
font-size: 18px;
font-weight: 700;
2026-10-01 16:54:35 +08:00
color: var(--havline-text);
2026-09-23 09:40:59 +08:00
line-height: 1.2;
}
.brand__tagline {
font-size: 11px;
2026-10-01 16:54:35 +08:00
color: var(--havline-text-muted);
2026-09-23 09:40:59 +08:00
margin-top: 2px;
line-height: 1.3;
}
/* ------------------------------ 导航 ------------------------------ */
.nav {
2026-09-23 09:40:59 +08:00
flex: 1;
overflow-y: auto;
padding: 10px 12px 16px;
2026-09-23 09:40:59 +08:00
}
.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;
2026-09-23 09:40:59 +08:00
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;
2026-09-23 09:40:59 +08:00
}
.nav__item--icon-only {
justify-content: center;
gap: 0;
padding: 0;
2026-09-23 09:40:59 +08:00
}
.nav__icon {
font-size: 18px;
flex: none;
color: var(--havline-text-muted);
transition: color 0.16s ease;
2026-09-23 09:40:59 +08:00
}
.nav__item:hover {
background: var(--havline-bg-muted);
color: var(--havline-text);
2026-09-23 09:40:59 +08:00
}
.nav__item:hover .nav__icon {
color: var(--havline-text-secondary);
2026-09-23 09:40:59 +08:00
}
.nav__item--active {
background: linear-gradient(90deg, var(--havline-brand-soft), rgba(16, 185, 129, 0.03));
color: var(--havline-brand-text);
font-weight: 650;
2026-09-23 09:40:59 +08:00
}
.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;
}
/* --------------------------- 底部状态区 --------------------------- */
2026-09-23 09:40:59 +08:00
.sider-footer {
2026-10-01 16:54:35 +08:00
padding: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
2026-09-23 09:40:59 +08:00
}
.sider-footer__version-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
2026-10-01 16:54:35 +08:00
margin-bottom: var(--havline-space-4);
2026-09-23 09:40:59 +08:00
}
.sider-footer__title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.sider-footer__brand {
font-size: 13px;
font-weight: 600;
2026-10-01 16:54:35 +08:00
color: var(--havline-text);
2026-09-23 09:40:59 +08:00
}
.sider-footer__version {
font-size: 12px;
2026-10-01 16:54:35 +08:00
color: var(--havline-text-muted);
2026-09-23 09:40:59 +08:00
}
.sider-footer__update {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0;
border: 0;
background: none;
2026-09-23 09:40:59 +08:00
font-size: 11px;
font-weight: 500;
font-family: inherit;
2026-10-01 16:54:35 +08:00
color: var(--havline-brand);
2026-09-23 09:40:59 +08:00
text-decoration: none;
white-space: nowrap;
cursor: pointer;
2026-09-23 09:40:59 +08:00
transition: opacity 0.15s;
}
.sider-footer__update:hover {
opacity: 0.8;
}
.sider-footer__update :deep(.n-icon) {
font-size: 12px;
}
.sider-footer__status {
display: flex;
align-items: center;
gap: 6px;
2026-10-01 16:54:35 +08:00
margin-bottom: var(--havline-space-4);
2026-09-23 09:40:59 +08:00
font-size: 12px;
2026-10-01 16:54:35 +08:00
color: var(--havline-success);
2026-09-23 09:40:59 +08:00
font-weight: 500;
}
.sider-footer__status-icon {
font-size: 8px;
2026-10-01 16:54:35 +08:00
color: var(--havline-success);
2026-09-23 09:40:59 +08:00
}
.sider-footer__github {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
2026-10-01 16:54:35 +08:00
color: var(--havline-text-secondary);
2026-09-23 09:40:59 +08:00
text-decoration: none;
transition: color 0.15s;
}
.sider-footer__github:hover {
2026-10-01 16:54:35 +08:00
color: var(--havline-text);
2026-09-23 09:40:59 +08:00
}
.sider-footer__github :deep(.n-icon) {
font-size: 15px;
}
/* ------------------------------ 顶栏与内容 ------------------------------ */
2026-09-23 09:40:59 +08:00
.app-main {
display: flex;
flex-direction: column;
min-height: 100vh;
2026-10-01 16:54:35 +08:00
background: var(--havline-bg);
2026-09-23 09:40:59 +08:00
}
.topbar {
flex-shrink: 0;
2026-10-01 16:54:35 +08:00
height: var(--havline-topbar-height);
padding: 0 var(--havline-space-5);
2026-09-23 09:40:59 +08:00
display: flex;
align-items: center;
justify-content: space-between;
2026-10-01 16:54:35 +08:00
gap: var(--havline-space-4);
background: var(--havline-surface);
border-bottom: 1px solid var(--havline-border);
2026-09-23 09:40:59 +08:00
}
.topbar__datetime {
font-size: 13px;
2026-10-01 16:54:35 +08:00
color: var(--havline-text-secondary);
2026-09-23 09:40:59 +08:00
}
.topbar__right {
display: flex;
align-items: center;
2026-10-01 16:54:35 +08:00
gap: var(--havline-space-4);
2026-09-23 09:40:59 +08:00
}
.user-btn {
display: flex;
align-items: center;
2026-10-01 16:54:35 +08:00
gap: var(--havline-space-3);
padding: 0 var(--havline-space-2);
2026-09-23 09:40:59 +08:00
}
.user-btn__icon,
.user-btn__chevron {
font-size: 18px;
2026-10-01 16:54:35 +08:00
color: var(--havline-text-secondary);
2026-09-23 09:40:59 +08:00
}
.user-name {
font-size: 14px;
2026-10-01 16:54:35 +08:00
color: var(--havline-text);
2026-09-23 09:40:59 +08:00
}
.content {
flex: 1;
2026-10-01 16:54:35 +08:00
padding: var(--havline-space-5);
2026-09-23 09:40:59 +08:00
overflow: auto;
}
.page-body {
min-height: 100%;
}
@media (max-width: 768px) {
.topbar,
.content {
2026-10-01 16:54:35 +08:00
padding-inline: var(--havline-space-4);
2026-09-23 09:40:59 +08:00
}
.user-name,
.topbar__datetime {
display: none;
}
}
</style>