新增分享码日志管理,美化界面,优化代码上传脚本

This commit is contained in:
eafonyang
2026-06-16 19:15:02 +08:00
parent 1ad4cd034a
commit 481261b334
14 changed files with 1437 additions and 145 deletions
+1 -1
View File
@@ -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 {
+25
View File
@@ -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
})
}
+134
View File
@@ -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>
+351
View File
@@ -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
View File
@@ -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">&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>
+6
View File
@@ -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' }
}
]
}
+40 -15
View File
@@ -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;
+351
View File
@@ -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>
+5
View File
@@ -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')