```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息 - 在JWT令牌中添加用户角色信息 - 新增get_my_permissions接口用于获取当前用户权限码列表 - 重构认证回调逻辑,增加错误日志记录 - 更新用户信息获取接口使用Authorization头验证 ```
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user