Bladeren bron

急救平台

ldl 2 dagen geleden
bovenliggende
commit
6e7e36996e

+ 81 - 0
src/api/emergency/data-hub/index.ts

@@ -0,0 +1,81 @@
+import request from '@/utils/request'
+
+const BASE = '/health/emergencyDataHub'
+
+/** 汇聚指标 */
+export interface DataHubMetrics {
+  connectedNodes: number
+  totalNodes: number
+  recentRecords: string
+  totalData: string
+  pendingReview: number
+  pendingNote: string
+}
+
+/** 核心数据域 */
+export interface DataDomain {
+  id: number
+  name: string
+  total: string
+  fields: string
+  latest: string
+  receivedAt: string
+  status: string
+}
+
+/** 县区上传状态 */
+export interface CountyUpload {
+  id: number
+  district: string
+  source: string
+  volume: string
+  latest: string
+  coverage: string
+  health: string
+  state: string
+}
+
+/** 实时监测指标 */
+export interface RealtimeMetric {
+  id: number
+  label: string
+  value: string
+  note: string
+  icon: string
+}
+
+/** 最近接收数据 */
+export interface RecentData {
+  id: number
+  type: string
+  source: string
+  recordId: string
+  fields: string
+  receivedAt: string
+  result: string
+}
+
+/** 获取汇聚指标 */
+export function getDataHubMetrics() {
+  return request<DataHubMetrics>({ url: `${BASE}/metrics`, method: 'get' })
+}
+
+/** 获取核心数据域列表 */
+export function getDataDomains() {
+  return request<DataDomain[]>({ url: `${BASE}/domains`, method: 'get' })
+}
+
+/** 获取县区上传状态列表 */
+export function getCountyUploads() {
+  return request<CountyUpload[]>({ url: `${BASE}/countyUploads`, method: 'get' })
+}
+
+/** 获取实时监测指标列表 */
+export function getRealtimeMetrics() {
+  return request<RealtimeMetric[]>({ url: `${BASE}/realtimeMetrics`, method: 'get' })
+}
+
+/** 获取最近接收关键数据(分页) */
+export function getRecentData(params: { pageNum: number; pageSize: number }) {
+  return request({ url: `${BASE}/recentData`, method: 'get', params })
+}

+ 155 - 30
src/views/emergency/data-hub/index.vue

@@ -7,27 +7,31 @@
       </div>
     </div>
 
+    <div v-if="notice" class="inline-toast">
+      <el-icon><CircleCheck /></el-icon>{{ notice }}
+    </div>
+
     <!-- 汇聚指标卡片 -->
-    <section class="ingest-metrics">
+    <section class="ingest-metrics" v-loading="metricsLoading">
       <article>
         <span>已接入县区节点</span>
-        <strong>14 <small>/ 14</small></strong>
+        <strong>{{ metrics?.connectedNodes ?? '-' }} <small>/ {{ metrics?.totalNodes ?? '-' }}</small></strong>
         <p><i></i>上报链路全部在线</p>
       </article>
       <article>
         <span>近 1 分钟接收记录</span>
-        <strong>1,286 <small>条</small></strong>
+        <strong>{{ metrics?.recentRecords?.split(' ')[0] ?? '-' }} <small>{{ metrics?.recentRecords?.split(' ')[1] ?? '' }}</small></strong>
         <p><i></i>峰值 2,400 条 / 分</p>
       </article>
       <article>
         <span>核心库累计数据</span>
-        <strong>8,642 <small>万条</small></strong>
+        <strong>{{ metrics?.totalData?.split(' ')[0] ?? '-' }} <small>{{ metrics?.totalData?.split(' ')[1] ?? '' }}</small></strong>
         <p><i></i>冷热分层存储正常</p>
       </article>
       <article class="attention">
         <span>待核验数据</span>
-        <strong>37 <small>项</small></strong>
-        <p><i></i>GPS 时效与设备字段</p>
+        <strong>{{ metrics?.pendingReview ?? '-' }} <small>项</small></strong>
+        <p><i></i>{{ metrics?.pendingNote ?? 'GPS 时效与设备字段' }}</p>
       </article>
     </section>
 
@@ -61,10 +65,10 @@
           <span>按统一数据模型沉淀县区上传记录,支持全字段追溯与质量审计</span>
         </div>
       </div>
-      <div class="domain-grid">
+      <div class="domain-grid" v-loading="domainsLoading">
         <article
           v-for="(domain, index) in dataDomains"
