Browse Source

feat: replace AppShell with TDesign MainContainer shell

main
xiaosi 1 month ago
parent
commit
eb6a4a3bfa
  1. 130
      src/layout/AppShell.vue
  2. 121
      src/layout/MainContainer.vue
  3. 365
      src/layout/components/SideMenu.vue
  4. 86
      src/layout/components/TopBar.vue
  5. 4
      src/router/index.js

130
src/layout/AppShell.vue

@ -1,130 +0,0 @@
<template>
<div class="app-shell">
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="brand">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div><strong>嘉谷</strong><small>低空智控平台</small></div>
</div>
<nav class="main-nav" aria-label="主导航">
<button class="nav-item" :class="{ active: isActive('monitor') }" @click="go('monitor')"><svg><use href="#i-radio"/></svg><span>实时监控</span></button>
<button class="nav-item" :class="{ active: isActive('devices') || isActive('device-detail') || isActive('control') }" @click="go('devices')"><svg><use href="#i-box"/></svg><span>设备管理</span></button>
<div v-if="!isAdmin" class="nav-group" :class="{ open: taskOpen }">
<button class="nav-item" :class="{ active: isActive('tasks') || isActive('replay'), expanded: taskOpen }" @click="toggleTask">
<svg><use href="#i-route"/></svg><span>任务管理</span><svg class="nav-arrow"><use href="#i-chevron"/></svg>
</button>
<div class="subnav">
<button class="subnav-item" :class="{ active: taskView === 'plans' }" @click="go('tasks', { view: 'plans' })"><span></span>任务计划</button>
<button class="subnav-item" :class="{ active: taskView === 'records' }" @click="go('tasks', { view: 'records' })"><span></span>执行记录</button>
<button class="subnav-item" :class="{ active: taskView === 'routes' }" @click="go('tasks', { view: 'routes' })"><span></span>航线库</button>
</div>
</div>
<div v-if="!isAdmin" class="nav-group" :class="{ open: mediaOpen }">
<button class="nav-item" :class="{ active: isActive('media'), expanded: mediaOpen }" @click="toggleMedia">
<svg><use href="#i-video"/></svg><span>媒体中心</span><svg class="nav-arrow"><use href="#i-chevron"/></svg>
</button>
<div class="subnav">
<button class="subnav-item" :class="{ active: mediaView === 'live' }" @click="go('media', { view: 'live' })"><span></span>直播中心</button>
<button class="subnav-item" :class="{ active: mediaView === 'videos' }" @click="go('media', { view: 'videos' })"><span></span>原始视频</button>
</div>
</div>
<button v-if="!isAdmin" class="nav-item" :class="{ active: isActive('alarms') }" @click="go('alarms')"><svg><use href="#i-alert"/></svg><span>告警运维</span></button>
<button v-if="!isAdmin" class="nav-item" :class="{ active: isActive('account') }" @click="go('account')"><svg><use href="#i-wallet"/></svg><span>账户中心</span></button>
<button v-if="isAdmin" class="nav-item" :class="{ active: isActive('users') }" @click="go('users')"><svg><use href="#i-user"/></svg><span>用户列表</span></button>
<button v-if="isAdmin" class="nav-item" :class="{ active: isActive('firmwares') }" @click="go('firmwares')"><svg><use href="#i-download"/></svg><span>固件管理</span></button>
<button v-if="isAdmin" class="nav-item" :class="{ active: isActive('system') }" @click="go('system')"><svg><use href="#i-settings"/></svg><span>系统管理</span></button>
</nav>
<div class="sidebar-bottom">
<div class="profile-wrap">
<button class="sidebar-profile" @click="profileOpen = !profileOpen" :aria-expanded="String(profileOpen)">
<span>{{ userName.charAt(0) }}</span>
<div><strong>{{ userName }}</strong><small>嘉谷科技</small></div>
<svg><use href="#i-chevron"/></svg>
</button>
<div class="profile-menu" v-show="profileOpen">
<div class="profile-menu-heading"><strong>{{ userName }}</strong><small>{{ userEmail }}</small></div>
<button class="logout-action" @click="logout">退出登录</button>
</div>
</div>
</div>
</aside>
<main class="workspace">
<button class="icon-button mobile-menu" title="打开导航" @click="sidebarOpen = !sidebarOpen"><svg><use href="#i-menu"/></svg></button>
<router-view />
</main>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/userStore'
import { useUiStore } from '@/stores/modules/uiStore'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const ui = useUiStore()
const sidebarOpen = ref(false)
const profileOpen = ref(false)
const taskOpen = ref(false)
const mediaOpen = ref(false)
const isAdmin = computed(() => userStore.isAdmin())
const userName = computed(() => userStore.user?.name || userStore.user?.phone || '平台管理员')
const userEmail = computed(() => userStore.user?.email || 'admin@jiagu-tech.cn')
const taskView = computed(() => route.query.view || 'plans')
const mediaView = computed(() => route.query.view || 'videos')
watch(
() => route.name,
(name) => {
taskOpen.value = name === 'tasks' || name === 'replay'
mediaOpen.value = name === 'media'
sidebarOpen.value = false
profileOpen.value = false
},
{ immediate: true }
)
function isActive(name) {
return route.name === name
}
function go(name, query) {
router.push({ name, query })
}
function toggleTask() {
if (route.name === 'tasks') {
taskOpen.value = !taskOpen.value
} else {
router.push({ name: 'tasks' })
}
}
function toggleMedia() {
if (route.name === 'media') {
mediaOpen.value = !mediaOpen.value
} else {
router.push({ name: 'media' })
}
}
async function logout() {
const ok = await ui.confirm('确认退出当前账号?', '退出后需要重新登录才能访问平台。', ['退出当前会话', '保留登录状态', '清除本地凭证'])
if (ok) {
userStore.logout()
router.push('/login')
ui.toast('已退出登录')
}
}
</script>

