|
|
@@ -0,0 +1,1580 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
+import echarts from '@/plugins/echarts'
|
|
|
+import 'echarts-gl'
|
|
|
+import {
|
|
|
+ ScatterChart,
|
|
|
+ HeatmapChart,
|
|
|
+ BoxplotChart,
|
|
|
+ GraphChart,
|
|
|
+ EffectScatterChart,
|
|
|
+ SunburstChart,
|
|
|
+ SankeyChart,
|
|
|
+ ThemeRiverChart
|
|
|
+} from 'echarts/charts'
|
|
|
+import {
|
|
|
+ DataZoomComponent,
|
|
|
+ BrushComponent,
|
|
|
+ RadarComponent
|
|
|
+} from 'echarts/components'
|
|
|
+import * as echartsApi from '@/api/data-board/nine-safety'
|
|
|
+import { getBuildList } from '@/api/system/badManage'
|
|
|
+import { useUserStore } from '@/store/modules/user'
|
|
|
+
|
|
|
+const userStore = useUserStore()
|
|
|
+
|
|
|
+// 补充注册本页面需要的图表类型(不影响其他页面)
|
|
|
+echarts.use([
|
|
|
+ ScatterChart,
|
|
|
+ HeatmapChart,
|
|
|
+ BoxplotChart,
|
|
|
+ GraphChart,
|
|
|
+ EffectScatterChart,
|
|
|
+ SunburstChart,
|
|
|
+ SankeyChart,
|
|
|
+ ThemeRiverChart,
|
|
|
+ DataZoomComponent,
|
|
|
+ BrushComponent,
|
|
|
+ RadarComponent
|
|
|
+])
|
|
|
+
|
|
|
+// ========================= 筛选条件 =========================
|
|
|
+const filter = reactive({
|
|
|
+ month: '' as string,
|
|
|
+ buildId: '' as string | number,
|
|
|
+ floorId: '' as string | number
|
|
|
+})
|
|
|
+const monthOptions = ref<string[]>([])
|
|
|
+// 楼栋、楼层使用真实接口
|
|
|
+const buildList = ref<any[]>([])
|
|
|
+const floorList = ref<any[]>([])
|
|
|
+const handleBuildChange = (buildId: any) => {
|
|
|
+ const selectedBuild = buildList.value.find((item: any) => item.id === buildId)
|
|
|
+ floorList.value = selectedBuild?.floorList || []
|
|
|
+ filter.floorId = ''
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 数据 =========================
|
|
|
+const loading = ref(false)
|
|
|
+const overview = ref<any>({})
|
|
|
+const nineRiskDist = ref<any[]>([])
|
|
|
+const riskTrend = ref<any[]>([])
|
|
|
+const floorHeatmap = ref<any>({})
|
|
|
+const ageScatter = ref<any[]>([])
|
|
|
+const scoreDist = ref<any[]>([])
|
|
|
+const signature = ref<any>({})
|
|
|
+const redList = ref<any[]>([])
|
|
|
+const assessorWorkload = ref<any[]>([])
|
|
|
+const nurseLevelRisk = ref<any[]>([])
|
|
|
+const riskCorrelation = ref<any>({})
|
|
|
+const riskRadar = ref<any>({})
|
|
|
+const bar3D = ref<any>({})
|
|
|
+
|
|
|
+// ========================= 图表实例管理 =========================
|
|
|
+const chartInstances = new Map<string, any>()
|
|
|
+const chartRefs = ref<Record<string, HTMLDivElement | undefined>>({})
|
|
|
+
|
|
|
+const setRef = (key: string) => (el: any) => {
|
|
|
+ chartRefs.value[key] = el
|
|
|
+}
|
|
|
+
|
|
|
+const createChart = (key: string, option: any) => {
|
|
|
+ const el = chartRefs.value[key]
|
|
|
+ if (!el) return null
|
|
|
+ let inst = chartInstances.get(key)
|
|
|
+ if (inst) {
|
|
|
+ inst.dispose()
|
|
|
+ }
|
|
|
+ inst = echarts.init(el)
|
|
|
+ // 统一注入 tooltip 深色背景,避免白字白底看不见
|
|
|
+ if (option.tooltip) {
|
|
|
+ option.tooltip = {
|
|
|
+ backgroundColor: 'rgba(2, 11, 30, 0.92)',
|
|
|
+ borderColor: 'rgba(0, 255, 255, 0.3)',
|
|
|
+ borderWidth: 1,
|
|
|
+ padding: [8, 12],
|
|
|
+ ...option.tooltip
|
|
|
+ }
|
|
|
+ }
|
|
|
+ inst.setOption(option)
|
|
|
+ chartInstances.set(key, inst)
|
|
|
+ return inst
|
|
|
+}
|
|
|
+
|
|
|
+const resizeAll = () => {
|
|
|
+ chartInstances.forEach((inst) => inst?.resize())
|
|
|
+}
|
|
|
+const disposeAll = () => {
|
|
|
+ chartInstances.forEach((inst) => inst?.dispose())
|
|
|
+ chartInstances.clear()
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 图表配色 =========================
|
|
|
+const COLORS = {
|
|
|
+ none: '#41e6a7',
|
|
|
+ low: '#00e0a7',
|
|
|
+ medium: '#ffd85a',
|
|
|
+ high: '#ff5a3c',
|
|
|
+ primary: '#00aaff',
|
|
|
+ cyan: '#00ffff',
|
|
|
+ bg: 'rgba(10, 47, 94, 0.28)',
|
|
|
+ border: 'rgba(0, 255, 255, 0.2)',
|
|
|
+ text: '#c7e0ff',
|
|
|
+ textDim: '#8fb0c7',
|
|
|
+ splitLine: 'rgba(26, 58, 107, 0.5)',
|
|
|
+ axisLine: '#1a3a6b'
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 图表配置生成 =========================
|
|
|
+
|
|
|
+/** 1. 九防风险等级分布 - 堆叠条形图 */
|
|
|
+const renderNineRiskDist = () => {
|
|
|
+ const data = nineRiskDist.value
|
|
|
+ if (!data.length) return
|
|
|
+ createChart('nineRisk', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ textStyle: { color: '#fff' },
|
|
|
+ formatter: (params: any) => {
|
|
|
+ const row = data[params[0].dataIndex]
|
|
|
+ return `${row.name}<br/>无风险:${row.none} 人<br/>低危:${row.low} 人<br/>中危:${row.medium} 人<br/>高危:${row.high} 人<br/>合计:${row.total} 人`
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: ['无风险', '低危', '中危', '高危'],
|
|
|
+ textStyle: { color: COLORS.text },
|
|
|
+ top: 0,
|
|
|
+ right: 0
|
|
|
+ },
|
|
|
+ grid: { left: 70, right: 30, top: 36, bottom: 20 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'value',
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: data.map((r) => r.name),
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: '#e4f9ff', fontWeight: 600 }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '无风险',
|
|
|
+ type: 'bar',
|
|
|
+ stack: 't',
|
|
|
+ barWidth: 18,
|
|
|
+ itemStyle: { color: COLORS.none },
|
|
|
+ data: data.map((r) => r.none)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '低危',
|
|
|
+ type: 'bar',
|
|
|
+ stack: 't',
|
|
|
+ itemStyle: { color: COLORS.low },
|
|
|
+ data: data.map((r) => r.low)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '中危',
|
|
|
+ type: 'bar',
|
|
|
+ stack: 't',
|
|
|
+ itemStyle: { color: COLORS.medium },
|
|
|
+ data: data.map((r) => r.medium)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '高危',
|
|
|
+ type: 'bar',
|
|
|
+ stack: 't',
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ color: '#fff',
|
|
|
+ fontWeight: 700,
|
|
|
+ formatter: (p: any) => (p.value ? `高危${p.value}` : '')
|
|
|
+ },
|
|
|
+ itemStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
|
|
+ { offset: 0, color: '#c7301f' },
|
|
|
+ { offset: 1, color: '#ff5a3c' }
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ data: data.map((r) => r.high)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 2. 风险等级整体占比 - 玫瑰饼图 */
|
|
|
+const renderOverallPie = () => {
|
|
|
+ const counts = { none: 0, low: 0, medium: 0, high: 0 }
|
|
|
+ nineRiskDist.value.forEach((r) => {
|
|
|
+ counts.none += r.none
|
|
|
+ counts.low += r.low
|
|
|
+ counts.medium += r.medium
|
|
|
+ counts.high += r.high
|
|
|
+ })
|
|
|
+ const data = [
|
|
|
+ { name: '无风险', value: counts.none, color: COLORS.none },
|
|
|
+ { name: '低危', value: counts.low, color: COLORS.low },
|
|
|
+ { name: '中危', value: counts.medium, color: COLORS.medium },
|
|
|
+ { name: '高危', value: counts.high, color: COLORS.high }
|
|
|
+ ].filter((d) => d.value > 0)
|
|
|
+ createChart('overallPie', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: '{b}<br/>{c} 人次 ({d}%)',
|
|
|
+ textStyle: { color: '#fff' }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ orient: 'horizontal',
|
|
|
+ right: 0,
|
|
|
+ top: 0,
|
|
|
+ textStyle: { color: COLORS.text, fontSize: 12 }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '风险等级',
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['35%', '70%'],
|
|
|
+ center: ['50%', '58%'],
|
|
|
+ itemStyle: { borderRadius: 8, borderColor: '#05132b', borderWidth: 2 },
|
|
|
+ label: { color: '#fff', formatter: '{b}\n{d}%', fontSize: 12 },
|
|
|
+ labelLine: { lineStyle: { color: '#557a9c' } },
|
|
|
+ data: data.map((d) => ({ name: d.name, value: d.value, itemStyle: { color: d.color } }))
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 3. 风险等级月度趋势 - 堆叠面积折线图 */
|
|
|
+const renderRiskTrend = () => {
|
|
|
+ const data = riskTrend.value
|
|
|
+ if (!data.length) return
|
|
|
+ createChart('riskTrend', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: { trigger: 'axis', textStyle: { color: '#fff' } },
|
|
|
+ legend: { data: ['无风险', '低危', '中危', '高危'], textStyle: { color: COLORS.text }, top: 0 },
|
|
|
+ grid: { left: 50, right: 30, top: 40, bottom: 30 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ boundaryGap: false,
|
|
|
+ data: data.map((d) => d.month),
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '无风险',
|
|
|
+ type: 'line',
|
|
|
+ stack: 't',
|
|
|
+ areaStyle: { opacity: 0.3 },
|
|
|
+ itemStyle: { color: COLORS.none },
|
|
|
+ data: data.map((d) => d.none)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '低危',
|
|
|
+ type: 'line',
|
|
|
+ stack: 't',
|
|
|
+ areaStyle: { opacity: 0.3 },
|
|
|
+ itemStyle: { color: COLORS.low },
|
|
|
+ data: data.map((d) => d.low)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '中危',
|
|
|
+ type: 'line',
|
|
|
+ stack: 't',
|
|
|
+ areaStyle: { opacity: 0.3 },
|
|
|
+ itemStyle: { color: COLORS.medium },
|
|
|
+ data: data.map((d) => d.medium)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '高危',
|
|
|
+ type: 'line',
|
|
|
+ stack: 't',
|
|
|
+ areaStyle: { opacity: 0.4 },
|
|
|
+ itemStyle: { color: COLORS.high },
|
|
|
+ data: data.map((d) => d.high)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 4. 楼栋×九防风险热力图 */
|
|
|
+const renderFloorHeatmap = () => {
|
|
|
+ const { floors, risks, data } = floorHeatmap.value
|
|
|
+ if (!floors || !data) return
|
|
|
+ const maxVal = Math.max(...data.map((d: any) => d[2]), 1)
|
|
|
+ createChart('floorHeatmap', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ textStyle: { color: '#fff' },
|
|
|
+ formatter: (p: any) =>
|
|
|
+ `${floors[p.data[0]]} - ${risks[p.data[1]]}<br/>高危人数:${p.data[2]} 人`
|
|
|
+ },
|
|
|
+ grid: { left: 60, right: 50, top: 30, bottom: 80 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: floors,
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text },
|
|
|
+ splitArea: {
|
|
|
+ show: true,
|
|
|
+ areaStyle: { color: ['rgba(0,255,255,0.02)', 'rgba(0,255,255,0.04)'] }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: risks,
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text },
|
|
|
+ splitArea: {
|
|
|
+ show: true,
|
|
|
+ areaStyle: { color: ['rgba(0,255,255,0.02)', 'rgba(0,255,255,0.04)'] }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ visualMap: {
|
|
|
+ min: 0,
|
|
|
+ max: maxVal,
|
|
|
+ calculable: true,
|
|
|
+ orient: 'horizontal',
|
|
|
+ left: 'center',
|
|
|
+ bottom: 10,
|
|
|
+ textStyle: { color: COLORS.text },
|
|
|
+ inRange: { color: ['#0a2f5e', '#00aaff', '#ffd85a', '#ff5a3c'] }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'heatmap',
|
|
|
+ data,
|
|
|
+ label: { show: true, color: '#fff', fontSize: 11 },
|
|
|
+ emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,255,255,0.5)' } }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 5. 年龄-高风险散点图 */
|
|
|
+const renderAgeScatter = () => {
|
|
|
+ const data = ageScatter.value
|
|
|
+ if (!data.length) return
|
|
|
+ const male = data.filter((d) => d.sex === 1).map((d) => [d.age, d.highCount, d.name])
|
|
|
+ const female = data.filter((d) => d.sex === 2).map((d) => [d.age, d.highCount, d.name])
|
|
|
+ createChart('ageScatter', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ textStyle: { color: '#fff' },
|
|
|
+ formatter: (p: any) => `${p.data[2]}<br/>年龄:${p.data[0]} 岁<br/>高风险项:${p.data[1]} 个`
|
|
|
+ },
|
|
|
+ legend: { data: ['男性', '女性'], textStyle: { color: COLORS.text }, top: 0 },
|
|
|
+ grid: { left: 50, right: 30, top: 40, bottom: 40 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '年龄',
|
|
|
+ nameTextStyle: { color: COLORS.textDim },
|
|
|
+ min: 60,
|
|
|
+ max: 100,
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '高风险项数',
|
|
|
+ nameTextStyle: { color: COLORS.textDim },
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '男性',
|
|
|
+ type: 'scatter',
|
|
|
+ symbolSize: 12,
|
|
|
+ itemStyle: { color: '#5578ff', opacity: 0.8 },
|
|
|
+ data: male
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '女性',
|
|
|
+ type: 'scatter',
|
|
|
+ symbolSize: 12,
|
|
|
+ itemStyle: { color: '#ff7ab0', opacity: 0.8 },
|
|
|
+ data: female
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 6. 评估得分分布 - 箱线图 */
|
|
|
+const renderScoreDist = () => {
|
|
|
+ const data = scoreDist.value
|
|
|
+ if (!data.length) return
|
|
|
+ createChart('scoreDist', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: { trigger: 'item', textStyle: { color: '#fff' } },
|
|
|
+ grid: { left: 50, right: 30, top: 30, bottom: 60 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: data.map((d) => d.name),
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text, rotate: 25, fontSize: 11 }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '得分',
|
|
|
+ nameTextStyle: { color: COLORS.textDim },
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'boxplot',
|
|
|
+ data: data.map((d) => [d.min, d.q1, d.median, d.q3, d.max]),
|
|
|
+ itemStyle: { color: 'rgba(0, 170, 255, 0.3)', borderColor: '#00aaff' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 7. 签署状态 - 环形图 */
|
|
|
+const renderSignature = () => {
|
|
|
+ const d = signature.value
|
|
|
+ if (!d.total) return
|
|
|
+ const rate = (d.signedRate || 0).toFixed(1)
|
|
|
+ createChart('signature', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: { trigger: 'item', formatter: '{b}: {c}人 ({d}%)', textStyle: { color: '#fff' } },
|
|
|
+ title: {
|
|
|
+ text: `${rate}%`,
|
|
|
+ subtext: '签署率',
|
|
|
+ left: 'center',
|
|
|
+ top: '40%',
|
|
|
+ textStyle: {
|
|
|
+ color: COLORS.cyan,
|
|
|
+ fontSize: 26,
|
|
|
+ fontWeight: 700,
|
|
|
+ textShadowColor: 'rgba(0,255,255,0.4)',
|
|
|
+ textShadowBlur: 10
|
|
|
+ },
|
|
|
+ subtextStyle: { color: COLORS.textDim, fontSize: 12 }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['58%', '78%'],
|
|
|
+ center: ['50%', '50%'],
|
|
|
+ silent: true,
|
|
|
+ label: { show: false },
|
|
|
+ labelLine: { show: false },
|
|
|
+ data: [
|
|
|
+ {
|
|
|
+ value: d.signed,
|
|
|
+ name: '已签署',
|
|
|
+ itemStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
|
|
|
+ { offset: 0, color: '#00ffff' },
|
|
|
+ { offset: 1, color: '#00aaff' }
|
|
|
+ ])
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { value: d.firstSignedOnly, name: '仅首次', itemStyle: { color: '#ffd85a' } },
|
|
|
+ {
|
|
|
+ value: d.unsigned,
|
|
|
+ name: '未签署',
|
|
|
+ itemStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
|
|
|
+ { offset: 0, color: '#ff9966' },
|
|
|
+ { offset: 1, color: '#ff5a3c' }
|
|
|
+ ])
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 8. 按护理级别统计风险 - 漏斗图 */
|
|
|
+const renderNurseLevelFunnel = () => {
|
|
|
+ const data = nurseLevelRisk.value
|
|
|
+ if (!data.length) return
|
|
|
+ const totalHigh = data.map((d) => ({ name: d.name, value: d.high })).filter((d) => d.value > 0)
|
|
|
+ createChart('nurseFunnel', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: { trigger: 'item', formatter: '{b}: {c} 个高危项', textStyle: { color: '#fff' } },
|
|
|
+ legend: { textStyle: { color: COLORS.text }, top: 0 },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'funnel',
|
|
|
+ left: '10%',
|
|
|
+ top: 40,
|
|
|
+ bottom: 20,
|
|
|
+ width: '80%',
|
|
|
+ label: { color: '#fff', fontSize: 12, formatter: '{b}: {c}' },
|
|
|
+ itemStyle: { borderColor: '#05132b', borderWidth: 1 },
|
|
|
+ data: totalHigh.map((d, i) => ({
|
|
|
+ name: d.name,
|
|
|
+ value: d.value,
|
|
|
+ itemStyle: { color: ['#ff5a3c', '#ff9966', '#ffd85a', '#00e0a7'][i % 4] }
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 9. 九防风险雷达图 */
|
|
|
+const renderRiskRadar = () => {
|
|
|
+ const { indicators, series } = riskRadar.value
|
|
|
+ if (!indicators || !series) return
|
|
|
+ const colors = ['#ff5a3c', '#ff9966', '#ffd85a', '#00e0a7']
|
|
|
+ createChart('riskRadar', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: { textStyle: { color: '#fff' } },
|
|
|
+ legend: { data: series.map((s: any) => s.name), textStyle: { color: COLORS.text }, top: 0 },
|
|
|
+ radar: {
|
|
|
+ indicator: indicators,
|
|
|
+ shape: 'polygon',
|
|
|
+ splitNumber: 5,
|
|
|
+ axisName: { color: '#e4f9ff', fontSize: 11 },
|
|
|
+ splitLine: { lineStyle: { color: 'rgba(26, 58, 107, 0.6)' } },
|
|
|
+ splitArea: { areaStyle: { color: ['rgba(0,255,255,0.03)', 'rgba(0,255,255,0.06)'] } },
|
|
|
+ axisLine: { lineStyle: { color: 'rgba(26, 58, 107, 0.6)' } }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'radar',
|
|
|
+ areaStyle: { opacity: 0.15 },
|
|
|
+ data: series.map((s: any, i: number) => ({
|
|
|
+ name: s.name,
|
|
|
+ value: s.value,
|
|
|
+ lineStyle: { color: colors[i], width: 2 },
|
|
|
+ itemStyle: { color: colors[i] },
|
|
|
+ areaStyle: { color: colors[i], opacity: 0.15 }
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 10. 评估人工作量 - 柱状图 */
|
|
|
+const renderAssessorWorkload = () => {
|
|
|
+ const data = assessorWorkload.value
|
|
|
+ if (!data.length) return
|
|
|
+ createChart('assessorWorkload', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, textStyle: { color: '#fff' } },
|
|
|
+ legend: { data: ['评估人数', '发现高危'], textStyle: { color: COLORS.text }, top: 0 },
|
|
|
+ grid: { left: 50, right: 30, top: 40, bottom: 30 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: data.map((d) => d.name),
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '评估人数',
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 18,
|
|
|
+ itemStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: '#00ffff' },
|
|
|
+ { offset: 1, color: '#0055aa' }
|
|
|
+ ]),
|
|
|
+ borderRadius: [4, 4, 0, 0]
|
|
|
+ },
|
|
|
+ data: data.map((d) => d.count)
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '发现高危',
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 18,
|
|
|
+ itemStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: '#ff7a6b' },
|
|
|
+ { offset: 1, color: '#c7301f' }
|
|
|
+ ]),
|
|
|
+ borderRadius: [4, 4, 0, 0]
|
|
|
+ },
|
|
|
+ data: data.map((d) => d.highCount)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 11. 九防风险相关性矩阵 - 热力图 */
|
|
|
+const renderRiskCorrelation = () => {
|
|
|
+ const { risks, matrix } = riskCorrelation.value
|
|
|
+ if (!risks || !matrix) return
|
|
|
+ const data: [number, number, number][] = []
|
|
|
+ for (let i = 0; i < matrix.length; i++) {
|
|
|
+ for (let j = 0; j < matrix[i].length; j++) {
|
|
|
+ data.push([j, i, matrix[i][j]])
|
|
|
+ }
|
|
|
+ }
|
|
|
+ createChart('riskCorrelation', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ textStyle: { color: '#fff' },
|
|
|
+ formatter: (p: any) => `${risks[p.data[1]]} → ${risks[p.data[0]]}<br/>相关性:${p.data[2]}`
|
|
|
+ },
|
|
|
+ grid: { left: 80, right: 50, top: 30, bottom: 80 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: risks,
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text, rotate: 35, fontSize: 11 },
|
|
|
+ splitArea: { show: true }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: risks,
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text, fontSize: 11 },
|
|
|
+ splitArea: { show: true }
|
|
|
+ },
|
|
|
+ visualMap: {
|
|
|
+ min: 0,
|
|
|
+ max: 1,
|
|
|
+ calculable: true,
|
|
|
+ orient: 'horizontal',
|
|
|
+ left: 'center',
|
|
|
+ bottom: 10,
|
|
|
+ textStyle: { color: COLORS.text },
|
|
|
+ inRange: { color: ['#0a2f5e', '#5578ff', '#00ffff', '#ffd85a', '#ff5a3c'] }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'heatmap',
|
|
|
+ data,
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: 10,
|
|
|
+ formatter: (p: any) => p.data[2].toFixed(2)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 12. 3D 楼栋×九防风险 - 3D 柱状图 */
|
|
|
+const render3DBar = () => {
|
|
|
+ const { floors, risks, data } = bar3D.value
|
|
|
+ if (!floors || !data) return
|
|
|
+ const maxVal = Math.max(...data.map((d: any) => d[2]), 1)
|
|
|
+ createChart('bar3D', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ formatter: (p: any) =>
|
|
|
+ `${floors[p.data[0]]} - ${risks[p.data[1]]}<br/>高危人数:${p.data[2]} 人`
|
|
|
+ },
|
|
|
+ visualMap: {
|
|
|
+ max: maxVal,
|
|
|
+ min: 0,
|
|
|
+ inRange: { color: ['#0a2f5e', '#00aaff', '#ffd85a', '#ff5a3c'] },
|
|
|
+ textStyle: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ xAxis3D: {
|
|
|
+ type: 'category',
|
|
|
+ data: floors,
|
|
|
+ name: '楼栋',
|
|
|
+ nameTextStyle: { color: COLORS.text },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ yAxis3D: {
|
|
|
+ type: 'category',
|
|
|
+ data: risks,
|
|
|
+ name: '九防',
|
|
|
+ nameTextStyle: { color: COLORS.text },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ zAxis3D: {
|
|
|
+ type: 'value',
|
|
|
+ name: '高危人数',
|
|
|
+ nameTextStyle: { color: COLORS.text },
|
|
|
+ axisLabel: { color: COLORS.text }
|
|
|
+ },
|
|
|
+ grid3D: {
|
|
|
+ viewControl: { autoRotate: true, autoRotateSpeed: 8, distance: 180 },
|
|
|
+ boxWidth: 180,
|
|
|
+ boxDepth: 180,
|
|
|
+ light: {
|
|
|
+ main: { intensity: 1.2, shadow: true },
|
|
|
+ ambient: { intensity: 0.3 }
|
|
|
+ },
|
|
|
+ environment: 'auto',
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ splitLine: { lineStyle: { color: 'rgba(26, 58, 107, 0.3)' } }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar3D',
|
|
|
+ data: data.map((d: any) => ({ value: [d[0], d[1], d[2]] })),
|
|
|
+ shading: 'color',
|
|
|
+ label: { show: false },
|
|
|
+ itemStyle: { opacity: 0.9 }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+/** 15. 评估完成情况 - 柱状图 */
|
|
|
+const renderAssessCompletion = () => {
|
|
|
+ const data = nineRiskDist.value
|
|
|
+ if (!data.length) return
|
|
|
+ createChart('assessCompletion', {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ textStyle: { color: '#fff' },
|
|
|
+ formatter: (params: any) => {
|
|
|
+ const row = data[params[0].dataIndex]
|
|
|
+ const rate = row.total > 0 ? ((row.high / row.total) * 100).toFixed(1) : '0.0'
|
|
|
+ return `${row.name}<br/>高危占比:${rate}%<br/>高危:${row.high}人 / 合计:${row.total}人`
|
|
|
+ }
|
|
|
+ },
|
|
|
+ grid: { left: 40, right: 20, top: 40, bottom: 30 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: data.map((d) => d.name),
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine } },
|
|
|
+ axisLabel: { color: COLORS.text, rotate: 35, fontSize: 11, margin: 10 }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '高危占比',
|
|
|
+ nameTextStyle: { color: COLORS.textDim, padding: [0, 0, 20, 0] },
|
|
|
+ splitLine: { lineStyle: { color: COLORS.splitLine, paddingBottom: 20 } },
|
|
|
+ axisLine: { lineStyle: { color: COLORS.axisLine, paddingBottom: 20 } },
|
|
|
+ axisLabel: { color: COLORS.text, formatter: '{value}%', paddingBottom: 20 }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 20,
|
|
|
+ itemStyle: {
|
|
|
+ color: (p: any) => {
|
|
|
+ const row = data[p.dataIndex]
|
|
|
+ const ratio = row.total > 0 ? row.high / row.total : 0
|
|
|
+ if (ratio > 0.2)
|
|
|
+ return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: '#ff5a3c' },
|
|
|
+ { offset: 1, color: '#c7301f' }
|
|
|
+ ])
|
|
|
+ if (ratio > 0.1)
|
|
|
+ return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: '#ffd85a' },
|
|
|
+ { offset: 1, color: '#cc9a00' }
|
|
|
+ ])
|
|
|
+ return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: '#00ffff' },
|
|
|
+ { offset: 1, color: '#0055aa' }
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ borderRadius: [4, 4, 0, 0]
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'top',
|
|
|
+ color: COLORS.cyan,
|
|
|
+ fontSize: 11,
|
|
|
+ formatter: (p: any) => {
|
|
|
+ const row = data[p.dataIndex]
|
|
|
+ const rate = row.total > 0 ? ((row.high / row.total) * 100).toFixed(0) : '0'
|
|
|
+ return `${rate}%`
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data: data.map((d) => {
|
|
|
+ const rate = d.total > 0 ? +((d.high / d.total) * 100).toFixed(1) : 0
|
|
|
+ return rate
|
|
|
+ })
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 渲染所有图表 =========================
|
|
|
+const renderAllCharts = () => {
|
|
|
+ renderNineRiskDist()
|
|
|
+ renderOverallPie()
|
|
|
+ renderRiskTrend()
|
|
|
+ renderFloorHeatmap()
|
|
|
+ renderAgeScatter()
|
|
|
+ renderScoreDist()
|
|
|
+ renderSignature()
|
|
|
+ renderNurseLevelFunnel()
|
|
|
+ renderRiskRadar()
|
|
|
+ renderAssessorWorkload()
|
|
|
+ renderRiskCorrelation()
|
|
|
+ render3DBar()
|
|
|
+ renderAssessCompletion()
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 数据加载 =========================
|
|
|
+const loadAllData = async () => {
|
|
|
+ loading.value = true
|
|
|
+ disposeAll()
|
|
|
+ const params = {
|
|
|
+ month: filter.month || undefined,
|
|
|
+ buildId: filter.buildId || undefined,
|
|
|
+ floorId: filter.floorId || undefined
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const [ov, dist, trend, fhm, asc, scd, sig, red, awl, nlr, corr, radar, b3d] =
|
|
|
+ await Promise.all([
|
|
|
+ echartsApi.getNineSafetyOverview(params),
|
|
|
+ echartsApi.getNineRiskDistribution(params),
|
|
|
+ echartsApi.getRiskLevelTrend({ buildId: params.buildId }),
|
|
|
+ echartsApi.getFloorRiskHeatmap({ month: params.month }),
|
|
|
+ echartsApi.getAgeRiskScatter(params),
|
|
|
+ echartsApi.getAssessScoreDistribution(params),
|
|
|
+ echartsApi.getSignatureStatus(params),
|
|
|
+ echartsApi.getHighRiskRedList(params),
|
|
|
+ echartsApi.getAssessorWorkload(params),
|
|
|
+ echartsApi.getRiskByNurseLevel(params),
|
|
|
+ echartsApi.getNineRiskCorrelation(params),
|
|
|
+ echartsApi.getRiskRadar(params),
|
|
|
+ echartsApi.get3DRiskBar({ month: params.month })
|
|
|
+ ])
|
|
|
+ overview.value = ov
|
|
|
+ nineRiskDist.value = dist
|
|
|
+ riskTrend.value = trend
|
|
|
+ floorHeatmap.value = fhm
|
|
|
+ ageScatter.value = asc
|
|
|
+ scoreDist.value = scd
|
|
|
+ signature.value = sig
|
|
|
+ redList.value = red
|
|
|
+ assessorWorkload.value = awl
|
|
|
+ nurseLevelRisk.value = nlr
|
|
|
+ riskCorrelation.value = corr
|
|
|
+ riskRadar.value = radar
|
|
|
+ bar3D.value = b3d
|
|
|
+ await nextTick()
|
|
|
+ renderAllCharts()
|
|
|
+ } catch (e) {
|
|
|
+ console.error('九防看板数据加载失败', e)
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 筛选交互 =========================
|
|
|
+const handleFilterChange = () => {
|
|
|
+ loadAllData()
|
|
|
+}
|
|
|
+const handleReset = () => {
|
|
|
+ filter.month = ''
|
|
|
+ filter.buildId = ''
|
|
|
+ filter.floorId = ''
|
|
|
+ floorList.value = []
|
|
|
+ loadAllData()
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 红名单风险标签颜色 =========================
|
|
|
+const riskTagColor = (name: string): string => {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ 噎食: '#ff9966',
|
|
|
+ 压疮: '#ffd85a',
|
|
|
+ 跌倒: '#ff5a3c',
|
|
|
+ 坠床: '#ff5a3c',
|
|
|
+ 烫伤: '#ffd85a',
|
|
|
+ 走失: '#ff9966',
|
|
|
+ 自杀: '#b380ff',
|
|
|
+ 误食: '#5578ff',
|
|
|
+ 文娱意外: '#5578ff'
|
|
|
+ }
|
|
|
+ return map[name] || '#00aaff'
|
|
|
+}
|
|
|
+
|
|
|
+// ========================= 生命周期 =========================
|
|
|
+onMounted(async () => {
|
|
|
+ const months = await echartsApi.getMonthList()
|
|
|
+ monthOptions.value = months
|
|
|
+ // 楼栋使用真实接口
|
|
|
+ try {
|
|
|
+ const tenantIds = userStore.orgTenantId
|
|
|
+ const tid = Array.isArray(tenantIds) ? tenantIds[0] : tenantIds
|
|
|
+ buildList.value = tid ? await getBuildList({ tenantIds: tid }) : await getBuildList({})
|
|
|
+ } catch (e) {
|
|
|
+ console.error('楼栋列表加载失败', e)
|
|
|
+ }
|
|
|
+ await loadAllData()
|
|
|
+ window.addEventListener('resize', resizeAll)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', resizeAll)
|
|
|
+ disposeAll()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="page" v-loading="loading">
|
|
|
+ <!-- 标题 + 筛选 -->
|
|
|
+ <div class="header">
|
|
|
+ <div class="title-wrap">
|
|
|
+ <div class="title-line"></div>
|
|
|
+ <h1 class="title">九防安全风险综合看板</h1>
|
|
|
+ <div class="title-line"></div>
|
|
|
+ </div>
|
|
|
+ <div class="filter-bar">
|
|
|
+ <div class="filter-item">
|
|
|
+ <span class="filter-label">楼栋</span>
|
|
|
+ <el-select
|
|
|
+ v-model="filter.buildId"
|
|
|
+ placeholder="全部楼栋"
|
|
|
+ clearable
|
|
|
+ class="filter-select"
|
|
|
+ @change="handleBuildChange"
|
|
|
+ >
|
|
|
+ <el-option v-for="b in buildList" :key="b.id" :label="b.buildName" :value="b.id" />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+ <div class="filter-item">
|
|
|
+ <span class="filter-label">楼层</span>
|
|
|
+ <el-select
|
|
|
+ v-model="filter.floorId"
|
|
|
+ placeholder="全部楼层"
|
|
|
+ clearable
|
|
|
+ class="filter-select"
|
|
|
+ :disabled="!filter.buildId"
|
|
|
+ @change="handleFilterChange"
|
|
|
+ >
|
|
|
+ <el-option v-for="f in floorList" :key="f.id" :label="f.floorName" :value="f.id" />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+ <el-button type="primary" @click="handleFilterChange">查询</el-button>
|
|
|
+ <el-button color="#666666" @click="handleReset">重置</el-button>
|
|
|
+ <span class="update-time">数据更新:{{ new Date().toLocaleString('zh-CN') }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- KPI -->
|
|
|
+ <div class="kpi-row" v-if="overview.totalElders">
|
|
|
+ <div class="kpi kpi-blue">
|
|
|
+ <div class="kpi-icon">
|
|
|
+ <svg
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ width="28"
|
|
|
+ height="28"
|
|
|
+ fill="none"
|
|
|
+ stroke="#84a0ff"
|
|
|
+ stroke-width="2"
|
|
|
+ >
|
|
|
+ <path d="M12 2l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-body">
|
|
|
+ <div class="kpi-label">知情书总数</div>
|
|
|
+ <div class="kpi-value">{{ overview.totalElders }}<span class="u">人</span></div>
|
|
|
+ <div class="kpi-sub">已覆盖九防风险评估档案</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="kpi kpi-red">
|
|
|
+ <div class="kpi-icon">
|
|
|
+ <svg
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ width="28"
|
|
|
+ height="28"
|
|
|
+ fill="none"
|
|
|
+ stroke="#ff9fb6"
|
|
|
+ stroke-width="2"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ d="M12 21s-7-4.5-9.5-9.2C.8 8.3 2.9 4.5 6.5 4.5c2 0 3.5 1.1 4.5 2.6C12 5.6 13.5 4.5 15.5 4.5 19.1 4.5 21.2 8.3 19.5 11.8 17 16.5 12 21 12 21z"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-body">
|
|
|
+ <div class="kpi-label">高风险总人次</div>
|
|
|
+ <div class="kpi-value">{{ overview.highRiskCount }}<span class="u">人次</span></div>
|
|
|
+ <div class="kpi-sub"
|
|
|
+ >占比 <em class="up">{{ overview.highRiskRatio }}%</em></div
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="kpi kpi-orange">
|
|
|
+ <div class="kpi-icon">
|
|
|
+ <svg
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ width="28"
|
|
|
+ height="28"
|
|
|
+ fill="none"
|
|
|
+ stroke="#ffbe9a"
|
|
|
+ stroke-width="2"
|
|
|
+ >
|
|
|
+ <rect x="3" y="5" width="18" height="16" rx="2" />
|
|
|
+ <path d="M7 9h10M7 13h10M7 17h6" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-body">
|
|
|
+ <div class="kpi-label">待签署知情书</div>
|
|
|
+ <div class="kpi-value">{{ overview.pendingSignatures }}<span class="u">份</span></div>
|
|
|
+ <div class="kpi-sub">签署率 {{ overview.signedRate }}%</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="kpi kpi-cyan">
|
|
|
+ <div class="kpi-icon">
|
|
|
+ <svg
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ width="28"
|
|
|
+ height="28"
|
|
|
+ fill="none"
|
|
|
+ stroke="#5df3ea"
|
|
|
+ stroke-width="2"
|
|
|
+ >
|
|
|
+ <path d="M4 12l4 4 12-12" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-body">
|
|
|
+ <div class="kpi-label">评估完成率</div>
|
|
|
+ <div class="kpi-value">{{ overview.evalCompletionRate }}<span class="u">%</span></div>
|
|
|
+ <div class="kpi-sub">9 项防险综合覆盖率</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="kpi kpi-purple">
|
|
|
+ <div class="kpi-icon">
|
|
|
+ <svg
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ width="28"
|
|
|
+ height="28"
|
|
|
+ fill="none"
|
|
|
+ stroke="#c9a0ff"
|
|
|
+ stroke-width="2"
|
|
|
+ >
|
|
|
+ <circle cx="12" cy="8" r="4" />
|
|
|
+ <path d="M4 20c0-4 4-6 8-6s8 2 8 6" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-body">
|
|
|
+ <div class="kpi-label">评估人员数</div>
|
|
|
+ <div class="kpi-value">{{ overview.assessorCount }}<span class="u">人</span></div>
|
|
|
+ <div class="kpi-sub">参与九防评估护士</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 图表区:自适应布局,大屏多列、小屏单列 -->
|
|
|
+ <div class="grid-auto">
|
|
|
+ <!-- 九防分布(大图) -->
|
|
|
+ <div class="card span-2">
|
|
|
+ <div class="card-title"
|
|
|
+ ><span class="dot dot-high"></span>风险全景 · 九防风险等级分布(按高危排序)</div
|
|
|
+ >
|
|
|
+ <div :ref="setRef('nineRisk')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 整体占比 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-mid"></span>风险等级整体占比</div>
|
|
|
+ <div :ref="setRef('overallPie')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 月度趋势 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-low"></span>风险等级月度趋势(近 6 个月)</div>
|
|
|
+ <div :ref="setRef('riskTrend')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 楼栋热力图 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-high"></span>九防 × 高危人数热力图</div>
|
|
|
+ <div :ref="setRef('floorHeatmap')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 年龄散点 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-mid"></span>年龄 × 高风险项 散点分布</div>
|
|
|
+ <div :ref="setRef('ageScatter')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 得分箱线图 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-low"></span>评估得分分布(箱线图)</div>
|
|
|
+ <div :ref="setRef('scoreDist')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 签署状态 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-mid"></span>知情书签署状态</div>
|
|
|
+ <div :ref="setRef('signature')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 护理级别漏斗 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-high"></span>按护理级别 · 高危项漏斗</div>
|
|
|
+ <div :ref="setRef('nurseFunnel')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 风险雷达 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-mid"></span>各护理级别 · 九防高危占比雷达</div>
|
|
|
+ <div :ref="setRef('riskRadar')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 评估人工作量 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-low"></span>评估人工作量统计</div>
|
|
|
+ <div :ref="setRef('assessorWorkload')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 相关性矩阵 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-mid"></span>九防风险相关性矩阵</div>
|
|
|
+ <div :ref="setRef('riskCorrelation')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 评估完成率 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title"><span class="dot dot-mid"></span>九防高危占比</div>
|
|
|
+ <div :ref="setRef('assessCompletion')" class="chart"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 3D柱状图 -->
|
|
|
+ <div class="card span-2">
|
|
|
+ <div class="card-title"
|
|
|
+ ><span class="dot dot-high"></span>3D 楼栋 × 九防高危人数(可旋转)</div
|
|
|
+ >
|
|
|
+ <div :ref="setRef('bar3D')" class="chart-tall"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 高风险红名单 -->
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-title danger-title">
|
|
|
+ <svg
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ width="18"
|
|
|
+ height="18"
|
|
|
+ fill="none"
|
|
|
+ stroke="#ff5a3c"
|
|
|
+ stroke-width="2"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
|
|
+ />
|
|
|
+ <path d="M12 9v4M12 17h.01" />
|
|
|
+ </svg>
|
|
|
+ 重点人群红名单 · 2 项及以上高风险(共 {{ redList.length }} 人)
|
|
|
+ <span class="tip danger">建议:纳入每日护士长巡床必查清单</span>
|
|
|
+ </div>
|
|
|
+ <div class="table-wrap" v-if="redList.length > 0">
|
|
|
+ <table class="red-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>档案编号</th><th>姓名</th><th>性别</th><th>年龄</th> <th>楼栋</th><th>床号</th
|
|
|
+ ><th>护理级别</th><th>评估人</th> <th>高风险项</th><th>数量</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr v-for="r in redList" :key="r.id">
|
|
|
+ <td class="mono">{{ r.id }}</td>
|
|
|
+ <td class="name">{{ r.name }}</td>
|
|
|
+ <td>{{ r.sex }}</td>
|
|
|
+ <td>{{ r.age }} 岁</td>
|
|
|
+ <td>{{ r.floor }}</td>
|
|
|
+ <td class="mono">{{ r.room }}</td>
|
|
|
+ <td>{{ r.nurseLevel }}</td>
|
|
|
+ <td>{{ r.assessor }}</td>
|
|
|
+ <td>
|
|
|
+ <span
|
|
|
+ v-for="rk in r.risks"
|
|
|
+ :key="rk"
|
|
|
+ class="risk-tag"
|
|
|
+ :style="{
|
|
|
+ background: riskTagColor(rk) + '33',
|
|
|
+ color: riskTagColor(rk),
|
|
|
+ borderColor: riskTagColor(rk) + '66'
|
|
|
+ }"
|
|
|
+ >{{ rk }}</span
|
|
|
+ >
|
|
|
+ </td>
|
|
|
+ <td
|
|
|
+ ><span class="high-count">{{ r.highCount }}</span></td
|
|
|
+ >
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ <div v-else class="empty-red"><span>暂无重点人群数据</span></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.page {
|
|
|
+ min-height: 100%;
|
|
|
+ padding: 20px 24px 28px;
|
|
|
+ background:
|
|
|
+ radial-gradient(ellipse at top, rgba(255, 90, 60, 0.08) 0%, rgba(2, 11, 30, 0) 45%),
|
|
|
+ radial-gradient(ellipse at 20% 40%, rgba(0, 255, 255, 0.06) 0%, rgba(2, 11, 30, 0) 50%),
|
|
|
+ linear-gradient(180deg, #020b1e 0%, #05132b 100%);
|
|
|
+ color: #fff;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.header {
|
|
|
+ margin-bottom: 18px;
|
|
|
+ .title-wrap {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ margin-bottom: 14px;
|
|
|
+ }
|
|
|
+ .title-line {
|
|
|
+ flex: 1;
|
|
|
+ height: 1px;
|
|
|
+ max-width: 220px;
|
|
|
+ background: linear-gradient(
|
|
|
+ 90deg,
|
|
|
+ rgba(0, 255, 255, 0) 0%,
|
|
|
+ #00aaff 50%,
|
|
|
+ rgba(0, 255, 255, 0) 100%
|
|
|
+ );
|
|
|
+ }
|
|
|
+ .title {
|
|
|
+ margin: 0 22px;
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 2px;
|
|
|
+ background: linear-gradient(90deg, #fff 0%, #00ffff 50%, #ff7a6b 100%);
|
|
|
+ -webkit-background-clip: text;
|
|
|
+ background-clip: text;
|
|
|
+ color: transparent;
|
|
|
+ text-shadow: 0 0 20px rgba(0, 255, 255, 0.2);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.filter-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ justify-content: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ .filter-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+ .filter-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #8fb0c7;
|
|
|
+ }
|
|
|
+ .filter-select {
|
|
|
+ width: 160px;
|
|
|
+ }
|
|
|
+ .update-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6c8db0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* el-select 暗色主题适配 */
|
|
|
+:deep(.el-select__wrapper) {
|
|
|
+ background-color: rgba(10, 47, 94, 0.5) !important;
|
|
|
+ box-shadow: 0 0 0 1px rgba(0, 255, 255, 0.3) inset !important;
|
|
|
+ .el-select__placeholder,
|
|
|
+ .el-select__selected-item {
|
|
|
+ color: #e4f9ff !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+:deep(.el-select__wrapper):hover {
|
|
|
+ box-shadow: 0 0 0 1px #00ffff inset !important;
|
|
|
+}
|
|
|
+:deep(.el-select__wrapper.is-focused) {
|
|
|
+ box-shadow:
|
|
|
+ 0 0 0 1px #00ffff inset,
|
|
|
+ 0 0 8px rgba(0, 255, 255, 0.3) !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* KPI 自适应:大屏 5 列,中屏 3 列,小屏 2 列 */
|
|
|
+.kpi-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
|
+ gap: 16px;
|
|
|
+ margin-bottom: 22px;
|
|
|
+}
|
|
|
+.kpi {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 14px;
|
|
|
+ padding: 14px 18px;
|
|
|
+ background: rgba(10, 47, 94, 0.3);
|
|
|
+ border-radius: 10px;
|
|
|
+ overflow: hidden;
|
|
|
+ box-shadow: 0 0 20px rgba(0, 170, 255, 0.05) inset;
|
|
|
+ .kpi-icon {
|
|
|
+ width: 50px;
|
|
|
+ height: 50px;
|
|
|
+ border-radius: 12px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+ .kpi-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #a9c8e0;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ }
|
|
|
+ .kpi-value {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 1.15;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ font-family: 'DIN Alternate', 'Arial Black', sans-serif;
|
|
|
+ .u {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #a9c8e0;
|
|
|
+ margin-left: 4px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .kpi-sub {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #6c8db0;
|
|
|
+ em {
|
|
|
+ font-style: normal;
|
|
|
+ color: #00ffff;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ .up {
|
|
|
+ color: #ff9c8c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.kpi-blue {
|
|
|
+ border: 1px solid rgba(85, 120, 255, 0.35);
|
|
|
+ .kpi-icon {
|
|
|
+ background: rgba(85, 120, 255, 0.12);
|
|
|
+ }
|
|
|
+ .kpi-value {
|
|
|
+ color: #84a0ff;
|
|
|
+ text-shadow: 0 0 12px rgba(85, 120, 255, 0.3);
|
|
|
+ }
|
|
|
+}
|
|
|
+.kpi-red {
|
|
|
+ border: 1px solid rgba(255, 122, 107, 0.35);
|
|
|
+ .kpi-icon {
|
|
|
+ background: rgba(255, 122, 107, 0.12);
|
|
|
+ }
|
|
|
+ .kpi-value {
|
|
|
+ color: #ff9fb6;
|
|
|
+ text-shadow: 0 0 12px rgba(255, 122, 153, 0.3);
|
|
|
+ }
|
|
|
+}
|
|
|
+.kpi-orange {
|
|
|
+ border: 1px solid rgba(255, 153, 102, 0.35);
|
|
|
+ .kpi-icon {
|
|
|
+ background: rgba(255, 153, 102, 0.12);
|
|
|
+ }
|
|
|
+ .kpi-value {
|
|
|
+ color: #ffbe9a;
|
|
|
+ text-shadow: 0 0 12px rgba(255, 153, 102, 0.3);
|
|
|
+ }
|
|
|
+}
|
|
|
+.kpi-cyan {
|
|
|
+ border: 1px solid rgba(0, 255, 255, 0.35);
|
|
|
+ .kpi-icon {
|
|
|
+ background: rgba(0, 255, 255, 0.12);
|
|
|
+ }
|
|
|
+ .kpi-value {
|
|
|
+ color: #5df3ea;
|
|
|
+ text-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
|
|
+ }
|
|
|
+}
|
|
|
+.kpi-purple {
|
|
|
+ border: 1px solid rgba(179, 128, 255, 0.35);
|
|
|
+ .kpi-icon {
|
|
|
+ background: rgba(179, 128, 255, 0.12);
|
|
|
+ }
|
|
|
+ .kpi-value {
|
|
|
+ color: #c9a0ff;
|
|
|
+ text-shadow: 0 0 12px rgba(179, 128, 255, 0.3);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 图表区自适应布局:每列最小 440px,自动填充 */
|
|
|
+.grid-auto {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(440px, 1fr));
|
|
|
+ gap: 18px;
|
|
|
+ margin-bottom: 18px;
|
|
|
+}
|
|
|
+/* 大图跨两列;小屏(<960px)下取消跨列,全部单列 */
|
|
|
+.grid-auto .span-2 {
|
|
|
+ grid-column: span 2;
|
|
|
+}
|
|
|
+@media (max-width: 960px) {
|
|
|
+ .grid-auto {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ }
|
|
|
+ .grid-auto .span-2 {
|
|
|
+ grid-column: span 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.card {
|
|
|
+ position: relative;
|
|
|
+ background: rgba(10, 47, 94, 0.28);
|
|
|
+ border: 1px solid rgba(0, 255, 255, 0.2);
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 14px 16px 12px;
|
|
|
+ box-shadow: 0 0 20px rgba(0, 170, 255, 0.06);
|
|
|
+ backdrop-filter: blur(4px);
|
|
|
+ /* 让子图表能撑满卡片剩余高度,避免被 Grid 同行高卡片拉伸后底部留白 */
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ &::before,
|
|
|
+ &::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ pointer-events: none;
|
|
|
+ }
|
|
|
+ &::before {
|
|
|
+ left: -1px;
|
|
|
+ top: -1px;
|
|
|
+ border-top: 2px solid #00ffff;
|
|
|
+ border-left: 2px solid #00ffff;
|
|
|
+ border-top-left-radius: 4px;
|
|
|
+ }
|
|
|
+ &::after {
|
|
|
+ right: -1px;
|
|
|
+ bottom: -1px;
|
|
|
+ border-bottom: 2px solid #00ffff;
|
|
|
+ border-right: 2px solid #00ffff;
|
|
|
+ border-bottom-right-radius: 4px;
|
|
|
+ }
|
|
|
+}
|
|
|
+.card-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ color: #e4f9ff;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+ border-left: 4px solid #00aaff;
|
|
|
+ padding-left: 10px;
|
|
|
+ .dot {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: inline-block;
|
|
|
+ }
|
|
|
+ .dot-high {
|
|
|
+ background: #ff5a3c;
|
|
|
+ box-shadow: 0 0 8px #ff5a3c;
|
|
|
+ }
|
|
|
+ .dot-mid {
|
|
|
+ background: #ffd85a;
|
|
|
+ box-shadow: 0 0 8px #ffd85a;
|
|
|
+ }
|
|
|
+ .dot-low {
|
|
|
+ background: #41e6a7;
|
|
|
+ box-shadow: 0 0 8px #41e6a7;
|
|
|
+ }
|
|
|
+ .tip {
|
|
|
+ margin-left: auto;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6c8db0;
|
|
|
+ font-weight: 400;
|
|
|
+ &.danger {
|
|
|
+ color: #ff9c8c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+.danger-title {
|
|
|
+ border-left-color: #ff5a3c;
|
|
|
+ color: #ffd9d1;
|
|
|
+}
|
|
|
+
|
|
|
+.chart {
|
|
|
+ width: 100%;
|
|
|
+ /* 最小 320px,自动撑满卡片剩余空间,修复同行高卡片造成的底部空白 */
|
|
|
+ flex: 1 0 320px;
|
|
|
+}
|
|
|
+.chart-tall {
|
|
|
+ width: 100%;
|
|
|
+ flex: 1 0 420px;
|
|
|
+}
|
|
|
+
|
|
|
+.table-wrap {
|
|
|
+ overflow-x: auto;
|
|
|
+}
|
|
|
+.red-table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-size: 13px;
|
|
|
+ th,
|
|
|
+ td {
|
|
|
+ padding: 10px 12px;
|
|
|
+ text-align: left;
|
|
|
+ border-bottom: 1px solid rgba(26, 58, 107, 0.4);
|
|
|
+ }
|
|
|
+ th {
|
|
|
+ background: rgba(255, 90, 60, 0.1);
|
|
|
+ color: #ffd9d1;
|
|
|
+ font-weight: 600;
|
|
|
+ border-bottom-color: rgba(255, 90, 60, 0.35);
|
|
|
+ }
|
|
|
+ tbody tr:hover {
|
|
|
+ background: rgba(0, 170, 255, 0.05);
|
|
|
+ }
|
|
|
+ td.name {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #e4f9ff;
|
|
|
+ }
|
|
|
+ .mono {
|
|
|
+ font-family: 'Consolas', monospace;
|
|
|
+ color: #8fb0c7;
|
|
|
+ }
|
|
|
+ .risk-tag {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ border: 1px solid;
|
|
|
+ margin: 2px 4px 2px 0;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ .high-count {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: rgba(255, 90, 60, 0.2);
|
|
|
+ color: #ff9c8c;
|
|
|
+ font-weight: 700;
|
|
|
+ }
|
|
|
+}
|
|
|
+.empty-red {
|
|
|
+ text-align: center;
|
|
|
+ padding: 40px 0;
|
|
|
+ color: #6c8db0;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 小屏下 KPI 双列 */
|
|
|
+@media (max-width: 640px) {
|
|
|
+ .kpi-row {
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|