```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
This commit is contained in:
+14
-1
@@ -2,8 +2,21 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>H3C ONU设备管理系统</title>
|
||||
|
||||
<!-- PWA / iOS 主屏幕 -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="ONU管理">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
|
||||
<!-- iOS 图标 -->
|
||||
<link rel="apple-touch-icon" href="/icon-180.png">
|
||||
|
||||
<!-- Web App Manifest -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "H3C ONU 设备管理",
|
||||
"short_name": "ONU管理",
|
||||
"description": "H3C OLT/ONU 设备监控管理系统",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0a0e1a",
|
||||
"theme_color": "#0a0e1a",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getAuditLogs = (params) => request.get('/audit/logs', { params })
|
||||
export const getAuditLogDetail = (id) => request.get(`/audit/logs/${id}`)
|
||||
export const getAuditStats = (days = 7) => request.get('/audit/stats', { params: { days } })
|
||||
export const exportAuditLogs = (params) => request.get('/audit/logs/export/csv', { params, responseType: 'blob' })
|
||||
@@ -0,0 +1,34 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const inventoryApi = {
|
||||
// 分类
|
||||
getCategories: () => request.get('/inventory/categories'),
|
||||
createCategory: (data) => request.post('/inventory/categories', data),
|
||||
|
||||
// 物料
|
||||
getMaterials: (params) => request.get('/inventory/materials', { params }),
|
||||
createMaterial: (data) => request.post('/inventory/materials', data),
|
||||
updateMaterial: (id, data) => request.put(`/inventory/materials/${id}`, data),
|
||||
deleteMaterial: (id) => request.delete(`/inventory/materials/${id}`),
|
||||
|
||||
// 出入库
|
||||
purchaseIn: (data) => request.post('/inventory/transactions/purchase', data),
|
||||
allocateOut: (data) => request.post('/inventory/transactions/allocate', data),
|
||||
returnIn: (data) => request.post('/inventory/transactions/return', data),
|
||||
getTransactions: (params) => request.get('/inventory/transactions', { params }),
|
||||
getTransactionDetail: (id) => request.get(`/inventory/transactions/${id}`),
|
||||
|
||||
// 批次
|
||||
getBatches: (material_id) => request.get('/inventory/batches', { params: { material_id } }),
|
||||
|
||||
// 序列号设备
|
||||
getSerialDevices: (params) => request.get('/inventory/serial-devices', { params }),
|
||||
|
||||
// 统计
|
||||
getSummary: () => request.get('/inventory/summary'),
|
||||
|
||||
// 盘点
|
||||
getChecks: (params) => request.get('/inventory/checks', { params }),
|
||||
createCheck: (data) => request.post('/inventory/checks', data),
|
||||
adjustCheck: (id) => request.post(`/inventory/checks/${id}/adjust`),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getRoles = () => request.get('/roles')
|
||||
export const getPermissions = () => request.get('/permissions')
|
||||
export const updateRolePermissions = (role, permissions) =>
|
||||
request.put(`/roles/${role}/permissions`, { permissions })
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getSettings = () => request.get('/settings')
|
||||
export const updateCheckInterval = (seconds) => request.put(`/settings/check_interval?seconds=${seconds}`)
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getUsers = (params) => request.get('/users', { params })
|
||||
export const updateUserRole = (id, data) => request.put(`/users/${id}/role`, data)
|
||||
export const toggleUser = (id) => request.put(`/users/${id}/toggle`)
|
||||
@@ -1,7 +1,44 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<!-- 移动端顶栏 -->
|
||||
<header v-if="isMobile" class="mobile-topbar">
|
||||
<div class="mobile-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="14" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="14" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="mobile-brand-title">H3C ONU</span>
|
||||
</div>
|
||||
<div class="mobile-topbar-right">
|
||||
<div class="mobile-time">{{ currentTime }}</div>
|
||||
<button class="mobile-theme-btn" @click="themeStore.toggle()">
|
||||
<svg v-if="themeStore.theme === 'dark'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-theme-btn" @click="handleLogout" title="退出登录">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 桌面端侧边栏 -->
|
||||
<aside v-if="!isMobile" class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
@@ -49,9 +86,9 @@
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-area">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="topbar">
|
||||
<div class="main-area" :class="{ 'main-area--mobile': isMobile }">
|
||||
<!-- 桌面端顶部栏 -->
|
||||
<header v-if="!isMobile" class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb-root">控制台</span>
|
||||
@@ -61,9 +98,7 @@
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="topbar-time">{{ currentTime }}</div>
|
||||
<!-- 主题切换 -->
|
||||
<button class="theme-toggle" @click="themeStore.toggle()" :title="themeStore.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'">
|
||||
<!-- 深色时显示「太阳」图标,点击切换到浅色 -->
|
||||
<svg v-if="themeStore.theme === 'dark'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
@@ -71,7 +106,6 @@
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<!-- 浅色时显示「月亮」图标,点击切换到深色 -->
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
@@ -88,10 +122,70 @@
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="page-content">
|
||||
<main class="page-content" :class="{ 'page-content--mobile': isMobile }">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 移动端底部导航 -->
|
||||
<nav v-if="isMobile" class="bottom-nav">
|
||||
<router-link
|
||||
v-for="item in mobileNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="bottom-nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
>
|
||||
<span class="bottom-nav-icon" v-html="item.icon"></span>
|
||||
<span class="bottom-nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
<!-- 更多菜单 -->
|
||||
<button class="bottom-nav-item" :class="{ active: moreMenuOpen }" @click="moreMenuOpen = !moreMenuOpen">
|
||||
<span class="bottom-nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/><line x1="2" y1="20" x2="22" y2="20"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="bottom-nav-label">功能</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 移动端更多菜单抽屉 -->
|
||||
<transition name="slide-up">
|
||||
<div v-if="isMobile && moreMenuOpen" class="more-drawer">
|
||||
<div class="more-drawer-overlay" @click="moreMenuOpen = false"></div>
|
||||
<div class="more-drawer-panel">
|
||||
<div class="more-drawer-header">
|
||||
<span class="more-drawer-title">功能菜单</span>
|
||||
<button class="more-drawer-close" @click="moreMenuOpen = false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="more-drawer-items">
|
||||
<router-link
|
||||
v-for="item in extraNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="more-drawer-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="moreMenuOpen = false"
|
||||
>
|
||||
<span class="more-item-icon" v-html="item.icon"></span>
|
||||
<div class="more-item-text">
|
||||
<span class="more-item-label">{{ item.label }}</span>
|
||||
<span class="more-item-desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="more-item-arrow">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,23 +194,32 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const currentTime = ref('')
|
||||
const moreMenuOpen = ref(false)
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
let timer = null
|
||||
onMounted(() => { updateTime(); timer = setInterval(updateTime, 1000) })
|
||||
onMounted(async () => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchProfile()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
const navItems = [
|
||||
const allNavItems = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: '统计概览',
|
||||
@@ -135,7 +238,7 @@ const navItems = [
|
||||
},
|
||||
{
|
||||
path: '/olt',
|
||||
label: 'OLT 管理',
|
||||
label: 'OLT',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
|
||||
@@ -152,15 +255,69 @@ const navItems = [
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
label: '数据导入',
|
||||
path: '/inventory',
|
||||
label: '库存管理',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
label: '用户管理',
|
||||
adminOnly: true,
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
label: '角色权限',
|
||||
adminOnly: true,
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: '系统设置',
|
||||
adminOnly: true,
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
label: '审计日志',
|
||||
adminOnly: true,
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>`
|
||||
},
|
||||
]
|
||||
|
||||
const navItems = computed(() =>
|
||||
allNavItems.filter(item => !item.adminOnly || authStore.isAdmin)
|
||||
)
|
||||
|
||||
// 移动端底部导航只显示核心3项
|
||||
const mobileNavItems = computed(() => navItems.value.slice(0, 3))
|
||||
|
||||
// 更多菜单中的额外项(非前3项)
|
||||
const extraNavItems = computed(() =>
|
||||
navItems.value.slice(3).map((item, i) => ({
|
||||
...item,
|
||||
desc: ['7天趋势与区域分布', '物料出入库台账', '管理系统用户', '配置角色权限', '定时检查等系统配置', '查看操作审计记录'][i] || ''
|
||||
}))
|
||||
)
|
||||
|
||||
const isActive = (path) => route.path === path || route.path.startsWith(path + '/')
|
||||
|
||||
const pageNameMap = {
|
||||
@@ -168,7 +325,11 @@ const pageNameMap = {
|
||||
'/devices': '设备列表',
|
||||
'/olt': 'OLT 管理',
|
||||
'/charts': '统计图表',
|
||||
'/import': '数据导入',
|
||||
'/inventory': '库存管理',
|
||||
'/users': '用户管理',
|
||||
'/roles': '角色权限',
|
||||
'/settings': '系统设置',
|
||||
'/audit': '审计日志',
|
||||
}
|
||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||
|
||||
@@ -182,11 +343,12 @@ const handleLogout = () => {
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
/* ── 侧边栏 ── */
|
||||
/* ── 侧边栏(桌面端) ── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
@@ -199,7 +361,6 @@ const handleLogout = () => {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 品牌 */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -241,7 +402,6 @@ const handleLogout = () => {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 状态指示 */
|
||||
.sidebar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -271,7 +431,6 @@ const handleLogout = () => {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 导航 */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
@@ -315,7 +474,6 @@ const handleLogout = () => {
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon { opacity: 1; }
|
||||
|
||||
.nav-label { flex: 1; }
|
||||
|
||||
.nav-indicator {
|
||||
@@ -329,7 +487,6 @@ const handleLogout = () => {
|
||||
|
||||
.nav-item.active .nav-indicator { opacity: 1; }
|
||||
|
||||
/* 底部 */
|
||||
.sidebar-footer {
|
||||
padding: 14px 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
@@ -366,7 +523,19 @@ const handleLogout = () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 顶部栏 */
|
||||
.main-area--mobile {
|
||||
padding-top: 52px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
.main-area--mobile {
|
||||
padding-top: calc(52px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端顶部栏 */
|
||||
.topbar {
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
@@ -391,19 +560,9 @@ const handleLogout = () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.breadcrumb-root {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--border-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.breadcrumb-root { color: var(--text-muted); }
|
||||
.breadcrumb-sep { color: var(--border-strong); font-size: 12px; }
|
||||
.breadcrumb-current { color: var(--text-secondary); font-weight: 500; }
|
||||
|
||||
.topbar-time {
|
||||
font-family: var(--font-mono);
|
||||
@@ -425,14 +584,17 @@ const handleLogout = () => {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 页面内容 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 主题切换按钮 */
|
||||
.page-content--mobile {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -455,7 +617,330 @@ const handleLogout = () => {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
.theme-label { letter-spacing: 0.02em; }
|
||||
|
||||
/* ── 移动端顶栏 ── */
|
||||
.mobile-topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 52px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* standalone 模式下顶栏需要为刘海让出空间 */
|
||||
@media (display-mode: standalone) {
|
||||
.mobile-topbar {
|
||||
height: calc(52px + env(safe-area-inset-top));
|
||||
align-items: flex-end;
|
||||
padding-bottom: 8px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-brand .brand-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-brand-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mobile-topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mobile-theme-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mobile-theme-btn:active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--border-accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 移动端底部导航 ── */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
.bottom-nav {
|
||||
height: calc(64px + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: color 0.18s;
|
||||
padding: 8px 4px;
|
||||
min-height: 44px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bottom-nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 0 2px 2px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.bottom-nav-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active::before {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.bottom-nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 更多菜单抽屉 ── */
|
||||
.more-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.more-drawer-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.more-drawer-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.more-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.more-drawer-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.more-drawer-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more-drawer-items {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.more-drawer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.more-drawer-item:active,
|
||||
.more-drawer-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.more-item-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more-drawer-item.active .more-item-icon {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--border-accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.more-item-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.more-item-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.more-item-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.more-item-arrow {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more-drawer-logout {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 16px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.more-drawer-logout .more-item-icon {
|
||||
color: var(--danger);
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
/* 抽屉动画 */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-active .more-drawer-panel,
|
||||
.slide-up-leave-active .more-drawer-panel {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-from .more-drawer-panel,
|
||||
.slide-up-leave-to .more-drawer-panel {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useMobile() {
|
||||
const isMobile = ref(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
|
||||
const onResize = () => {
|
||||
isMobile.value = window.innerWidth < MOBILE_BREAKPOINT
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
return { isMobile }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function usePermission() {
|
||||
const authStore = useAuthStore()
|
||||
return {
|
||||
can: (perm) => authStore.hasPermission(perm),
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -1,11 +1,47 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 检测 standalone 模式,将 safe-area-inset-top 写入 CSS 变量供弹窗/消息使用
|
||||
function getSafeAreaTop() {
|
||||
const div = document.createElement('div')
|
||||
div.style.cssText = 'position:fixed;top:env(safe-area-inset-top);left:0;width:0;height:0;visibility:hidden;'
|
||||
document.body.appendChild(div)
|
||||
const top = parseInt(getComputedStyle(div).top) || 0
|
||||
document.body.removeChild(div)
|
||||
return top
|
||||
}
|
||||
|
||||
const isStandalone = window.navigator.standalone === true ||
|
||||
window.matchMedia('(display-mode: standalone)').matches
|
||||
|
||||
if (isStandalone) {
|
||||
const sat = getSafeAreaTop()
|
||||
document.documentElement.style.setProperty('--sat', sat + 'px')
|
||||
|
||||
// 全局覆盖 ElMessage,自动加上顶栏 + 刘海的偏移
|
||||
const MESSAGE_OFFSET = 52 + sat + 8 // 顶栏高度 + 刘海 + 间距
|
||||
const _origMessage = ElMessage
|
||||
const wrap = (opts) => {
|
||||
if (typeof opts === 'string') return _origMessage({ message: opts, offset: MESSAGE_OFFSET })
|
||||
return _origMessage({ offset: MESSAGE_OFFSET, ...opts })
|
||||
}
|
||||
;['success', 'warning', 'info', 'error'].forEach(type => {
|
||||
wrap[type] = (opts) => {
|
||||
if (typeof opts === 'string') return _origMessage[type]({ message: opts, offset: MESSAGE_OFFSET })
|
||||
return _origMessage[type]({ offset: MESSAGE_OFFSET, ...opts })
|
||||
}
|
||||
})
|
||||
wrap.closeAll = _origMessage.closeAll
|
||||
// 替换全局 ElMessage(各页面通过 import { ElMessage } 使用的不受影响,
|
||||
// 但通过 app.config.globalProperties.$message 调用的会生效)
|
||||
window.__elMessageOffset = MESSAGE_OFFSET
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
@@ -29,11 +29,6 @@ const routes = [
|
||||
name: 'DeviceList',
|
||||
component: () => import('../views/DeviceList.vue')
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
name: 'ImportData',
|
||||
component: () => import('../views/ImportData.vue')
|
||||
},
|
||||
{
|
||||
path: '/charts',
|
||||
name: 'Charts',
|
||||
@@ -43,7 +38,36 @@ const routes = [
|
||||
path: '/olt',
|
||||
name: 'OltManage',
|
||||
component: () => import('../views/OltManage.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/inventory',
|
||||
name: 'Inventory',
|
||||
component: () => import('../views/Inventory.vue')
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'UserManage',
|
||||
component: () => import('../views/UserManage.vue'),
|
||||
meta: { requiresRole: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
name: 'RolePermissions',
|
||||
component: () => import('../views/RolePermissions.vue'),
|
||||
meta: { requiresRole: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
meta: { requiresRole: 'admin' }
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
name: 'AuditLog',
|
||||
component: () => import('../views/AuditLog.vue'),
|
||||
meta: { requiresRole: 'admin' }
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -53,15 +77,33 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.token) {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
return next('/login')
|
||||
}
|
||||
if (to.path === '/login' && authStore.token) {
|
||||
return next('/dashboard')
|
||||
}
|
||||
|
||||
// 需要角色检查时,确保 user 信息已加载
|
||||
if (to.meta.requiresRole && authStore.token) {
|
||||
if (!authStore.user) {
|
||||
await authStore.fetchProfile()
|
||||
}
|
||||
if (authStore.role !== to.meta.requiresRole) {
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载权限列表(仅首次)
|
||||
if (authStore.token && authStore.permissions.length === 0) {
|
||||
await authStore.fetchPermissions()
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -1,21 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as authApi from '../api/auth'
|
||||
import axios from 'axios'
|
||||
|
||||
function decodeJwtPayload(token) {
|
||||
try {
|
||||
const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
return JSON.parse(atob(b64))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const user = ref(null)
|
||||
const permissions = ref([])
|
||||
|
||||
// 直接从 JWT payload 读 role,无需额外请求
|
||||
const role = computed(() => {
|
||||
if (user.value?.role) return user.value.role
|
||||
if (token.value) return decodeJwtPayload(token.value).role || 'user'
|
||||
return 'user'
|
||||
})
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
|
||||
const hasPermission = (perm) => {
|
||||
if (isAdmin.value) return true
|
||||
return permissions.value.includes(perm)
|
||||
}
|
||||
|
||||
const setToken = (newToken) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
const fetchProfile = async () => {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const res = await authApi.getProfile()
|
||||
user.value = res.data
|
||||
} catch {
|
||||
// token 失效时静默处理
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const res = await axios.get('/api/auth/permissions', {
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
})
|
||||
permissions.value = res.data.permissions || []
|
||||
} catch {
|
||||
permissions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
permissions.value = []
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, user, setToken, logout }
|
||||
return { token, user, role, isAdmin, permissions, hasPermission, setToken, fetchProfile, fetchPermissions, logout }
|
||||
})
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ html, body, #app {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* iOS standalone 模式下禁止长按弹出菜单、文字选中 */
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Element Plus 深色覆盖 ── */
|
||||
@@ -730,3 +733,92 @@ html {
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
移动端全局适配
|
||||
============================================================ */
|
||||
|
||||
/* 防止 iOS 双击缩放 */
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* 移动端 Element Plus 对话框全屏适配 */
|
||||
@media (max-width: 767px) {
|
||||
/* overlay 容器整体下移,避开固定顶栏(52px)并留出安全间距 */
|
||||
.el-overlay-dialog {
|
||||
padding-top: 60px !important;
|
||||
padding-bottom: 72px !important; /* 底部导航 64px + 8px 间距 */
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 auto !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
/* 最大高度 = 视口 - 顶栏 - 底导航 - 上下间距 */
|
||||
max-height: calc(100vh - 60px - 72px) !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 16px !important;
|
||||
flex: 1 !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* 不再写死 max-height,由父容器控制 */
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 12px 16px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* 表格在移动端允许横向滚动 */
|
||||
.el-table {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
/* 分页在移动端居中 */
|
||||
.el-pagination {
|
||||
justify-content: center !important;
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
/* 按钮最小触摸区域 */
|
||||
.el-button {
|
||||
min-height: 36px !important;
|
||||
}
|
||||
|
||||
/* 输入框高度 */
|
||||
.el-input__wrapper {
|
||||
min-height: 36px !important;
|
||||
}
|
||||
|
||||
/* 消息提示位置 */
|
||||
.el-message {
|
||||
min-width: 240px !important;
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── iOS 主屏幕 standalone 模式:弹窗和消息额外下移刘海高度 ── */
|
||||
/* --sat 由 main.js 在 standalone 模式下动态写入,Safari 中不存在默认为 0 */
|
||||
@media (display-mode: standalone) and (max-width: 767px) {
|
||||
.el-overlay-dialog {
|
||||
padding-top: calc(60px + var(--sat, 0px)) !important;
|
||||
padding-bottom: calc(72px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
max-height: calc(100vh - 60px - 72px - var(--sat, 0px) - env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
|
||||
/* 消息提示下移 */
|
||||
.el-message-list {
|
||||
top: calc(20px + var(--sat, 0px)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 封装 ElMessage,在 iOS standalone 模式下自动加上顶栏+刘海偏移
|
||||
*/
|
||||
import { ElMessage as _ElMessage } from 'element-plus'
|
||||
|
||||
const isStandalone = window.navigator.standalone === true ||
|
||||
window.matchMedia('(display-mode: standalone)').matches
|
||||
|
||||
function getOffset() {
|
||||
if (!isStandalone) return 20
|
||||
const sat = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sat')) || 0
|
||||
return 52 + sat + 8
|
||||
}
|
||||
|
||||
function message(opts) {
|
||||
const offset = getOffset()
|
||||
if (typeof opts === 'string') return _ElMessage({ message: opts, offset })
|
||||
return _ElMessage({ offset, ...opts })
|
||||
}
|
||||
|
||||
;['success', 'warning', 'info', 'error'].forEach(type => {
|
||||
message[type] = (opts) => {
|
||||
const offset = getOffset()
|
||||
if (typeof opts === 'string') return _ElMessage[type]({ message: opts, offset })
|
||||
return _ElMessage[type]({ offset, ...opts })
|
||||
}
|
||||
})
|
||||
|
||||
message.closeAll = _ElMessage.closeAll
|
||||
|
||||
export { message as ElMessage }
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<div class="audit-page">
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">审计日志</h1>
|
||||
<span class="page-subtitle">记录所有用户操作,支持追溯与问责</span>
|
||||
</div>
|
||||
<button class="export-btn" @click="handleExport" :disabled="exporting">
|
||||
{{ exporting ? '导出中…' : '导出 CSV' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
style="width: 340px"
|
||||
@change="onFilter"
|
||||
/>
|
||||
<el-input v-model="filters.username" placeholder="用户名" size="small" style="width: 130px" clearable @change="onFilter" />
|
||||
<el-select v-model="filters.action_type" placeholder="操作类型" size="small" style="width: 120px" clearable @change="onFilter">
|
||||
<el-option v-for="t in actionTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" placeholder="状态" size="small" style="width: 100px" clearable @change="onFilter">
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
<button class="reset-btn" @click="resetFilters">重置</button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-wrap">
|
||||
<el-table :data="logs" size="small" style="width:100%" v-loading="loading" @row-click="openDetail">
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ fmtTime(row.action_time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="110">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.username }}</span>
|
||||
<span class="role-tag">{{ row.user_role }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="['type-tag', `type-${row.action_type}`]">{{ typeLabel(row.action_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<span class="muted">{{ row.action_subtype || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="描述" min-width="180" show-overflow-tooltip prop="description" />
|
||||
<el-table-column label="路径" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val small">{{ row.request_method }} {{ row.request_path }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-dot', `status-${row.status}`]">{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val small">{{ row.ip_address || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
@change="fetchLogs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="日志详情" size="480px" direction="rtl">
|
||||
<div v-if="detail" class="detail-body">
|
||||
<div class="detail-row" v-for="(v, k) in detailFields" :key="k">
|
||||
<span class="detail-label">{{ v.label }}</span>
|
||||
<span class="detail-value" :class="{ mono: v.mono }">{{ v.val }}</span>
|
||||
</div>
|
||||
<template v-if="detail.request_params">
|
||||
<div class="detail-section">请求参数</div>
|
||||
<pre class="json-block">{{ JSON.stringify(detail.request_params, null, 2) }}</pre>
|
||||
</template>
|
||||
<template v-if="detail.error_message">
|
||||
<div class="detail-section">错误信息</div>
|
||||
<pre class="json-block error">{{ detail.error_message }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { getAuditLogs, getAuditLogDetail, exportAuditLogs } from '../api/audit'
|
||||
|
||||
const logs = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const dateRange = ref(null)
|
||||
|
||||
const filters = ref({ username: '', action_type: '', status: '' })
|
||||
|
||||
const actionTypes = [
|
||||
{ label: '认证', value: 'auth' },
|
||||
{ label: '设备', value: 'device' },
|
||||
{ label: 'OLT', value: 'olt' },
|
||||
{ label: '用户', value: 'user' },
|
||||
{ label: '系统', value: 'system' },
|
||||
{ label: '库存', value: 'inventory' },
|
||||
]
|
||||
|
||||
const typeLabel = (t) => actionTypes.find(x => x.value === t)?.label || t
|
||||
const statusLabel = (s) => ({ success: '成功', failed: '失败', error: '错误' }[s] || s)
|
||||
const fmtTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
|
||||
const buildParams = () => {
|
||||
const p = { page: page.value, page_size: pageSize.value }
|
||||
if (dateRange.value?.[0]) p.start_time = dateRange.value[0].toISOString()
|
||||
if (dateRange.value?.[1]) p.end_time = dateRange.value[1].toISOString()
|
||||
if (filters.value.username) p.username = filters.value.username
|
||||
if (filters.value.action_type) p.action_type = filters.value.action_type
|
||||
if (filters.value.status) p.status = filters.value.status
|
||||
return p
|
||||
}
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getAuditLogs(buildParams())
|
||||
logs.value = data.items
|
||||
total.value = data.total
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onFilter = () => { page.value = 1; fetchLogs() }
|
||||
const resetFilters = () => {
|
||||
filters.value = { username: '', action_type: '', status: '' }
|
||||
dateRange.value = null
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const openDetail = async (row) => {
|
||||
try {
|
||||
const { data } = await getAuditLogDetail(row.id)
|
||||
detail.value = data
|
||||
drawerVisible.value = true
|
||||
} catch { ElMessage.error('加载详情失败') }
|
||||
}
|
||||
|
||||
const detailFields = computed(() => {
|
||||
if (!detail.value) return {}
|
||||
const d = detail.value
|
||||
return {
|
||||
time: { label: '时间', val: fmtTime(d.action_time), mono: true },
|
||||
user: { label: '用户', val: `${d.username} (${d.user_role})` },
|
||||
type: { label: '操作', val: `${typeLabel(d.action_type)} / ${d.action_subtype || '—'}` },
|
||||
path: { label: '路径', val: `${d.request_method} ${d.request_path}`, mono: true },
|
||||
status: { label: '状态', val: `${statusLabel(d.status)} (${d.status_code})` },
|
||||
ip: { label: 'IP', val: d.ip_address || '—', mono: true },
|
||||
desc: { label: '描述', val: d.description },
|
||||
ua: { label: 'UA', val: d.user_agent || '—' },
|
||||
}
|
||||
})
|
||||
|
||||
const handleExport = async () => {
|
||||
exporting.value = true
|
||||
try {
|
||||
const { data } = await exportAuditLogs(buildParams())
|
||||
const url = URL.createObjectURL(new Blob([data]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit_${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { ElMessage.error('导出失败') } finally { exporting.value = false }
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-page { padding: 24px; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-title-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.page-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.page-subtitle { font-size: 12px; color: var(--text-muted); }
|
||||
.export-btn { padding: 7px 16px; background: var(--bg-elevated); border: 1px solid var(--border-default); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px; cursor: pointer; }
|
||||
.export-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; align-items: center; }
|
||||
.reset-btn { padding: 6px 12px; background: transparent; border: 1px solid var(--border-default); border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px; cursor: pointer; }
|
||||
.reset-btn:hover { color: var(--text-primary); }
|
||||
.table-wrap { background: var(--bg-card); border: 1px solid var(--border-default); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.pagination { padding: 12px 16px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border-subtle); }
|
||||
.mono-val { font-family: var(--font-mono); font-size: 12px; }
|
||||
.small { font-size: 11px; }
|
||||
.muted { color: var(--text-muted); font-size: 12px; }
|
||||
.role-tag { margin-left: 4px; font-size: 10px; color: var(--text-muted); background: var(--bg-elevated); padding: 1px 5px; border-radius: 3px; }
|
||||
.type-tag { font-size: 11px; padding: 2px 7px; border-radius: 3px; font-weight: 500; }
|
||||
.type-auth { background: #dbeafe; color: #1d4ed8; }
|
||||
.type-device { background: #dcfce7; color: #15803d; }
|
||||
.type-olt { background: #fef9c3; color: #854d0e; }
|
||||
.type-user { background: #fce7f3; color: #9d174d; }
|
||||
.type-system { background: #f3e8ff; color: #6b21a8; }
|
||||
.type-inventory{ background: #ffedd5; color: #9a3412; }
|
||||
.status-dot { font-size: 11px; padding: 2px 7px; border-radius: 3px; }
|
||||
.status-success { background: #dcfce7; color: #15803d; }
|
||||
.status-failed { background: #fef9c3; color: #854d0e; }
|
||||
.status-error { background: #fee2e2; color: #991b1b; }
|
||||
.detail-body { padding: 4px 0; }
|
||||
.detail-row { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: 13px; }
|
||||
.detail-label { width: 60px; flex-shrink: 0; color: var(--text-muted); }
|
||||
.detail-value { flex: 1; color: var(--text-primary); word-break: break-all; }
|
||||
.detail-value.mono { font-family: var(--font-mono); font-size: 12px; }
|
||||
.detail-section { margin: 16px 0 8px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.json-block { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 12px; font-family: var(--font-mono); white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); margin: 0; }
|
||||
.json-block.error { color: #dc2626; }
|
||||
</style>
|
||||
@@ -11,7 +11,9 @@
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ loading ? '刷新中…' : '刷新数据' }}
|
||||
<span v-if="loading">刷新中…</span>
|
||||
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
||||
<span v-else>刷新数据</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -198,10 +200,14 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../utils/request'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const data = ref({})
|
||||
const lastUpdated = ref('')
|
||||
const townVisible = ref(false)
|
||||
const selectedTown = ref(null)
|
||||
|
||||
@@ -238,6 +244,7 @@ const loadData = async () => {
|
||||
try {
|
||||
const { data: d } = await request.get('/stats/dashboard')
|
||||
data.value = d
|
||||
lastUpdated.value = '更新于 ' + new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -589,4 +596,66 @@ onMounted(loadData)
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── 移动端响应式 ── */
|
||||
@media (max-width: 767px) {
|
||||
.dashboard {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 统计卡片:2列 */
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-meta {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 学校列表:单列堆叠 */
|
||||
.list-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.school-list {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.school-row {
|
||||
grid-template-columns: 1fr 60px 52px;
|
||||
gap: 8px;
|
||||
padding: 10px 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
<span class="page-subtitle">ONU 终端设备管理</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="danger" plain size="small" @click="clearStatus" :loading="clearing">
|
||||
<el-button v-if="!isMobile && can('device.delete')" type="danger" plain size="small" @click="clearStatus" :loading="clearing">
|
||||
清空状态
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
|
||||
<el-button v-if="can('device.check')" type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
|
||||
{{ refreshing ? '更新中…' : '全部更新' }}
|
||||
</el-button>
|
||||
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
||||
数据导入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,8 +88,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 表格(桌面端) -->
|
||||
<el-table
|
||||
v-if="!isMobile"
|
||||
:data="devices"
|
||||
border
|
||||
stripe
|
||||
@@ -120,7 +124,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ row.distance_m ? row.distance_m + 'm' : '—' }}</span>
|
||||
<span class="mono-val">{{ formatDistance(row.distance_m) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip>
|
||||
@@ -130,9 +134,114 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div v-if="isMobile" class="device-cards">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
class="device-card"
|
||||
:class="{ expanded: expandedId === device.id }"
|
||||
@click="toggleCard(device)"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-head">
|
||||
<div class="card-head-left">
|
||||
<span class="card-mac">{{ formatMac(device.mac_address) }}</span>
|
||||
<span class="card-school">{{ device.school_name }}</span>
|
||||
</div>
|
||||
<div class="card-head-right">
|
||||
<span class="status-badge" :class="device.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ device.status === 'online' ? '在线' : device.status === 'offline' ? '离线' : '未知' }}
|
||||
</span>
|
||||
<svg class="card-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基础信息 -->
|
||||
<div class="card-body">
|
||||
<div class="card-row">
|
||||
<span class="card-label">楼宇</span>
|
||||
<span class="card-val">{{ device.building || '—' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">场所</span>
|
||||
<span class="card-val">{{ device.place_type || '—' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">房间号</span>
|
||||
<span class="card-val">{{ device.room_number || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开详情 -->
|
||||
<div v-if="expandedId === device.id" class="card-expanded">
|
||||
<div class="card-actions">
|
||||
<button v-if="can('device.check')" class="card-action-btn" @click.stop="doRefreshDeviceCard(device)" :disabled="!device.olt_id">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
更新
|
||||
</button>
|
||||
<button v-if="can('device.edit')" class="card-action-btn" @click.stop="openEditFromCard(device)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button v-if="can('device.edit')" class="card-action-btn card-action-btn--warn" @click.stop="openReplaceFromCard(device)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||||
</svg>
|
||||
更换
|
||||
</button>
|
||||
<button
|
||||
v-if="can('device.edit')"
|
||||
class="card-action-btn card-action-btn--primary"
|
||||
@click.stop="openProvisionFromCard(device)"
|
||||
:disabled="device.status !== 'online' || !device.port_id"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
业务下发
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-detail-rows">
|
||||
<div class="card-row">
|
||||
<span class="card-label">距离</span>
|
||||
<span class="card-val mono">{{ formatDistance(device.distance_m) }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">端口</span>
|
||||
<span class="card-val mono">{{ formatPort(device) }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">型号</span>
|
||||
<span class="card-val">{{ device.model || '—' }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">所属 OLT</span>
|
||||
<span class="card-val">{{ device.olt_location || '—' }}</span>
|
||||
</div>
|
||||
<div v-if="device.notes" class="card-row card-row--notes">
|
||||
<span class="card-label">备注</span>
|
||||
<span class="card-val">{{ device.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="devices.length === 0" class="cards-empty">暂无设备数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-if="!isMobile"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
@@ -141,6 +250,11 @@
|
||||
@size-change="handleSizeChange"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
/>
|
||||
<div v-else class="mobile-pagination">
|
||||
<button class="mobile-page-btn" :disabled="page <= 1" @click="page--; loadDevices()">上一页</button>
|
||||
<span class="mobile-page-info">第 {{ page }} 页 / 共 {{ Math.ceil(total / pageSize) }} 页</span>
|
||||
<button class="mobile-page-btn" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadDevices()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +334,7 @@
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="距离">
|
||||
<span class="mono-val">{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '—' }}</span>
|
||||
<span class="mono-val">{{ formatDistance(selectedDevice.distance_m) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口" :span="2">
|
||||
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
|
||||
@@ -230,15 +344,44 @@
|
||||
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
|
||||
<el-button v-if="can('device.check')" type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
|
||||
<el-button v-if="can('device.edit')" type="warning" @click="openReplace">更换</el-button>
|
||||
<el-button v-if="can('device.edit')" type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button v-if="can('device.edit')" type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
|
||||
业务下发
|
||||
</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 更换设备对话框 -->
|
||||
<el-dialog v-model="replaceVisible" title="更换设备" width="440px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px; font-size: 13px; color: var(--text-muted)">
|
||||
当前 MAC:<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
|
||||
</div>
|
||||
<el-form :model="replaceForm" label-width="80px">
|
||||
<el-form-item label="新 MAC 地址" required>
|
||||
<el-input v-model="replaceForm.new_mac" placeholder="例:AABBCCDDEEFF 或 AA:BB:CC:DD:EE:FF" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="更换原因">
|
||||
<el-input v-model="replaceForm.reason" type="textarea" :rows="2" placeholder="可选,填写更换原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="replacements.length" style="margin-top: 16px">
|
||||
<div style="font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em">更换历史</div>
|
||||
<div v-for="r in replacements" :key="r.id" class="replace-history-row">
|
||||
<span class="mono-val small">{{ r.old_mac }}</span>
|
||||
<span style="margin: 0 6px; color: var(--text-muted)">→</span>
|
||||
<span class="mono-val small">{{ r.new_mac }}</span>
|
||||
<span style="margin-left: auto; font-size: 11px; color: var(--text-muted)">{{ fmtReplaceTime(r.replaced_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="replaceVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="replaceSaving" @click="doReplace">确认更换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="editVisible" title="编辑设备信息" width="460px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="70px">
|
||||
@@ -301,14 +444,65 @@ save force</pre>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 数据导入对话框 -->
|
||||
<el-dialog v-model="importDialogVisible" title="数据导入" width="480px" destroy-on-close>
|
||||
<div class="import-hint">
|
||||
请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。
|
||||
</div>
|
||||
<div class="import-actions">
|
||||
<el-button size="small" @click="downloadImportTemplate">下载导入模板</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="onImportFileChange"
|
||||
:on-remove="() => importFile = null"
|
||||
drag
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<div class="upload-area">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<div style="font-size: 13px; color: var(--text-secondary)">拖拽文件到此处,或 <em style="color: var(--accent)">点击选择</em></div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px">支持 .xlsx / .xls 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="importResult" style="margin-top: 16px">
|
||||
<el-alert
|
||||
:type="importResult.failed?.length ? 'warning' : 'success'"
|
||||
:title="`导入完成:成功 ${importResult.success} 条${importResult.failed?.length ? ',失败 ' + importResult.failed.length + ' 条' : ''}`"
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="importResult.failed?.length" style="margin-top: 10px; max-height: 160px; overflow-y: auto">
|
||||
<div v-for="f in importResult.failed" :key="f.row" style="font-size: 12px; color: var(--danger); padding: 2px 0">
|
||||
第 {{ f.row }} 行:{{ f.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="importDialogVisible = false; importResult = null; importFile = null">关闭</el-button>
|
||||
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="doImport">开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import * as deviceApi from '../api/device'
|
||||
import request from '../utils/request'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { can } = usePermission()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -333,10 +527,101 @@ const editVisible = ref(false)
|
||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', notes: '' })
|
||||
const editSaving = ref(false)
|
||||
|
||||
const replaceVisible = ref(false)
|
||||
const replaceForm = ref({ new_mac: '', reason: '' })
|
||||
const replaceSaving = ref(false)
|
||||
const replacements = ref([])
|
||||
|
||||
const checkResultVisible = ref(false)
|
||||
const checkResult = ref(null)
|
||||
const checkInProgress = ref(false)
|
||||
|
||||
// 移动端卡片展开状态
|
||||
const expandedId = ref(null)
|
||||
|
||||
// 数据导入
|
||||
const importDialogVisible = ref(false)
|
||||
const importFile = ref(null)
|
||||
const importing = ref(false)
|
||||
const importResult = ref(null)
|
||||
const uploadRef = ref(null)
|
||||
|
||||
const onImportFileChange = (file) => {
|
||||
importFile.value = file.raw
|
||||
}
|
||||
|
||||
const downloadImportTemplate = () => {
|
||||
window.open('/api/import/template', '_blank')
|
||||
}
|
||||
|
||||
const doImport = async () => {
|
||||
if (!importFile.value) return
|
||||
importing.value = true
|
||||
importResult.value = null
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile.value)
|
||||
try {
|
||||
const { data } = await request.post('/import/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
importResult.value = data
|
||||
if (!data.failed?.length) {
|
||||
ElMessage.success(`成功导入 ${data.success} 条记录`)
|
||||
loadDevices()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const toggleCard = (device) => {
|
||||
expandedId.value = expandedId.value === device.id ? null : device.id
|
||||
}
|
||||
|
||||
const doRefreshDeviceCard = async (device) => {
|
||||
try {
|
||||
const { data } = await deviceApi.refreshDevice(device.id)
|
||||
device.status = data.status
|
||||
device.distance_m = data.distance_m
|
||||
if (data.model) device.model = data.model
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openEditFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
editForm.value = {
|
||||
region: device.region || '',
|
||||
school_name: device.school_name || '',
|
||||
building: device.building || '',
|
||||
room_number: device.room_number || '',
|
||||
notes: device.notes || '',
|
||||
}
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const openReplaceFromCard = async (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
replaceForm.value = { new_mac: '', reason: '' }
|
||||
replacements.value = []
|
||||
try {
|
||||
const { data } = await request.get(`/devices/${device.id}/replacements`)
|
||||
replacements.value = data
|
||||
} catch {}
|
||||
replaceVisible.value = true
|
||||
}
|
||||
|
||||
const openProvisionFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
|
||||
const clearStatus = async () => {
|
||||
clearing.value = true
|
||||
try {
|
||||
@@ -387,6 +672,12 @@ const formatPort = (device) => {
|
||||
return `Onu${device.slot_number || '?'}/${device.port_number || '?'}`
|
||||
}
|
||||
|
||||
const formatDistance = (d) => {
|
||||
if (!d && d !== 0) return '—'
|
||||
if (d === 1000) return '1000 米内'
|
||||
return d + 'm'
|
||||
}
|
||||
|
||||
const openDetail = (row) => {
|
||||
selectedDevice.value = { ...row }
|
||||
provisionResult.value = null
|
||||
@@ -400,7 +691,7 @@ const doRefreshDevice = async () => {
|
||||
selectedDevice.value.status = data.status
|
||||
selectedDevice.value.distance_m = data.distance_m
|
||||
if (data.model) selectedDevice.value.model = data.model
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + formatDistance(data.distance_m) : ''}`)
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '更新失败')
|
||||
@@ -409,6 +700,37 @@ const doRefreshDevice = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openReplace = async () => {
|
||||
replaceForm.value = { new_mac: '', reason: '' }
|
||||
replacements.value = []
|
||||
try {
|
||||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/replacements`)
|
||||
replacements.value = data
|
||||
} catch {}
|
||||
detailVisible.value = false
|
||||
replaceVisible.value = true
|
||||
}
|
||||
|
||||
const doReplace = async () => {
|
||||
if (!replaceForm.value.new_mac.trim()) {
|
||||
ElMessage.warning('请输入新 MAC 地址')
|
||||
return
|
||||
}
|
||||
replaceSaving.value = true
|
||||
try {
|
||||
await request.post(`/devices/${selectedDevice.value.id}/replace`, replaceForm.value)
|
||||
ElMessage.success('更换成功')
|
||||
replaceVisible.value = false
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '更换失败')
|
||||
} finally {
|
||||
replaceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fmtReplaceTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
region: selectedDevice.value.region || '',
|
||||
@@ -674,6 +996,15 @@ onMounted(() => {
|
||||
|
||||
.mono-val.accent { color: var(--accent); font-size: 13px; }
|
||||
|
||||
.replace-history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
.replace-history-row:last-child { border-bottom: none; }
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
@@ -752,4 +1083,292 @@ onMounted(() => {
|
||||
line-height: 1.7;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ── 移动端卡片 ── */
|
||||
.device-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.device-card:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-head-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-mac {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card-school {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-chevron {
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-card.expanded .card-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 52px;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card-val {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.card-val.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-row--notes .card-val {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-expanded {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 9px 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.card-action-btn:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.card-action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-action-btn--primary {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--border-accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-action-btn--primary:active {
|
||||
background: rgba(0,210,180,0.25);
|
||||
}
|
||||
|
||||
.card-action-btn--warn {
|
||||
background: rgba(245,158,11,0.1);
|
||||
border-color: rgba(245,158,11,0.3);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.card-action-btn--warn:active {
|
||||
background: rgba(245,158,11,0.2);
|
||||
}
|
||||
|
||||
.card-detail-rows {
|
||||
padding: 10px 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cards-empty {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 移动端筛选栏 */
|
||||
@media (max-width: 767px) {
|
||||
.device-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-group .el-select,
|
||||
.filter-group .el-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.filter-bar .el-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.data-stats {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrap .el-pagination {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-page-btn {
|
||||
padding: 9px 20px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-page-btn:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-page-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mobile-page-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-actions {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</el-upload>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button @click="handleDownloadTemplate" size="default">
|
||||
<el-button v-if="can('device.import')" @click="handleDownloadTemplate" size="default">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 5px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
@@ -66,6 +66,7 @@
|
||||
下载模板
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="can('device.import')"
|
||||
type="primary"
|
||||
size="default"
|
||||
:loading="uploading"
|
||||
@@ -74,6 +75,7 @@
|
||||
>
|
||||
{{ uploading ? '导入中…' : '开始导入' }}
|
||||
</el-button>
|
||||
<el-alert v-if="!can('device.import')" type="warning" :closable="false" title="您没有导入权限" show-icon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,8 +121,11 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import * as importApi from '../api/import'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
|
||||
const { can } = usePermission()
|
||||
|
||||
const selectedFile = ref(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,14 @@
|
||||
<span class="page-subtitle">光线路终端设备配置与监控</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" size="small" @click="openAddDialog">
|
||||
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 5px">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
添加 OLT
|
||||
</el-button>
|
||||
<el-upload
|
||||
v-if="!isMobile && can('olt.manage')"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleImportFile"
|
||||
@@ -22,19 +23,19 @@
|
||||
>
|
||||
<el-button type="success" size="small" :loading="importing">批量导入</el-button>
|
||||
</el-upload>
|
||||
<el-button size="small" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button type="danger" plain size="small" @click="openDuplicateMacs">
|
||||
<el-button v-if="!isMobile && can('olt.manage')" size="small" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button v-if="!isMobile && can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
|
||||
重复 MAC
|
||||
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button type="warning" plain size="small" @click="openNewDevices">
|
||||
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
|
||||
新增设备
|
||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
||||
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
||||
环路检测
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
|
||||
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
|
||||
快速扫描
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -42,7 +43,14 @@
|
||||
|
||||
<!-- OLT 列表 -->
|
||||
<div class="data-panel">
|
||||
<el-table :data="devices" size="small" style="width: 100%">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar" v-if="!isMobile">
|
||||
<el-select v-model="filterRegion" placeholder="全部区域" clearable size="small" style="width: 160px" @change="fetchDevices">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</div>
|
||||
<!-- 桌面端表格 -->
|
||||
<el-table v-if="!isMobile" :data="filteredDevices" size="small" style="width: 100%">
|
||||
<el-table-column type="index" label="#" width="52" align="center">
|
||||
<template #default="{ $index }">
|
||||
<span class="row-index">{{ $index + 1 }}</span>
|
||||
@@ -53,6 +61,11 @@
|
||||
<span class="ip-link" @click="openDetail(row)">{{ row.ip_address }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="90">
|
||||
<template #default="{ row }">
|
||||
<span class="region-tag">{{ row.region || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ row.username }}</span>
|
||||
@@ -85,6 +98,50 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 移动端 OLT 卡片 -->
|
||||
<div v-if="isMobile" class="olt-cards">
|
||||
<div v-for="device in devices" :key="device.id" class="olt-card">
|
||||
<div class="olt-card-head">
|
||||
<div class="olt-card-head-left">
|
||||
<span class="olt-ip">{{ device.ip_address }}</span>
|
||||
<span class="olt-location">{{ device.location || '—' }}</span>
|
||||
</div>
|
||||
<div class="olt-card-head-right">
|
||||
<span
|
||||
v-if="loopStatusMap[device.id]"
|
||||
class="loop-badge"
|
||||
@click="showLoopDetail(device.id)"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
环路 {{ loopStatusMap[device.id].length }}
|
||||
</span>
|
||||
<span v-else class="olt-status-ok">
|
||||
<span class="status-dot-sm online-dot"></span>正常
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="olt-card-actions">
|
||||
<button class="olt-action-btn" @click="openDetail(device)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
快速扫描
|
||||
</button>
|
||||
<button v-if="can('olt.port_manage')" class="olt-action-btn" @click="openPortManagerFromCard(device)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<circle cx="7" cy="9" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
端口管理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="devices.length === 0" class="olt-cards-empty">暂无 OLT 设备</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速扫描结果 -->
|
||||
@@ -206,6 +263,11 @@
|
||||
<el-form-item label="IP 地址"><el-input v-model="form.ip_address" :disabled="isEdit" /></el-form-item>
|
||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" type="password" placeholder="不修改请留空" /></el-form-item>
|
||||
<el-form-item label="区域">
|
||||
<el-select v-model="form.region" style="width: 100%">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="安装位置"><el-input v-model="form.location" /></el-form-item>
|
||||
<el-form-item label="槽位命令"><el-input v-model="form.slot_command" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||
@@ -225,6 +287,7 @@
|
||||
<el-descriptions-item label="用户名">
|
||||
<span class="mono-val">{{ selectedDevice.username }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ selectedDevice.region || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="安装位置">{{ selectedDevice.location || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="槽位命令" :span="2">
|
||||
<span class="mono-val cmd">{{ selectedDevice.slot_command || '—' }}</span>
|
||||
@@ -236,7 +299,7 @@
|
||||
<div class="detail-actions">
|
||||
<!-- 第一行:设备操作 -->
|
||||
<div class="action-row">
|
||||
<el-button type="info" :loading="portsLoading" @click="openPortManager">
|
||||
<el-button v-if="can('olt.port_manage')" type="info" :loading="portsLoading" @click="openPortManager">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
|
||||
@@ -250,7 +313,7 @@
|
||||
</svg>
|
||||
扫描发现
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="discovering" @click="discoverOlt">
|
||||
<el-button v-if="can('olt.discover')" type="warning" :loading="discovering" @click="discoverOlt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px;vertical-align:-2px">
|
||||
<path d="M12 5v14M5 12l7 7 7-7"/>
|
||||
</svg>
|
||||
@@ -260,8 +323,8 @@
|
||||
<!-- 第二行:管理操作 + 关闭 -->
|
||||
<div class="action-row action-row--secondary">
|
||||
<div class="action-left">
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button type="danger" @click="confirmDelete">删除</el-button>
|
||||
<el-button v-if="can('olt.manage')" type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button v-if="can('olt.manage')" type="danger" @click="confirmDelete">删除</el-button>
|
||||
</div>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
@@ -298,7 +361,8 @@
|
||||
<div class="port-status-text">{{ portStatusText(port) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="port-hint">点击端口可切换开启/关闭状态</div>
|
||||
<div class="port-hint" v-if="can('olt.port_manage')">点击端口可切换开启/关闭状态</div>
|
||||
<div class="port-hint" v-else>仅查看端口状态(无管理权限)</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :loading="portsLoading" @click="loadPorts">刷新</el-button>
|
||||
@@ -342,7 +406,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
|
||||
<el-button v-if="can('olt.discover')" type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
|
||||
<el-button @click="scanVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -440,11 +504,22 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import request from '../utils/request'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { can } = usePermission()
|
||||
|
||||
const devices = ref([])
|
||||
const filterRegion = ref('')
|
||||
const regions = ref([])
|
||||
const filteredDevices = computed(() =>
|
||||
filterRegion.value ? devices.value.filter(d => d.region === filterRegion.value) : devices.value
|
||||
)
|
||||
const dialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const scanVisible = ref(false)
|
||||
@@ -471,7 +546,7 @@ const loopDetailInterfaces = ref([])
|
||||
const quickScanning = ref(false)
|
||||
const quickScanVisible = ref(false)
|
||||
const quickScanResult = ref({ total_online: 0, total_offline: 0, total_new: 0, results: [], errors: [] })
|
||||
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' })
|
||||
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', region: '城区', location: '', description: '' })
|
||||
|
||||
const portManagerVisible = ref(false)
|
||||
const portsLoading = ref(false)
|
||||
@@ -508,6 +583,7 @@ const openPortManager = async () => {
|
||||
}
|
||||
|
||||
const togglePort = async (port) => {
|
||||
if (!can('olt.port_manage')) return
|
||||
const isUp = port.status === 'up'
|
||||
const action = isUp ? 'shutdown' : 'undo shutdown'
|
||||
const actionText = isUp ? '关闭' : '开启'
|
||||
@@ -535,6 +611,13 @@ const togglePort = async (port) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRegions = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/regions')
|
||||
regions.value = data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fetchDevices = async () => {
|
||||
const { data } = await request.get('/olt/devices')
|
||||
devices.value = data
|
||||
@@ -549,7 +632,7 @@ const fetchDuplicateCount = async () => {
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEdit.value = false
|
||||
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' }
|
||||
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', region: regions.value[0] || '', location: '', description: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -566,6 +649,7 @@ const openEdit = () => {
|
||||
username: selectedDevice.value.username,
|
||||
password: '',
|
||||
slot_command: selectedDevice.value.slot_command || 'display onu slot',
|
||||
region: selectedDevice.value.region || '城区',
|
||||
location: selectedDevice.value.location || '',
|
||||
description: selectedDevice.value.description || ''
|
||||
}
|
||||
@@ -794,7 +878,19 @@ const downloadTemplate = () => {
|
||||
window.open('/api/olt/template', '_blank')
|
||||
}
|
||||
|
||||
// 移动端卡片操作
|
||||
const openEditFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
openEdit()
|
||||
}
|
||||
|
||||
const openPortManagerFromCard = async (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
await openPortManager()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRegions()
|
||||
fetchDevices()
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
@@ -851,6 +947,25 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.region-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表格内元素 */
|
||||
.row-index {
|
||||
font-family: var(--font-mono);
|
||||
@@ -1148,4 +1263,147 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── 移动端 OLT 卡片 ── */
|
||||
.olt-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.olt-card {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.olt-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.olt-card-head-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.olt-ip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.olt-location {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.olt-card-head-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.olt-status-ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 5px var(--success);
|
||||
}
|
||||
|
||||
.olt-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.olt-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 9px 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.olt-action-btn:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.olt-action-btn--edit {
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.olt-cards-empty {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 移动端页头 */
|
||||
@media (max-width: 767px) {
|
||||
.olt-manage {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions .el-button,
|
||||
.header-actions > * {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* 移动端按钮区只显示2列(移动端只剩新增设备、环路检测、快速扫描) */
|
||||
.header-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* 端口管理对话框:限制高度支持滚动 */
|
||||
.switch-panel {
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">角色权限配置</h2>
|
||||
</div>
|
||||
|
||||
<div class="layout" v-if="!loading">
|
||||
<!-- 左侧角色列表 -->
|
||||
<div class="role-list">
|
||||
<div
|
||||
v-for="r in roles"
|
||||
:key="r.role"
|
||||
class="role-item"
|
||||
:class="{ active: selectedRole === r.role, disabled: r.role === 'admin' }"
|
||||
@click="selectRole(r)"
|
||||
>
|
||||
<span class="role-name">{{ roleLabel(r.role) }}</span>
|
||||
<span class="role-perm-count">{{ r.role === 'admin' ? '全部' : r.permissions.length + ' 项' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧权限矩阵 -->
|
||||
<div class="perm-panel">
|
||||
<div v-if="!selectedRole" class="perm-empty">选择左侧角色查看权限</div>
|
||||
<template v-else>
|
||||
<div class="perm-panel-header">
|
||||
<span class="perm-panel-title">{{ roleLabel(selectedRole) }} 的权限</span>
|
||||
<div class="perm-actions" v-if="selectedRole !== 'admin'">
|
||||
<button class="btn btn-ghost" @click="resetPerms">重置</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存权限' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRole === 'admin'" class="admin-notice">
|
||||
admin 角色拥有全部权限,不可修改。
|
||||
</div>
|
||||
|
||||
<div v-else class="perm-modules">
|
||||
<div v-for="(perms, module) in allPermissions" :key="module" class="perm-module">
|
||||
<div class="module-header">
|
||||
<span class="module-name">{{ moduleLabel(module) }}</span>
|
||||
<label class="check-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isModuleAllChecked(module, perms)"
|
||||
:indeterminate="isModuleIndeterminate(module, perms)"
|
||||
@change="toggleModule(module, perms, $event.target.checked)"
|
||||
/>
|
||||
全选
|
||||
</label>
|
||||
</div>
|
||||
<div class="perm-items">
|
||||
<label v-for="p in perms" :key="p.code" class="perm-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="p.code"
|
||||
v-model="selectedPerms"
|
||||
/>
|
||||
<div class="perm-info">
|
||||
<span class="perm-name">{{ p.name }}</span>
|
||||
<span class="perm-code">{{ p.code }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="loading-tip">加载中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getRoles, getPermissions, updateRolePermissions } from '../api/role'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const roles = ref([])
|
||||
const allPermissions = ref({}) // { module: [{ code, name, description }] }
|
||||
const selectedRole = ref('')
|
||||
const selectedPerms = ref([])
|
||||
let originalPerms = []
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: '超级管理员' },
|
||||
{ value: 'area_admin', label: '区域管理员' },
|
||||
{ value: 'school_admin', label: '学校管理员' },
|
||||
{ value: 'user', label: '普通用户' },
|
||||
]
|
||||
const moduleLabels = {
|
||||
device: '设备管理',
|
||||
olt: 'OLT 管理',
|
||||
user: '用户管理',
|
||||
system: '系统管理',
|
||||
}
|
||||
|
||||
const roleLabel = (r) => roleOptions.find(o => o.value === r)?.label || r
|
||||
const moduleLabel = (m) => moduleLabels[m] || m
|
||||
|
||||
const selectRole = (r) => {
|
||||
if (r.role === 'admin') return
|
||||
selectedRole.value = r.role
|
||||
selectedPerms.value = [...r.permissions]
|
||||
originalPerms = [...r.permissions]
|
||||
}
|
||||
|
||||
const resetPerms = () => {
|
||||
selectedPerms.value = [...originalPerms]
|
||||
}
|
||||
|
||||
const isModuleAllChecked = (module, perms) =>
|
||||
perms.every(p => selectedPerms.value.includes(p.code))
|
||||
|
||||
const isModuleIndeterminate = (module, perms) => {
|
||||
const checked = perms.filter(p => selectedPerms.value.includes(p.code)).length
|
||||
return checked > 0 && checked < perms.length
|
||||
}
|
||||
|
||||
const toggleModule = (module, perms, checked) => {
|
||||
const codes = perms.map(p => p.code)
|
||||
if (checked) {
|
||||
selectedPerms.value = [...new Set([...selectedPerms.value, ...codes])]
|
||||
} else {
|
||||
selectedPerms.value = selectedPerms.value.filter(c => !codes.includes(c))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateRolePermissions(selectedRole.value, selectedPerms.value)
|
||||
// 更新本地 roles 数据
|
||||
const r = roles.value.find(r => r.role === selectedRole.value)
|
||||
if (r) r.permissions = [...selectedPerms.value]
|
||||
originalPerms = [...selectedPerms.value]
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [rolesRes, permsRes] = await Promise.all([getRoles(), getPermissions()])
|
||||
roles.value = rolesRes.data
|
||||
allPermissions.value = permsRes.data
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { padding: 24px; max-width: 1100px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.role-list {
|
||||
width: 180px; flex-shrink: 0;
|
||||
border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.role-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 13px 16px; cursor: pointer; border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.15s; color: var(--text-secondary); font-size: 13px;
|
||||
}
|
||||
.role-item:last-child { border-bottom: none; }
|
||||
.role-item:hover { background: var(--bg-hover); }
|
||||
.role-item.active { background: var(--accent-dim); color: var(--accent); }
|
||||
.role-item.disabled { cursor: default; opacity: 0.6; }
|
||||
.role-name { font-weight: 500; }
|
||||
.role-perm-count { font-size: 11px; color: var(--text-muted); }
|
||||
.role-item.active .role-perm-count { color: var(--accent); opacity: 0.7; }
|
||||
.perm-panel {
|
||||
flex: 1; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
|
||||
min-height: 300px;
|
||||
}
|
||||
.perm-empty { padding: 60px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
.perm-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.perm-panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.perm-actions { display: flex; gap: 8px; }
|
||||
.admin-notice { padding: 20px; color: var(--text-muted); font-size: 13px; }
|
||||
.perm-modules { padding: 16px 20px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.perm-module { }
|
||||
.module-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.module-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.check-all { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); cursor: pointer; }
|
||||
.perm-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
||||
.perm-item {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px;
|
||||
border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
|
||||
cursor: pointer; transition: border-color 0.15s; background: var(--bg-elevated);
|
||||
}
|
||||
.perm-item:has(input:checked) { border-color: var(--border-accent); background: var(--accent-dim); }
|
||||
.perm-item input { margin-top: 2px; accent-color: var(--accent); cursor: pointer; }
|
||||
.perm-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.perm-name { font-size: 13px; color: var(--text-primary); font-weight: 500; }
|
||||
.perm-code { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); }
|
||||
.loading-tip { padding: 60px; text-align: center; color: var(--text-muted); }
|
||||
.btn { padding: 7px 16px; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
|
||||
.btn-ghost { background: var(--bg-elevated); border-color: var(--border-default); color: var(--text-secondary); }
|
||||
.btn-ghost:hover { border-color: var(--border-strong); }
|
||||
.btn-primary { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent); color: #fff; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<span class="page-subtitle">管理员专属配置项</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-header">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 8px">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
定时检查间隔
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="setting-desc">控制系统自动扫描 OLT 设备状态的频率。设置后立即生效,无需重启服务。</p>
|
||||
|
||||
<div class="interval-form">
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model.number="intervalMinutes"
|
||||
type="number"
|
||||
:min="5"
|
||||
:max="1440"
|
||||
class="interval-input"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<span class="input-unit">分钟</span>
|
||||
</div>
|
||||
<div class="preset-btns">
|
||||
<button
|
||||
v-for="p in presets"
|
||||
:key="p.value"
|
||||
class="preset-btn"
|
||||
:class="{ active: intervalMinutes === p.value }"
|
||||
@click="intervalMinutes = p.value"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="intervalMinutes < 5" class="warn-tip">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
最小间隔为 5 分钟
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<span class="current-hint">当前设置:{{ currentIntervalLabel }}</span>
|
||||
<button
|
||||
class="save-btn"
|
||||
:disabled="saving || intervalMinutes < 5 || intervalMinutes > 1440"
|
||||
@click="save"
|
||||
>
|
||||
<svg v-if="saving" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ saving ? '保存中…' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="next-run-hint">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
下次扫描:{{ nextCheckLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { getSettings, updateCheckInterval } from '../api/settings'
|
||||
|
||||
const intervalMinutes = ref(30)
|
||||
const currentSeconds = ref(1800)
|
||||
const nextCheckAt = ref(null)
|
||||
const isScanning = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const presets = [
|
||||
{ label: '5 分钟', value: 5 },
|
||||
{ label: '15 分钟', value: 15 },
|
||||
{ label: '30 分钟', value: 30 },
|
||||
{ label: '1 小时', value: 60 },
|
||||
{ label: '2 小时', value: 120 },
|
||||
]
|
||||
|
||||
const currentIntervalLabel = computed(() => {
|
||||
const m = Math.round(currentSeconds.value / 60)
|
||||
return m >= 60 ? `${m / 60} 小时` : `${m} 分钟`
|
||||
})
|
||||
|
||||
const nextCheckLabel = computed(() => {
|
||||
if (isScanning.value) return '扫描中…'
|
||||
if (!nextCheckAt.value) return '暂无记录(尚未执行过扫描)'
|
||||
const ts = parseInt(nextCheckAt.value) * 1000
|
||||
const now = Date.now()
|
||||
if (ts <= now) return '即将执行'
|
||||
const diff = Math.round((ts - now) / 1000)
|
||||
const h = Math.floor(diff / 3600)
|
||||
const m = Math.floor((diff % 3600) / 60)
|
||||
const s = diff % 60
|
||||
const parts = []
|
||||
if (h > 0) parts.push(`${h} 小时`)
|
||||
if (m > 0) parts.push(`${m} 分钟`)
|
||||
if (s > 0 && h === 0) parts.push(`${s} 秒`)
|
||||
const timeStr = new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
return `${timeStr}(${parts.join(' ')} 后)`
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const { data } = await getSettings()
|
||||
const s = data?.check_interval_seconds
|
||||
if (s) {
|
||||
currentSeconds.value = parseInt(s.value)
|
||||
intervalMinutes.value = Math.round(currentSeconds.value / 60)
|
||||
}
|
||||
const n = data?.next_check_at
|
||||
nextCheckAt.value = n?.value || null
|
||||
isScanning.value = n?.running || false
|
||||
} catch {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (intervalMinutes.value < 5 || intervalMinutes.value > 1440) return
|
||||
saving.value = true
|
||||
try {
|
||||
const seconds = intervalMinutes.value * 60
|
||||
await updateCheckInterval(seconds)
|
||||
currentSeconds.value = seconds
|
||||
ElMessage.success('设置已保存,下次检查将按新间隔执行')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
const timer = setInterval(load, 10000)
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
padding: 24px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.interval-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.interval-input {
|
||||
width: 100px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.interval-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.interval-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.input-unit {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preset-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.warn-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.current-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.next-run-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.save-btn:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.settings-page {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
<div class="header-filters">
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="filter-input"
|
||||
placeholder="搜索用户名/邮箱"
|
||||
@input="onSearch"
|
||||
/>
|
||||
<select v-model="filterRole" class="filter-select" @change="onSearch">
|
||||
<option value="">全部角色</option>
|
||||
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>区域/学校</th>
|
||||
<th>状态</th>
|
||||
<th>最后登录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="7" class="td-center">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="!users.length">
|
||||
<td colspan="7" class="td-center td-empty">暂无用户</td>
|
||||
</tr>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="td-username">{{ u.username }}</td>
|
||||
<td class="td-muted">{{ u.email || '—' }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
||||
</td>
|
||||
<td class="td-muted">
|
||||
<span v-if="u.assigned_area">{{ u.assigned_area }}</span>
|
||||
<span v-if="u.assigned_school"> / {{ u.assigned_school }}</span>
|
||||
<span v-if="!u.assigned_area && !u.assigned_school">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="u.is_active ? 'status-active' : 'status-disabled'">
|
||||
{{ u.is_active ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-muted">{{ formatTime(u.last_login) }}</td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="btn-sm btn-primary" @click="openEdit(u)">编辑</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
:class="u.is_active ? 'btn-danger' : 'btn-success'"
|
||||
@click="handleToggle(u)"
|
||||
>
|
||||
{{ u.is_active ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination" v-if="total > pageSize">
|
||||
<button class="page-btn" :disabled="page === 0" @click="page--; loadUsers()">上一页</button>
|
||||
<span class="page-info">{{ page + 1 }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||
<button class="page-btn" :disabled="(page + 1) * pageSize >= total" @click="page++; loadUsers()">下一页</button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">编辑用户:{{ editUser.username }}</span>
|
||||
<button class="modal-close" @click="editUser = null">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label class="form-label">角色</label>
|
||||
<select v-model="editForm.role" class="form-select" @change="onRoleChange">
|
||||
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 区域管理员:多选区域 -->
|
||||
<div class="form-row" v-if="editForm.role === 'area_admin'">
|
||||
<label class="form-label">分配区域(可多选)</label>
|
||||
<div class="multi-toolbar">
|
||||
<input v-model="areaSearch" class="multi-search" placeholder="搜索区域…" />
|
||||
<button class="multi-btn" @click="selectAllAreas">全选</button>
|
||||
<button class="multi-btn" @click="invertAreas">反选</button>
|
||||
</div>
|
||||
<div class="multi-select-wrap">
|
||||
<div
|
||||
v-for="r in filteredRegions"
|
||||
:key="r"
|
||||
class="multi-option"
|
||||
:class="{ selected: editForm.selectedAreas.includes(r) }"
|
||||
@click="toggleArea(r)"
|
||||
>{{ r }}</div>
|
||||
<div v-if="!filteredRegions.length" class="multi-empty">暂无匹配区域</div>
|
||||
</div>
|
||||
<div class="selected-hint" v-if="editForm.selectedAreas.length">
|
||||
已选:{{ editForm.selectedAreas.join('、') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学校管理员:先选区域筛选,再多选学校 -->
|
||||
<div class="form-row" v-if="editForm.role === 'school_admin'">
|
||||
<label class="form-label">按区域筛选学校</label>
|
||||
<select v-model="schoolFilterRegion" class="form-select" @change="loadSchools">
|
||||
<option value="">全部区域</option>
|
||||
<option v-for="r in allRegions" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" v-if="editForm.role === 'school_admin'">
|
||||
<label class="form-label">分配学校(可多选)</label>
|
||||
<div class="multi-toolbar">
|
||||
<input v-model="schoolSearch" class="multi-search" placeholder="搜索学校…" />
|
||||
<button class="multi-btn" @click="selectAllSchools">全选</button>
|
||||
<button class="multi-btn" @click="invertSchools">反选</button>
|
||||
</div>
|
||||
<div class="multi-select-wrap">
|
||||
<div
|
||||
v-for="s in filteredSchoolList"
|
||||
:key="s"
|
||||
class="multi-option"
|
||||
:class="{ selected: editForm.selectedSchools.includes(s) }"
|
||||
@click="toggleSchool(s)"
|
||||
>{{ s }}</div>
|
||||
<div v-if="!filteredSchoolList.length" class="multi-empty">暂无匹配学校</div>
|
||||
</div>
|
||||
<div class="selected-hint" v-if="editForm.selectedSchools.length">
|
||||
已选 {{ editForm.selectedSchools.length }} 所:{{ editForm.selectedSchools.join('、') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" @click="editUser = null">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getUsers, updateUserRole, toggleUser } from '../api/user'
|
||||
import request from '../utils/request'
|
||||
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(0)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const filterRole = ref('')
|
||||
let searchTimer = null
|
||||
|
||||
const editUser = ref(null)
|
||||
const editForm = ref({ role: 'user', selectedAreas: [], selectedSchools: [] })
|
||||
const saving = ref(false)
|
||||
|
||||
const allRegions = ref([])
|
||||
const allSchools = ref([])
|
||||
const filteredSchools = ref([])
|
||||
const schoolFilterRegion = ref('')
|
||||
|
||||
// 搜索词
|
||||
const areaSearch = ref('')
|
||||
const schoolSearch = ref('')
|
||||
|
||||
// 过滤后的区域/学校列表
|
||||
const filteredRegions = computed(() =>
|
||||
areaSearch.value
|
||||
? allRegions.value.filter(r => r.includes(areaSearch.value))
|
||||
: allRegions.value
|
||||
)
|
||||
|
||||
const filteredSchoolList = computed(() =>
|
||||
schoolSearch.value
|
||||
? filteredSchools.value.filter(s => s.includes(schoolSearch.value))
|
||||
: filteredSchools.value
|
||||
)
|
||||
|
||||
// 全选/反选 - 区域
|
||||
const selectAllAreas = () => {
|
||||
editForm.value.selectedAreas = [...filteredRegions.value]
|
||||
}
|
||||
const invertAreas = () => {
|
||||
editForm.value.selectedAreas = filteredRegions.value.filter(
|
||||
r => !editForm.value.selectedAreas.includes(r)
|
||||
)
|
||||
}
|
||||
|
||||
// 全选/反选 - 学校
|
||||
const selectAllSchools = () => {
|
||||
const toAdd = filteredSchoolList.value.filter(s => !editForm.value.selectedSchools.includes(s))
|
||||
editForm.value.selectedSchools = [...editForm.value.selectedSchools, ...toAdd]
|
||||
}
|
||||
const invertSchools = () => {
|
||||
editForm.value.selectedSchools = filteredSchoolList.value.filter(
|
||||
s => !editForm.value.selectedSchools.includes(s)
|
||||
)
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: '超级管理员' },
|
||||
{ value: 'area_admin', label: '区域管理员' },
|
||||
{ value: 'school_admin', label: '学校管理员' },
|
||||
{ value: 'user', label: '普通用户' },
|
||||
]
|
||||
|
||||
const roleLabel = (role) => roleOptions.find(r => r.value === role)?.label || role
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '—'
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const loadRegions = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/devices/regions')
|
||||
allRegions.value = data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const loadSchools = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/devices/schools', {
|
||||
params: schoolFilterRegion.value ? { region: schoolFilterRegion.value } : {}
|
||||
})
|
||||
allSchools.value = data
|
||||
filteredSchools.value = data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers({
|
||||
skip: page.value * pageSize,
|
||||
limit: pageSize,
|
||||
keyword: keyword.value || undefined,
|
||||
role: filterRole.value || undefined,
|
||||
})
|
||||
users.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => { page.value = 0; loadUsers() }, 300)
|
||||
}
|
||||
|
||||
const openEdit = (u) => {
|
||||
editUser.value = u
|
||||
schoolFilterRegion.value = ''
|
||||
areaSearch.value = ''
|
||||
schoolSearch.value = ''
|
||||
const areas = u.assigned_area ? u.assigned_area.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
const schools = u.assigned_school ? u.assigned_school.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
editForm.value = { role: u.role, selectedAreas: areas, selectedSchools: schools }
|
||||
if (u.role === 'school_admin') loadSchools()
|
||||
}
|
||||
|
||||
const onRoleChange = () => {
|
||||
editForm.value.selectedAreas = []
|
||||
editForm.value.selectedSchools = []
|
||||
schoolFilterRegion.value = ''
|
||||
areaSearch.value = ''
|
||||
schoolSearch.value = ''
|
||||
if (editForm.value.role === 'school_admin') loadSchools()
|
||||
}
|
||||
|
||||
const toggleArea = (r) => {
|
||||
const idx = editForm.value.selectedAreas.indexOf(r)
|
||||
if (idx >= 0) editForm.value.selectedAreas.splice(idx, 1)
|
||||
else editForm.value.selectedAreas.push(r)
|
||||
}
|
||||
|
||||
const toggleSchool = (s) => {
|
||||
const idx = editForm.value.selectedSchools.indexOf(s)
|
||||
if (idx >= 0) editForm.value.selectedSchools.splice(idx, 1)
|
||||
else editForm.value.selectedSchools.push(s)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { role: editForm.value.role, assigned_area: null, assigned_school: null }
|
||||
if (editForm.value.role === 'area_admin' && editForm.value.selectedAreas.length) {
|
||||
payload.assigned_area = editForm.value.selectedAreas.join(',')
|
||||
}
|
||||
if (editForm.value.role === 'school_admin' && editForm.value.selectedSchools.length) {
|
||||
payload.assigned_school = editForm.value.selectedSchools.join(',')
|
||||
}
|
||||
await updateUserRole(editUser.value.id, payload)
|
||||
editUser.value = null
|
||||
await loadUsers()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (u) => {
|
||||
await toggleUser(u.id)
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadRegions()
|
||||
loadSchools()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { padding: 24px; max-width: 1200px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.header-filters { display: flex; gap: 10px; }
|
||||
.filter-input, .filter-select {
|
||||
padding: 7px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated); color: var(--text-primary); font-size: 13px; outline: none;
|
||||
}
|
||||
.filter-input:focus, .filter-select:focus { border-color: var(--accent); }
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th {
|
||||
padding: 10px 14px; text-align: left; font-weight: 500; color: var(--text-muted);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border-subtle); white-space: nowrap;
|
||||
}
|
||||
.data-table td { padding: 11px 14px; border-bottom: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table tr:hover td { background: var(--bg-hover); }
|
||||
.td-center { text-align: center; }
|
||||
.td-empty { color: var(--text-muted); }
|
||||
.td-username { font-weight: 500; color: var(--text-primary); }
|
||||
.td-muted { color: var(--text-muted); font-size: 12px; }
|
||||
.role-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.role-admin { background: rgba(139,92,246,0.15); color: #a78bfa; }
|
||||
.role-area_admin { background: rgba(59,130,246,0.15); color: #60a5fa; }
|
||||
.role-school_admin { background: rgba(16,185,129,0.15); color: #34d399; }
|
||||
.role-user { background: var(--bg-elevated); color: var(--text-muted); }
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.status-active { background: rgba(16,185,129,0.15); color: var(--success); }
|
||||
.status-disabled { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||||
.action-btns { display: flex; gap: 6px; }
|
||||
.btn-sm { padding: 4px 10px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
|
||||
.btn-primary { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent); color: #fff; }
|
||||
.btn-danger { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||||
.btn-success { background: rgba(16,185,129,0.1); border-color: rgba(16,185,129,0.3); color: var(--success); }
|
||||
.btn-success:hover { background: var(--success); color: #fff; }
|
||||
.pagination { display: flex; align-items: center; gap: 12px; margin-top: 16px; justify-content: flex-end; }
|
||||
.page-btn { padding: 6px 14px; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-elevated); color: var(--text-secondary); font-size: 13px; cursor: pointer; }
|
||||
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.page-info { font-size: 13px; color: var(--text-muted); }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 500; display: flex; align-items: center; justify-content: center; }
|
||||
.modal { background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: var(--radius-lg); width: 480px; max-width: 92vw; max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 14px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
|
||||
.modal-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.modal-close { background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
|
||||
.form-row { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
||||
.form-select {
|
||||
padding: 8px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated); color: var(--text-primary); font-size: 13px; outline: none;
|
||||
}
|
||||
.form-select:focus { border-color: var(--accent); }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border-subtle); flex-shrink: 0; }
|
||||
.btn { padding: 7px 18px; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
|
||||
.btn-ghost { background: var(--bg-elevated); border-color: var(--border-default); color: var(--text-secondary); }
|
||||
.btn-ghost:hover { border-color: var(--border-strong); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* 多选标签区 */
|
||||
.multi-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.multi-search {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.multi-search:focus { border-color: var(--accent); }
|
||||
.multi-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.multi-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.multi-select-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.multi-option {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-surface);
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.multi-option:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.multi-option.selected { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); font-weight: 500; }
|
||||
.multi-empty { font-size: 12px; color: var(--text-muted); padding: 4px; }
|
||||
.selected-hint { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
|
||||
</style>
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user