feat: v0.9.0 新增记录日志、设备更换记录及IMC服务集成
- 新增 RecordsLog.vue 操作记录日志页面 - 新增 ReplacementRecords.vue 设备更换记录页面 - 新增 imc_service.py IMC网管系统集成服务 - 新增 datetime.js 前端日期时间工具函数 - 新增 OLT时间同步.md 文档 - 扩展 devices.py API:设备更换记录、批量操作等 - 扩展 ssh_service.py:OLT时间同步功能 - 扩展 olt.py:新增时间同步相关接口 - 更新 DeviceList.vue:增强设备列表功能 - 更新路由和导航菜单 - 将 .claude/ 和 .mcp.json 加入 .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,24 @@
|
||||
</svg>
|
||||
更新
|
||||
</button>
|
||||
<button v-if="can('device.view')" class="card-action-btn" @click.stop="openOpticalPowerFromCard(device)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||
</svg>
|
||||
光功率
|
||||
</button>
|
||||
<button v-if="can('device.view')" class="card-action-btn" @click.stop="openONUEventsFromCard(device)" :disabled="!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>
|
||||
<button v-if="can('device.check')" class="card-action-btn card-action-btn--danger" @click.stop="confirmRebootFromCard(device)">
|
||||
<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"/>
|
||||
@@ -344,13 +362,20 @@
|
||||
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<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 && !(selectedDevice.slot_number && selectedDevice.port_number))">
|
||||
业务下发
|
||||
</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<div class="detail-footer">
|
||||
<div class="detail-footer-left">
|
||||
<el-button v-if="can('device.check')" type="info" size="small" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新状态</el-button>
|
||||
<el-button v-if="can('device.view')" size="small" :loading="opticalLoading" @click="openOpticalPower">光功率</el-button>
|
||||
<el-button v-if="can('device.view')" size="small" :loading="eventsLoading" @click="openONUEvents" :disabled="!selectedDevice.port_id">上下线记录</el-button>
|
||||
<el-button v-if="can('device.check')" type="danger" plain size="small" :loading="rebooting" @click="confirmReboot">重启</el-button>
|
||||
</div>
|
||||
<div class="detail-footer-right">
|
||||
<el-button v-if="can('device.edit')" size="small" @click="openReplace">更换</el-button>
|
||||
<el-button v-if="can('device.edit')" type="primary" size="small" @click="openEdit">编辑</el-button>
|
||||
<el-button v-if="can('device.edit')" type="success" size="small" @click="openProvision" :disabled="selectedDevice.status !== 'online' || (!selectedDevice.port_id && !(selectedDevice.slot_number && selectedDevice.port_number))">业务下发</el-button>
|
||||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -457,8 +482,89 @@ save force</pre>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 上下线记录弹窗 -->
|
||||
<el-dialog v-model="eventsVisible" title="上下线事件记录" width="min(92vw, 620px)" destroy-on-close>
|
||||
<div v-if="eventsLoading" class="dialog-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在从 OLT 获取记录,请稍候…</span>
|
||||
</div>
|
||||
<template v-else-if="eventsData">
|
||||
<div class="events-meta">
|
||||
<span class="events-intf">接口:<em>{{ eventsData.interface }}</em></span>
|
||||
<span class="events-olt" v-if="eventsData.olt_location">OLT:{{ eventsData.olt_location }}</span>
|
||||
<el-tooltip content="时间来自 OLT 设备时钟,可能与实际时间有偏差" placement="top">
|
||||
<span class="events-hint">⚠ OLT 时钟</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="!eventsData.events.length" class="events-empty">暂无事件记录</div>
|
||||
<div v-else class="events-list">
|
||||
<div
|
||||
v-for="(ev, idx) in eventsData.events"
|
||||
:key="idx"
|
||||
class="event-row"
|
||||
:class="ev.status.toLowerCase() === 'up' ? 'ev-up' : 'ev-down'"
|
||||
>
|
||||
<div class="ev-indicator"></div>
|
||||
<div class="ev-body">
|
||||
<div class="ev-top">
|
||||
<span class="ev-event">{{ fmtONUEvent(ev.event) }}</span>
|
||||
<span class="ev-status" :class="ev.status.toLowerCase() === 'up' ? 'status-up' : 'status-down'">
|
||||
{{ fmtONUStatus(ev.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ev-time">{{ ev.datetime_str }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button :loading="eventsLoading" @click="doFetchEvents">刷新</el-button>
|
||||
<el-button @click="eventsVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 光功率查询对话框 -->
|
||||
<el-dialog v-model="opticalVisible" title="光功率查询" width="min(90vw, 440px)" destroy-on-close>
|
||||
<div v-if="opticalLoading" class="dialog-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在查询光功率,请稍候…</span>
|
||||
</div>
|
||||
<template v-else-if="opticalResult">
|
||||
<div class="optical-grid">
|
||||
<div class="optical-card rx">
|
||||
<span class="optical-label">接收光功率</span>
|
||||
<span class="optical-val">{{ fmtPower(opticalResult.power_in) }}</span>
|
||||
<span class="optical-unit">dBm</span>
|
||||
</div>
|
||||
<div class="optical-card tx">
|
||||
<span class="optical-label">发送光功率</span>
|
||||
<span class="optical-val">{{ fmtPower(opticalResult.power_out) }}</span>
|
||||
<span class="optical-unit">dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="optical-meta">
|
||||
<div class="optical-meta-row" v-if="opticalResult.epon_dev_name">
|
||||
<span class="optical-meta-label">OLT 设备</span>
|
||||
<span class="optical-meta-val mono">{{ opticalResult.epon_dev_name }}</span>
|
||||
</div>
|
||||
<div class="optical-meta-row" v-if="opticalResult.olt_if_name">
|
||||
<span class="optical-meta-label">端口</span>
|
||||
<span class="optical-meta-val mono">{{ opticalResult.olt_if_name }}</span>
|
||||
</div>
|
||||
<div class="optical-meta-row" v-if="opticalResult.onu_if_desc">
|
||||
<span class="optical-meta-label">ONU 描述</span>
|
||||
<span class="optical-meta-val">{{ opticalResult.onu_if_desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button :loading="opticalLoading" @click="doFetchOpticalPower">刷新</el-button>
|
||||
<el-button @click="opticalVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 数据导入对话框 -->
|
||||
<el-dialog v-model="importDialogVisible" title="数据导入" width="480px" destroy-on-close>
|
||||
<el-dialog v-model="importDialogVisible" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||||
<div class="import-hint">
|
||||
请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。
|
||||
</div>
|
||||
@@ -509,10 +615,12 @@ save force</pre>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from '../utils/message'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import * as deviceApi from '../api/device'
|
||||
import request from '../utils/request'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
import { fmtTime as fmtReplaceTime } from '../utils/datetime'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { can } = usePermission()
|
||||
@@ -562,6 +670,85 @@ const importing = ref(false)
|
||||
const importResult = ref(null)
|
||||
const uploadRef = ref(null)
|
||||
|
||||
// 光功率
|
||||
const opticalVisible = ref(false)
|
||||
const opticalLoading = ref(false)
|
||||
const opticalResult = ref(null)
|
||||
|
||||
// 重启
|
||||
const rebooting = ref(false)
|
||||
|
||||
// 上下线记录
|
||||
const eventsVisible = ref(false)
|
||||
const eventsLoading = ref(false)
|
||||
const eventsData = ref(null)
|
||||
|
||||
const ONU_EVENT_MAP = {
|
||||
'Registration': '注册上线',
|
||||
'Deregistration': '注销离线',
|
||||
'Power Failure': '断电',
|
||||
'Power Recovery': '供电恢复',
|
||||
'Link Down': '链路断开',
|
||||
'Link Up': '链路恢复',
|
||||
'TFTP Start': '固件升级开始',
|
||||
'TFTP Success': '固件升级成功',
|
||||
'TFTP Failure': '固件升级失败',
|
||||
'Reset': '重置',
|
||||
'Dying Gasp': '掉电告警',
|
||||
}
|
||||
const ONU_STATUS_MAP = { 'Up': '上线', 'Offline': '离线', 'Down': '离线' }
|
||||
|
||||
const fmtONUEvent = (e) => ONU_EVENT_MAP[e] || e
|
||||
const fmtONUStatus = (s) => ONU_STATUS_MAP[s] || s
|
||||
|
||||
const fmtPower = (v) => {
|
||||
if (!v || v.trim() === '--' || v.trim() === '') return '—'
|
||||
return v
|
||||
}
|
||||
|
||||
const openOpticalPower = () => {
|
||||
opticalResult.value = null
|
||||
opticalVisible.value = true
|
||||
doFetchOpticalPower()
|
||||
}
|
||||
|
||||
const doFetchOpticalPower = async () => {
|
||||
opticalLoading.value = true
|
||||
try {
|
||||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/optical-power`)
|
||||
opticalResult.value = data
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '获取光功率失败')
|
||||
opticalVisible.value = false
|
||||
} finally {
|
||||
opticalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmReboot = () => {
|
||||
ElMessageBox.confirm(
|
||||
`确认重启设备 ${formatMac(selectedDevice.value.mac_address)}?重启过程约需 30-60 秒,期间设备将短暂离线。`,
|
||||
'重启确认',
|
||||
{ confirmButtonText: '确认重启', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(doReboot).catch(() => {})
|
||||
}
|
||||
|
||||
const doReboot = async () => {
|
||||
rebooting.value = true
|
||||
try {
|
||||
const { data } = await request.post(`/devices/${selectedDevice.value.id}/reboot`)
|
||||
if (data.success) {
|
||||
ElMessage.success(data.message)
|
||||
} else {
|
||||
ElMessage.error(data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '重启失败')
|
||||
} finally {
|
||||
rebooting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onImportFileChange = (file) => {
|
||||
importFile.value = file.raw
|
||||
}
|
||||
@@ -634,8 +821,41 @@ const openReplaceFromCard = async (device) => {
|
||||
replaceVisible.value = true
|
||||
}
|
||||
|
||||
const openProvisionFromCard = (device) => {
|
||||
const openOpticalPowerFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
openOpticalPower()
|
||||
}
|
||||
|
||||
const openONUEvents = () => {
|
||||
eventsData.value = null
|
||||
eventsVisible.value = true
|
||||
doFetchEvents()
|
||||
}
|
||||
|
||||
const doFetchEvents = async () => {
|
||||
eventsLoading.value = true
|
||||
try {
|
||||
const { data } = await request.get(`/devices/${selectedDevice.value.id}/onu-events`)
|
||||
eventsData.value = data
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '获取事件记录失败')
|
||||
eventsVisible.value = false
|
||||
} finally {
|
||||
eventsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openONUEventsFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
openONUEvents()
|
||||
}
|
||||
|
||||
const confirmRebootFromCard = (device) => {
|
||||
selectedDevice.value = { ...device }
|
||||
confirmReboot()
|
||||
}
|
||||
|
||||
const openProvisionFromCard = (device) => { selectedDevice.value = { ...device }
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
@@ -773,7 +993,7 @@ const doReplace = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fmtReplaceTime = (t) => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
// fmtReplaceTime 从 utils/datetime 导入,已在文件顶部
|
||||
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
@@ -1250,29 +1470,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 7px;
|
||||
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;
|
||||
padding: 9px 4px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-action-btn:active {
|
||||
@@ -1294,6 +1515,16 @@ onMounted(() => {
|
||||
background: rgba(0,210,180,0.25);
|
||||
}
|
||||
|
||||
.card-action-btn--danger {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.3);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card-action-btn--danger:active {
|
||||
background: rgba(239,68,68,0.2);
|
||||
}
|
||||
|
||||
.card-action-btn--warn {
|
||||
background: rgba(245,158,11,0.1);
|
||||
border-color: rgba(245,158,11,0.3);
|
||||
@@ -1407,6 +1638,23 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 设备详情弹窗 footer 两端布局 */
|
||||
.detail-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-footer-left,
|
||||
.detail-footer-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
@@ -1418,6 +1666,195 @@ onMounted(() => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 上下线事件记录弹窗 */
|
||||
.events-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.events-intf em {
|
||||
font-style: normal;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.events-olt { color: var(--text-secondary); }
|
||||
|
||||
.events-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--warning, #f59e0b);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.events-empty {
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.event-row:last-child { border-bottom: none; }
|
||||
|
||||
.ev-indicator {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.ev-up .ev-indicator { background: var(--success); }
|
||||
.ev-down .ev-indicator { background: var(--danger); }
|
||||
|
||||
.ev-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ev-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ev-event {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ev-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
background: var(--success-dim);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.status-down {
|
||||
background: var(--danger-dim);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.ev-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 光功率弹窗 */
|
||||
.optical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.optical-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 20px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.optical-card.rx {
|
||||
background: var(--success-dim);
|
||||
border-color: rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.optical-card.tx {
|
||||
background: var(--info-dim);
|
||||
border-color: rgba(59,130,246,0.25);
|
||||
}
|
||||
|
||||
.optical-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.optical-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.optical-card.rx .optical-val { color: var(--success); }
|
||||
.optical-card.tx .optical-val { color: var(--info); }
|
||||
|
||||
.optical-unit {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.optical-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.optical-meta-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.optical-meta-row:last-child { border-bottom: none; }
|
||||
|
||||
.optical-meta-label {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.optical-meta-val {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.optical-meta-val.mono { font-family: var(--font-mono); }
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user