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
+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>