-          :key="domain.name"
+          :key="domain.id"
           class="domain-item"
           :class="{ 'domain-item--attention': domain.status === 'attention' }"
         >
@@ -103,14 +107,14 @@
           </el-button>
         </header>
         <div class="table-scroll county-upload-table-scroll">
-          <el-table :data="countyUploads" class="data-table county-upload-table" table-layout="fixed" height="304">
+          <el-table :data="countyUploads" class="data-table county-upload-table" table-layout="fixed" height="304" v-loading="countyLoading">
             <el-table-column type="index" label="序号" align="center" />
             <el-table-column label="县区">
               <template #default="{ row }"><strong>{{ row.district }}</strong></template>
             </el-table-column>
             <el-table-column prop="source" label="上报节点" />
-            <el-table-column prop="volume" label="近 5 分钟接收" />
-            <el-table-column prop="latest" label="最近入库" />
+            <el-table-column prop="volume" label="近 5 分钟接收" class-name="mono" />
+            <el-table-column prop="latest" label="最近入库" class-name="mono" />
             <el-table-column label="已覆盖数据">
               <template #default="{ row }"><span>{{ row.coverage }}</span></template>
             </el-table-column>
@@ -130,7 +134,7 @@
             <span>接口与存储状态</span>
           </div>
         </header>
-        <div class="realtime-ingest-list">
+        <div class="realtime-ingest-list" v-loading="realtimeLoading">
           <article v-for="item in realtimeIngestMetrics" :key="item.label">
             <span class="realtime-ingest-icon" aria-hidden="true">
               <el-icon :size="18"><component :is="iconMap[item.icon]" /></el-icon>
@@ -157,15 +161,15 @@
         <span class="live-indicator"><i></i>实时接收</span>
       </header>
       <div class="table-scroll">
-        <el-table :data="paginatedRecentData" class="data-table" table-layout="fixed">
+        <el-table :data="recentData" class="data-table" table-layout="fixed" v-loading="recentLoading">
           <el-table-column type="index" label="序号" width="72" align="center" />
           <el-table-column label="数据类型" min-width="130">
             <template #default="{ row }"><strong>{{ row.type }}</strong></template>
           </el-table-column>
           <el-table-column prop="source" label="上传来源" min-width="180" />
-          <el-table-column prop="id" label="记录标识" min-width="180" />
+          <el-table-column prop="recordId" label="记录标识" min-width="180" class-name="mono" />
           <el-table-column prop="fields" label="关键字段" min-width="260" />
-          <el-table-column prop="receivedAt" label="接收时间" min-width="120" />
+          <el-table-column prop="receivedAt" label="接收时间" min-width="120" class-name="mono" />
           <el-table-column label="存储结果" min-width="110">
             <template #default="{ row }">
               <el-tag :type="row.result === '已入库' ? 'success' : 'warning'" size="small">{{ row.result }}</el-tag>
@@ -178,8 +182,10 @@
           v-model:current-page="recentTablePage"
           v-model:page-size="recentTablePageSize"
           :page-sizes="[5, 10, 20, 50]"
-          :total="recentData.length"
+          :total="recentTotal"
           layout="total, sizes, prev, pager, next, jumper"
+          @current-change="loadRecentData"
+          @size-change="loadRecentData"
         />
       </div>
     </section>
@@ -187,7 +193,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
+import { ref, onMounted } from 'vue'
 import {
   Connection,
   Monitor,
@@ -207,18 +213,56 @@ import {
   MapLocation
 } from '@element-plus/icons-vue'
 import {
-  dataDomains,
-  ingestSteps,
-  countyUploads,
-  realtimeIngestMetrics,
-  recentData
-} from '@/data/emergency'
+  getDataHubMetrics,
+  getDataDomains,
+  getCountyUploads,
+  getRealtimeMetrics,
+  getRecentData,
+  type DataHubMetrics,
+  type DataDomain,
+  type CountyUpload,
+  type RealtimeMetric,
+  type RecentData
+} from '@/api/emergency/data-hub'
 
 const notice = ref('')
 
