三页面重设计与移动端适配;接入 PWA 与构建分包
CI / test (push) Successful in 47s

This commit is contained in:
2026-07-21 19:26:22 +08:00
parent ac457282ce
commit 6129173fe6
64 changed files with 6243 additions and 549 deletions
+23 -7
View File
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { NPopselect } from 'naive-ui'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MobileTabBar from './MobileTabBar.vue'
import MoreSheet from './MoreSheet.vue'
import SidebarNav from './SidebarNav.vue'
import { logout as logoutApi } from '@/api/auth'
import SerialConsolePanel from '@/components/instance/SerialConsolePanel.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { regionCity } from '@/composables/useFormat'
import { useIsMobile } from '@/composables/useIsMobile'
import { useRegionAlias } from '@/composables/useRegionAlias'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
@@ -22,6 +25,10 @@ const route = useRoute()
const router = useRouter()
const { alias } = useRegionAlias()
/** ≤767px:侧栏让位于底部导航 + 「更多」抽屉(设计稿定稿形态) */
const isMobile = useIsMobile()
const showMore = ref(false)
/** 胶囊内区域只显城市短名(友好名括号内文字),完整名留在菜单与悬浮提示 */
const regionFull = computed(() => alias(scope.region))
const regionShort = computed(() => regionCity(regionFull.value))
@@ -59,14 +66,15 @@ async function logout() {
<template>
<div class="flex min-h-screen">
<SidebarNav :collapsed="app.sidebarCollapsed" @logout="logout" />
<SidebarNav v-if="!isMobile" :collapsed="app.sidebarCollapsed" @logout="logout" />
<main class="flex min-w-0 flex-1 flex-col">
<header
class="flex h-14 flex-none items-center justify-between gap-3 border-b border-line pr-6 pl-4"
class="flex h-14 flex-none items-center justify-between gap-3 border-b border-line pr-6 pl-4 max-md:pr-3"
>
<div class="flex min-w-0 items-center gap-2.5">
<button
v-if="!isMobile"
class="flex h-8 w-8 flex-none cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink"
title="收起 / 展开侧栏"
aria-label="收起或展开侧栏"
@@ -147,7 +155,7 @@ async function logout() {
>
<path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6" />
</svg>
<span class="max-w-[200px] truncate text-[13px] max-lg:max-w-[120px] max-md:hidden">
<span class="max-w-[200px] truncate text-[13px] max-lg:max-w-[120px] max-md:max-w-[84px]">
{{ scope.currentConfig?.alias ?? '选择租户' }}
</span>
<svg
@@ -194,7 +202,7 @@ async function logout() {
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 0 20 15.3 15.3 0 0 1 0-20" />
</svg>
<span class="max-w-[160px] truncate text-[13px] max-md:hidden">
<span class="max-w-[160px] truncate text-[13px] max-md:max-w-[72px]">
{{ regionShort }}
</span>
<svg
@@ -213,12 +221,20 @@ async function logout() {
</div>
</header>
<!-- 内容区限宽居中避免超宽屏下的空置感 -->
<div class="mx-auto w-full max-w-[1560px]">
<!-- 内容区限宽居中避免超宽屏下的空置感;移动端为底栏预留高度 -->
<div
class="mx-auto w-full max-w-[1560px]"
:style="isMobile ? { paddingBottom: 'calc(66px + env(safe-area-inset-bottom))' } : undefined"
>
<RouterView />
</div>
</main>
<template v-if="isMobile">
<MobileTabBar @more="showMore = true" />
<MoreSheet v-model:show="showMore" />
</template>
<!-- 串行控制台全局挂载切换路由不断开会话 -->
<SerialConsolePanel
v-model:show="consoleStore.show"
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { findNavItem, mobileMoreNames, mobileTabNames, navIcon } from './navItems'
/** 移动端底部导航:高频四项直达 + 「更多」唤起抽屉(设计稿 ①) */
const emit = defineEmits<{ more: [] }>()
const route = useRoute()
const router = useRouter()
const tabs = mobileTabNames.map((name) => {
const item = findNavItem(name)
return { ...item, icon: navIcon(item.iconPaths, 'h-[22px] w-[22px] flex-none') }
})
const moreIcon = navIcon(
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
'h-[22px] w-[22px] flex-none',
)
const activeName = computed(() => {
const current = String(route.name)
return tabs.find((t) => t.activeNames.includes(current))?.name ?? ''
})
/** 当前路由属「更多」内页面时高亮更多槽 */
const moreActive = computed(() => {
const current = String(route.name)
return mobileMoreNames.some((n) => findNavItem(n).activeNames.includes(current))
})
function go(name: string) {
if (name !== String(route.name)) void router.push({ name })
}
</script>
<template>
<nav
class="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-bg px-1 pt-1.5"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex">
<button
v-for="t in tabs"
:key="t.name"
class="flex min-h-[52px] flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md pb-1"
:class="activeName === t.name ? 'text-accent' : 'text-ink-3'"
@click="go(t.name)"
>
<component :is="t.icon" />
<span class="text-[10px] font-medium">{{ t.label }}</span>
</button>
<button
class="flex min-h-[52px] flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md pb-1"
:class="moreActive ? 'text-accent' : 'text-ink-3'"
@click="emit('more')"
>
<component :is="moreIcon" />
<span class="text-[10px] font-medium">更多</span>
</button>
</div>
</nav>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { NDrawer } from 'naive-ui'
import { useRoute, useRouter } from 'vue-router'
import { findNavItem, mobileMoreNames, navIcon } from './navItems'
/** 「更多」底部抽屉:收纳低频七项(设计稿 ④) */
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
const route = useRoute()
const router = useRouter()
const items = mobileMoreNames.map((name) => {
const item = findNavItem(name)
return { ...item, icon: navIcon(item.iconPaths, 'h-[22px] w-[22px] flex-none') }
})
function go(name: string) {
emit('update:show', false)
if (name !== String(route.name)) void router.push({ name })
}
function isActive(activeNames: string[]): boolean {
return props.show && activeNames.includes(String(route.name))
}
</script>
<template>
<NDrawer
:show="show"
placement="bottom"
height="auto"
:auto-focus="false"
@update:show="emit('update:show', $event)"
>
<div class="bg-bg px-4 pt-2" style="padding-bottom: calc(16px + env(safe-area-inset-bottom))">
<div class="mx-auto mb-3 mt-1 h-1 w-9 rounded-full bg-ink/20"></div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="it in items"
:key="it.name"
class="flex cursor-pointer flex-col items-center gap-1.5 rounded-md border border-line bg-card py-3.5"
:class="isActive(it.activeNames) ? 'text-accent' : 'text-ink-2'"
@click="go(it.name)"
>
<component :is="it.icon" />
<span class="text-[11.5px] text-ink">{{ it.label }}</span>
</button>
</div>
</div>
</NDrawer>
</template>
+17 -133
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { h, type Component } from 'vue'
import { type Component } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import AppLogo from '@/components/AppLogo.vue'
import { navGroups, navIcon } from './navItems'
defineProps<{ collapsed: boolean }>()
const emit = defineEmits<{ logout: [] }>()
@@ -21,128 +22,17 @@ interface NavGroup {
items: NavItem[]
}
function icon(paths: string): Component {
return () =>
h('svg', {
class: 'h-4 w-4 flex-none',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
innerHTML: paths,
})
}
/** 条目定义共享自 navItems.ts(与移动端底栏 / 「更多」抽屉同源) */
const groups: NavGroup[] = navGroups.map((g) => ({
label: g.label,
items: g.items.map((i) => ({
name: i.name,
label: i.label,
activeNames: i.activeNames,
icon: navIcon(i.iconPaths),
})),
}))
const groups: NavGroup[] = [
{
label: '概览',
items: [
{
name: 'overview',
label: '总览',
activeNames: ['overview'],
icon: icon(
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
),
},
],
},
{
label: '资源',
items: [
{
name: 'tenants',
label: '租户',
activeNames: ['tenants', 'tenant-detail'],
icon: icon(
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
),
},
{
name: 'instances',
label: '实例',
activeNames: ['instances', 'instance-detail'],
icon: icon(
'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
),
},
{
name: 'network',
label: '网络',
activeNames: ['network', 'vcn-detail'],
icon: icon(
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
),
},
{
name: 'boot-volumes',
label: '存储',
activeNames: ['boot-volumes'],
icon: icon(
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
),
},
{
name: 'object-storage',
label: '对象存储',
activeNames: ['object-storage'],
icon: icon(
'<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/>',
),
},
],
},
{
label: '自动化',
items: [
{
name: 'tasks',
label: '任务',
activeNames: ['tasks'],
icon: icon('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
},
{
name: 'ai-gateway',
label: 'AI 网关',
activeNames: ['ai-gateway'],
icon: icon(
'<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z"/>',
),
},
],
},
{
label: '系统',
items: [
{
name: 'proxies',
label: '代理',
activeNames: ['proxies'],
icon: icon(
'<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
),
},
{
name: 'logs',
label: '日志',
activeNames: ['logs'],
icon: icon(
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
),
},
{
name: 'settings',
label: '设置',
activeNames: ['settings'],
icon: icon(
'<line x1="21" y1="4" x2="14" y2="4"/><line x1="10" y1="4" x2="3" y2="4"/><line x1="21" y1="12" x2="12" y2="12"/><line x1="8" y1="12" x2="3" y2="12"/><line x1="21" y1="20" x2="16" y2="20"/><line x1="12" y1="20" x2="3" y2="20"/><line x1="14" y1="2" x2="14" y2="6"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="16" y1="18" x2="16" y2="22"/>',
),
},
],
},
]
function isActive(item: NavItem): boolean {
return item.activeNames.includes(String(route.name))
@@ -167,7 +57,7 @@ function itemClass(item: NavItem): string {
:class="collapsed ? 'justify-center px-0!' : ''"
>
<AppLogo :size="24" class="flex-none" />
<span v-if="!collapsed" class="font-serif text-lg font-semibold max-md:hidden">
<span v-if="!collapsed" class="font-serif text-lg font-semibold">
OCI Portal
</span>
</div>
@@ -176,7 +66,7 @@ function itemClass(item: NavItem): string {
<template v-for="group in groups" :key="group.label">
<div
v-if="!collapsed"
class="px-2 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-wider whitespace-nowrap text-ink-3 max-md:hidden"
class="px-2 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-wider whitespace-nowrap text-ink-3"
>
{{ group.label }}
</div>
@@ -190,12 +80,11 @@ function itemClass(item: NavItem): string {
<template #default="{ navigate }">
<div
:class="[itemClass(item), collapsed ? 'justify-center gap-0 px-0' : '']"
class="max-md:justify-center max-md:gap-0 max-md:px-0"
:title="item.label"
@click="navigate"
>
<component :is="item.icon" />
<span v-if="!collapsed" class="max-md:hidden">{{ item.label }}</span>
<span v-if="!collapsed">{{ item.label }}</span>
</div>
</template>
</RouterLink>
@@ -212,13 +101,13 @@ function itemClass(item: NavItem): string {
>
A
</div>
<div v-if="!collapsed" class="min-w-0 flex-1 max-md:hidden">
<div v-if="!collapsed" class="min-w-0 flex-1">
<div class="text-[13px] font-medium">admin</div>
<div class="text-[11.5px] text-ink-3">管理员</div>
</div>
<button
v-if="!collapsed"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink max-md:hidden"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink"
title="退出登录"
aria-label="退出登录"
@click="emit('logout')"
@@ -240,9 +129,4 @@ function itemClass(item: NavItem): string {
.sb-aside.collapsed {
width: 64px;
}
@media (max-width: 768px) {
.sb-aside {
width: 64px !important;
}
}
</style>
+149
View File
@@ -0,0 +1,149 @@
import { h, type Component } from 'vue'
/** 侧栏 / 底栏 / 「更多」抽屉共享的导航条目定义(唯一来源) */
export interface NavItem {
name: string
label: string
iconPaths: string
activeNames: string[]
}
export interface NavGroup {
label: string
items: NavItem[]
}
/** 细线风格图标工厂:paths 为 24 viewBox 下的 svg 内部标记 */
export function navIcon(paths: string, cls = 'h-4 w-4 flex-none'): Component {
return () =>
h('svg', {
class: cls,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
innerHTML: paths,
})
}
export const navGroups: NavGroup[] = [
{
label: '概览',
items: [
{
name: 'overview',
label: '总览',
activeNames: ['overview'],
iconPaths:
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
},
],
},
{
label: '资源',
items: [
{
name: 'tenants',
label: '租户',
activeNames: ['tenants', 'tenant-detail'],
iconPaths:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
},
{
name: 'instances',
label: '实例',
activeNames: ['instances', 'instance-detail'],
iconPaths:
'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
},
{
name: 'network',
label: '网络',
activeNames: ['network', 'vcn-detail'],
iconPaths:
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
},
{
name: 'boot-volumes',
label: '存储',
activeNames: ['boot-volumes'],
iconPaths:
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
},
{
name: 'object-storage',
label: '对象存储',
activeNames: ['object-storage'],
iconPaths:
'<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/>',
},
],
},
{
label: '自动化',
items: [
{
name: 'tasks',
label: '任务',
activeNames: ['tasks'],
iconPaths: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
},
{
name: 'ai-gateway',
label: 'AI 网关',
activeNames: ['ai-gateway'],
iconPaths:
'<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z"/>',
},
],
},
{
label: '系统',
items: [
{
name: 'proxies',
label: '代理',
activeNames: ['proxies'],
iconPaths:
'<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
},
{
name: 'logs',
label: '日志',
activeNames: ['logs'],
iconPaths:
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
},
{
name: 'settings',
label: '设置',
activeNames: ['settings'],
iconPaths:
'<line x1="21" y1="4" x2="14" y2="4"/><line x1="10" y1="4" x2="3" y2="4"/><line x1="21" y1="12" x2="12" y2="12"/><line x1="8" y1="12" x2="3" y2="12"/><line x1="21" y1="20" x2="16" y2="20"/><line x1="12" y1="20" x2="3" y2="20"/><line x1="14" y1="2" x2="14" y2="6"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="16" y1="18" x2="16" y2="22"/>',
},
],
},
]
const allItems = navGroups.flatMap((g) => g.items)
export function findNavItem(name: string): NavItem {
const item = allItems.find((i) => i.name === name)
if (!item) throw new Error(`unknown nav item: ${name}`)
return item
}
/** 移动端底栏高频四项;其余进「更多」抽屉 */
export const mobileTabNames = ['overview', 'tenants', 'instances', 'logs']
export const mobileMoreNames = [
'network',
'boot-volumes',
'object-storage',
'tasks',
'ai-gateway',
'proxies',
'settings',
]