5 changed files with 574 additions and 132 deletions
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
Loading…
Reference in new issue