|
@@ -0,0 +1,932 @@
|
|
|
|
|
+<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 { CalendarComponent, 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,
|
|
|
|
|
+ CalendarComponent,
|
|
|
|
|
+ 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 calendarData = ref<[string, number][]>([])
|
|
|
|
|
+
|
|
|
|
|
+// ========================= 图表实例管理 =========================
|
|
|
|
|
+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)
|
|
|
|
|
+ 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'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const levelNames: Record<string, string> = {
|
|
|
|
|
+ none: '无风险',
|
|
|
|
|
+ low: '低危',
|
|
|
|
|
+ medium: '中危',
|
|
|
|
|
+ high: '高危'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ========================= 图表配置生成 =========================
|
|
|
|
|
+
|
|
|
|
|
+/** 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: 'vertical', right: 10, top: 'center', textStyle: { color: COLORS.text, fontSize: 12 } },
|
|
|
|
|
+ series: [{
|
|
|
|
|
+ name: '风险等级', type: 'pie', radius: ['35%', '70%'], center: ['38%', '50%'],
|
|
|
|
|
+ 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 }
|
|
|
|
|
+ }]
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 13. 月度评估日历热力图 */
|
|
|
|
|
+const renderCalendar = () => {
|
|
|
|
|
+ const data = calendarData.value
|
|
|
|
|
+ if (!data.length) return
|
|
|
|
|
+ createChart('calendar', {
|
|
|
|
|
+ backgroundColor: 'transparent',
|
|
|
|
|
+ tooltip: { textStyle: { color: '#fff' }, formatter: (p: any) => `${p.data[0]}<br/>评估次数:${p.data[1]} 次` },
|
|
|
|
|
+ visualMap: {
|
|
|
|
|
+ min: 0, max: 15, calculable: true, orient: 'horizontal', left: 'center', top: 0,
|
|
|
|
|
+ textStyle: { color: COLORS.text }, inRange: { color: ['#0a2f5e', '#00aaff', '#00ffff', '#ffd85a', '#ff5a3c'] }
|
|
|
|
|
+ },
|
|
|
|
|
+ calendar: {
|
|
|
|
|
+ top: 60, left: 40, right: 40, bottom: 20,
|
|
|
|
|
+ range: ['2026-01-01', '2026-07-24'],
|
|
|
|
|
+ cellSize: ['auto', 14],
|
|
|
|
|
+ itemStyle: { color: 'rgba(10, 47, 94, 0.3)', borderColor: 'rgba(26, 58, 107, 0.5)', borderWidth: 1 },
|
|
|
|
|
+ splitLine: { lineStyle: { color: 'rgba(0, 255, 255, 0.15)' } },
|
|
|
|
|
+ yearLabel: { color: COLORS.text, fontSize: 14 },
|
|
|
|
|
+ monthLabel: { color: COLORS.text },
|
|
|
|
|
+ dayLabel: { color: COLORS.textDim }
|
|
|
|
|
+ },
|
|
|
|
|
+ series: [{ type: 'heatmap', coordinateSystem: 'calendar', data }]
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 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: 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, formatter: '{value}%' } },
|
|
|
|
|
+ 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()
|
|
|
|
|
+ renderCalendar()
|
|
|
|
|
+ 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, cal
|
|
|
|
|
+ ] = 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 }),
|
|
|
|
|
+ echartsApi.getMonthlyAssessCalendar({ buildId: params.buildId })
|
|
|
|
|
+ ])
|
|
|
|
|
+ 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
|
|
|
|
|
+ calendarData.value = cal
|
|
|
|
|
+ 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.month" placeholder="全部月份" clearable class="filter-select" @change="handleFilterChange">
|
|
|
|
|
+ <el-option v-for="m in monthOptions" :key="m" :label="m" :value="m" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <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">
|
|
|
|
|
+ <div class="card-title"><span class="dot dot-high"></span>3D 楼栋 × 九防高危人数(可旋转)</div>
|
|
|
|
|
+ <div :ref="setRef('bar3D')" class="chart-tall"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 日历热力图(全宽) -->
|
|
|
|
|
+ <div class="card span-full">
|
|
|
|
|
+ <div class="card-title"><span class="dot dot-low"></span>半年度评估频次日历</div>
|
|
|
|
|
+ <div :ref="setRef('calendar')" class="chart-calendar"></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; }
|
|
|
|
|
+.grid-auto .span-full { grid-column: 1 / -1; }
|
|
|
|
|
+@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);
|
|
|
|
|
+ &::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%; height: 320px; }
|
|
|
|
|
+.chart-tall { width: 100%; height: 420px; }
|
|
|
|
|
+.chart-calendar { width: 100%; height: 200px; }
|
|
|
|
|
+
|
|
|
|
|
+.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>
|