121
src/layout/MainContainer.vue

@ -0,0 +1,121 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import SideMenu from '@/layout/components/SideMenu.vue'
import TopBar from '@/layout/components/TopBar.vue'
const route = useRoute()
const sidebarOpen = ref(false)
watch(
() => route.fullPath,
() => {
sidebarOpen.value = false
}
)
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function closeSidebar() {
sidebarOpen.value = false
}
</script>
<template>
<t-layout :class="s.root">
<t-aside :class="[s.aside, { [s.asideOpen]: sidebarOpen }]">
<SideMenu />
</t-aside>
<div v-if="sidebarOpen" :class="s.mask" @click="closeSidebar" />
<t-layout :class="s.main">
<t-header :class="s.header">
<TopBar @toggle-sidebar="toggleSidebar" />
</t-header>
<t-content :class="s.content">
<!-- MapLayer mounts in Task 5; keep relative content for overlay -->
<router-view />
</t-content>
</t-layout>
</t-layout>
</template>
<style lang="less" module="s">
.root {
height: 100vh;
overflow: hidden;
background: #eef3f7;
:global {
.t-layout__sider {
width: 208px !important;
min-width: 208px;
flex-shrink: 0;
background: #1a2b36 !important;
border: none !important;
}
.t-layout__header {
height: auto;
padding: 0;
background: transparent;
border: none;
line-height: normal;
}
.t-layout__content {
padding: 0;
}
}
}
.aside {
height: 100vh;
z-index: 30;
}
.main {
min-width: 0;
height: 100vh;
overflow: hidden;
}
.header {
flex-shrink: 0;
z-index: 20;
}
.content {
position: relative;
height: 100%;
overflow: auto;
background: #eef3f7;
}
.mask {
display: none;
}
@media (max-width: 1020px) {
.aside {
position: fixed;
left: -208px;
top: 0;
transition: left 0.2s ease;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
}
.asideOpen {
left: 0;
}
.mask {
display: block;
position: fixed;
inset: 0;
background: rgba(15, 23, 32, 0.36);
z-index: 25;
}
}
</style>

365
src/layout/components/SideMenu.vue