+// 汇聚指标
+const metrics = ref<DataHubMetrics | null>(null)
+const metricsLoading = ref(false)
+
+// 核心数据域
+const dataDomains = ref<DataDomain[]>([])
+const domainsLoading = ref(false)
+
+// 县区上传状态
+const countyUploads = ref<CountyUpload[]>([])
+const countyLoading = ref(false)
+
+// 实时监测指标
+const realtimeIngestMetrics = ref<RealtimeMetric[]>([])
+const realtimeLoading = ref(false)
+
+// 最近接收数据(服务端分页)
+const recentData = ref<RecentData[]>([])
+const recentLoading = ref(false)
+const recentTablePage = ref(1)
+const recentTablePageSize = ref(5)
+const recentTotal = ref(0)
+
 // 链路步骤图标
 const ingestStepIcons = [Upload, Lock, Switch, CircleCheck, Coin, Box]
 
+// 链路步骤(固定流程说明)
+const ingestSteps = [
+  ['县区上传', '14 个节点并发上报'],
+  ['安全接入', '身份校验与签名验真'],
+  ['标准映射', '字段、编码与主数据匹配'],
+  ['质量核验', '完整性、时效性、一致性'],
+  ['核心存储', '主题库与轨迹时序库'],
+  ['业务服务', '概览、资源与任务查询'],
+]
+
 // 数据域图标
 const domainIcons = [OfficeBuilding, User, Van, Timer, FirstAidKit, MapLocation]
 
@@ -230,18 +274,82 @@ const iconMap: Record<string, any> = {
   'file-check': Document,
 }
 
-// 最近接收分页
-const recentTablePage = ref(1)
-const recentTablePageSize = ref(5)
-const paginatedRecentData = computed(() => {
-  const start = (recentTablePage.value - 1) * recentTablePageSize.value
-  return recentData.slice(start, start + recentTablePageSize.value)
-})
+async function loadMetrics() {
+  metricsLoading.value = true
+  try {
+    const res = await getDataHubMetrics()
+    metrics.value = res.data
+  } catch (e) {
+    console.error('加载汇聚指标失败', e)
+  } finally {
+    metricsLoading.value = false
+  }
+}
+
+async function loadDomains() {
+  domainsLoading.value = true
+  try {
+    const res = await getDataDomains()
+    dataDomains.value = res.data
+  } catch (e) {
+    console.error('加载核心数据域失败', e)
+  } finally {
+    domainsLoading.value = false
+  }
+}
+
+async function loadCountyUploads() {
+  countyLoading.value = true
+  try {
+    const res = await getCountyUploads()
+    countyUploads.value = res.data
+  } catch (e) {
+    console.error('加载县区上传状态失败', e)
+  } finally {
+    countyLoading.value = false
+  }
+}
+
+async function loadRealtimeMetrics() {
+  realtimeLoading.value = true
+  try {
+    const res = await getRealtimeMetrics()
+    realtimeIngestMetrics.value = res.data
+  } catch (e) {
+    console.error('加载实时监测指标失败', e)
+  } finally {
+    realtimeLoading.value = false
+  }
+}
+
+async function loadRecentData() {
+  recentLoading.value = true
+  try {
+    const res = await getRecentData({
+      pageNum: recentTablePage.value,
+      pageSize: recentTablePageSize.value
+    })
+    recentData.value = res.rows
+    recentTotal.value = res.total
+  } catch (e) {
+    console.error('加载最近接收数据失败', e)
+  } finally {
+    recentLoading.value = false
+  }
+}
 
 function showNotice(message: string) {
   notice.value = message
   setTimeout(() => { notice.value = '' }, 2200)
 }
+
+onMounted(() => {
+  loadMetrics()
+  loadDomains()
+  loadCountyUploads()
+  loadRealtimeMetrics()
+  loadRecentData()
+})
 </script>
 
 <style scoped lang="scss">
