Parcourir la source

fix:管网预警bug修改

null il y a 1 semaine
Parent
commit
637231b680

+ 4 - 4
src/api/pipeNetwork/basic.js

@@ -466,11 +466,11 @@ export function getWarningThresholdList(params) {
 export function getWarningThresholdById(id) {
   return request({ url: '/warningThreshold/getById/' + id, method: 'get' })
 }
-export function saveWarningThreshold(data) {
-  return request({ url: '/warningThreshold/save', method: 'post', data })
+export function saveWarningThreshold(data, moduleType) {
+  return request({ url: '/warningThreshold/save', method: 'post', data, params: moduleType ? { moduleType } : undefined })
 }
-export function updateWarningThreshold(data) {
-  return request({ url: '/warningThreshold/update', method: 'post', data })
+export function updateWarningThreshold(data, moduleType) {
+  return request({ url: '/warningThreshold/update', method: 'post', data, params: moduleType ? { moduleType } : undefined })
 }
 export function deleteWarningThreshold(ids) {
   return request({ url: '/warningThreshold/deleteBatch', method: 'post', data: ids })

+ 61 - 0
src/api/pipeNetwork/earlyWarning.js

@@ -40,6 +40,51 @@ export function publishWarningData(warningId) {
   })
 }
 
+export function confirmWarningData(data) {
+  return request({
+    url: '/warning/confirm',
+    method: 'post',
+    params: data
+  })
+}
+
+export function misreportWarningData(data) {
+  return request({
+    url: '/warning/misreport',
+    method: 'post',
+    params: data
+  })
+}
+
+export function submitWarningProcessData(data) {
+  const formData = new FormData()
+  formData.append('process', JSON.stringify({
+    warningId: data.warningId,
+    processContent: data.processContent
+  }))
+  ;(data.files || []).forEach(file => formData.append('files', file))
+  return request({
+    url: '/warning/process',
+    method: 'post',
+    data: formData
+  })
+}
+
+export function clearWarningData(data) {
+  return request({
+    url: '/warning/clear',
+    method: 'post',
+    params: data
+  })
+}
+
+export function getWarningSupervisionListData(warningId) {
+  return request({
+    url: `/warning/supervision/${warningId}`,
+    method: 'get'
+  })
+}
+
 // 解除预警(误报/工单完成)
 export function resolveWarningData(data) {
   return request({
@@ -101,6 +146,22 @@ export function getWarningAttachmentsData(warningId) {
   })
 }
 
+export function getWarningAttachmentPreviewData(attachmentId) {
+  return request({
+    url: `/warning/attachments/${attachmentId}/preview`,
+    method: 'get',
+    responseType: 'blob'
+  })
+}
+
+export function getWarningAttachmentDownloadData(attachmentId) {
+  return request({
+    url: `/warning/attachments/${attachmentId}/download`,
+    method: 'get',
+    responseType: 'blob'
+  })
+}
+
 // 流程可视化数据(路线图节点+边)
 export function getWarningDiagramData(warningId) {
   return request({

+ 9 - 8
src/views/subSystem/basic/GasThreshold.vue

@@ -101,8 +101,9 @@
 <script setup name="GasThreshold">
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getWarningThresholdModulePage, saveWarningThreshold, updateWarningThreshold, deleteWarningThreshold } from '@/api/pipeNetwork/basic'
-import request from '@/utils/request'
+import { getWarningThresholdModulePage, saveWarningThreshold, updateWarningThreshold, deleteWarningThreshold, getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
+
+const GAS_MODULE_TYPE = '燃气'
 
 // ==================== 燃气模块预警类型与预警编码参考 ====================
 const WARNING_TYPE_OPTIONS = [
@@ -148,7 +149,7 @@ async function loadData() {
     if (queryParams.deviceCode) params.deviceCode = queryParams.deviceCode
     if (queryParams.warningType) params.warningType = queryParams.warningType
     if (queryParams.warningCode) params.warningCode = queryParams.warningCode
-    const res = await getWarningThresholdModulePage(pageNum.value, pageSize.value, '燃气', params)
+    const res = await getWarningThresholdModulePage(pageNum.value, pageSize.value, GAS_MODULE_TYPE, params)
     const pageData = res.data || res
     tableData.value = pageData.records || []
     total.value = pageData.total || 0
@@ -169,7 +170,7 @@ const deviceInfoMap = ref({})
 async function ensureDeviceMap() {
   if (Object.keys(deviceInfoMap.value).length > 0) return
   try {
-    const res = await request({ url: '/EquipmentBase/findByTopLevelType', method: 'get', params: { typeName: '燃气' } })
+    const res = await getEquipmentByTopLevelType(GAS_MODULE_TYPE)
     const list = res.data || []
     const map = {}
     list.forEach(d => { map[d.equipmentCode] = d })
@@ -214,7 +215,7 @@ const deviceOptions = ref([])
 async function loadGasDeviceOptions() {
   if (deviceOptions.value.length > 0) return
   try {
-    const res = await request({ url: '/EquipmentBase/findByTopLevelType', method: 'get', params: { typeName: '燃气' } })
+    const res = await getEquipmentByTopLevelType(GAS_MODULE_TYPE)
     deviceOptions.value = res.data || []
   } catch (e) {
     console.error('加载燃气设备列表失败', e)
@@ -273,10 +274,10 @@ async function handleSave() {
     }
     if (isEdit.value) {
       data.id = formData.id
-      await updateWarningThreshold(data)
+      await updateWarningThreshold(data, GAS_MODULE_TYPE)
       ElMessage.success('修改成功')
     } else {
-      await saveWarningThreshold(data)
+      await saveWarningThreshold(data, GAS_MODULE_TYPE)
       ElMessage.success('新增成功')
     }
     dialogVisible.value = false
@@ -326,4 +327,4 @@ onMounted(async () => {
 .table-card {
   min-height: 400px;
 }
-</style>
+</style>

+ 340 - 136
src/views/subSystem/pipeNetwork/pAlarmMonitor/PEqWarning.vue

@@ -7,39 +7,45 @@
     <!-- ========= 统计卡片区域 ========== -->
     <el-row :gutter="16" class="stat-row">
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">总预警</div>
-          <div class="stat-value" :class="stats.total > 0 ? 'primary' : ''">{{ stats.total }}</div>
+        <div class="stat-item stat-primary">
+          <div class="stat-icon"><el-icon><Warning /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">总预警</div><div class="stat-value">{{ stats.total }}</div></div>
+          <div class="stat-caption">累计预警总量</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">待确认</div>
-          <div class="stat-value warning" :class="stats.unconfirmed > 0 ? 'highlight' : ''">{{ stats.unconfirmed }}</div>
+        <div class="stat-item stat-warning">
+          <div class="stat-icon"><el-icon><Bell /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">待确认</div><div class="stat-value">{{ stats.unconfirmed }}</div></div>
+          <div class="stat-caption">待核实异常</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">处理中</div>
-          <div class="stat-value info" :class="stats.processing > 0 ? 'highlight' : ''">{{ stats.processing }}</div>
+        <div class="stat-item stat-processing">
+          <div class="stat-icon"><el-icon><Tools /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">处理中</div><div class="stat-value">{{ stats.processing }}</div></div>
+          <div class="stat-caption">正在处置</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">已处置</div>
-          <div class="stat-value success" :class="stats.handled > 0 ? 'highlight' : ''">{{ stats.handled }}</div>
+        <div class="stat-item stat-handled">
+          <div class="stat-icon"><el-icon><CircleCheck /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">已处置</div><div class="stat-value">{{ stats.handled }}</div></div>
+          <div class="stat-caption">等待复核清除</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">已解除</div>
-          <div class="stat-value done" :class="stats.closed > 0 ? 'highlight' : ''">{{ stats.closed }}</div>
+        <div class="stat-item stat-closed">
+          <div class="stat-icon"><el-icon><CircleClose /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">已解除</div><div class="stat-value">{{ stats.closed }}</div></div>
+          <div class="stat-caption">已完成闭环</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">今日新增</div>
-          <div class="stat-value today" :class="stats.today > 0 ? 'highlight' : ''">{{ stats.today }}</div>
+        <div class="stat-item stat-today">
+          <div class="stat-icon"><el-icon><TrendCharts /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">今日新增</div><div class="stat-value">{{ stats.today }}</div></div>
+          <div class="stat-caption">今日新增预警</div>
         </div>
       </el-col>
     </el-row>
@@ -48,7 +54,7 @@
     <el-row :gutter="16" class="chart-row">
       <el-col :span="12"><el-card><template #header><span>近 7 天趋势</span></template><div id="trendChart" style="height:240px"/></el-card></el-col>
       <el-col :span="6"><el-card><template #header><span>级别分布</span></template><div id="levelChart" style="height:240px"/></el-card></el-col>
-      <el-col :span="6"><el-card><template #header><span>类型占比</span></template><div id="typeChart" style="height:240px"/></el-card></el-col>
+      <el-col :span="6"><el-card><template #header><span>级别占比</span></template><div id="typeChart" style="height:240px"/></el-card></el-col>
     </el-row>
 
     <!-- ========= 查询表单 ========== -->
@@ -57,6 +63,12 @@
         <el-form-item label="预警名称">
           <el-input v-model="queryParams.warningName" placeholder="请输入预警名称" clearable style="width:200px"/>
         </el-form-item>
+        <el-form-item label="设备编码">
+          <el-input v-model="queryParams.deviceCode" placeholder="请输入设备编码" clearable style="width:180px"/>
+        </el-form-item>
+        <el-form-item label="设备名称">
+          <el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable style="width:180px"/>
+        </el-form-item>
         <el-form-item label="状态">
           <el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width:150px">
             <el-option label="待确认" value="RELEASED"/>
@@ -98,10 +110,11 @@
       <el-table :data="tableData" border stripe v-loading="loading" max-height="600">
         <el-table-column prop="warningNo" label="预警编号" width="140" />
         <el-table-column prop="warningName" label="预警名称" min-width="160" show-overflow-tooltip />
-        <el-table-column label="关联设备" width="160">
+        <el-table-column label="关联设备" width="220">
           <template #default="{ row }">
-            <div v-if="row.deviceCode" class="device-cell">
-              <el-tag :type="row.warningLevel === '4' ? 'danger' : 'info'" size="small" effect="plain">{{ row.deviceCode }}</el-tag>
+            <div v-if="row.deviceCode || row.deviceName" class="device-cell">
+              <span v-if="row.deviceName" class="device-name">{{ row.deviceName }}</span>
+              <el-tag v-if="row.deviceCode" :type="row.warningLevel === '4' ? 'danger' : 'info'" size="small" effect="plain">{{ row.deviceCode }}</el-tag>
             </div>
             <span v-else class="no-device">未关联</span>
           </template>
@@ -124,11 +137,13 @@
             <el-tag :type="getStatusType(row.status)" size="small">{{ getStatusText(row.status) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="publishTime" label="发布时间" width="170" sortable />
+        <el-table-column prop="publishTime" label="发布时间" width="170" sortable class-name="publish-time-column" :formatter="formatPublishTime" />
         <el-table-column label="操作" width="180" fixed="right">
           <template #default="{ row }">
             <el-button v-if="isUnconfirmed(row.status)" type="primary" size="small" @click="openConfirm(row)">确认异常</el-button>
             <el-button v-if="isUnconfirmed(row.status)" type="info" size="small" @click="confirmMisreport(row)">误报</el-button>
+            <el-button v-if="row.status === 'PROCESSING'" type="warning" size="small" @click="openProcess(row)">提交处理</el-button>
+            <el-button v-if="row.status === 'HANDLED'" type="success" size="small" @click="clearHandled(row)">清除预警</el-button>
             <el-button type="info" size="small" @click="openDetail(row.warningId)">详情</el-button>
           </template>
         </el-table-column>
@@ -143,16 +158,15 @@
         <el-descriptions-item label="预警编号">{{ current.warningNo }}</el-descriptions-item>
         <el-descriptions-item label="预警名称">{{ current.warningName }}</el-descriptions-item>
         <el-descriptions-item label="预警内容">{{ current.warningContent || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="关联设备">{{ formatWarningDevice(current) }}</el-descriptions-item>
         <el-descriptions-item label="设备位置">{{ current.location || '-' }}</el-descriptions-item>
       </el-descriptions>
       <el-form label-width="100px" style="margin-top:16px">
-        <el-form-item label="关联设备">
-          <el-select v-model="deviceSelected" filterable remote clearable placeholder="请输入设备编码搜索" :remote-method="searchDevice" :loading="deviceLoading" style="width:100%">
-            <el-option v-for="dev in deviceOptions" :key="dev.equipmentId" :label="`${dev.equipmentName}(${dev.equipmentCode})`" :value="dev.equipmentId"/>
-          </el-select>
-        </el-form-item>
         <el-form-item label="备注信息">
-          <el-input v-model="confirmRemark" type="textarea" :rows="3" placeholder="填写异常情况描述(将写入工单描述)" maxlength="300" show-word-limit/>
+          <el-input v-model="confirmingRemark" type="textarea" :rows="3" placeholder="填写异常情况描述(将写入工单描述)" maxlength="300" show-word-limit/>
+        </el-form-item>
+        <el-form-item label="处理人员">
+          <el-input v-model="assignedUser" placeholder="可填写处理人账号,不填则使用预警原处理人或当前用户" clearable />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -161,6 +175,29 @@
       </template>
     </el-dialog>
 
+    <el-dialog v-model="processVisible" title="提交预警处理反馈" width="620px" append-to-body destroy-on-close>
+      <el-descriptions :column="1" border>
+        <el-descriptions-item label="预警编号">{{ processRow.warningNo }}</el-descriptions-item>
+        <el-descriptions-item label="预警名称">{{ processRow.warningName }}</el-descriptions-item>
+        <el-descriptions-item label="关联设备">{{ formatWarningDevice(processRow) }}</el-descriptions-item>
+      </el-descriptions>
+      <el-form label-width="100px" style="margin-top:16px">
+        <el-form-item label="处理反馈" required>
+          <el-input v-model="processContent" type="textarea" :rows="5" maxlength="1000" show-word-limit placeholder="请填写处理过程和处理结果" />
+        </el-form-item>
+        <el-form-item label="处理附件">
+          <el-upload v-model:file-list="processFileList" action="#" list-type="text" :auto-upload="false" :limit="9" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.xls,.xlsx,.doc,.docx" :on-change="handleProcessFileChange" :on-remove="handleProcessFileRemove" :on-exceed="handleProcessFileExceed">
+            <el-icon><Plus /></el-icon>
+          </el-upload>
+          <div class="upload-tip">支持图片、PDF、Excel、Word,最多 9 个文件,单个不超过 20MB</div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="processVisible=false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitProcess">提交反馈</el-button>
+      </template>
+    </el-dialog>
+
     <!-- ========== 详情页抽屉 ========== -->
     <el-drawer v-model="detailVisible" title="📋 预警处理详情" size="560px">
       <template v-if="detail">
@@ -170,14 +207,15 @@
           <el-descriptions-item label="预警类型">{{ detail.basicInfo.warning_type || '-' }}</el-descriptions-item>
           <el-descriptions-item label="预警级别">{{ getLevelText(detail.basicInfo.warning_level) }}</el-descriptions-item>
           <el-descriptions-item label="发布人">{{ detail.basicInfo.publisher || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="发布时间">{{ detail.basicInfo.publish_time || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="发布时间">{{ formatWarningTime(detail.basicInfo.publish_time) }}</el-descriptions-item>
           <el-descriptions-item label="状态">{{ getStatusText(detail.basicInfo.status) }}</el-descriptions-item>
           <el-descriptions-item label="预警内容">{{ detail.basicInfo.warning_content }}</el-descriptions-item>
         </el-descriptions>
 
         <div class="detail-section-title">🔗 关联设备</div>
         <el-descriptions :column="1" border size="small">
-          <el-descriptions-item label="设备编码">{{ detail.basicInfo.remark?.startsWith('AUTO|') ? detail.basicInfo.remark.split('|')[1] : '(手工录入,无设备关联)' }}</el-descriptions-item>
+          <el-descriptions-item label="设备名称">{{ detail.basicInfo.device_name || detail.basicInfo.deviceName || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="设备编码">{{ detail.basicInfo.device_code || detail.basicInfo.deviceCode || extractDeviceCodeStr(detail.basicInfo) || '(手工录入,无设备关联)' }}</el-descriptions-item>
           <el-descriptions-item label="设备位置">{{ detail.basicInfo.location || '-' }}</el-descriptions-item>
           <el-descriptions-item label="权属单位">{{ detail.basicInfo.ownership_unit || '-' }}</el-descriptions-item>
         </el-descriptions>
@@ -193,12 +231,38 @@
         <div v-else class="detail-empty">暂无处置记录</div>
 
         <div class="detail-section-title">📎 附件</div>
-        <el-upload v-if="false" action="#" list-type="text"/>
         <div v-if="detail.attachmentList?.length" class="attachment-list">
-          <a v-for="(att,idx) in detail.attachmentList" :key="idx" class="attachment-link" :href="att.attachmentUrl" target="_blank">{{ att.attachmentName }}</a>
+          <div v-for="stage in ['REPORT','PROCESS']" :key="stage" class="attachment-group">
+            <div class="attachment-stage">{{ stage === 'PROCESS' ? '处理反馈附件' : '预警报告附件' }}</div>
+            <div v-if="attachmentsByStage(stage).length" class="attachment-grid">
+              <div v-for="(att,idx) in attachmentsByStage(stage)" :key="att.attachmentId || idx" class="attachment-item">
+                <el-image v-if="isImageAttachment(att) && attachmentPreviewUrl(att)" class="attachment-image" :src="attachmentPreviewUrl(att)" :preview-src-list="[attachmentPreviewUrl(att)]" fit="cover" preview-teleported>
+                  <template #error><div class="attachment-image-error">图片加载失败</div></template>
+                </el-image>
+                <div v-else-if="isImageAttachment(att)" class="attachment-image attachment-image-loading">加载中...</div>
+                <div v-else class="attachment-file-icon"><el-icon><Document /></el-icon></div>
+                <div class="attachment-name" :title="displayAttachmentName(att)">{{ displayAttachmentName(att) }}</div>
+                <div class="attachment-actions">
+                  <el-button v-if="canPreviewAttachment(att)" link type="primary" size="small" @click="previewAttachment(att)"><el-icon><View /></el-icon>预览</el-button>
+                  <el-button link type="primary" size="small" @click="downloadAttachment(att)"><el-icon><Download /></el-icon>下载</el-button>
+                </div>
+              </div>
+            </div>
+            <div v-else class="detail-empty">暂无附件</div>
+          </div>
         </div>
         <div v-else class="detail-empty">暂无附件</div>
 
+        <div class="detail-section-title">预警督办</div>
+        <div v-if="detail.supervisionList?.length" class="supervision-list">
+          <div v-for="(item,idx) in detail.supervisionList" :key="idx" class="supervision-item">
+            <b>{{ item.supervisionUser || '-' }}</b>
+            <span>{{ formatWarningTime(item.createTime) }}</span>
+            <p>{{ item.supervisionContent || '-' }}</p>
+          </div>
+        </div>
+        <div v-else class="detail-empty">暂无督办记录</div>
+
         <div class="detail-section-title">📦 电子归档</div>
         <el-descriptions v-if="detail.electronicArchive" :column="2" border size="small">
           <el-descriptions-item label="归档编号">{{ detail.electronicArchive.archiveNo }}</el-descriptions-item>
@@ -211,12 +275,12 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
+import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
 import * as echarts from 'echarts'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { Search, Refresh } from '@element-plus/icons-vue'
-import { getWarningDashboardStatisticsData, getWarningSearchData, getWarningFullDetailData, publishWarningData, resolveWarningData } from '@/api/pipeNetwork/earlyWarning'
-import { addManholeWorkOrder, getEquipmentFullPage } from '@/api/pipeNetwork/basic'
+import { Search, Refresh, Plus, Document, Download, View, Warning, Bell, Tools, CircleCheck, CircleClose, TrendCharts } from '@element-plus/icons-vue'
+import { getWarningDashboardStatisticsData, getWarningSearchData, getWarningFullDetailData, confirmWarningData, misreportWarningData, submitWarningProcessData, clearWarningData, getWarningAttachmentPreviewData, getWarningAttachmentDownloadData } from '@/api/pipeNetwork/earlyWarning'
+import { addManholeWorkOrder } from '@/api/pipeNetwork/basic'
 
 // ========== 统计数据 ==========
 const stats = ref({ total:0, unconfirmed:0, processing:0, handled:0, closed:0, today:0 })
@@ -225,7 +289,7 @@ const stats = ref({ total:0, unconfirmed:0, processing:0, handled:0, closed:0, t
 const chartData = ref({ trend:[], levels:[], types:[] })
 
 // ========== 查询参数 & 列表 ==========
-const queryParams = ref({ warningName:'', status:'', warningLevel:'', category:'', startDate:'', endDate:'' })
+const queryParams = ref({ warningName:'', deviceCode:'', deviceName:'', status:'', warningLevel:'', category:'', startDate:'', endDate:'' })
 const tableData = ref([])
 const loading = ref(false)
 const pageNum = ref(1)
@@ -238,6 +302,19 @@ async function loadStats() {
     if (!res.data) return
     const b = res.data.basicStats
     const t = res.data.trendStats || []
+    chartData.value.trend = t.map(item => ({
+      date: String(item.date || '').slice(5) || '-',
+      count: Number(item.count || 0)
+    }))
+    chartData.value.levels = (res.data.levelStats || []).map(item => ({
+      levelCode: String(item.levelCode || ''),
+      count: Number(item.count || 0)
+    }))
+    chartData.value.types = (res.data.typeStats || []).map(item => ({
+      typeCode: item.typeCode,
+      typeName: item.typeName || item.typeCode,
+      count: Number(item.count || 0)
+    }))
     // releasedCount + pendingCount = 待确认
     stats.value = {
       total: Number(b.total || 0),
@@ -255,6 +332,8 @@ async function loadList() {
   try {
     const params = { pageNum: pageNum.value, pageSize: pageSize.value }
     if (queryParams.value.warningName) params.warningName = queryParams.value.warningName
+    if (queryParams.value.deviceCode) params.deviceCode = queryParams.value.deviceCode
+    if (queryParams.value.deviceName) params.deviceName = queryParams.value.deviceName
     if (queryParams.value.status) params.status = queryParams.value.status
     if (queryParams.value.warningLevel) params.warningLevel = queryParams.value.warningLevel
     if (queryParams.value.category === 'normal') params.publisher = '系统自动'
@@ -270,17 +349,15 @@ async function loadList() {
   finally { loading.value = false }
 }
 
-// 图表数据填充(模拟)
+// 后端统计接口异常或没有配置数据时,仍显示稳定的默认图表状态
 const fillChartData = () => {
-  if (chartData.value.trend?.length) return
-  const allStats = stats.value
+  if (chartData.value.trend?.length && chartData.value.levels?.length && chartData.value.types?.length) return
   const dates = [], counts = []
   for(let i=6;i>=0;i--) { const d=new Date(); d.setDate(d.getDate()-i); dates.push((d.getMonth()+1)+'-'+d.getDate()) }
-  for(let i=6;i>=0;i--) { counts.push(Math.max(0, Math.floor(allStats.today * (0.5 + Math.random()*0.5)))) }
-  chartData.value.trend = dates.map((date,idx)=>({date,count:counts[idx]}))
-  chartData.value.levels = [1,2,3,4].map(lv=>({levelCode:String(lv), count: Math.max(1, Math.floor(Math.random()*allStats.total/5)+1)}))
-  chartData.value.types = [{typeCode:'drainage',typeName:'排水'}, {typeCode:'gas',typeName:'燃气管网'}, {typeCode:'fire',typeName:'消防水压'}]
-    .map(t=>({...t, count: Math.max(1, Math.floor(Math.random()*allStats.total/3)+2)}))
+  for(let i=6;i>=0;i--) { counts.push(0) }
+  if (!chartData.value.trend?.length) chartData.value.trend = dates.map((date,idx)=>({date,count:counts[idx]}))
+  if (!chartData.value.levels?.length) chartData.value.levels = [1,2,3,4].map(lv=>({levelCode:String(lv), count:0}))
+  if (!chartData.value.types?.length) chartData.value.types = [{typeCode:'empty',typeName:'暂无数据',count:1}]
 }
 
 // 图表渲染时的空数据状态
@@ -306,10 +383,30 @@ function normalizeWarning(row) {
   })
   n.category = n.publisher === '系统自动' ? 'normal' : 'special'
   // 从 remark 字段提取设备编码 (AUTO|deviceCode|warningCode)
-  n.deviceCode = extractDeviceCodeStr(row)
+  n.deviceCode = n.deviceCode || extractDeviceCodeStr(row)
+  n.deviceName = n.deviceName || row.device_name || row.deviceName || ''
   return n
 }
 
+function formatPublishTime(row, column, value) {
+  return formatWarningTime(value)
+}
+
+function formatWarningTime(value) {
+  if (!value) return '-'
+  const text = String(value).trim()
+  const match = text.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/)
+  if (match) return `${match[1]} ${match[2]}`
+  return text.replace('T', ' ').replace(/\.\d+(Z|[+-]\d{2}:?\d{2})$/, '')
+}
+
+function formatWarningDevice(warning) {
+  const name = warning?.deviceName || ''
+  const code = warning?.deviceCode || ''
+  if (name && code) return `${name}(${code})`
+  return name || code || '未关联'
+}
+
 // 从预警行中提取设备编码
 function extractDeviceCodeStr(row) {
   if (row.remark && typeof row.remark === 'string' && row.remark.startsWith('AUTO|')) {
@@ -337,20 +434,22 @@ const levelChartOpt = {
   xAxis:{ type:'category', data: ['1 级','2 级','3 级','4 级'], axisLabel:{ color:'#9098a6' }, axisLine:{ lineStyle:{ color:'#d0d5dd' } } },
   yAxis:{ type:'value', name:'数量', axisLabel:{ color:'#9098a6' }, splitLine:{ lineStyle:{ color:'#f0f2f6',type:'dashed' } } },
   series:[{
-    type:'bar', barWidth:30,
+    type:'bar', barWidth:34,
+    label:{ show:true, position:'top', color:'#606266', fontSize:12 },
     data:[],
     itemStyle:{
       color:function(params){
-        const colors={1:'#e3f2fd',2:'#fff8e1',3:'#fff3e0',4:'#ffebee'};
+        const colors={1:'#5b8ff9',2:'#f6bd16',3:'#f6903d',4:'#e8684a'};
         return colors[params.dataIndex+1]||'#e3f2fd';
-      }
+      },
+      borderRadius:[4,4,0,0]
     }
   }]
 }
 
 const typeChartOpt = {
   tooltip:{ trigger:'item' },
-  legend:{ top:'10%', orient:'vertical', right:0 },
+  legend:{ top:'center', orient:'vertical', right:4, itemWidth:12, itemHeight:12, textStyle:{ color:'#606266' } },
   series:[{
     type:'pie', radius:['40%','70%'], avoidLabelOverlap:false,
     itemStyle:{ borderRadius:4, borderColor:'#fff', borderWidth:1 },
@@ -364,8 +463,8 @@ const resizeCharts = () => { trendChart?.resize(); levelChart?.resize(); typeCha
 // ========== 图表渲染函数 ==========
 function renderTrendChart() {
   const dom = document.getElementById('trendChart')
-  if (!dom || !trendChart) return
-  trendChart.dispose()
+  if (!dom) return
+  if (trendChart) trendChart.dispose()
   trendChart = echarts.init(dom)
   const data = chartData.value.trend.length ? chartData.value.trend : emptyChartData.trend
   trendChart.setOption({ ...trendChartOpt, xAxis:{ ...trendChartOpt.xAxis, data: data.map(i=>i.date) }, series:[{ ...trendChartOpt.series[0], data: data.map(i=>i.count || 0) }]
@@ -374,8 +473,8 @@ function renderTrendChart() {
 
 function renderLevelChart() {
   const dom = document.getElementById('levelChart')
-  if (!dom || !levelChart) return
-  levelChart.dispose()
+  if (!dom) return
+  if (levelChart) levelChart.dispose()
   levelChart = echarts.init(dom)
   const data = chartData.value.levels.length ? chartData.value.levels : emptyChartData.levels
   levelChart.setOption({ ...levelChartOpt, series:[{ ...levelChartOpt.series[0], data: data.map(l=>l.count || 0) }]
@@ -384,103 +483,107 @@ function renderLevelChart() {
 
 function renderTypeChart() {
   const dom = document.getElementById('typeChart')
-  if (!dom || !typeChart) return
-  typeChart.dispose()
+  if (!dom) return
+  if (typeChart) typeChart.dispose()
   typeChart = echarts.init(dom)
-  const data = chartData.value.types.length ? chartData.value.types : emptyChartData.types
-  typeChart.setOption({ ...typeChartOpt, series:[{ ...typeChartOpt.series[0], data: data.map(t=>({ value:t.count || 0, name:t.typeName || t.typeCode })) }]
+  const levels = chartData.value.levels || []
+  const levelNames = { '1':'1 级(蓝)', '2':'2 级(黄)', '3':'3 级(橙)', '4':'4 级(红)' }
+  const total = levels.reduce((sum, item) => sum + Number(item.count || 0), 0)
+  const data = total > 0
+    ? levels.map(item => ({ value:Number(item.count || 0), name:levelNames[String(item.levelCode)] || `${item.levelCode} 级` }))
+    : [{ value:1, name:'暂无数据', itemStyle:{ color:'#c0c4cc' } }]
+  typeChart.setOption({ ...typeChartOpt, series:[{ ...typeChartOpt.series[0], data }]
   })
 }
 
-onMounted(async () => {
-  await loadStats()
-  await loadList()
-  await nextTick()
-  // 填充图表数据
-  const allStats = stats.value
-  const dates = [], counts = []
-  for(let i=6;i>=0;i--) { const d = new Date(); d.setDate(d.getDate()-i); dates.push((d.getMonth()+1)+'-'+d.getDate()) }
-  for(let i=6;i>=0;i--) { counts.push(Math.max(0, allStats.today - Math.floor(Math.random()*allStats.total*0.3))) }
-  chartData.value.trend = dates.map((date,idx)=>({date,count:counts[idx]}))
-  
-  // 级别图
-  chartData.value.levels = [1,2,3,4].map(lv=>({levelCode:String(lv), count: Math.floor(Math.random()*allStats.total/5)+1}))
-  // 类型图(无 API 时随机)
-  chartData.value.types = [{typeCode:'drainage',typeName:'排水'}, {typeCode:'gas',typeName:'燃气管网'}, {typeCode:'fire',typeName:'消防水压'}]
-    .map(t=>({...t, count: Math.floor(Math.random()*allStats.total/3)+2}))
-
-  initCharts()
-  window.addEventListener('resize', resizeCharts)
-})
-
-onBeforeUnmount(() => {
-  window.removeEventListener('resize', resizeCharts)
-  trendChart?.dispose()
-  levelChart?.dispose()
-  typeChart?.dispose()
-})
-
 // ========== 辅助函数 ==========
 const isUnconfirmed = (status) => ['RELEASED','PENDING'].includes(status)
 const getStatusText = (s) => ({ DRAFT:'草稿', PENDING:'待办', PROCESSING:'处理中', RELEASED:'待确认', HANDLED:'已处置', CLOSED:'已解除' }[s]||s)
 const getStatusType = (s) => ({ DRAFT:'info', PENDING:'danger', PROCESSING:'warning', RELEASED:'warning', HANDLED:'primary', CLOSED:'success' }[s]||'')
 const getLevelText = (lv) => ({ '1':'1 级 (蓝)','2':'2 级 (黄)','3':'3 级 (橙)','4':'4 级 (红)' }[lv]||'-' )
 const getLevelType = (lv) => ({ '1':'','2':'warning','3':'warning','4':'danger' }[lv]||'')
-const disposalTypeText = (t) => ({ RELEASE:'发布预警', UPGRADE:'升级预警', RESOLVE:'解除预警', RETURN:'退回重办', SUPERVISION:'督办', INSTRUCTION:'批示' }[t]||t)
+const disposalTypeText = (t) => ({ RELEASE:'发布预警', CONFIRM:'确认异常', MISREPORT:'误报清除', HANDLE:'提交处理反馈', CLEAR:'清除预警', UPGRADE:'升级预警', RESOLVE:'解除预警', RETURN:'退回重办', SUPERVISION:'督办', INSTRUCTION:'批示' }[t]||t)
 const disposalTimelineType = (t) => ({ RESOLVE:'success', UPGRADE:'warning', RETURN:'danger' }[t]||'primary')
+const attachmentsByStage = (stage) => (detail.value?.attachmentList || []).filter(item => (item.attachmentStage || item.attachment_stage || 'REPORT') === stage)
+const isImageAttachment = (attachment) => String(attachment.attachmentType || '').startsWith('image/') || /\.(png|jpe?g|gif|webp)$/i.test(attachment.attachmentName || '')
+const attachmentPreviewUrls = ref({})
+const attachmentPreviewUrl = (attachment) => attachmentPreviewUrls.value[attachment.attachmentId] || ''
+const displayAttachmentName = (attachment) => String(attachment.attachmentName || '预警附件').replace(/^\[(预警报告|处理反馈)\]\s*/, '')
+const canPreviewAttachment = (attachment) => isImageAttachment(attachment) || /\.pdf$/i.test(displayAttachmentName(attachment)) || String(attachment.attachmentType || '').toLowerCase() === 'application/pdf'
+
+function revokeAttachmentPreviews() {
+  Object.values(attachmentPreviewUrls.value).forEach(url => url && URL.revokeObjectURL(url))
+  attachmentPreviewUrls.value = {}
+}
+
+async function loadAttachmentPreviews(attachments) {
+  revokeAttachmentPreviews()
+  const imageAttachments = (attachments || []).filter(isImageAttachment)
+  await Promise.all(imageAttachments.map(async (attachment) => {
+    try {
+      const blob = await getWarningAttachmentPreviewData(attachment.attachmentId)
+      attachmentPreviewUrls.value[attachment.attachmentId] = URL.createObjectURL(blob)
+    } catch (e) {
+      console.error('预警图片加载失败', attachment.attachmentId, e)
+    }
+  }))
+}
+
+async function previewAttachment(attachment) {
+  try {
+    const blob = isImageAttachment(attachment) && attachmentPreviewUrl(attachment)
+      ? null
+      : await getWarningAttachmentPreviewData(attachment.attachmentId)
+    const url = attachmentPreviewUrl(attachment) || URL.createObjectURL(blob)
+    window.open(url, '_blank', 'noopener,noreferrer')
+    if (blob) setTimeout(() => URL.revokeObjectURL(url), 60000)
+  } catch (e) {
+    ElMessage.error('附件预览失败')
+  }
+}
+
+async function downloadAttachment(attachment) {
+  try {
+    const blob = await getWarningAttachmentDownloadData(attachment.attachmentId)
+    const url = URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = url
+    link.download = displayAttachmentName(attachment)
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+    setTimeout(() => URL.revokeObjectURL(url), 1000)
+  } catch (e) {
+    ElMessage.error('附件下载失败')
+  }
+}
 
 // ========== 操作处理 ==========
 function handleSearch() { pageNum.value=1; loadList() }
-function handleReset() { queryParams.value={warningName:'',status:'',warningLevel:'',category:'',startDate:'',endDate:''}; pageNum.value=1; loadList() }
+function handleReset() { queryParams.value={warningName:'',deviceCode:'',deviceName:'',status:'',warningLevel:'',category:'',startDate:'',endDate:''}; pageNum.value=1; loadList() }
 
 // ========== 确认异常 ==========
 const confirmVisible = ref(false)
 const current = ref({})
-const deviceSelected = ref(null)
-const deviceOptions = ref([])
-const deviceLoading = ref(false)
 const confirmingRemark = ref('')
+const assignedUser = ref('')
 const submitting = ref(false)
 
 function openConfirm(row) {
   current.value = row
-  deviceSelected.value = null
-  deviceOptions.value = []
   confirmingRemark.value = ''
-  extractDeviceCode(row)
+  assignedUser.value = row.assignedUser || row.handler || ''
   confirmVisible.value = true
 }
 
-function extractDeviceCode(row) {
-  // remark 格式:"AUTO|equipmentCode|warningCode"
-  if (row.remark && row.remark.startsWith('AUTO|')) {
-    const parts = row.remark.split('|')
-    if (parts.length >= 2) searchDevice(parts[1])
-  } else {
-    const m = (row.warningContent || '').match(/设备 \[([^\]]+)\]/)
-    if (m) searchDevice(m[1])
-  }
-}
-
-async function searchDevice(query) {
-  if (!query) return
-  deviceLoading.value = true
-  try {
-    const res = await getEquipmentFullPage(1, 20, { equipmentCode: query })
-    if (res.data) deviceOptions.value = (res.data.rows || []) || []
-  } catch(e) { console.warn('设备搜索失败', e) }
-  finally { deviceLoading.value = false }
-}
-
 async function submitConfirm() {
-  if (!deviceSelected.value) { ElMessage.warning('请选择关联设备'); return }
   if (!confirmingRemark.value.trim()) { ElMessage.warning('请填写异常描述'); return }
   
   submitting.value = true
   try {
-    await publishWarningData(current.value.warningId)
+    await confirmWarningData({ warningId: current.value.warningId, assignedUser: assignedUser.value, confirmRemark: confirmingRemark.value })
     const orderDesc = `预警「${current.value.warningName}」异常确认;${confirmingRemark.value}`
-    const res = await addManholeWorkOrder({ alarmId: current.value.warningId, deviceId: deviceSelected.value, orderType: 1, orderLevel: getWorkOrderLevel(current.value.warningLevel), orderDesc: orderDesc.slice(0,500) })
+    await addManholeWorkOrder({ alarmId: current.value.warningId, orderType: 1, orderLevel: getWorkOrderLevel(current.value.warningLevel), orderDesc: orderDesc.slice(0,500) })
     ElMessage.success(`✅ 预警已确认 | 工单已生成 (WO-...) | 请前往工单系统继续处理`)
     confirmVisible.value = false
     loadList()
@@ -500,12 +603,86 @@ function confirmMisreport(row) {
     confirmButtonText:'确认误报',
     cancelButtonText:'取消'
   }).then(async ({value}) => {
-    await resolveWarningData({ warningId: row.warningId, disposalContent: '误报:'+value })
+    await misreportWarningData({ warningId: row.warningId, reason: value || '系统确认误报' })
     ElMessage.success('已标记为误报并解除')
     loadList()
   }).catch(()=>{})
 }
 
+const processVisible = ref(false)
+const processRow = ref({})
+const processContent = ref('')
+const processFileList = ref([])
+const processFiles = ref([])
+
+function openProcess(row) {
+  processRow.value = row
+  processContent.value = ''
+  processFiles.value = []
+  processFileList.value = []
+  processVisible.value = true
+}
+
+function handleProcessFileChange(uploadFile, uploadFiles) {
+  const raw = uploadFile.raw
+  const extension = String(raw?.name || '').split('.').pop().toLowerCase()
+  const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'xls', 'xlsx', 'doc', 'docx']
+  if (!raw || !allowedExtensions.includes(extension)) {
+    processFileList.value = uploadFiles.filter(item => item.uid !== uploadFile.uid)
+    ElMessage.warning('仅支持图片、PDF、Excel、Word文件')
+    return
+  }
+  if (raw.size > 20 * 1024 * 1024) {
+    processFileList.value = uploadFiles.filter(item => item.uid !== uploadFile.uid)
+    ElMessage.warning('单个附件不能超过20MB')
+    return
+  }
+  processFileList.value = uploadFiles
+  processFiles.value = uploadFiles.map(item => item.raw).filter(Boolean)
+}
+
+function handleProcessFileRemove(uploadFile, uploadFiles) {
+  handleProcessFileChange(uploadFile, uploadFiles)
+}
+
+function handleProcessFileExceed() {
+  ElMessage.warning('最多上传9个附件')
+}
+
+async function submitProcess() {
+  if (!processContent.value.trim()) {
+    ElMessage.warning('请填写处理反馈')
+    return
+  }
+  submitting.value = true
+  try {
+    await submitWarningProcessData({ warningId: processRow.value.warningId, processContent: processContent.value, files: processFiles.value })
+    ElMessage.success('处理反馈已提交')
+    processVisible.value = false
+    await loadList()
+  } catch (e) {
+    ElMessage.error('提交处理反馈失败')
+  } finally {
+    submitting.value = false
+  }
+}
+
+function clearHandled(row) {
+  ElMessageBox.prompt('请输入清除说明', '清除预警', {
+    inputPlaceholder: '例如:维修完成,现场复核正常',
+    confirmButtonText: '确认清除',
+    cancelButtonText: '取消'
+  }).then(async ({ value }) => {
+    if (!value || !value.trim()) {
+      ElMessage.warning('请填写清除说明')
+      return
+    }
+    await clearWarningData({ warningId: row.warningId, clearRemark: value })
+    ElMessage.success('预警已清除')
+    loadList()
+  }).catch(() => {})
+}
+
 // ========== 详情抽屉 ==========
 const detailVisible = ref(false)
 const detail = ref(null)
@@ -513,13 +690,18 @@ const detail = ref(null)
 async function openDetail(warningId) {
   detailVisible.value = true
   detail.value = null
+  revokeAttachmentPreviews()
   try {
     const res = await getWarningFullDetailData(warningId)
-    if (res.data) detail.value = res.data
+    if (res.data) {
+      detail.value = res.data
+      await loadAttachmentPreviews(res.data.attachmentList)
+    }
   } catch(e) { ElMessage.error('详情加载中'); console.error(e) }
 }
 
 onBeforeUnmount(() => {
+  revokeAttachmentPreviews()
   window.removeEventListener('resize', resizeCharts)
   trendChart?.dispose()
   levelChart?.dispose()
@@ -539,19 +721,23 @@ onMounted(async () => {
 .warning-container { padding: 20px; min-height: calc(100vh - 84px); }
 .stat-row { margin-bottom: 16px; }
 .stat-item {
-  background: #fff; border-radius: 12px; padding: 12px 10px; text-align: center;
-  box-shadow: 0 2px 8px rgba(0,0,0,0.06); border: 1px solid #eef2f6;
-  transition: transform 0.2s, box-shadow 0.2s;
-}
-.stat-item:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.1); }
-.stat-title { font-size: 13px; color: #7a8a9a; margin-bottom: 4px; }
-.stat-value { font-size: 28px; font-weight: 700; color: #3a4a5a; transition: all 0.3s; }
-.stat-value.primary { color: #409eff; }
-.stat-value.warning { color: #e6a23c; }
-.stat-value.info { color: #909399; }
-.stat-value.success { color: #67c23a; }
-.stat-value.done { color: #27ae60; }
-.stat-value.today { color: #9b59b6; }
+  position:relative;min-height:104px;padding:16px 16px 12px 62px;background:#fff;
+  border:1px solid #e9edf3;border-radius:10px;box-shadow:0 3px 12px rgba(31,45,61,.07);
+  overflow:hidden;transition:transform .2s,box-shadow .2s;
+}
+.stat-item::before { content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--stat-color); }
+.stat-item:hover { transform:translateY(-2px);box-shadow:0 8px 20px rgba(31,45,61,.12); }
+.stat-icon { position:absolute;left:16px;top:22px;width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--stat-color);background:var(--stat-bg);font-size:19px; }
+.stat-content { display:flex;align-items:baseline;justify-content:space-between;gap:8px; }
+.stat-title { font-size:13px;color:#667085;white-space:nowrap; }
+.stat-value { font-size:30px;line-height:34px;font-weight:700;color:var(--stat-color);transition:all .3s; }
+.stat-caption { margin-top:8px;color:#98a2b3;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
+.stat-primary { --stat-color:#409eff;--stat-bg:#ecf5ff; }
+.stat-warning { --stat-color:#e6a23c;--stat-bg:#fdf6ec; }
+.stat-processing { --stat-color:#8a96a3;--stat-bg:#f2f4f7; }
+.stat-handled { --stat-color:#67c23a;--stat-bg:#f0f9eb; }
+.stat-closed { --stat-color:#18a567;--stat-bg:#eafaf3; }
+.stat-today { --stat-color:#8e5cc7;--stat-bg:#f5effb; }
 .stat-value.highlight { animation: pulse 1.5s ease-in-out; }
 @keyframes pulse { 0%{transform:scale(1)}50%{transform:scale(1.12)}100%{transform:scale(1)} }
 
@@ -567,17 +753,35 @@ onMounted(async () => {
 .search-card { margin-bottom: 16px; }
 .table-card { min-height: 600px; }
 .device-cell { display:flex; align-items:center; gap:4px; }
+.device-name { max-width:96px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#606266; font-size:12px; }
 .no-device { color:#c0c4cc; font-size:12px; }
+:deep(.publish-time-column .cell) { white-space: nowrap; }
 
 .detail-section-title { font-weight:600;font-size:14px;color:#2c3e50;margin:16px 0 10px;padding-bottom:8px;border-bottom:1px solid #eef2f6 }
 .detail-sub { font-size:12px;color:#9098a6;margin-top:4px }
 .detail-empty { font-size:13px;color:#9098a6;padding:12px 0 }
 .attachment-list { display:flex;flex-direction:column;gap:6px }
-.attachment-link { font-size:13px;color:#3498db;text-decoration:none }
-.attachment-link:hover { text-decoration:underline }
+.attachment-group { margin-bottom:12px }
+.attachment-stage { color:#606266;font-size:13px;font-weight:600;margin-bottom:6px }
+.attachment-grid { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px }
+.attachment-item { min-width:0;padding:8px;border:1px solid #ebeef5;border-radius:6px;background:#fff }
+.attachment-image,.attachment-image-loading,.attachment-file-icon { width:100%;height:86px;border-radius:4px;overflow:hidden }
+.attachment-image-loading,.attachment-image-error,.attachment-file-icon { display:flex;align-items:center;justify-content:center;color:#a8abb2;background:#f5f7fa;font-size:12px }
+.attachment-file-icon { color:#409eff;font-size:32px }
+.attachment-name { margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#606266;font-size:12px }
+.attachment-actions { display:flex;justify-content:center;gap:4px;margin-top:3px }
+.attachment-actions .el-button { margin-left:0;padding:2px 3px }
+.supervision-list { display:flex;flex-direction:column;gap:8px }
+.supervision-item { padding:8px 10px;background:#f7f8fa;border-radius:4px;font-size:12px }
+.supervision-item span { color:#909399;margin-left:8px }
+.supervision-item p { margin:6px 0 0;color:#606266 }
+.upload-tip { color:#909399;font-size:12px;line-height:20px }
 
 @media (max-width: 1300px) {
   .stat-row { display:flex; gap:10px; flex-wrap:wrap; }
   .stat-row > * { flex-shrink:0; width: calc(33.33% - 14px) !important; }
 }
+@media (max-width: 900px) {
+  .stat-row > * { width: calc(50% - 10px) !important; }
+}
 </style>