@ -0,0 +1,365 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/userStore'
import { useUiStore } from '@/stores/modules/uiStore'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const ui = useUiStore()
const isAdmin = computed(() => userStore.isAdmin())
const userName = computed(() => userStore.user?.name || userStore.user?.phone || '平台管理员')
const userEmail = computed(() => userStore.user?.email || 'admin@jiagu-tech.cn')
const profileOpen = ref(false)
const activeValue = computed(() => {
const name = route.name
if (name === 'devices' || name === 'device-detail' || name === 'control') return 'devices'
if (name === 'tasks' || name === 'replay') {
const view = name === 'replay' ? 'records' : route.query.view || 'plans'
return `tasks:${view}`
}
if (name === 'media') return `media:${route.query.view || 'videos'}`
return name
})
const expanded = ref([])
watch(
() => route.name,
(name) => {
const next = []
if (name === 'tasks' || name === 'replay') next.push('tasks')
if (name === 'media') next.push('media')
expanded.value = next
profileOpen.value = false
},
{ immediate: true }
)
function onChange(value) {
if (typeof value !== 'string') return
if (value.startsWith('tasks:')) {
router.push({ name: 'tasks', query: { view: value.slice(6) } })
return
}
if (value.startsWith('media:')) {
router.push({ name: 'media', query: { view: value.slice(6) } })
return
}
router.push({ name: value })
}
function onExpand(value) {
expanded.value = value
}
async function logout() {
profileOpen.value = false
const ok = await ui.confirm('确认退出当前账号?', '退出后需要重新登录才能访问平台。', [
'退出当前会话',
'保留登录状态',
'清除本地凭证',
])
if (ok) {
userStore.logout()
router.push('/login')
ui.toast('已退出登录')
}
}
</script>
<template>
<div :class="s.root">
<div :class="s.brand">
<div :class="s.brandMark"><span /><span /><span /></div>
<div>
<strong>嘉谷</strong>
<small>低空智控平台</small>
</div>
</div>
<t-menu
theme="dark"
:value="activeValue"
:expanded="expanded"
width="208px"
:class="s.menu"
@change="onChange"
@expand="onExpand"
>
<t-menu-item value="monitor">
<template #icon>
<svg :class="s.icon"><use href="#i-radio" /></svg>
</template>
实时监控
</t-menu-item>
<t-menu-item value="devices">
<template #icon>
<svg :class="s.icon"><use href="#i-box" /></svg>
</template>
设备管理
</t-menu-item>
<t-submenu v-if="!isAdmin" value="tasks" title="任务管理">
<template #icon>
<svg :class="s.icon"><use href="#i-route" /></svg>
</template>
<t-menu-item value="tasks:plans">任务计划</t-menu-item>
<t-menu-item value="tasks:records">执行记录</t-menu-item>
<t-menu-item value="tasks:routes">航线库</t-menu-item>
</t-submenu>
<t-submenu v-if="!isAdmin" value="media" title="媒体中心">
<template #icon>
<svg :class="s.icon"><use href="#i-video" /></svg>
</template>
<t-menu-item value="media:live">直播中心</t-menu-item>
<t-menu-item value="media:videos">原始视频</t-menu-item>
</t-submenu>
<t-menu-item v-if="!isAdmin" value="alarms">
<template #icon>
<svg :class="s.icon"><use href="#i-alert" /></svg>
</template>
告警运维
</t-menu-item>
<t-menu-item v-if="!isAdmin" value="account">
<template #icon>
<svg :class="s.icon"><use href="#i-wallet" /></svg>
</template>
账户中心
</t-menu-item>
<t-menu-item v-if="isAdmin" value="users">
<template #icon>
<svg :class="s.icon"><use href="#i-user" /></svg>
</template>
用户列表
</t-menu-item>
<t-menu-item v-if="isAdmin" value="firmwares">
<template #icon>
<svg :class="s.icon"><use href="#i-download" /></svg>
</template>
固件管理
</t-menu-item>
<t-menu-item v-if="isAdmin" value="system">
<template #icon>
<svg :class="s.icon"><use href="#i-settings" /></svg>
</template>
系统管理
</t-menu-item>
</t-menu>
<div :class="s.bottom">
<button :class="s.profile" type="button" :aria-expanded="String(profileOpen)" @click="profileOpen = !profileOpen">
<span>{{ userName.charAt(0) }}</span>
<div>
<strong>{{ userName }}</strong>
<small>嘉谷科技</small>
</div>
<svg :class="s.chevron"><use href="#i-chevron" /></svg>
</button>
<div v-show="profileOpen" :class="s.profileMenu">
<div :class="s.profileHeading">
<strong>{{ userName }}</strong>
<small>{{ userEmail }}</small>
</div>
<button type="button" :class="s.logout" @click="logout">退出登录</button>
</div>
</div>
</div>
</template>
<style lang="less" module="s">
.root {
display: flex;
flex-direction: column;
height: 100%;
background: #1a2b36;
color: #d7e0e8;
}
.brand {
height: 64px;
padding: 0 20px;
display: flex;
align-items: center;
gap: 11px;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
strong {
display: block;
font-size: 19px;
line-height: 20px;
}
small {
display: block;
margin-top: 2px;
font-size: 12px;
color: #8fa0ad;
}
}
.brandMark {
width: 28px;
height: 28px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
span {
display: block;
border-radius: 2px;
background: #7db5f5;
}
}
.menu {
flex: 1;
overflow: auto;
border-right: none !important;
background: transparent !important;
:global {
.t-default-menu__inner {
background: transparent;
}
.t-menu__item {
margin: 0 10px 5px;
border-radius: 5px;
}
}
}
.icon {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.bottom {
position: relative;
padding: 0 10px 14px;
flex-shrink: 0;
}
.profile {
width: 100%;
min-height: 44px;
padding: 0 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: #dbe4eb;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-align: left;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
> span {
width: 30px;
height: 30px;
border-radius: 50%;
background: #2d6fba;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
strong {
display: block;
font-size: 12px;
color: #f2f6f9;
}
small {
display: block;
color: #8fa0ad;
font-size: 10px;
}
div {
flex: 1;
min-width: 0;
}
}
.chevron {
width: 12px;
height: 12px;
margin-left: auto;
transform: rotate(-90deg);
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
color: #8fa0ad;
}
.profileMenu {
position: absolute;
left: 10px;
right: 10px;
bottom: 62px;
padding: 10px;
border-radius: 8px;
background: #243746;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
z-index: 5;
}
.profileHeading {
margin-bottom: 8px;
strong {
display: block;
color: #fff;
font-size: 13px;
}
small {
color: #8fa0ad;
font-size: 11px;
}
}
.logout {
width: 100%;
height: 34px;
border: 0;
border-radius: 5px;
background: rgba(200, 69, 75, 0.16);
color: #ffb4b8;
cursor: pointer;
&:hover {
background: rgba(200, 69, 75, 0.28);
color: #fff;
}
}
</style>

86
src/layout/components/TopBar.vue

@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const emit = defineEmits(['toggle-sidebar'])
const route = useRoute()
const titles = {
monitor: '实时监控',
devices: '设备管理',
'device-detail': '设备详情',
control: '设备控制',
tasks: '任务管理',
replay: '任务回放',
media: '媒体中心',
alarms: '告警运维',
account: '账户中心',
users: '用户列表',
firmwares: '固件管理',
system: '系统管理',
logs: '操作日志',
}
const pageTitle = computed(() => titles[route.name] || '嘉谷低空智控平台')
</script>
<template>
<div :class="s.root">
<button :class="s.mobileMenu" type="button" title="打开导航" @click="emit('toggle-sidebar')">
<svg><use href="#i-menu" /></svg>
</button>
<div :class="s.title">{{ pageTitle }}</div>
<div :class="s.spacer" />
</div>
</template>
<style lang="less" module="s">
.root {
height: 56px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-bottom: 1px solid #e7edf2;
}
.mobileMenu {
display: none;
width: 36px;
height: 36px;
border: 0;
border-radius: 8px;
background: rgba(26, 43, 54, 0.76);
color: #fff;
align-items: center;
justify-content: center;
cursor: pointer;
svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.title {
font-size: 16px;
font-weight: 600;
color: #1f2a33;
}
.spacer {
flex: 1;
}
@media (max-width: 1020px) {
.mobileMenu {
display: inline-flex;
}
}
</style>

4
src/router/index.js

@ -1,12 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/modules/userStore'
import AppShell from '../layout/AppShell.vue'
import MainContainer from '@/layout/MainContainer.vue'
const routes = [
{ path: '/login', name: 'login', component: () => import('../views/LoginView/LoginView.vue') },
{
path: '/',
component: AppShell,
component: MainContainer,
redirect: '/monitor',
children: [
{ path: 'monitor', name: 'monitor', component: () => import('../views/MonitorView/MonitorView.vue') },

Loading…
Cancel
Save