@@ -262,6 +370,19 @@ function showNotice(message: string) {
   .subtitle { margin: 0; font-size: 13px; color: #909399; }
 }
 
+.inline-toast {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 16px;
+  padding: 10px 16px;
+  background: #f0f9eb;
+  border: 1px solid #e1f3d8;
+  border-radius: 6px;
+  color: #67c23a;
+  font-size: 13px;
+}
+
 /* 汇聚指标 */
 .ingest-metrics {
   display: grid;
@@ -639,4 +760,8 @@ function showNotice(message: string) {
   0%, 100% { opacity: 1; }
   50% { opacity: 0.3; }
 }
+
+:deep(.mono .cell) {
+  font-family: Bahnschrift, 'DIN Alternate', 'Courier New', monospace;
+}
 </style>

+ 71 - 1
src/views/emergency/index.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <div class="emergency-home-page">
     <!-- 模块标题 -->
     <div class="module-header">
@@ -49,6 +49,24 @@
         </div>
       </div>
     </div>
+
+    <!-- 常用快捷 -->
+    <div class="quick-section">
+      <h2 class="section-title">常用快捷</h2>
+      <div class="quick-grid">
+        <div
+          v-for="item in navItems"
+          :key="item.path"
+          class="quick-item"
+          @click="navigateTo(item.path)"
+        >
+          <el-icon :size="18" :style="{ color: item.iconColor }">
+            <component :is="item.icon" />
+          </el-icon>
+          <span>{{ item.title }}</span>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
@@ -305,4 +323,56 @@ const navigateTo = (path: string) => {
     white-space: nowrap;
   }
 }
+
+.quick-section {
+  background: white;
+  border-radius: 8px;
+  padding: 24px;
+  margin-bottom: 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+
+  .section-title {
+    font-size: 18px;
+    font-weight: 600;
+    margin: 0 0 20px;
+    color: #2c3e50;
+    padding-left: 10px;
+    border-left: 4px solid #409eff;
+  }
+}
+
+.quick-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+  gap: 12px;
+}
+
+.quick-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 14px 16px;
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  background: #fafbfc;
+
+  span {
+    font-size: 14px;
+    color: #2c3e50;
+    font-weight: 500;
+  }
+
+  &:hover {
+    border-color: #409eff;
+    background: #ecf5ff;
+    transform: translateY(-2px);
+    box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
+
+    span {
+      color: #409eff;
+    }
+  }
+}
 </style>

+ 8 - 3
src/views/emergency/record-detail/index.vue

@@ -145,7 +145,7 @@
     </div>
   </div>
 
-  <section v-else class="task-detail-missing">
+  <section v-else-if="loadFailed" class="task-detail-missing">
     <el-icon :size="28"><Warning /></el-icon>
     <h1>未找到该急救任务</h1>
     <p>任务编号不存在或当前账号没有访问权限。</p>
@@ -167,15 +167,20 @@ const notice = ref('')
 let noticeTimer: number
 
 const task = ref<HealthEmergencyTaskDetailVO | null>(null)
+const loadFailed = ref(false)
 
 async function loadTask() {
   const id = route.query.id as string
-  if (!id) return
+  if (!id) {
+    loadFailed.value = true
+    return
+  }
   try {
     const res = await getEmergencyTask(id)
     task.value = res.data
+    if (!task.value) loadFailed.value = true
   } catch {
-    task.value = null
+    loadFailed.value = true
   }
 }
 

+ 40 - 12
src/views/emergency/resources/index.vue

@@ -40,8 +40,9 @@
         <div
           v-for="eq in equipmentList"
           :key="eq.equipmentKey"
-          class="equipment-item"
+          class="equipment-item equipment-item--link"
           :class="`equipment-item--${eq.tone}`"
+          @click="goToEquipmentLedger(eq.equipmentKey)"
         >
           <div class="equipment-item__head">
             <span class="equipment-icon" :class="`equipment-icon--${eq.tone}`">
@@ -54,6 +55,7 @@
               <span>已配 / 应配 {{ eq.equipped }} / {{ eq.expected }}</span>
             </div>
             <b :class="`equipment-item__rate--${eq.tone}`">{{ eq.rate }}%</b>
+            <el-icon class="equipment-item__arrow" :size="16"><ArrowRight /></el-icon>
           </div>
           <div class="progress-track">
             <span :class="`progress--${eq.tone}`" :style="{ width: `${eq.rate}%` }"></span>
@@ -93,26 +95,27 @@
             <el-button @click="resetQuery">重置</el-button>
           </el-form-item>
         </el-form>
-        <el-table :data="vehicleList" class="data-table" table-layout="fixed" v-loading="vehicleLoading">
-          <el-table-column label="车辆编码" width="130" align="center">
+        <el-table :data="vehicleList" class="data-table" v-loading="vehicleLoading">
+          <el-table-column label="车辆编码" min-width="110" align="center">
             <template #default="{ row }">
               <el-button link type="primary" @click="openVehicleDetail(row)">{{ row.vehicleCode }}</el-button>
             </template>
           </el-table-column>
-          <el-table-column prop="organization" label="所属机构" min-width="160" show-overflow-tooltip />
-          <el-table-column prop="vehicleType" label="车辆类型" width="120" align="center" />
-          <el-table-column label="运行状态" width="100" align="center">
+          <el-table-column prop="organization" label="所属机构" min-width="110" show-overflow-tooltip />
+          <el-table-column prop="district" label="所属区县" min-width="100" align="center" />
+          <el-table-column prop="vehicleType" label="车辆类型" min-width="110" align="center" />
+          <el-table-column label="运行状态" min-width="100" align="center">
             <template #default="{ row }">
               <span class="status-pill" :class="`status-pill--${vehicleStatusTone(row.state)}`">{{ row.state }}</span>
             </template>
           </el-table-column>
