新增分享码日志管理,美化界面,优化代码上传脚本
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
|
||||
<title>耳机管理平台</title>
|
||||
<style>
|
||||
html, body {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取分享码日志
|
||||
* @param {Object} params
|
||||
* @param {number} params.skip
|
||||
* @param {number} params.limit
|
||||
* @param {string} [params.mac_addr]
|
||||
* @param {string} [params.share_code]
|
||||
* @param {'export'|'import'} [params.action]
|
||||
* @param {string} [params.ip_addr]
|
||||
* @param {string} [params.start_at] - ISO / 可被 Date 解析的字符串
|
||||
* @param {string} [params.end_at]
|
||||
* @param {'id'|'create_at'} [params.sort_by]
|
||||
* @param {'asc'|'desc'} [params.sort_order]
|
||||
*/
|
||||
export function getShareCodeLogs(params) {
|
||||
return request({
|
||||
url: '/share-code/logs',
|
||||
method: 'get',
|
||||
params,
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="sidebar-brand" :class="{ 'is-collapsed': collapsed }">
|
||||
<div class="sidebar-brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="luxMarkGrad" x1="4" y1="4" x2="36" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#38bdf8" />
|
||||
<stop offset="0.55" stop-color="#818cf8" />
|
||||
<stop offset="1" stop-color="#a78bfa" />
|
||||
</linearGradient>
|
||||
<linearGradient id="luxMarkGlow" x1="20" y1="0" x2="20" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#38bdf8" stop-opacity="0.35" />
|
||||
<stop offset="1" stop-color="#6366f1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="luxMarkShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-color="#38bdf8" flood-opacity="0.35" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="36" height="36" rx="11" fill="url(#luxMarkGlow)" />
|
||||
<rect
|
||||
x="2.75"
|
||||
y="2.75"
|
||||
width="34.5"
|
||||
height="34.5"
|
||||
rx="10.25"
|
||||
stroke="url(#luxMarkGrad)"
|
||||
stroke-width="1.5"
|
||||
fill="rgba(15, 23, 42, 0.55)"
|
||||
/>
|
||||
<g filter="url(#luxMarkShadow)">
|
||||
<rect x="11.5" y="13" width="3.2" height="13" rx="1.6" fill="url(#luxMarkGrad)" />
|
||||
<rect x="17.4" y="10.5" width="3.2" height="15.5" rx="1.6" fill="url(#luxMarkGrad)" />
|
||||
<rect x="23.3" y="15" width="3.2" height="11" rx="1.6" fill="url(#luxMarkGrad)" />
|
||||
<path
|
||||
d="M11 27.5C14.2 24.8 17.1 29.6 20.3 27.1C23.1 24.9 25.8 28.4 28.5 27.2"
|
||||
stroke="url(#luxMarkGrad)"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-brand-wordmark">
|
||||
<span class="sidebar-brand-name">LUXSIN</span>
|
||||
<span class="sidebar-brand-tag">CMS</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-brand-mark {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.sidebar-brand-mark svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-brand-wordmark {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-brand-name {
|
||||
font-family: 'Outfit', var(--lux-font, sans-serif);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1;
|
||||
background: linear-gradient(105deg, #f8fafc 0%, #bae6fd 38%, #c4b5fd 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar-brand-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-family: 'Outfit', var(--lux-font, sans-serif);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
line-height: 1.6;
|
||||
color: #7dd3fc;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.sidebar-brand.is-collapsed {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand.is-collapsed .sidebar-brand-wordmark {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="tabs-view">
|
||||
<el-tabs
|
||||
v-model="activeKey"
|
||||
type="card"
|
||||
class="tabs-view-tabs"
|
||||
@tab-click="handleTabClick"
|
||||
@tab-remove="handleTabRemove"
|
||||
>
|
||||
<el-tab-pane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:name="tab.key"
|
||||
:closable="tab.closable"
|
||||
>
|
||||
<template #label>
|
||||
<el-dropdown
|
||||
trigger="contextmenu"
|
||||
placement="bottom-start"
|
||||
@command="(cmd) => handleContextCommand(cmd, tab.key)"
|
||||
>
|
||||
<span class="tabs-view-label">
|
||||
<el-icon v-if="resolveTabIcon(tab.icon)" class="tabs-view-label-icon">
|
||||
<component :is="resolveTabIcon(tab.icon)" />
|
||||
</el-icon>
|
||||
<span class="tabs-view-label-text">{{ tab.title }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-if="tab.closable"
|
||||
command="close"
|
||||
>
|
||||
关闭
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="close_others">关闭其他</el-dropdown-item>
|
||||
<el-dropdown-item command="close_all">关闭全部</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="tabs-view-body">
|
||||
<router-view v-slot="{ Component, route: r }">
|
||||
<KeepAlive :include="keepAliveInclude">
|
||||
<component :is="Component" :key="r.fullPath" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, markRaw, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Document, List, Upload, Collection, House } from '@element-plus/icons-vue'
|
||||
|
||||
const STORAGE_KEY = 'dashboard_tabs_v1'
|
||||
const HOME_PATH = '/brand'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const iconMap = markRaw({
|
||||
document: Document,
|
||||
list: List,
|
||||
upload: Upload,
|
||||
collection: Collection,
|
||||
home: House
|
||||
})
|
||||
|
||||
function resolveTabIcon(iconKey) {
|
||||
const key = typeof iconKey === 'string' ? iconKey.trim().toLowerCase() : ''
|
||||
return key && iconMap[key] ? iconMap[key] : null
|
||||
}
|
||||
|
||||
function getRouteTitle(r) {
|
||||
const t = r?.meta?.title
|
||||
return typeof t === 'string' && t.trim() ? t.trim() : (r?.name ? String(r.name) : r?.path || '')
|
||||
}
|
||||
|
||||
function getRouteIconKey(r) {
|
||||
const i = r?.meta?.icon
|
||||
return typeof i === 'string' ? i.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function normalizeTabFromRoute(r) {
|
||||
const fullPath = r.fullPath || r.path
|
||||
const key = fullPath
|
||||
const name = r.name ? String(r.name) : ''
|
||||
const title = getRouteTitle(r)
|
||||
const iconKey = getRouteIconKey(r)
|
||||
return {
|
||||
key,
|
||||
fullPath,
|
||||
path: r.path,
|
||||
name,
|
||||
title,
|
||||
icon: iconKey,
|
||||
closable: r.path !== HOME_PATH
|
||||
}
|
||||
}
|
||||
|
||||
function defaultHomeTab() {
|
||||
return {
|
||||
key: HOME_PATH,
|
||||
fullPath: HOME_PATH,
|
||||
path: HOME_PATH,
|
||||
name: '',
|
||||
title: '首页',
|
||||
icon: 'home',
|
||||
closable: false
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = ref([defaultHomeTab()])
|
||||
const activeKey = ref(HOME_PATH)
|
||||
|
||||
const keepAliveInclude = computed(() => {
|
||||
const names = tabs.value.map((t) => t.name).filter(Boolean)
|
||||
return Array.from(new Set(names))
|
||||
})
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
const data = {
|
||||
activeKey: activeKey.value,
|
||||
tabs: tabs.value.map((t) => ({
|
||||
key: t.key,
|
||||
fullPath: t.fullPath,
|
||||
path: t.path,
|
||||
name: t.name,
|
||||
title: t.title,
|
||||
icon: t.icon,
|
||||
closable: t.closable
|
||||
}))
|
||||
}
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return false
|
||||
const parsed = JSON.parse(raw)
|
||||
const list = Array.isArray(parsed?.tabs) ? parsed.tabs : null
|
||||
if (!list || !list.length) return false
|
||||
|
||||
const restoredTabs = list.map((t) => {
|
||||
const iconKey = typeof t.icon === 'string' ? t.icon.trim().toLowerCase() : ''
|
||||
return {
|
||||
key: String(t.key || t.fullPath || t.path || ''),
|
||||
fullPath: String(t.fullPath || t.path || ''),
|
||||
path: String(t.path || ''),
|
||||
name: t.name ? String(t.name) : '',
|
||||
title: t.title ? String(t.title) : '',
|
||||
icon: iconKey,
|
||||
closable: Boolean(t.closable)
|
||||
}
|
||||
}).filter((t) => t.key)
|
||||
|
||||
const hasHome = restoredTabs.some((t) => t.path === HOME_PATH)
|
||||
tabs.value = hasHome ? restoredTabs : [defaultHomeTab(), ...restoredTabs]
|
||||
|
||||
if (parsed?.activeKey) {
|
||||
activeKey.value = String(parsed.activeKey)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function findTabIndexByKey(key) {
|
||||
return tabs.value.findIndex((t) => t.key === key)
|
||||
}
|
||||
|
||||
function upsertTab(r) {
|
||||
// 不把根布局路由(path='/')加入 tabs,只加入 children 的实际页面
|
||||
if (!r?.path || r.path === '/' || r.path === '/login') return
|
||||
const tab = normalizeTabFromRoute(r)
|
||||
const idx = findTabIndexByKey(tab.key)
|
||||
if (idx >= 0) {
|
||||
tabs.value[idx] = { ...tabs.value[idx], ...tab }
|
||||
} else {
|
||||
tabs.value.push(tab)
|
||||
}
|
||||
activeKey.value = tab.key
|
||||
}
|
||||
|
||||
function activateByKey(key) {
|
||||
const tab = tabs.value.find((t) => t.key === key)
|
||||
if (!tab) return
|
||||
activeKey.value = tab.key
|
||||
if (tab.fullPath && tab.fullPath !== route.fullPath) {
|
||||
router.push(tab.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabClick(pane) {
|
||||
const key = pane?.props?.name
|
||||
if (typeof key !== 'string') return
|
||||
activateByKey(key)
|
||||
}
|
||||
|
||||
function removeTabByKey(key) {
|
||||
const idx = findTabIndexByKey(key)
|
||||
if (idx < 0) return
|
||||
const tab = tabs.value[idx]
|
||||
if (!tab.closable) return
|
||||
|
||||
const wasActive = activeKey.value === key
|
||||
tabs.value.splice(idx, 1)
|
||||
|
||||
if (wasActive) {
|
||||
const next = tabs.value[idx - 1] || tabs.value[idx] || tabs.value[0]
|
||||
if (next) {
|
||||
activateByKey(next.key)
|
||||
} else {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabRemove(key) {
|
||||
if (typeof key !== 'string') return
|
||||
removeTabByKey(key)
|
||||
}
|
||||
|
||||
function handleCloseOthers(currentKey) {
|
||||
const current = tabs.value.find((t) => t.key === currentKey)
|
||||
const home = tabs.value.find((t) => t.path === HOME_PATH) || defaultHomeTab()
|
||||
if (!current) {
|
||||
tabs.value = [home]
|
||||
activateByKey(home.key)
|
||||
return
|
||||
}
|
||||
const keep = current.path === HOME_PATH ? [home] : [home, current]
|
||||
// 去重(避免当前就是 home)
|
||||
const uniq = []
|
||||
for (const t of keep) {
|
||||
if (!uniq.some((x) => x.key === t.key)) uniq.push(t)
|
||||
}
|
||||
tabs.value = uniq
|
||||
activateByKey(current.path === HOME_PATH ? home.key : current.key)
|
||||
}
|
||||
|
||||
function handleCloseAll() {
|
||||
const home = tabs.value.find((t) => t.path === HOME_PATH) || defaultHomeTab()
|
||||
tabs.value = [home]
|
||||
activateByKey(home.key)
|
||||
}
|
||||
|
||||
function handleContextCommand(cmd, tabKey) {
|
||||
if (cmd === 'close') {
|
||||
removeTabByKey(tabKey)
|
||||
return
|
||||
}
|
||||
if (cmd === 'close_others') {
|
||||
handleCloseOthers(tabKey)
|
||||
return
|
||||
}
|
||||
if (cmd === 'close_all') {
|
||||
handleCloseAll()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
restore()
|
||||
// 首次进入时,确保当前路由存在对应 tab
|
||||
upsertTab(route)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
upsertTab(route)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [tabs.value.map((t) => t.key).join('|'), activeKey.value],
|
||||
() => persist()
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tabs-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs-view-tabs {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tabs-view-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs-view-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.tabs-view-label-text {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs-view-label-icon {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tabs-view :deep(.el-tabs__nav-scroll) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-view :deep(.el-tabs__nav-wrap) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tabs-view :deep(.el-tabs__header) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.tabs-view :deep(.el-tabs__nav-next),
|
||||
.tabs-view :deep(.el-tabs__nav-prev) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tabs-view :deep(.el-tabs__item) {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.tabs-view :deep(.el-tabs__item.is-active) .tabs-view-label-icon {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
</style>
|
||||
|
||||
+229
-39
@@ -7,47 +7,91 @@
|
||||
</div>
|
||||
|
||||
<el-container class="lux-shell-body">
|
||||
<el-aside width="220px" class="sidebar-glass">
|
||||
<div class="sidebar-logo">
|
||||
<h2>Luxsin CMS</h2>
|
||||
<el-aside
|
||||
class="sidebar-glass"
|
||||
:class="{ 'is-collapsed': isSidebarCollapsed }"
|
||||
@mouseenter="isSidebarCollapsed = false"
|
||||
@mouseleave="isSidebarCollapsed = true"
|
||||
>
|
||||
<div class="sidebar-inner">
|
||||
<div class="sidebar-logo">
|
||||
<SidebarLogo :collapsed="isSidebarCollapsed" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav-wrap">
|
||||
<nav
|
||||
class="sidebar-rail"
|
||||
:class="{ 'is-active': isSidebarCollapsed }"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<router-link
|
||||
v-for="item in railNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-rail-item"
|
||||
:class="{ 'is-active': activeMenu === item.path }"
|
||||
:title="item.title"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<el-menu
|
||||
class="lux-menu lux-menu--expanded"
|
||||
:class="{ 'is-active': !isSidebarCollapsed }"
|
||||
:default-active="activeMenu"
|
||||
v-model:openeds="openedMenus"
|
||||
background-color="transparent"
|
||||
text-color="#cbd5e1"
|
||||
active-text-color="#7dd3fc"
|
||||
router
|
||||
>
|
||||
<el-sub-menu index="1">
|
||||
<template #title>
|
||||
<el-icon><Headset /></el-icon>
|
||||
<span>耳机管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/brand">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>品牌管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/model">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>型号管理</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="upgrade">
|
||||
<template #title>
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>升级管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/ota">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>OTA 管理</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="share-code">
|
||||
<template #title>
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>分享码</span>
|
||||
</template>
|
||||
<el-menu-item index="/share-code/log">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>分享日志</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</div>
|
||||
</div>
|
||||
<el-menu
|
||||
class="lux-menu"
|
||||
:default-active="activeMenu"
|
||||
background-color="transparent"
|
||||
text-color="#cbd5e1"
|
||||
active-text-color="#7dd3fc"
|
||||
router
|
||||
>
|
||||
<el-sub-menu index="1">
|
||||
<template #title>
|
||||
<el-icon><Headset /></el-icon>
|
||||
<span>耳机管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/brand">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>品牌管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/model">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>型号管理</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="upgrade">
|
||||
<template #title>
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>升级管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/ota">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>OTA 管理</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container class="lux-right-col">
|
||||
<el-header class="lux-header">
|
||||
<el-breadcrumb class="lux-breadcrumb" separator="/">
|
||||
<el-breadcrumb-item>首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="breadcrumbSection">{{ breadcrumbSection }}</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="breadcrumbTitle">{{ breadcrumbTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<el-button
|
||||
circle
|
||||
type="primary"
|
||||
@@ -60,7 +104,7 @@
|
||||
</el-button>
|
||||
</el-header>
|
||||
<el-main class="lux-main">
|
||||
<router-view />
|
||||
<TabsView />
|
||||
</el-main>
|
||||
<el-footer class="lux-footer" height="auto">
|
||||
<span class="lux-footer-copy">©Powered By EafonYang</span>
|
||||
@@ -71,16 +115,57 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Headset, Collection, List, Upload, Document, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { Headset, Collection, List, Upload, Document, SwitchButton, Share } from '@element-plus/icons-vue'
|
||||
import { clearToken } from '@/utils/auth'
|
||||
import TabsView from '@/components/TabsView.vue'
|
||||
import SidebarLogo from '@/components/SidebarLogo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const isSidebarCollapsed = ref(true)
|
||||
|
||||
const openedMenus = ref([])
|
||||
|
||||
function resolveOpenedMenus(path) {
|
||||
if (path.startsWith('/brand') || path.startsWith('/model')) return ['1']
|
||||
if (path.startsWith('/ota')) return ['upgrade']
|
||||
if (path.startsWith('/share-code')) return ['share-code']
|
||||
return []
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
openedMenus.value = resolveOpenedMenus(path)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const railNavItems = [
|
||||
{ path: '/brand', title: '品牌管理', icon: Collection },
|
||||
{ path: '/model', title: '型号管理', icon: List },
|
||||
{ path: '/ota', title: 'OTA 管理', icon: Document },
|
||||
{ path: '/share-code/log', title: '分享日志', icon: Share }
|
||||
]
|
||||
|
||||
const breadcrumbTitle = computed(() => {
|
||||
const t = route.meta?.title
|
||||
return typeof t === 'string' ? t : ''
|
||||
})
|
||||
|
||||
const breadcrumbSection = computed(() => {
|
||||
const path = route.path || ''
|
||||
if (path.startsWith('/brand') || path.startsWith('/model')) return '耳机管理'
|
||||
if (path.startsWith('/ota')) return '升级管理'
|
||||
if (path.startsWith('/share-code')) return '分享码'
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
clearToken()
|
||||
ElMessage.success('已退出登录')
|
||||
@@ -96,4 +181,109 @@ const handleLogout = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-rail,
|
||||
.lux-menu--expanded {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-rail:not(.is-active),
|
||||
.lux-menu--expanded:not(.is-active) {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-rail.is-active,
|
||||
.lux-menu--expanded.is-active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-glass.is-collapsed .sidebar-logo {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-rail {
|
||||
padding: 8px 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-rail-item {
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-rail-item:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.sidebar-rail-item.is-active {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(56, 189, 248, 0.22),
|
||||
rgba(99, 102, 241, 0.14)
|
||||
);
|
||||
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.25) inset;
|
||||
}
|
||||
|
||||
.sidebar-rail-item .el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lux-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lux-breadcrumb {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.lux-breadcrumb :deep(.el-breadcrumb__inner) {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.lux-breadcrumb :deep(.el-breadcrumb__inner.is-link:hover) {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,6 +32,12 @@ const routes = [
|
||||
name: 'Ota',
|
||||
component: () => import('@/views/ota/index.vue'),
|
||||
meta: { title: 'OTA 管理', icon: 'upload' }
|
||||
},
|
||||
{
|
||||
path: '/share-code/log',
|
||||
name: 'ShareCodeLog',
|
||||
component: () => import('@/views/share-code/log.vue'),
|
||||
meta: { title: '分享日志', icon: 'document' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -84,12 +84,24 @@ body {
|
||||
|
||||
.lux-shell-body > .el-container {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-left: 64px;
|
||||
}
|
||||
|
||||
.lux-shell-body > .el-container > .lux-right-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* —— 侧栏玻璃 —— */
|
||||
.lux-shell .sidebar-glass {
|
||||
position: relative;
|
||||
width: 220px !important;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
flex: none;
|
||||
width: 64px !important;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -99,38 +111,51 @@ body {
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
transition: width 0.30s ease, box-shadow 0.30s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.lux-shell .sidebar-glass:not(.is-collapsed) {
|
||||
width: 220px !important;
|
||||
box-shadow:
|
||||
4px 0 24px rgba(0, 0, 0, 0.28),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.lux-shell .sidebar-glass.is-collapsed {
|
||||
width: 64px !important;
|
||||
}
|
||||
|
||||
.lux-shell .sidebar-logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 14px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, transparent 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.lux-shell .sidebar-logo h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
color: #f1f5f9;
|
||||
text-shadow: 0 0 24px rgba(56, 189, 248, 0.25);
|
||||
.lux-shell .sidebar-glass.is-collapsed .sidebar-logo {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.lux-shell .lux-menu.el-menu {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-right: none !important;
|
||||
background: transparent !important;
|
||||
padding: 8px 6px 16px;
|
||||
}
|
||||
|
||||
.lux-shell .lux-menu--expanded.is-active.el-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lux-shell .lux-menu .el-sub-menu__title {
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="share-code-log-container">
|
||||
<el-card class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||
<el-form-item label="MAC">
|
||||
<el-input
|
||||
v-model="searchForm.mac_addr"
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
clearable
|
||||
style="width: 210px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分享码">
|
||||
<el-input
|
||||
v-model="searchForm.share_code"
|
||||
placeholder="5 位"
|
||||
clearable
|
||||
maxlength="5"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作">
|
||||
<el-select
|
||||
v-model="searchForm.action"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="导出" value="export" />
|
||||
<el-option label="导入" value="import" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="IP">
|
||||
<el-input
|
||||
v-model="searchForm.ip_addr"
|
||||
placeholder="IPv4/IPv6"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间">
|
||||
<el-date-picker
|
||||
v-model="searchForm.timeRange"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始"
|
||||
end-placeholder="结束"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
:default-time="defaultTime"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="table-card">
|
||||
<div class="card-header">
|
||||
<span class="title">分享日志</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" min-width="70" />
|
||||
<el-table-column prop="mac_addr" label="MAC" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="share_code" label="分享码" min-width="90" />
|
||||
<el-table-column prop="action" label="动作" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.action === 'export'" type="success" size="small">导出</el-tag>
|
||||
<el-tag v-else-if="row.action === 'import'" type="warning" size="small">导入</el-tag>
|
||||
<span v-else>{{ row.action || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_addr" label="IP" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="expire_at" label="到期时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.expire_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_at" label="操作时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.create_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="EQ 快照" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="handleViewEq(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="eqDialogVisible"
|
||||
:title="eqDialogTitle"
|
||||
width="780px"
|
||||
class="eq-dialog"
|
||||
@closed="resetEqDialog"
|
||||
>
|
||||
<div v-if="eqDialogData === null">
|
||||
<el-empty description="暂无数据" :image-size="80" />
|
||||
</div>
|
||||
<div v-else class="eq-dialog-body">
|
||||
<VueJsonPretty
|
||||
:data="eqDialogData"
|
||||
:deep="3"
|
||||
:show-length="true"
|
||||
:show-line-number="false"
|
||||
:show-icon="true"
|
||||
:show-select-controller="false"
|
||||
:editable="false"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="eqDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import VueJsonPretty from 'vue-json-pretty'
|
||||
import 'vue-json-pretty/lib/styles.css'
|
||||
import { getShareCodeLogs } from '@/api/shareCodeLog'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const defaultTime = [
|
||||
new Date(2000, 1, 1, 0, 0, 0),
|
||||
new Date(2000, 1, 1, 23, 59, 59)
|
||||
]
|
||||
|
||||
const searchForm = reactive({
|
||||
mac_addr: '',
|
||||
share_code: '',
|
||||
action: undefined,
|
||||
ip_addr: '',
|
||||
timeRange: null
|
||||
})
|
||||
|
||||
const eqDialogVisible = ref(false)
|
||||
const eqDialogTitle = ref('EQ 快照')
|
||||
const eqDialogData = ref(null)
|
||||
|
||||
function resetEqDialog() {
|
||||
eqDialogTitle.value = 'EQ 快照'
|
||||
eqDialogData.value = null
|
||||
}
|
||||
|
||||
function safeParseJson(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'object') return value
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return { _raw: value }
|
||||
}
|
||||
}
|
||||
return { value }
|
||||
}
|
||||
|
||||
function handleViewEq(row) {
|
||||
eqDialogTitle.value = `${row.mac_addr || ''} · ${row.share_code || ''}`.trim()
|
||||
eqDialogData.value = safeParseJson(row.eq_data)
|
||||
eqDialogVisible.value = true
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params = {
|
||||
skip: (pagination.page - 1) * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
mac_addr: searchForm.mac_addr || undefined,
|
||||
share_code: searchForm.share_code || undefined,
|
||||
action: searchForm.action || undefined,
|
||||
ip_addr: searchForm.ip_addr || undefined,
|
||||
sort_by: 'id',
|
||||
sort_order: 'desc'
|
||||
}
|
||||
|
||||
if (Array.isArray(searchForm.timeRange) && searchForm.timeRange.length === 2) {
|
||||
params.start_at = searchForm.timeRange[0] || undefined
|
||||
params.end_at = searchForm.timeRange[1] || undefined
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getShareCodeLogs(buildQueryParams())
|
||||
if (res.code === 1 && res.data) {
|
||||
tableData.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else if (res.code === 2) {
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.mac_addr = ''
|
||||
searchForm.share_code = ''
|
||||
searchForm.action = undefined
|
||||
searchForm.ip_addr = ''
|
||||
searchForm.timeRange = null
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-code-log-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.eq-dialog-body {
|
||||
max-height: 64vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-tree) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #cbd5e1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-tree-node.is-highlight),
|
||||
.eq-dialog :deep(.vjs-tree-node:hover) {
|
||||
background-color: rgba(51, 65, 85, 0.72);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-key) {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-value-string) {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-value-number) {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-value-boolean) {
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
.eq-dialog :deep(.vjs-value-null) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,11 @@ import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: 'true',
|
||||
__VUE_PROD_DEVTOOLS__: 'false',
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
|
||||
Reference in New Issue
Block a user