feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
This commit is contained in:
2026-04-06 00:40:08 +08:00
parent dfa8fa62a8
commit f1f8518985
71 changed files with 9402 additions and 191 deletions
+14 -1
View File
@@ -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

+14
View File
@@ -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" }
]
}
+6
View File
@@ -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' })
+34
View File
@@ -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`),
}
+6
View File
@@ -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 })
+4
View File
@@ -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}`)
+5
View File
@@ -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`)
+525 -40
View File
@@ -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>
+16
View File
@@ -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
View File
@@ -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)
+54 -12
View File
@@ -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
+50 -2
View File
@@ -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 }
})
+92
View File
@@ -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;
}
}
+31
View File
@@ -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 }
+248
View File
@@ -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>
+70 -1
View File
@@ -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>
+629 -10
View File
@@ -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>
+7 -2
View File
@@ -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
+275 -17
View File
@@ -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>
+214
View File
@@ -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>
+352
View File
@@ -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>
+454
View File
@@ -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>
+1 -1
View File
@@ -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
}
}