-          <el-table-column label="设备配备" width="130" align="center">
+          <el-table-column label="设备配备" min-width="110" align="center">
             <template #default="{ row }">
               <span class="status-pill" :class="`status-pill--${equipmentTone(row.equipment)}`">{{ row.equipment }}</span>
             </template>
           </el-table-column>
-          <el-table-column prop="network" label="连接状态" width="110" align="center" />
-          <el-table-column prop="lastPositionTime" label="更新时间" width="100" align="center" class-name="mono" />
+          <el-table-column prop="network" label="连接状态" min-width="100" align="center" />
+          <el-table-column prop="lastPositionTime" label="更新时间" min-width="100" align="center" class-name="mono" />
         </el-table>
         <div v-if="vehicleTotal > 0" style="padding: 12px 16px; display: flex; justify-content: flex-end;">
           <el-pagination
@@ -244,11 +247,17 @@
 
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue'
-import { Van, Monitor, Connection, Tools, SetUp, FirstAidKit, CircleCheck, Promotion, Wind, Briefcase } from '@element-plus/icons-vue'
+import { useRouter } from 'vue-router'
+import { Monitor, Tools, SetUp, FirstAidKit, CircleCheck, Promotion, WindPower, Briefcase, ArrowRight } from '@element-plus/icons-vue'
 import { getVehicleList, getVehicleStats, getEquipmentList, getPendingWorkOrders, getWorkOrderDetail, updateWorkOrder } from '@/api/emergency/resource'
 import type { HealthEmergencyVehicleVO, HealthEmergencyVehicleStatsVO, HealthEmergencyEquipmentVO, HealthEmergencyDeviceWorkOrderVO } from '@/api/emergency/resource/types'
 
 const notice = ref('')
+const router = useRouter()
+
+function goToEquipmentLedger(key: string) {
+  router.push({ path: '/emergency/equipment-ledger', query: { key } })
+}
 
 // 统计
 const stats = ref<HealthEmergencyVehicleStatsVO>({
@@ -262,7 +271,7 @@ const equipmentList = ref<HealthEmergencyEquipmentVO[]>([])
 const equipmentIconMap: Record<string, any> = {
   monitor: Promotion,
   defibrillator: Monitor,
-  ventilator: Wind,
+  ventilator: WindPower,
   resuscitator: Briefcase
 }
 
@@ -283,7 +292,7 @@ const pendingWorkOrders = ref<HealthEmergencyDeviceWorkOrderVO[]>([])
 const workOrderIconMap: Record<string, any> = {
   heart: Promotion,
   activity: Monitor,
-  wind: Wind,
+  wind: WindPower,
   briefcase: Briefcase
 }
 
@@ -577,6 +586,25 @@ onMounted(() => {
   border: 1px solid #e4e7ed;
   border-radius: 6px;
   border-left: 3px solid;
+  cursor: default;
+}
+
+.equipment-item--link {
+  cursor: pointer;
+  transition: box-shadow 0.2s, border-color 0.2s;
+}
+.equipment-item--link:hover {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+  border-color: #c0c4cc;
+}
+
+.equipment-item__arrow {
+  margin-left: auto;
+  color: #c0c4cc;
+  flex-shrink: 0;
+}
+.equipment-item--link:hover .equipment-item__arrow {
+  color: #409eff;
 }
 
 .equipment-item--good { border-left-color: #67c23a; }

+ 1 - 1
src/views/health/examinationLab/index.vue

@@ -410,7 +410,7 @@ onBeforeUnmount(() => { window.removeEventListener('resize', handleResize); char
 .metrics-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px; }
 .metrics-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 16px; }
 .metrics-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
-.metrics-grid-5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 16px; }
+.metrics-grid-5 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
 .metric-card { background: white; padding: 20px; border-radius: 8px; display: flex; gap: 16px; align-items: center;
   .metric-icon { width: 56px; height: 56px; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: white; flex-shrink: 0; }
   .metric-content { flex: 1; } .metric-value { font-size: 22px; font-weight: 700; color: #2c3e50; } .metric-label { font-size: 13px; color: #909399; margin-top: 4px; }