ソースを参照

增加九防图表3

chenjun 2 週間 前
コミット
33969c4078

+ 270 - 0
.workbuddy/memory/2026-08-06.md

@@ -0,0 +1,270 @@
+# 2026-08-06
+
+## elderly/apply/nine-precautions/risk-disclosure-statement/Form.vue — secondElderSign 字符串/数组互转
+
+用户要求:用 `TableUpload` 的 `secondElderSign` 字段,提交时转成字符串、回显时转成数组(参照 check-in ProcessForm.vue 的做法)。
+
+背景:`TableUpload`(`src/components/UploadFile/src/TableUpload.vue`)的 `modelValue` 必须是数组 `FileItem[]`(内部 `fileList = props.modelValue || []`,并 `push`/`splice`),而后端以 JSON 字符串存储该字段。原 `open()` 用 `Object.assign(dataForm, res)` 直接把后端字符串塞进 `secondElderSign`,编辑/查看时组件会拿到字符串而崩。
+
+改动:
+- 新增两个辅助函数 `toFileArray(val)`(字符串→数组,带 Array.isArray/JSON.parse 兜底)与 `toFileString(val)`(空数组→'',否则 JSON.stringify)。
+- `open()`:`Object.assign` 后加 `dataForm.secondElderSign = toFileArray(res.secondElderSign)`(回显转数组)。
+- `submitForm()`:`submitData` 加 `secondElderSign: toFileString(dataForm.secondElderSign)`(提交转字符串)。
+- 因 `reactive` 里 `secondElderSign: []` 被推成 `never[]`,在声明处和 `resetForm` 处改为 `[] as any[]`,消除 TS2322。
+
+类型检查:引入的 TS2322 已消除。Form.vue 仍剩 441/819 两处 TS6133(未使用变量 updateRiskCheckboxes / nextAssessDate),为改动前既有的,与本次无关、不影响运行。
+
+## elderly/apply/nine-precautions/risk-disclosure-statement/index.vue — 导出 secondElderSign 为图片
+
+用户要求:导出(handleImportCard / ButtonImport)时把 `secondElderSign`(后端存 JSON 字符串,形如 `[{fileUrl, fileName, fileSize, createTime}]`,存的是 OSS 图片 URL)导出成真实图片嵌入 Excel。
+
+根因/决策:原导出用 `exportWithExpandedObjectArrays`(`src/utils/excel-export.ts`,底层 SheetJS/xlsx 社区版)**无法往 Excel 嵌图片**,且原代码压根没导出该字段。改用项目已有的 `exceljs`(^4.4.0,已在 node_modules;`social-worker/floor-activity-record/photo/index.vue` 已有可复用的「ExcelJS + addImage + fetch 二进制」范式)。
+
+改动:
+- 移除 `exportWithExpandedObjectArrays` 导入,新增 `import ExcelJS from 'exceljs'`、`import { getAccessToken } from '@/utils/auth'`、`import { saveAs } from 'file-saver'`。
+- 重写 `handleImportCard`:拉取列表后交给新函数 `exportRiskDisclosureToExcel(exportList)`。
+- 新增 `exportRiskDisclosureToExcel`:建 workbook/worksheet,18 个文本列 + 动态「二次告知签名」图片列(列数=所有行签名图最大数量,无图则不生成图片列);逐行 addRow,对每行 `secondElderSign` 用 `parseFileList` 解析出 fileUrl 数组,fetch 成 ArrayBuffer 后 `workbook.addImage({buffer, extension})` + `worksheet.addImage(imageId, {tl, ext:{width:90,height:90}})` 嵌入;失败则单元格写「图片加载失败」。
+- 新增 `parseFileList`(JSON 字符串/数组→`{fileUrl,fileName}[]`,带兜底)、`fetchImageAsBuffer`(复用 photo 页写法,fetch 带 `Authorization: Bearer token`)、`getImageExtension`(png→png,其余→jpeg;注意 ExcelJS 不支持 'jpg' 字面量,必须映射到 'jpeg')。
+
+类型检查:引入的 `getImageExtension` 返回类型含 'jpg' 导致 TS2322,已改为返回 `'png' | 'jpeg'` 修正。index.vue 其余报错(模板 el-tag :type 返回 string、handleEdit/handleView 多传参、若干 TS6133 未用变量)均为 385 行之前的既有问题,与本次无关。
+
+## data-board/nine-safety-measures/index.vue — 对接真实看板接口(前两个对象)
+
+用户要求:后台接口 `nineRiskBoardGet`(`src/api/data-board/nine-safety.ts` 第一个接口)已写好,先对接返回的 `overview` 和 `itemRiskDistributions` 两个对象,其余字段后续再对接。
+
+改动:
+- `loadAllData`:
+  - 在参数里补 `tenantIds`(从 `userStore.orgTenantId` 取,数组化后传入)。
+  - 把 `getNineSafetyOverview` 和 `getNineRiskDistribution` 两个 mock 调用合并成一次真实请求 `echartsApi.nineRiskBoardGet(params)`。
+  - 其余 11 个 mock 接口继续保留(月度趋势、热力图、散点、箱线图、签署状态、红名单、评估人工作量、护理级别、相关性、雷达、3D 柱状图)。
+- 字段映射:
+  - `overview`:
+    - `disclosureTotal` → `totalElders`(知情书总数)
+    - `highRiskTimes` → `highRiskCount`(高风险总人次)
+    - `highRiskTimesRatio` → `highRiskRatio`(高风险人次占比 %)
+    - `disclosureUnsignedCount` → `pendingSignatures`(待签署知情书)
+    - `100 - disclosureUnsignedRatio` → `signedRate`(签署率 %)
+    - `assessCompleteRate` → `evalCompletionRate`(评估完成率 %)
+    - `assessorCount` → `assessorCount`(评估人员数)
+  - `itemRiskDistributions`:
+    - `itemName` → `name`
+    - `highCount` → `high`
+    - `middleCount` → `medium`
+    - `lowCount` → `low`
+    - `unknownCount` → `none`
+    - `totalCount` → `total`
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — highRiskHeatMap 结构变更适配
+
+用户反馈:`highRiskHeatMap` 字段结构有变化(顶部加了楼栋/楼层筛选后),`cells` 改为对象数组。
+
+新结构:
+- `items`:`{code: itemCode, name: 九防名}`
+- `builds`:`{code: buildId, name: 楼栋名}`
+- `cells`:`{buildId, buildName, itemCode, itemName, highRiskElderCount, maxValue}`
+
+改动:
+- 热力图映射不再依赖隐式 `x/y` 下标,改为通过 `itemCode` / `buildId` 在 `items` / `builds` 里查找索引。
+- `heatCells` 过滤掉找不到对应索引的异常单元格。
+- 顺带把 `maxValue` 存到 `floorHeatmap` / `bar3D` 对象里,供后续色阶归一化使用(当前渲染仍用自身计算的最大值,兼容安全)。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 对接真实看板接口(剩余全部字段)
+
+用户发了 12 张接口字段截图,要求把剩余字段全部对接。
+
+改动:
+- 新增 `riskLevelRatios` ref,`renderOverallPie` 改为从该 ref 读取(不再从 `nineRiskDist` 聚合)。
+- `loadAllData`:
+  - 只保留 `getRiskRadar` 一个 mock 调用(真实接口未提供"护理级别 × 九防高危占比"雷达图明细)。
+  - 其余 10 个 mock 接口全部移除,统一从 `nineRiskBoardGet` 一次返回的数据里映射。
+- 字段映射:
+  - `riskLevelRatios` → `renderOverallPie`:`riskLevelName/riskLevel` → `name`,`count` → `value`,按 `riskLevel` 映射颜色。
+  - `monthlyTrends` → `riskTrend`:`month/highCount/middleCount/lowCount/unknownCount` → `month/high/medium/low/none`。
+  - `highRiskHeatMap` → `floorHeatmap` / `bar3D`:items → risks,builds → floors,cells → `[buildIndex, itemIndex, value]`。
+  - `highRiskElders` → `ageScatter`:`elderName/elderAge/elderSex/highRiskCount/nurseLevelName` → `name/age/sex/highCount/nurseLevel`。
+  - `itemScoreDistributions` → `scoreDist`:真实返回 buckets,按 count 累计计算五数概括(min/q1/median/q3/max)供箱线图使用。
+  - `disclosureSignRate` → `signature`:`signedCount/unsignedCount/total/signRate` → `signed/unsigned/total/signedRate`,`firstSignedOnly` 给 0。
+  - `nurseLevelHighRiskElderCounts` → `nurseLevelRisk`:`nurseLevelName/count` → `name/high`。
+  - `assessorStats` → `assessorWorkload`:`assessor/assessedElderCount/highRiskElderCount` → `name/count/highCount`。
+  - `multiHighRiskElders` → `redList`:`elderId/elderName/elderSex/elderAge/bedName/buildName/nurseLevelName/assessor/highRiskItems/highRiskCount/assessDate` → 页面字段;`highRiskItems` 按逗号拆分并去掉"防"字。
+- 模板:红名单表格"评估日期"列从写死的 `2022-12-12` 改为 `r.assessDate`。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — highRiskHeatMap 结构变更适配
+
+用户反馈:`highRiskHeatMap` 字段结构有变化(顶部加了楼栋/楼层筛选后),`cells` 改为对象数组。
+
+新结构:
+- `items`:`{code: itemCode, name: 九防名}`
+- `builds`:`{code: buildId, name: 楼栋名}`
+- `cells`:`{buildId, buildName, itemCode, itemName, highRiskElderCount, maxValue}`
+
+改动:
+- 热力图映射不再依赖隐式 `x/y` 下标,改为通过 `itemCode` / `buildId` 在 `items` / `builds` 里查找索引。
+- `heatCells` 过滤掉找不到对应索引的异常单元格。
+- 顺带把 `maxValue` 存到 `floorHeatmap` / `bar3D` 对象里,供后续色阶归一化使用(当前渲染仍用自身计算的最大值,兼容安全)。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 雷达图改用 nurseLevelHighRiskRecordCounts
+
+用户要求:「各护理级别 · 九防高危占比雷达」使用 `nurseLevelHighRiskRecordCounts` 数组。
+
+背景:该数组只有 `{nurseLevelId, nurseLevelName, count, ratio}`,没有按九防拆分。原 mock 雷达图是「护理级别 × 九防」矩阵,真实数据无法直接还原该形态。
+
+改动:
+- `loadAllData`:移除最后一个 mock 调用 `getRiskRadar`,`nineRiskBoardGet` 成为唯一数据源。
+- 护理级别数据映射:从 `bd.nurseLevelHighRiskRecordCounts` 读取,给 `nurseLevelRisk` 增加 `count`/`ratio`,保留 `high`(=count)供漏斗图继续使用。
+- `renderRiskRadar`:改为读 `nurseLevelRisk`;indicator = 各护理级别名称;series 两个:「高危占比(%)」用真实 ratio,「记录数(归一化)」用 `count/maxCount*100` 避免数量级失真;tooltip 显示真实记录数和占比。
+- 删除 `riskRadar` ref。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — highRiskHeatMap 结构变更适配
+
+用户反馈:`highRiskHeatMap` 字段结构有变化(顶部加了楼栋/楼层筛选后),`cells` 改为对象数组。
+
+新结构:
+- `items`:`{code: itemCode, name: 九防名}`
+- `builds`:`{code: buildId, name: 楼栋名}`
+- `cells`:`{buildId, buildName, itemCode, itemName, highRiskElderCount, maxValue}`
+
+改动:
+- 热力图映射不再依赖隐式 `x/y` 下标,改为通过 `itemCode` / `buildId` 在 `items` / `builds` 里查找索引。
+- `heatCells` 过滤掉找不到对应索引的异常单元格。
+- 顺带把 `maxValue` 存到 `floorHeatmap` / `bar3D` 对象里,供后续色阶归一化使用(当前渲染仍用自身计算的最大值,兼容安全)。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 评估得分分布箱线图数据映射修正
+
+用户反馈:「评估得分分布(箱线图)」渲染异常,大部分项目被拉成 0~100 的细长线条,只有防跌倒、防噎食等少数项目能看出箱子。
+
+根因:后台 `itemScoreDistributions` 返回了真实的 `minScore / maxScore`,也返回了 `buckets`(分桶统计)。原映射用 `buckets[0].min` 和 `buckets[last].max` 作为箱线图的 min/max,导致每个项目的上下须都钉死在 0 和 100,箱子被压扁;四分位数也只用桶下边界,没有插值。
+
+改动:
+- min/max 改为优先使用后台真实 `minScore / maxScore`,缺失时才回退到桶边界。
+- 四分位数 `q1 / median / q3` 在桶内做线性插值,而不是直接取桶下边界。
+- 空桶跳过,避免异常点干扰累计。
+- 五数概括统一保留 2 位小数,避免浮点精度导致十几位小数。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 按护理级别 · 高危项漏斗字段修正
+
+用户反馈:「按护理级别 · 高危项漏斗」图表为空,后台真实字段是 `nurseLevelHighRiskElderCounts`。
+
+根因:`loadAllData` 里护理级别数据仍从 `bd.nurseLevelHighRiskRecordCounts` 读取,与后台实际返回字段不一致,导致 `nurseLevelRisk` 为空,漏斗图没有数据。
+
+改动:
+- `nurseLevelRisk` 改为优先读取 `bd.nurseLevelHighRiskElderCounts`,同时兼容旧名 `bd.nurseLevelHighRiskRecordCounts` 作为 fallback。
+- 漏斗图和雷达图共用 `nurseLevelRisk`,结构保持 `{name, count, ratio, none, low, medium, high}` 不变。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 雷达图适配 nurseLevelHighRiskRecordCounts 新结构
+
+用户反馈:`nurseLevelHighRiskRecordCounts` 数据结构已变化,需要按新结构重新渲染「各护理级别 · 九防高危占比雷达」。
+
+新结构:
+```js
+[
+  {
+    nurseLevelId,
+    nurseLevelName,
+    ratio,
+    totalCount,
+    items: [
+      { itemCode, itemName, count, ratio }
+    ]
+  }
+]
+```
+
+改动:
+- 新增 `nurseLevelRadarData` ref,结构为 `{ indicators: [{name, max}], series: [{name, value, count, lineStyle, itemStyle, areaStyle}] }`。
+- `loadAllData`:从 `bd.nurseLevelHighRiskRecordCounts` 解析雷达数据;indicator 取第一条护理级别的 `items.itemName`;每个护理级别作为一条 radar series,value 为各九防项的 `ratio`(保留 2 位小数),count 为原始人数供 tooltip 使用。
+- `renderRiskRadar`:改为读取 `nurseLevelRadarData`;indicator = 九防项目名称;legend 显示各护理级别名称;tooltip 展示该护理级别下每个九防项的占比和人数。
+- 漏斗图继续使用 `nurseLevelRisk`(来自 `nurseLevelHighRiskElderCounts`),与雷达图数据源解耦。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 评估人工作量统计 X 轴防拥挤
+
+用户反馈:「评估人工作量统计」图表 X 轴标签太多、互相重叠。
+
+改动:
+- `renderAssessorWorkload`:
+  - `grid.bottom` 从 30 加大到 90,给旋转标签留出垂直空间。
+  - `xAxis.axisLabel.rotate` 从 40 度加大到 60 度,`fontSize` 从 11 降到 10,`margin` 从 8 加到 12。
+  - 新增 `dataZoom` slider,默认展示前 12 条(或 40%,取较小者),底部出现横向滚动条,可拖动查看其余评估人。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 修复 3D 楼栋图 tooltip 显示 undefined
+
+问题:3D 楼栋×九防高危人数图表 hover 时 tooltip 显示 `undefined - undefined`,`高危人数:undefined 人`。
+
+根因:ECharts `bar3D` 的 tooltip formatter 参数里,数据坐标/值位于 `params.value`,而 `params.data` 是 `{ value: [...] }` 对象。原代码用 `p.data[0]` / `p.data[2]` 访问,拿到的是 undefined。
+
+改动:`render3DBar` 的 tooltip formatter 改为从 `p.value` 读取 `[楼栋索引, 九防索引, 高危人数]`,并用 `floors` / `risks` 数组反查名称;同时加 `?? '-'` 兜底,避免极端情况再次显示 undefined。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 红名单九防筛选生效 + 评估日期格式化
+
+问题:
+1. 重点人群红名单的九防风险下拉筛选没有过滤效果。
+2. 评估日期显示为数组形式 `[2026,7,28]`,需要格式化为正常日期。
+
+根因:
+- 之前 `nineRiskOptions` 的 value 已去掉"防"前缀,但 `redList.risks` 存储格式与下拉 value 的对应关系存在歧义,导致筛选不直观;同时 `el-select` 的 `clearable` 可能让空值判断不够健壮。
+- `assessDate` 后台直接返回了 `[年,月,日]` 数组,原样渲染到表格。
+
+改动:
+- 统一使用带"防"前缀的风险名称:`redList.risks` 保留原始值(不再 `.replace('防', '')`),`nineRiskOptions` 的 value 也使用 `r.name`。
+- 风险标签颜色计算时去掉"防"前缀以匹配 `riskTagColor` 的 key:`riskTagColor(rk.replace('防', ''))`。
+- 新增 `formatAssessDate` 工具函数,支持数组、Date、字符串,统一输出 `yyyy-MM-dd`。
+- `redList` 映射时 `assessDate` 使用 `formatAssessDate(e.assessDate)`。
+- 模板中评估日期直接显示 `r.assessDate`(函数已返回 '-' 兜底)。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 红名单风险筛选仍显示 0 人
+
+问题:选择"防噎食"后,红名单显示"共 0 人",但数据中应包含该风险项的长者。
+
+根因:
+- `e.highRiskItems` 可能以数组形式返回,原代码只按字符串 `.split(',')` 解析,导致数组被序列化为 `"防噎食,防压疮"` 这样的单一项。
+- 即使解析正确,精确匹配 `===` 对空格、前缀不一致等边界情况不够健壮。
+
+改动:
+- `redList.risks` 解析改为同时支持数组和字符串:数组逐项 `String().trim()`,字符串按逗号拆分。
+- `filteredRedList` 匹配逻辑增强:
+  - 精确相等 `s === redRiskFilter.value`
+  - 去掉"防"前缀后相等 `s.replace('防', '') === keyword`
+  - 互相包含匹配 `s.includes(redRiskFilter.value) || redRiskFilter.value.includes(s)`
+- 空字符串风险项会被过滤掉。
+
+类型检查:NO_NINE_SAFETY_ERRORS。
+
+## data-board/nine-safety-measures/index.vue — 简易精神状态映射为防误食
+
+问题:后端把「防误食」这条风险命名为「简易精神状态」,导致红名单选「防误食」过滤不出数据,且页面各图表会显示「简易精神状态」。
+
+改动:
+- 新增 `normalizeRiskName` 工具函数:输入 `"简易精神状态"` 时返回 `"防误食"`,其他名称原样返回。
+- 在 `loadAllData` 中所有涉及九防项目名称的地方统一应用该映射:
+  - `nineRiskDist`(九防风险等级分布)
+  - `heatItems`(热力图/3D 图的九防轴标签)
+  - `scoreDist`(评估得分分布箱线图)
+  - `nurseLevelRadarData`(各护理级别雷达 indicator)
+  - `redList.risks`(红名单高风险项标签与下拉筛选)
+- 红名单下拉选项本身来自 `NINE_RISKS`,已固定为「防误食」,映射后筛选可正常命中。
+
+类型检查:NO_NINE_SAFETY_ERRORS。

+ 12 - 395
src/api/data-board/nine-safety.ts

@@ -1,3 +1,14 @@
+import request from '@/config/axios'
+
+// 获取九防安全风险综合看板数据
+export const nineRiskBoardGet = (params:any) => {
+  return request.get({
+    url: `/nine-risk-board/get`,
+    params: params
+  })
+}
+
+
 /**
  * 九防安全看板 - Mock API
  * 基于风险知情书 + 九防项目列表的数据结构,模拟后端返回的聚合统计数据。
@@ -14,404 +25,10 @@ export const NINE_RISKS = [
   { key: 'wandering', name: '防走失' },
   { key: 'selfHarm', name: '防自杀' },
   { key: 'foodDrug', name: '防误食' },
-  { key: 'entertainment', name: '防文娱意外' }
-]
-
-const FLOORS = ['1号楼', '2号楼', '3号楼', '4号楼', '5号楼', '6号楼']
-const NURSE_LEVELS = ['特级护理', '一级护理', '二级护理', '三级护理']
-const ASSESSORS = ['张护士', '李护士', '王护士', '赵护士', '刘护士', '陈护士']
-const LEVELS = ['none', 'low', 'medium', 'high']
-const ELDER_NAMES = [
-  '张大爷', '王奶奶', '李爷爷', '赵奶奶', '刘大爷', '陈奶奶', '杨爷爷', '黄奶奶',
-  '吴大爷', '周奶奶', '徐爷爷', '孙奶奶', '马大爷', '朱奶奶', '胡爷爷', '郭奶奶',
-  '何大爷', '高奶奶', '林爷爷', '罗奶奶', '郑大爷', '梁奶奶', '宋爷爷', '谢奶奶'
+  { key: 'entertainment', name: '防文娱活动意外' }
 ]
 
-// ========================= 简易随机数(带 seed,保证筛选一致性) =========================
-const seededRandom = (seed: number) => {
-  let s = seed
-  return () => {
-    s = (s * 9301 + 49297) % 233280
-    return s / 233280
-  }
-}
-
-const pick = <T>(arr: T[], rnd: () => number): T => arr[Math.floor(rnd() * arr.length)]
-const randInt = (min: number, max: number, rnd: () => number) =>
-  Math.floor(rnd() * (max - min + 1)) + min
-
-/** 根据筛选条件生成种子 */
-const makeSeed = (month?: string, buildId?: string | number, floorId?: string | number) => {
-  let seed = 20260724
-  if (month) seed += month.split('-').reduce((s, v) => s + parseInt(v), 0) * 31
-  if (buildId) seed += Number(buildId) * 7
-  if (floorId) seed += Number(floorId) * 13
-  return seed
-}
-
-// ========================= 模拟延迟 =========================
-const delay = <T>(data: T, ms = 200): Promise<T> =>
-  new Promise((resolve) => setTimeout(() => resolve(JSON.parse(JSON.stringify(data))), ms))
-
-// ========================= 生成原始长者数据 =========================
-const generateElders = (month?: string, buildId?: string | number, floorId?: string | number) => {
-  const rnd = seededRandom(makeSeed(month, buildId, floorId))
-  const targetFloors = buildId ? [FLOORS[Number(buildId) % FLOORS.length]] : FLOORS
-  // 选了楼层时人数减少(模拟单层数据)
-  const count = floorId ? randInt(15, 35, rnd) : randInt(60, 120, rnd)
-  const elders: any[] = []
-
-  for (let i = 0; i < count; i++) {
-    const fl = pick(targetFloors, rnd)
-    const roomNum = randInt(101, 320, rnd)
-    const bedNo = randInt(1, 6, rnd)
-    const name = pick(ELDER_NAMES, rnd) + (i % 3 === 0 ? `(${i})` : '')
-    const sex = rnd() > 0.45 ? 1 : 2
-    const age = randInt(65, 98, rnd)
-    const nurseLevel = pick(NURSE_LEVELS, rnd)
-    const assessor = pick(ASSESSORS, rnd)
-
-    // 九防风险数据
-    const riskData: any = {}
-    NINE_RISKS.forEach((r) => {
-      // 年龄越大、护理级别越高,风险越高
-      let levelWeight = 0.3
-      if (age > 85) levelWeight += 0.2
-      if (nurseLevel === '特级护理' || nurseLevel === '一级护理') levelWeight += 0.2
-      const roll = rnd()
-      let level: string
-      if (roll < levelWeight * 0.15) level = 'high'
-      else if (roll < levelWeight * 0.5) level = 'medium'
-      else if (roll < levelWeight) level = 'low'
-      else level = 'none'
-
-      riskData[r.key] = {
-        id: randInt(1, 9999, rnd),
-        level,
-        remark: { low: false, medium: false, high: false }
-      }
-    })
-
-    // 签署状态
-    const signed = rnd() > 0.25
-    const firstRiskDate = new Date(2026, randInt(0, 6, rnd), randInt(1, 28, rnd))
-
-    elders.push({
-      id: 10000 + i,
-      elderId: 19000 + i,
-      elderName: name,
-      elderSex: sex,
-      elderAge: age,
-      floorName: fl,
-      bedName: `${fl}-${roomNum}-${String(bedNo).padStart(2, '0')}`,
-      nurseLevelName: nurseLevel,
-      riskData: JSON.stringify(riskData),
-      firstRisk: firstRiskDate.toISOString().slice(0, 10),
-      signDate: signed ? firstRiskDate.toISOString().slice(0, 10) : null,
-      assessor,
-      assessScore: randInt(5, 30, rnd),
-      riskLevel: pick(LEVELS, rnd),
-      createTime: firstRiskDate.getTime(),
-      tenantId: 195
-    })
-  }
-
-  return elders
-}
-
-// ========================= 聚合统计接口 =========================
-
-/** 1. KPI 总览 */
-export const getNineSafetyOverview = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  let highCount = 0
-  let assessedItems = 0
-  let signedCount = 0
-  elders.forEach((e) => {
-    const rd = JSON.parse(e.riskData)
-    NINE_RISKS.forEach((r) => {
-      const node = rd[r.key]
-      if (node?.id) assessedItems++
-      if (node?.level === 'high') highCount++
-    })
-    if (e.signDate) signedCount++
-  })
-  const total = elders.length
-  const expected = total * 9
-  return delay({
-    totalElders: total,
-    assessedElders: total,
-    highRiskCount: highCount,
-    highRiskRatio: expected > 0 ? +((highCount / expected) * 100).toFixed(1) : 0,
-    signedCount,
-    pendingSignatures: total - signedCount,
-    signedRate: total > 0 ? +((signedCount / total) * 100).toFixed(1) : 0,
-    evalCompletionRate: expected > 0 ? +((assessedItems / expected) * 100).toFixed(1) : 0,
-    assessorCount: ASSESSORS.length
-  })
-}
-
-/** 2. 九防各项风险等级分布 */
-export const getNineRiskDistribution = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const result = NINE_RISKS.map((r) => {
-    const row = { name: r.name, key: r.key, none: 0, low: 0, medium: 0, high: 0, total: 0 }
-    elders.forEach((e) => {
-      const node = JSON.parse(e.riskData)[r.key]
-      if (node?.level && row.hasOwnProperty(node.level)) {
-        row[node.level]++
-        row.total++
-      }
-    })
-    return row
-  }).sort((a, b) => b.high - a.high)
-  return delay(result)
-}
-
-/** 3. 风险等级月度趋势(近 6 个月) */
-export const getRiskLevelTrend = (params?: { buildId?: string | number }) => {
-  const months: string[] = []
-  const now = new Date(2026, 6, 1) // 2026-07
-  for (let i = 5; i >= 0; i--) {
-    const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
-    months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
-  }
-  const trend = months.map((m) => {
-    const elders = generateElders(m, params?.buildId)
-    const counts = { none: 0, low: 0, medium: 0, high: 0 }
-    elders.forEach((e) => {
-      const rd = JSON.parse(e.riskData)
-      NINE_RISKS.forEach((r) => {
-        const level = rd[r.key]?.level
-        if (level && counts.hasOwnProperty(level)) counts[level]++
-      })
-    })
-    return { month: m, ...counts }
-  })
-  return delay(trend)
-}
-
-/** 4. 楼栋 × 九防风险热力图 */
-export const getFloorRiskHeatmap = (params?: { month?: string }) => {
-  const floors = FLOORS
-  const risks = NINE_RISKS.map((r) => r.name)
-  const data: [number, number, number][] = []
-  floors.forEach((_, fi) => {
-    risks.forEach((_, ri) => {
-      const elders = generateElders(params?.month, FLOORS[fi])
-      let high = 0
-      elders.forEach((e) => {
-        const node = JSON.parse(e.riskData)[NINE_RISKS[ri].key]
-        if (node?.level === 'high') high++
-      })
-      data.push([fi, ri, high])
-    })
-  })
-  return delay({ floors, risks, data })
-}
-
-/** 5. 年龄-高风险散点图 */
-export const getAgeRiskScatter = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const data = elders
-    .map((e) => {
-      const rd = JSON.parse(e.riskData)
-      let high = 0
-      NINE_RISKS.forEach((r) => {
-        if (rd[r.key]?.level === 'high') high++
-      })
-      return {
-        name: e.elderName,
-        age: e.elderAge,
-        sex: e.elderSex,
-        highCount: high,
-        room: e.bedName,
-        nurseLevel: e.nurseLevelName
-      }
-    })
-    .filter((d) => d.highCount > 0)
-  return delay(data)
-}
-
-/** 6. 评估得分分布(箱线图) */
-export const getAssessScoreDistribution = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const result = NINE_RISKS.map((r) => {
-    const scores: number[] = []
-    elders.forEach((e) => {
-      const node = JSON.parse(e.riskData)[r.key]
-      if (node?.id) scores.push(randInt(5, 30, seededRandom(e.id + r.key.length)))
-    })
-    scores.sort((a, b) => a - b)
-    const q = (p: number) => {
-      if (scores.length === 0) return 0
-      const idx = Math.floor(scores.length * p)
-      return scores[idx]
-    }
-    return {
-      name: r.name,
-      min: scores[0] || 0,
-      q1: q(0.25),
-      median: q(0.5),
-      q3: q(0.75),
-      max: scores[scores.length - 1] || 0
-    }
-  })
-  return delay(result)
-}
-
-/** 7. 签署状态统计 */
-export const getSignatureStatus = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  let signed = 0
-  let firstSignedOnly = 0
-  let unsigned = 0
-  elders.forEach((e) => {
-    if (e.signDate) signed++
-    else if (e.firstRisk) firstSignedOnly++
-    else unsigned++
-  })
-  return delay({
-    signed,
-    firstSignedOnly,
-    unsigned,
-    total: elders.length,
-    signedRate: elders.length > 0 ? +((signed / elders.length) * 100).toFixed(1) : 0
-  })
-}
 
-/** 8. 高风险红名单 */
-export const getHighRiskRedList = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const list = elders
-    .map((e) => {
-      const rd = JSON.parse(e.riskData)
-      const risks: string[] = []
-      NINE_RISKS.forEach((r) => {
-        if (rd[r.key]?.level === 'high') risks.push(r.name.replace('防', ''))
-      })
-      return {
-        id: e.id,
-        name: e.elderName,
-        sex: e.elderSex === 1 ? '男' : '女',
-        age: e.elderAge,
-        room: e.bedName,
-        floor: e.floorName,
-        nurseLevel: e.nurseLevelName,
-        risks,
-        highCount: risks.length,
-        assessor: e.assessor
-      }
-    })
-    .filter((d) => d.highCount >= 2)
-    .sort((a, b) => b.highCount - a.highCount)
-  return delay(list)
-}
-
-/** 9. 评估人工作量 */
-export const getAssessorWorkload = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const map = new Map<string, { count: number; highCount: number }>()
-  elders.forEach((e) => {
-    if (!map.has(e.assessor)) map.set(e.assessor, { count: 0, highCount: 0 })
-    const item = map.get(e.assessor)!
-    item.count++
-    const rd = JSON.parse(e.riskData)
-    NINE_RISKS.forEach((r) => {
-      if (rd[r.key]?.level === 'high') item.highCount++
-    })
-  })
-  const result = Array.from(map.entries())
-    .map(([name, v]) => ({ name, count: v.count, highCount: v.highCount }))
-    .sort((a, b) => b.count - a.count)
-  return delay(result)
-}
-
-/** 10. 按护理级别统计风险 */
-export const getRiskByNurseLevel = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const result = NURSE_LEVELS.map((level) => {
-    const row = { name: level, none: 0, low: 0, medium: 0, high: 0 }
-    elders.filter((e) => e.nurseLevelName === level).forEach((e) => {
-      const rd = JSON.parse(e.riskData)
-      NINE_RISKS.forEach((r) => {
-        const lv = rd[r.key]?.level
-        if (lv && row.hasOwnProperty(lv)) row[lv]++
-      })
-    })
-    return row
-  })
-  return delay(result)
-}
 
-/** 11. 九防风险相关性矩阵 */
-export const getNineRiskCorrelation = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const risks = NINE_RISKS.map((r) => r.name)
-  const matrix: number[][] = []
-  for (let i = 0; i < NINE_RISKS.length; i++) {
-    matrix[i] = []
-    for (let j = 0; j < NINE_RISKS.length; j++) {
-      if (i === j) {
-        matrix[i][j] = 1
-      } else {
-        // 模拟相关性:同类型风险相关性较高
-        const baseCorr = Math.abs(i - j) <= 2 ? 0.4 : 0.15
-        const noise = (seededRandom(makeSeed(params?.month, params?.buildId, params?.floorId) + i * 9 + j)() - 0.5) * 0.3
-        matrix[i][j] = +Math.max(0, Math.min(1, baseCorr + noise)).toFixed(2)
-      }
-    }
-  }
-  return delay({ risks, matrix })
-}
 
-/** 12. 九防风险雷达图(各护理级别对比) */
-export const getRiskRadar = (params?: { month?: string; buildId?: string | number; floorId?: string | number }) => {
-  const elders = generateElders(params?.month, params?.buildId, params?.floorId)
-  const indicators = NINE_RISKS.map((r) => ({ name: r.name, max: 100 }))
-  const series = NURSE_LEVELS.map((level) => {
-    const data = NINE_RISKS.map((r) => {
-      let total = 0
-      let high = 0
-      elders.filter((e) => e.nurseLevelName === level).forEach((e) => {
-        const node = JSON.parse(e.riskData)[r.key]
-        if (node) {
-          total++
-          if (node.level === 'high') high++
-        }
-      })
-      return total > 0 ? Math.round((high / total) * 100) : 0
-    })
-    return { name: level, value: data }
-  })
-  return delay({ indicators, series })
-}
 
-/** 13. 3D 楼栋 × 九防 × 风险等级 */
-export const get3DRiskBar = (params?: { month?: string }) => {
-  const floors = FLOORS
-  const risks = NINE_RISKS.map((r) => r.name)
-  const data: [number, number, number][] = []
-  floors.forEach((_, fi) => {
-    risks.forEach((_, ri) => {
-      const elders = generateElders(params?.month, FLOORS[fi])
-      let high = 0
-      elders.forEach((e) => {
-        const node = JSON.parse(e.riskData)[NINE_RISKS[ri].key]
-        if (node?.level === 'high') high++
-      })
-      data.push([fi, ri, high])
-    })
-  })
-  return delay({ floors, risks, data })
-}
-
-/** 15. 月份列表(近 6 个月) */
-export const getMonthList = () => {
-  const months: string[] = []
-  const now = new Date(2026, 6, 1)
-  for (let i = 5; i >= 0; i--) {
-    const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
-    months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
-  }
-  return delay(months)
-}

+ 2 - 2
src/components/UploadFile/src/TableUpload.vue

@@ -11,8 +11,8 @@
             v-if="isImageFile(row.fileUrl)"
             :src="row.fileUrl"
             :preview-src-list="[row.fileUrl]"
-            fit="cover"
-            style="width: 40px; height: 40px; cursor: pointer"
+            fit="contain"
+            style="width: 100%; height: 42px; cursor: pointer;display: flex;align-items: center;justify-content: center;"
           />
           <Icon v-else icon="ep:document" style="font-size: 20px; color: #909399" />
         </template>

+ 335 - 94
src/views/data-board/nine-safety-measures/index.vue

@@ -12,11 +12,7 @@ import {
   SankeyChart,
   ThemeRiverChart
 } from 'echarts/charts'
-import {
-  DataZoomComponent,
-  BrushComponent,
-  RadarComponent
-} from 'echarts/components'
+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'
@@ -58,6 +54,7 @@ const handleBuildChange = (buildId: any) => {
 const loading = ref(false)
 const overview = ref<any>({})
 const nineRiskDist = ref<any[]>([])
+const riskLevelRatios = ref<any[]>([])
 const riskTrend = ref<any[]>([])
 const floorHeatmap = ref<any>({})
 const ageScatter = ref<any[]>([])
@@ -70,18 +67,31 @@ const nineRiskOptions = computed(() =>
   [{ label: '全部', value: '' }].concat(
     echartsApi.NINE_RISKS.map((r: any) => ({
       label: r.name,
-      value: r.name.replace('防', '')
+      value: r.name
     }))
   )
 )
 const filteredRedList = computed(() => {
   if (!redRiskFilter.value) return redList.value
-  return redList.value.filter((r: any) => Array.isArray(r.risks) && r.risks.includes(redRiskFilter.value))
+  const keyword = redRiskFilter.value.replace('防', '')
+  return redList.value.filter((r: any) =>
+    Array.isArray(r.risks) &&
+    r.risks.some((risk: any) => {
+      const s = String(risk || '').trim()
+      if (!s) return false
+      return (
+        s === redRiskFilter.value ||
+        s.replace('防', '') === keyword ||
+        s.includes(redRiskFilter.value) ||
+        redRiskFilter.value.includes(s)
+      )
+    })
+  )
 })
 const assessorWorkload = ref<any[]>([])
 const nurseLevelRisk = ref<any[]>([])
+const nurseLevelRadarData = ref<any>({ indicators: [], series: [] })
 const riskCorrelation = ref<any>({})
-const riskRadar = ref<any>({})
 const bar3D = ref<any>({})
 
 // ========================= 图表实例管理 =========================
@@ -222,19 +232,13 @@ const renderNineRiskDist = () => {
 
 /** 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)
+  const data = riskLevelRatios.value
+    .map((r: any) => ({
+      name: r.name,
+      value: r.value,
+      itemStyle: { color: r.color }
+    }))
+    .filter((d: any) => d.value > 0)
   createChart('overallPie', {
     backgroundColor: 'transparent',
     tooltip: {
@@ -257,7 +261,7 @@ const renderOverallPie = () => {
         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 } }))
+        data
       }
     ]
   })
@@ -532,8 +536,8 @@ const renderNurseLevelFunnel = () => {
       {
         type: 'funnel',
         left: '10%',
-        top: 40,
-        bottom: 20,
+        top: 50,
+        bottom: 10,
         width: '80%',
         label: { color: '#fff', fontSize: 12, formatter: '{b}: {c}' },
         itemStyle: { borderColor: '#05132b', borderWidth: 1 },
@@ -547,15 +551,29 @@ const renderNurseLevelFunnel = () => {
   })
 }
 
-/** 9. 九防风险雷达图 */
+/** 9. 各护理级别 · 九防高危占比雷达 */
 const renderRiskRadar = () => {
-  const { indicators, series } = riskRadar.value
-  if (!indicators || !series) return
-  const colors = ['#ff5a3c', '#ff9966', '#ffd85a', '#00e0a7']
+  const { indicators, series } = nurseLevelRadarData.value
+  if (!indicators.length || !series.length) return
   createChart('riskRadar', {
     backgroundColor: 'transparent',
-    tooltip: { textStyle: { color: '#fff' } },
-    legend: { data: series.map((s: any) => s.name), textStyle: { color: COLORS.text }, top: 0 },
+    tooltip: {
+      textStyle: { color: '#fff' },
+      formatter: (p: any) => {
+        const s = series[p.dataIndex]
+        if (!s) return p.name
+        const lines = s.value.map((v: number, i: number) => {
+          return `${indicators[i].name}:${v}%(${s.count[i] ?? '-'} 人)`
+        })
+        return [`<strong>${s.name}</strong>`, ...lines].join('<br/>')
+      }
+    },
+    legend: {
+      data: series.map((s: any) => s.name),
+      textStyle: { color: COLORS.text },
+      top: 0,
+      type: 'scroll'
+    },
     radar: {
       indicator: indicators,
       shape: 'polygon',
@@ -568,14 +586,7 @@ const renderRiskRadar = () => {
     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 }
-        }))
+        data: series
       }
     ]
   })
@@ -589,12 +600,28 @@ const renderAssessorWorkload = () => {
     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 },
+    grid: { left: 50, right: 30, top: 40, bottom: 90 },
+    dataZoom: [
+      {
+        type: 'slider',
+        show: true,
+        xAxisIndex: [0],
+        start: 0,
+        end: Math.min(40, (20 / data.length) * 100),
+        height: 14,
+        bottom: 8,
+        borderColor: 'transparent',
+        backgroundColor: 'rgba(10,47,94,0.3)',
+        fillerColor: 'rgba(0,170,255,0.25)',
+        handleStyle: { color: '#00aaff' },
+        textStyle: { color: COLORS.text }
+      }
+    ],
     xAxis: {
       type: 'category',
       data: data.map((d) => d.name),
       axisLine: { lineStyle: { color: COLORS.axisLine } },
-      axisLabel: { color: COLORS.text }
+      axisLabel: { color: COLORS.text, rotate: 60, fontSize: 10, margin: 12 }
     },
     yAxis: {
       type: 'value',
@@ -698,8 +725,10 @@ const render3DBar = () => {
   createChart('bar3D', {
     backgroundColor: 'transparent',
     tooltip: {
-      formatter: (p: any) =>
-        `${floors[p.data[0]]} - ${risks[p.data[1]]}<br/>高危人数:${p.data[2]} 人`
+      formatter: (p: any) => {
+        const v = Array.isArray(p.value) ? p.value : p.data?.value || []
+        return `${floors[v[0]] ?? '-'} - ${risks[v[1]] ?? '-'}<br/>高危人数:${v[2] ?? '-'} 人`
+      }
     },
     visualMap: {
       max: maxVal,
@@ -751,7 +780,6 @@ const render3DBar = () => {
   })
 }
 
-
 /** 15. 评估完成情况 - 柱状图 */
 const renderAssessCompletion = () => {
   const data = nineRiskDist.value
@@ -849,41 +877,240 @@ const renderAllCharts = () => {
 const loadAllData = async () => {
   loading.value = true
   disposeAll()
+  const tenantId = userStore.orgTenantId
   const params = {
-    month: filter.month || undefined,
+    trendMonths: 6,
     buildId: filter.buildId || undefined,
-    floorId: filter.floorId || undefined
+    floorId: filter.floorId || undefined,
+    tenantIds: tenantId ? (Array.isArray(tenantId) ? tenantId : [tenantId]) : undefined,
+    multiHighRiskThreshold: 1
   }
   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
+    // 真实接口:九防安全风险综合看板
+    const bd = (await echartsApi.nineRiskBoardGet(params)) || {}
+    const ov = bd.overview || {}
+
+    // 1. 顶部核心指标映射(保持页面模板字段名不变)
+    overview.value = {
+      totalElders: ov.disclosureTotal ?? 0,
+      highRiskCount: ov.highRiskTimes ?? 0,
+      highRiskRatio: ov.highRiskTimesRatio ?? 0,
+      pendingSignatures: ov.disclosureUnsignedCount ?? 0,
+      signedRate:
+        typeof ov.disclosureUnsignedRatio === 'number'
+          ? +(100 - ov.disclosureUnsignedRatio).toFixed(2)
+          : 100,
+      evalCompletionRate: ov.assessCompleteRate ?? 0,
+      assessorCount: ov.assessorCount ?? 0
+    }
+
+    // 2. 九防风险等级分布映射(真实字段 → 图表内部字段)
+    nineRiskDist.value = (bd.itemRiskDistributions || []).map((item: any) => ({
+      name: normalizeRiskName(item.itemName),
+      high: item.highCount ?? 0,
+      medium: item.middleCount ?? 0,
+      low: item.lowCount ?? 0,
+      none: item.unknownCount ?? 0,
+      total: item.totalCount ?? 0
+    }))
+
+    // 3. 风险等级整体占比
+    const levelNameMap: Record<string, string> = {
+      none: '无风险',
+      low: '低危',
+      medium: '中危',
+      high: '高危',
+      unknown: '无风险'
+    }
+    const levelColorMap: Record<string, string> = {
+      none: COLORS.none,
+      low: COLORS.low,
+      medium: COLORS.medium,
+      high: COLORS.high,
+      unknown: COLORS.none
+    }
+    riskLevelRatios.value = (bd.riskLevelRatios || []).map((r: any) => ({
+      name: r.riskLevelName || levelNameMap[r.riskLevel] || r.riskLevel,
+      value: r.count ?? 0,
+      color: levelColorMap[r.riskLevel] || COLORS.none
+    }))
+
+    // 4. 风险等级月度趋势
+    riskTrend.value = (bd.monthlyTrends || []).map((t: any) => ({
+      month: t.month,
+      high: t.highCount ?? 0,
+      medium: t.middleCount ?? 0,
+      low: t.lowCount ?? 0,
+      none: t.unknownCount ?? 0
+    }))
+
+    // 5. 九防 × 楼栋高危人数热力图(同时供 3D 柱状图复用)
+    const hm = bd.highRiskHeatMap || {}
+    const heatItems = (hm.items || []).map((i: any) => normalizeRiskName(i.name || i.itemName || ''))
+    const heatBuilds = (hm.builds || []).map((b: any) => b.name || b.buildName || '')
+
+    // 新结构通过 code/id 找索引,不再依赖隐式下标
+    const itemCodeToIndex = new Map<string, number>()
+    const buildIdToIndex = new Map<string | number, number>()
+    ;(hm.items || []).forEach((i: any, idx: number) => {
+      if (i.code) itemCodeToIndex.set(String(i.code), idx)
+    })
+    ;(hm.builds || []).forEach((b: any, idx: number) => {
+      if (b.code) buildIdToIndex.set(String(b.code), idx)
+    })
+
+    const heatCells = (hm.cells || [])
+      .map((c: any) => {
+        const x =
+          c.itemIndex ??
+          itemCodeToIndex.get(String(c.itemCode ?? '')) ??
+          c.x ??
+          c.column ??
+          -1
+        const y =
+          c.buildIndex ??
+          buildIdToIndex.get(String(c.buildId ?? '')) ??
+          c.y ??
+          c.row ??
+          -1
+        if (x < 0 || y < 0) return null
+        const v = c.highRiskElderCount ?? c.value ?? c.count ?? c.highCount ?? 0
+        // 热力图与 3D 柱状图均使用 [楼栋索引, 九防索引, 值]
+        return [y, x, v]
+      })
+      .filter(Boolean)
+
+    floorHeatmap.value = {
+      floors: heatBuilds,
+      risks: heatItems,
+      data: heatCells,
+      maxValue: hm.maxValue ?? 0
+    }
+    bar3D.value = {
+      floors: heatBuilds,
+      risks: heatItems,
+      data: heatCells,
+      maxValue: hm.maxValue ?? 0
+    }
+
+    // 6. 年龄 × 高风险项散点
+    ageScatter.value = (bd.highRiskElders || []).map((e: any) => ({
+      name: e.elderName,
+      age: e.elderAge,
+      sex: e.elderSex,
+      highCount: e.highRiskCount ?? 0,
+      room: '',
+      nurseLevel: e.nurseLevelName
+    }))
+
+    // 7. 评估得分分布(真实返回 minScore/maxScore + buckets,转成箱线图五数概括)
+    scoreDist.value = (bd.itemScoreDistributions || []).map((item: any) => {
+      const buckets = [...(item.buckets || [])].sort(
+        (a: any, b: any) => (a.min ?? 0) - (b.min ?? 0)
+      )
+      const total = buckets.reduce((s: number, b: any) => s + (b.count ?? 0), 0)
+      // 在桶内做线性插值,估算四分位数
+      const boundary = (p: number) => {
+        if (total === 0 || buckets.length === 0) return item.minScore ?? 0
+        const target = total * p
+        let cumulative = 0
+        for (const b of buckets) {
+          const count = b.count ?? 0
+          if (count === 0) continue
+          if (cumulative + count >= target) {
+            const ratio = (target - cumulative) / count
+            return (b.min ?? 0) + ((b.max ?? b.min ?? 0) - (b.min ?? 0)) * ratio
+          }
+          cumulative += count
+        }
+        return buckets[buckets.length - 1].max ?? item.maxScore ?? 0
+      }
+      const round2 = (n: number) => +n.toFixed(2)
+      return {
+        name: normalizeRiskName(item.itemName),
+        min: round2(item.minScore ?? buckets[0]?.min ?? 0),
+        q1: round2(boundary(0.25)),
+        median: round2(boundary(0.5)),
+        q3: round2(boundary(0.75)),
+        max: round2(item.maxScore ?? buckets[buckets.length - 1]?.max ?? 0)
+      }
+    })
+
+    // 8. 知情书签署状态
+    const sr = bd.disclosureSignRate || {}
+    signature.value = {
+      signed: sr.signedCount ?? 0,
+      firstSignedOnly: 0,
+      unsigned: sr.unsignedCount ?? 0,
+      total: sr.total ?? 0,
+      signedRate: sr.signRate ?? 0
+    }
+
+    // 9. 按护理级别 · 高危项漏斗 / 雷达
+    // 后端字段为 nurseLevelHighRiskElderCounts(漏斗),优先读取;兼容旧名 nurseLevelHighRiskRecordCounts
+    nurseLevelRisk.value = (
+      bd.nurseLevelHighRiskElderCounts ||
+      bd.nurseLevelHighRiskRecordCounts || []
+    ).map((n: any) => ({
+      name: n.nurseLevelName,
+      count: n.count ?? 0,
+      ratio: n.ratio ?? 0,
+      none: 0,
+      low: 0,
+      medium: 0,
+      high: n.count ?? 0
+    }))
+
+    // 9.1 各护理级别 · 九防高危占比雷达(新结构:每项护理级别带 items 数组)
+    const radarRecords = bd.nurseLevelHighRiskRecordCounts || []
+    if (radarRecords.length && Array.isArray(radarRecords[0].items)) {
+      // 以第一条护理级别的 items 顺序作为雷达 indicator
+      const indicators = radarRecords[0].items.map((it: any) => ({
+        name: normalizeRiskName(it.itemName),
+        max: 100
+      }))
+      const colors = ['#ff5a3c', '#00e0a7', '#ffd85a', '#00aaff', '#ff9966', '#5578ff', '#b380ff']
+      nurseLevelRadarData.value = {
+        indicators,
+        series: radarRecords.map((n: any, idx: number) => ({
+          name: n.nurseLevelName,
+          value: n.items.map((it: any) => +(it.ratio ?? 0).toFixed(2)),
+          count: n.items.map((it: any) => it.count ?? 0),
+          lineStyle: { color: colors[idx % colors.length], width: 2 },
+          itemStyle: { color: colors[idx % colors.length] },
+          areaStyle: { color: colors[idx % colors.length], opacity: 0.1 }
+        }))
+      }
+    }
+
+    // 10. 评估人工作量
+    assessorWorkload.value = (bd.assessorStats || []).map((a: any) => ({
+      name: a.assessor,
+      count: a.assessedElderCount ?? 0,
+      highCount: a.highRiskElderCount ?? 0
+    }))
+
+    // 12. 2 项及以上高风险长者列表(红名单)
+    redList.value = (bd.multiHighRiskElders || []).map((e: any) => ({
+      id: e.elderId,
+      name: e.elderName,
+      sex: e.elderSex === 1 ? '男' : '女',
+      age: e.elderAge,
+      room: e.bedName || '',
+      floor: e.buildName || '',
+      nurseLevel: e.nurseLevelName,
+      assessor: e.assessor,
+      risks: (() => {
+        const raw = e.highRiskItems
+        const arr = Array.isArray(raw)
+          ? raw.map((s: any) => normalizeRiskName(String(s).trim()))
+          : String(raw || '').split(',').map((s: string) => normalizeRiskName(s.trim()))
+        return arr.filter(Boolean)
+      })(),
+      highCount: e.highRiskCount ?? 0,
+      assessDate: formatAssessDate(e.assessDate)
+    }))
+
     await nextTick()
     renderAllCharts()
   } catch (e) {
@@ -906,6 +1133,25 @@ const handleReset = () => {
 }
 
 // ========================= 红名单风险标签颜色 =========================
+const formatAssessDate = (v: any): string => {
+  if (!v) return '-'
+  if (Array.isArray(v)) {
+    const [y, m, d] = v
+    if (!y) return '-'
+    return `${y}-${String(m ?? 1).padStart(2, '0')}-${String(d ?? 1).padStart(2, '0')}`
+  }
+  if (v instanceof Date) {
+    const y = v.getFullYear()
+    const m = v.getMonth() + 1
+    const d = v.getDate()
+    return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
+  }
+  if (typeof v === 'string') return v.split(' ')[0] || '-'
+  return String(v)
+}
+// 后端把「防误食」命名为「简易精神状态」,前端统一做临时映射
+const normalizeRiskName = (name: string): string =>
+  name === '简易精神状态' ? '防误食' : name
 const riskTagColor = (name: string): string => {
   const map: Record<string, string> = {
     噎食: '#ff9966',
@@ -1142,16 +1388,16 @@ onUnmounted(() => {
         <div :ref="setRef('riskRadar')" class="chart"></div>
       </div>
       <!-- 评估人工作量 -->
-      <div class="card">
+      <div class="card span-2">
         <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('riskCorrelation')" class="chart"></div>-->
+      <!--      </div>-->
       <!-- 评估完成率 -->
       <div class="card">
         <div class="card-title"><span class="dot dot-mid"></span>九防高危占比</div>
@@ -1182,13 +1428,8 @@ onUnmounted(() => {
           />
           <path d="M12 9v4M12 17h.01" />
         </svg>
-        重点人群红名单 · 2 项及以上高风险(共 {{ filteredRedList.length }} 人)
-        <el-select
-          v-model="redRiskFilter"
-          placeholder="全部"
-          clearable
-          class="red-risk-select"
-        >
+        重点人群红名单 · 1 项及以上高风险(共 {{ filteredRedList.length }} 人)
+        <el-select v-model="redRiskFilter" placeholder="全部" clearable class="red-risk-select">
           <el-option
             v-for="opt in nineRiskOptions"
             :key="opt.value"
@@ -1201,8 +1442,8 @@ onUnmounted(() => {
         <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>
+              <th>档案编号</th><th>姓名</th><th>性别</th><th>年龄</th> <th>床号</th><th>护理级别</th
+              ><th>评估人</th> <th>高风险项</th><th>评估日期</th>
             </tr>
           </thead>
           <tbody>
@@ -1211,25 +1452,25 @@ onUnmounted(() => {
               <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 style="max-width: 15vw">{{ 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'
+                    background: riskTagColor(rk.replace('防', '')) + '33',
+                    color: riskTagColor(rk.replace('防', '')),
+                    borderColor: riskTagColor(rk.replace('防', '')) + '66'
                   }"
                   >{{ rk }}</span
                 >
               </td>
               <td
-                ><span class="high-count">2022-12-12</span></td
+                ><span class="high-count">{{ r.assessDate }}</span></td
               >
             </tr>
           </tbody>

+ 269 - 61
src/views/elderly/apply/nine-precautions/risk-disclosure-statement/Form.vue

@@ -32,19 +32,25 @@
             />
           </el-form-item>
         </el-col>
-<!--        <el-col :span="12">-->
-<!--          <el-form-item label="证件号码">-->
-<!--            <TgInput v-model="dataForm.idCard" disabled />-->
-<!--          </el-form-item>-->
-<!--        </el-col>-->
+        <!--        <el-col :span="12">-->
+        <!--          <el-form-item label="证件号码">-->
+        <!--            <TgInput v-model="dataForm.idCard" disabled />-->
+        <!--          </el-form-item>-->
+        <!--        </el-col>-->
       </el-row>
 
       <!-- 长者详情展示 - 对应图片中的表一表头信息 -->
-      <div  class="elder-detail-wrap">
+      <div class="elder-detail-wrap">
         <el-descriptions :column="2" border size="small">
-          <el-descriptions-item label="姓名">{{ elderDetail.elderName || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="性别">{{ getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, elderDetail.elderSex) || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="年龄">{{ elderDetail.elderAge || '-' }}岁</el-descriptions-item>
+          <el-descriptions-item label="姓名">{{
+            elderDetail.elderName || '-'
+          }}</el-descriptions-item>
+          <el-descriptions-item label="性别">{{
+            getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, elderDetail.elderSex) || '-'
+          }}</el-descriptions-item>
+          <el-descriptions-item label="年龄"
+            >{{ elderDetail.elderAge || '-' }}岁</el-descriptions-item
+          >
           <el-descriptions-item label="床号">{{ elderDetail.bedName || '-' }}</el-descriptions-item>
         </el-descriptions>
       </div>
@@ -53,9 +59,7 @@
       <div class="section-title">服务安全风险知情告知书</div>
       <div class="notice-content">
         <p class="notice-header">尊敬的老年人/相关第三方/监护人:</p>
-        <p class="notice-text">
-          您好!感谢您对本机构的信任和支持。
-        </p>
+        <p class="notice-text"> 您好!感谢您对本机构的信任和支持。 </p>
         <p class="notice-text">
           经我院评估您或您相关第三方的身体状况,您或您亲属在我院养老期间,存在下列服务安全风险。为保证对您或您相关第三方的服务质量,特向您告知!我院将针对老年人的情况做好相关防范措施,请您理解、配合、并支持相关工作措施的落实。
         </p>
@@ -93,9 +97,15 @@
                 </td>
                 <td class="col-remark">
                   <span class="remark-text">风险程度:</span>
-                  <el-checkbox v-model="item.lowChecked" :disabled="isView" size="small">低危</el-checkbox>
-                  <el-checkbox v-model="item.mediumChecked" :disabled="isView" size="small">中危</el-checkbox>
-                  <el-checkbox v-model="item.highChecked" :disabled="isView" size="small">高危</el-checkbox>
+                  <el-checkbox v-model="item.lowChecked" :disabled="isView" size="small"
+                    >低危</el-checkbox
+                  >
+                  <el-checkbox v-model="item.mediumChecked" :disabled="isView" size="small"
+                    >中危</el-checkbox
+                  >
+                  <el-checkbox v-model="item.highChecked" :disabled="isView" size="small"
+                    >高危</el-checkbox
+                  >
                 </td>
               </tr>
             </tbody>
@@ -113,7 +123,9 @@
             style="width: 150px"
             :disabled="isView"
           />
-          <span class="date-value">{{ dataForm.firstRisk ? formatDate(dataForm.firstRisk) : '无' }}</span>
+          <span class="date-value">{{
+            dataForm.firstRisk ? formatDate(dataForm.firstRisk) : '无'
+          }}</span>
         </div>
 
         <!-- 告知签名区域 -->
@@ -122,28 +134,33 @@
           <p class="signature-desc">
             通过对老年人的整体评估,老年人有可能发生以上风险,其风险及防范措施已向老年人或相关第三方进行了如实告知。
           </p>
-          <p class="signature-declare">
-            (老年人或相关第三方声明:本人已知悉并签字确认。)
-          </p>
-          
+          <p class="signature-declare"> (老年人或相关第三方声明:本人已知悉并签字确认。) </p>
+
           <div class="signature-item">
             <span class="signature-label">经办人(护理人员):</span>
-
           </div>
-          
+
           <div class="signature-item">
             <span class="signature-label">老年人或相关第三方签名:</span>
+          </div>
 
+          <div class="signature-item">
+            <span class="signature-label">签名日期:</span>
           </div>
         </div>
       </div>
 
- 
+      <div style="background-color: #fafafa;padding: 10px 20px;border-radius: 8px">
+        <div style="padding: 10px 0; font-weight: bold; color: #333333">打印并签名后上传纸质版</div>
+        <TableUpload :limit="1" fun-name="其他附件" v-model="dataForm.secondElderSign" :isDetail="isView" />
+      </div>
     </el-form>
 
     <template #footer>
       <el-button v-if="!isView" @click="handleClosed">取消</el-button>
-      <el-button v-if="!isView" type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
+      <el-button v-if="!isView" type="primary" @click="submitForm" :loading="submitLoading"
+        >确定</el-button
+      >
       <el-button v-if="isView" @click="handleClosed">关闭</el-button>
       <el-button v-if="isView" type="success" @click="handleExport">打印</el-button>
     </template>
@@ -176,6 +193,23 @@ const userStore = useUserStore()
 
 defineOptions({ name: 'RiskDisclosureStatementForm' })
 
+// TableUpload 的 v-model 必须是数组(FileItem[]);后端以 JSON 字符串存储。
+// 回显时把字符串转数组,提交时把数组转字符串,与 check-in ProcessForm 一致。
+const toFileArray = (val: any): any[] => {
+  if (!val) return []
+  if (Array.isArray(val)) return val
+  try {
+    const parsed = JSON.parse(val)
+    return Array.isArray(parsed) ? parsed : []
+  } catch {
+    return []
+  }
+}
+const toFileString = (val: any): string => {
+  if (!val || !Array.isArray(val) || val.length === 0) return ''
+  return JSON.stringify(val)
+}
+
 const emit = defineEmits(['success'])
 
 const dialogVisible = ref(false)
@@ -192,15 +226,96 @@ const elderDetail = ref<any>({})
 
 // 风险项目表格数据 - name 用于匹配接口
 const riskTableItems = ref([
-  { key: 'asphyxiation', name: '防噎食评估', api: asphyxiationPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'pressureUlcer', name: '防压疮评估', api: pressureSoresPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'fall', name: '防跌倒风险评估', api: fallDownPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'bedFall', name: '防坠床评估', api: fallPreventionPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'scald', name: '防烫伤评估', api: burnPreventionPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'wandering', name: '防走失风险评估', api: wanderAwayPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'selfHarm', name: '防自伤和他伤', api: ngasrPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'foodDrug', name: '防食品药品误食评估', api: mmseGetPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-  { key: 'entertainment', name: '防文娱活动意外', api: entertainmentPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false }
+  {
+    key: 'asphyxiation',
+    name: '防噎食评估',
+    api: asphyxiationPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'pressureUlcer',
+    name: '防压疮评估',
+    api: pressureSoresPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'fall',
+    name: '防跌倒风险评估',
+    api: fallDownPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'bedFall',
+    name: '防坠床评估',
+    api: fallPreventionPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'scald',
+    name: '防烫伤评估',
+    api: burnPreventionPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'wandering',
+    name: '防走失风险评估',
+    api: wanderAwayPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'selfHarm',
+    name: '防自伤和他伤',
+    api: ngasrPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'foodDrug',
+    name: '防食品药品误食评估',
+    api: mmseGetPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  },
+  {
+    key: 'entertainment',
+    name: '防文娱活动意外',
+    api: entertainmentPage,
+    recordId: null as any,
+    riskLevel: '',
+    lowChecked: false,
+    mediumChecked: false,
+    highChecked: false
+  }
 ])
 
 // 接口返回的风险数据选项(模拟数据,实际从接口获取)
@@ -313,11 +428,16 @@ const loadElderRiskData = async (elderId: string, preserveSelection = false) =>
 // 获取风险等级标签
 const getRiskLevelLabel = (riskLevel: string) => {
   switch (riskLevel) {
-    case 'none': return '无风险'
-    case 'low': return '低风险'
-    case 'medium': return '中风险'
-    case 'high': return '高风险'
-    default: return '无风险'
+    case 'none':
+      return '无风险'
+    case 'low':
+      return '低风险'
+    case 'medium':
+      return '中风险'
+    case 'high':
+      return '高风险'
+    default:
+      return '无风险'
   }
 }
 
@@ -359,7 +479,7 @@ const dataForm = reactive({
   // 第二次告知签名
   secondRisk: '',
   secondNurseSign: '',
-  secondElderSign: '',
+  secondElderSign: [] as any[],
   secondSignDate: '',
   secondChangeRisk: '',
   secondRiskLevel: '',
@@ -392,7 +512,7 @@ const handleSelectElder = async (item: any) => {
       foodDrug: [],
       entertainment: []
     }
-    riskTableItems.value.forEach(tableItem => {
+    riskTableItems.value.forEach((tableItem) => {
       tableItem.recordId = null
       tableItem.riskLevel = ''
       tableItem.lowChecked = false
@@ -427,11 +547,13 @@ const open = async (row?: any, viewMode = false) => {
     try {
       const res = await getSafetyRiskNoticeById(row.id)
       Object.assign(dataForm, res)
+      // 回显:secondElderSign 后端为字符串,转数组供 TableUpload 展示
+      dataForm.secondElderSign = toFileArray(res.secondElderSign)
       // 解析风险项目
       if (res.riskData) {
         const parsedData = JSON.parse(res.riskData)
         // 解析风险表格数据
-        riskTableItems.value = riskTableItems.value.map(item => {
+        riskTableItems.value = riskTableItems.value.map((item) => {
           const saved = parsedData[item.key]
           if (saved) {
             return {
@@ -491,7 +613,7 @@ const resetForm = () => {
     firstSignDate: '',
     secondRisk: '',
     secondNurseSign: '',
-    secondElderSign: '',
+    secondElderSign: [] as any[],
     secondSignDate: '',
     secondChangeRisk: '',
     secondRiskLevel: '',
@@ -502,15 +624,96 @@ const resetForm = () => {
   })
   elderDetail.value = {}
   riskTableItems.value = [
-    { key: 'asphyxiation', name: '防噎食评估', api: asphyxiationPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'pressureUlcer', name: '防压疮评估', api: pressureSoresPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'fall', name: '防跌倒风险评估', api: fallDownPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'bedFall', name: '防坠床评估', api: fallPreventionPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'scald', name: '防烫伤评估', api: burnPreventionPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'wandering', name: '防走失风险评估', api: wanderAwayPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'selfHarm', name: '防自伤和他伤', api: ngasrPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'foodDrug', name: '防食品药品误食评估', api: mmseGetPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false },
-    { key: 'entertainment', name: '防文娱活动意外', api: entertainmentPage, recordId: null as any, riskLevel: '', lowChecked: false, mediumChecked: false, highChecked: false }
+    {
+      key: 'asphyxiation',
+      name: '防噎食评估',
+      api: asphyxiationPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'pressureUlcer',
+      name: '防压疮评估',
+      api: pressureSoresPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'fall',
+      name: '防跌倒风险评估',
+      api: fallDownPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'bedFall',
+      name: '防坠床评估',
+      api: fallPreventionPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'scald',
+      name: '防烫伤评估',
+      api: burnPreventionPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'wandering',
+      name: '防走失风险评估',
+      api: wanderAwayPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'selfHarm',
+      name: '防自伤和他伤',
+      api: ngasrPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'foodDrug',
+      name: '防食品药品误食评估',
+      api: mmseGetPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    },
+    {
+      key: 'entertainment',
+      name: '防文娱活动意外',
+      api: entertainmentPage,
+      recordId: null as any,
+      riskLevel: '',
+      lowChecked: false,
+      mediumChecked: false,
+      highChecked: false
+    }
   ]
   riskOptionsMap.value = {
     asphyxiation: [],
@@ -541,7 +744,7 @@ const submitForm = async () => {
   try {
     // 处理风险项目数据 - 将表格数据转换为存储格式
     const riskDataObj: Record<string, any> = {}
-    riskTableItems.value.forEach(item => {
+    riskTableItems.value.forEach((item) => {
       riskDataObj[item.key] = {
         id: item.recordId, // 选中的评估记录ID
         level: item.riskLevel, // 风险等级
@@ -553,10 +756,12 @@ const submitForm = async () => {
         }
       }
     })
-    
+
     const submitData = {
       ...dataForm,
-      riskData: JSON.stringify(riskDataObj)
+      riskData: JSON.stringify(riskDataObj),
+      // 提交:secondElderSign 转成 JSON 字符串给后端
+      secondElderSign: toFileString(dataForm.secondElderSign)
     }
 
     if (isEdit.value) {
@@ -598,7 +803,7 @@ const handleExport = () => {
     const lowChecked = item.lowChecked ? '☑' : '☐'
     const mediumChecked = item.mediumChecked ? '☑' : '☐'
     const highChecked = item.highChecked ? '☑' : '☐'
-    
+
     riskTableHtml += `
       <tr>
         <td style="border: 1px solid #333; padding: 8px; text-align: center;">${index + 1}</td>
@@ -698,7 +903,9 @@ const handleExport = () => {
           
           <div class="signature-item">
             <span>老年人或相关第三方签名:</span>
-           
+          </div>
+           <div class="signature-item">
+            <span class="signature-label">签名日期:</span>
           </div>
         </div>
 
@@ -722,10 +929,10 @@ const handleExport = () => {
 // 获取风险等级文本
 const getRiskLevelText = (level: string): string => {
   const levelMap: Record<string, string> = {
-    'none': '无风险',
-    'low': '低风险',
-    'medium': '中风险',
-    'high': '高风险'
+    none: '无风险',
+    low: '低风险',
+    medium: '中风险',
+    high: '高风险'
   }
   return levelMap[level] || level || '-'
 }
@@ -778,7 +985,8 @@ defineExpose({ open })
     border-collapse: collapse;
     font-size: 14px;
 
-    th, td {
+    th,
+    td {
       border: 1px solid #dcdfe6;
       padding: 10px 8px;
       text-align: center;

+ 189 - 54
src/views/elderly/apply/nine-precautions/risk-disclosure-statement/index.vue

@@ -243,7 +243,9 @@ import NutritionalRiskForm from '@/views/elderly/apply/nine-precautions/nutritio
 import ButtonImport from '@/components/ButtonImport/src/ButtonImport.vue'
 import { useUserStore } from '@/store/modules/user'
 import { formatToDateTime } from '@/utils/dateUtil'
-import { exportWithExpandedObjectArrays } from '@/utils/excel-export'
+import ExcelJS from 'exceljs'
+import { getAccessToken } from '@/utils/auth'
+import { saveAs } from 'file-saver'
 const userStore = useUserStore()
 defineOptions({ name: 'RiskDisclosureStatement' })
 
@@ -387,8 +389,8 @@ const handleImportCard = async () => {
   exportLoading.value = true
   try {
     const queryP = {
-      pageNo: 1,
-      pageSize: 9999,
+      pageNo: queryParams.pageNo,
+      pageSize: queryParams.pageSize,
       elderName: queryParams.elderName,
       floorName: queryParams.floorName,
       tenantIds: queryParams.tenantIds
@@ -404,57 +406,7 @@ const handleImportCard = async () => {
       return
     }
 
-    const processedData = exportList.map((row: any, index: number) => {
-      const rd = row.riskData || {}
-      return {
-        index: index + 1,
-        elderName: row.elderName || '',
-        elderSex: getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, row.elderSex) || '',
-        elderAge: row.elderAge ?? '',
-        bedName: row.bedName || '',
-        contractNumber: row.contractNumber || '',
-        nurseLevelName: row.nurseLevelName || '',
-        asphyxiationRisk: getRiskLevelText(rd.asphyxiation?.level || '-'),
-        pressureUlcerRisk: getRiskLevelText(rd.pressureUlcer?.level || '-'),
-        fallRisk: getRiskLevelText(rd.fall?.level || '-'),
-        bedFallRisk: getRiskLevelText(rd.bedFall?.level || '-'),
-        scaldRisk: getRiskLevelText(rd.scald?.level || '-'),
-        wanderingRisk: getRiskLevelText(rd.wandering?.level || '-'),
-        selfHarmRisk: getRiskLevelText(rd.selfHarm?.level || '-'),
-        foodDrugRisk: getRiskLevelText(rd.foodDrug?.level || '-'),
-        entertainmentRisk: getRiskLevelText(rd.entertainment?.level || '-'),
-        createTime: row.createTime ? formatToDateTime(row.createTime) : '',
-        creator: row.creator || ''
-      }
-    })
-
-    const headers = [
-      { key: 'index', title: '序号' },
-      { key: 'elderName', title: '长者姓名' },
-      { key: 'elderSex', title: '性别' },
-      { key: 'elderAge', title: '年龄' },
-      { key: 'bedName', title: '床号' },
-      { key: 'contractNumber', title: '合同号' },
-      { key: 'nurseLevelName', title: '护理级别' },
-      { key: 'asphyxiationRisk', title: '防噎食评估' },
-      { key: 'pressureUlcerRisk', title: '防压疮评估' },
-      { key: 'fallRisk', title: '防跌倒评估' },
-      { key: 'bedFallRisk', title: '防坠床评估' },
-      { key: 'scaldRisk', title: '防烫伤评估' },
-      { key: 'wanderingRisk', title: '防走失评估' },
-      { key: 'selfHarmRisk', title: '防自伤他伤评估' },
-      { key: 'foodDrugRisk', title: '防食品药品误食评估' },
-      { key: 'entertainmentRisk', title: '防文娱活动意外' },
-      { key: 'createTime', title: '记录时间' },
-      { key: 'creator', title: '记录人' }
-    ]
-
-    exportWithExpandedObjectArrays(
-      processedData,
-      headers,
-      `安全风险告知书_${new Date().getTime()}.xlsx`,
-      '安全风险告知书'
-    )
+    await exportRiskDisclosureToExcel(exportList)
     message.success('导出成功')
   } catch (error) {
     console.error('导出失败:', error)
@@ -464,6 +416,189 @@ const handleImportCard = async () => {
   }
 }
 
+/**
+ * 解析 secondElderSign 字段(后端为 JSON 字符串)为文件数组
+ * 形如:[{"fileUrl":"...","fileName":"二维码.png","fileSize":7443,"createTime":"..."}]
+ */
+const parseFileList = (val: any): { fileUrl: string; fileName: string }[] => {
+  if (!val) return []
+  try {
+    const arr = typeof val === 'string' ? JSON.parse(val) : val
+    return Array.isArray(arr) ? arr.filter((x: any) => x && x.fileUrl) : []
+  } catch (e) {
+    return []
+  }
+}
+
+/**
+ * 导出安全风险告知书(带「二次告知签名」图片列)
+ * @param data 已解析 riskData 的列表数据
+ */
+const exportRiskDisclosureToExcel = async (data: any[]) => {
+  const workbook = new ExcelJS.Workbook()
+  const worksheet = workbook.addWorksheet('安全风险告知书')
+
+  // 每行解析出签名图片数组
+  const rows = data.map((row, index) => {
+    const rd = row.riskData || {}
+    return {
+      index: index + 1,
+      elderName: row.elderName || '',
+      elderSex: getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, row.elderSex) || '',
+      elderAge: row.elderAge ?? '',
+      bedName: row.bedName || '',
+      contractNumber: row.contractNumber || '',
+      nurseLevelName: row.nurseLevelName || '',
+      asphyxiationRisk: getRiskLevelText(rd.asphyxiation?.level || '-'),
+      pressureUlcerRisk: getRiskLevelText(rd.pressureUlcer?.level || '-'),
+      fallRisk: getRiskLevelText(rd.fall?.level || '-'),
+      bedFallRisk: getRiskLevelText(rd.bedFall?.level || '-'),
+      scaldRisk: getRiskLevelText(rd.scald?.level || '-'),
+      wanderingRisk: getRiskLevelText(rd.wandering?.level || '-'),
+      selfHarmRisk: getRiskLevelText(rd.selfHarm?.level || '-'),
+      foodDrugRisk: getRiskLevelText(rd.foodDrug?.level || '-'),
+      entertainmentRisk: getRiskLevelText(rd.entertainment?.level || '-'),
+      createTime: row.createTime ? formatToDateTime(row.createTime) : '',
+      creator: row.creator || '',
+      files: parseFileList(row.secondElderSign)
+    }
+  })
+
+  // 图片列数 = 所有行中签名图片的最大数量(无图片则不生成图片列)
+  const maxImages = rows.reduce((m, r) => Math.max(m, r.files.length), 0)
+  const imageColumns =
+    maxImages > 0
+      ? Array.from({ length: maxImages }, (_, i) => ({
+          header: maxImages > 1 ? `纸质版签名图片${i + 1}` : '纸质版签名图片',
+          key: `sign${i}`,
+          width: 22
+        }))
+      : []
+
+  // 文本列
+  worksheet.columns = [
+    { header: '序号', key: 'index', width: 8 },
+    { header: '长者姓名', key: 'elderName', width: 12 },
+    { header: '性别', key: 'elderSex', width: 8 },
+    { header: '年龄', key: 'elderAge', width: 8 },
+    { header: '床号', key: 'bedName', width: 14 },
+    { header: '合同号', key: 'contractNumber', width: 16 },
+    { header: '护理级别', key: 'nurseLevelName', width: 14 },
+    { header: '防噎食评估', key: 'asphyxiationRisk', width: 14 },
+    { header: '防压疮评估', key: 'pressureUlcerRisk', width: 14 },
+    { header: '防跌倒评估', key: 'fallRisk', width: 14 },
+    { header: '防坠床评估', key: 'bedFallRisk', width: 14 },
+    { header: '防烫伤评估', key: 'scaldRisk', width: 14 },
+    { header: '防走失评估', key: 'wanderingRisk', width: 14 },
+    { header: '防自伤他伤评估', key: 'selfHarmRisk', width: 16 },
+    { header: '防食品药品误食评估', key: 'foodDrugRisk', width: 18 },
+    { header: '防文娱活动意外', key: 'entertainmentRisk', width: 14 },
+    { header: '记录时间', key: 'createTime', width: 20 },
+    { header: '记录人', key: 'creator', width: 14 },
+    ...imageColumns
+  ]
+
+  // 表头样式
+  const headerRow = worksheet.getRow(1)
+  headerRow.font = { bold: true, size: 12 }
+  headerRow.fill = {
+    type: 'pattern',
+    pattern: 'solid',
+    fgColor: { argb: 'FFE0E0E0' }
+  }
+  headerRow.alignment = { horizontal: 'center', vertical: 'middle' }
+
+  const textColCount = 18 // 文本列数量,图片列从此之后开始
+  const imgStartCol0 = textColCount // 第一张图片列的 0-based 列号
+
+  for (let i = 0; i < rows.length; i++) {
+    const r = rows[i]
+    const row = worksheet.addRow({
+      index: r.index,
+      elderName: r.elderName,
+      elderSex: r.elderSex,
+      elderAge: r.elderAge,
+      bedName: r.bedName,
+      contractNumber: r.contractNumber,
+      nurseLevelName: r.nurseLevelName,
+      asphyxiationRisk: r.asphyxiationRisk,
+      pressureUlcerRisk: r.pressureUlcerRisk,
+      fallRisk: r.fallRisk,
+      bedFallRisk: r.bedFallRisk,
+      scaldRisk: r.scaldRisk,
+      wanderingRisk: r.wanderingRisk,
+      selfHarmRisk: r.selfHarmRisk,
+      foodDrugRisk: r.foodDrugRisk,
+      entertainmentRisk: r.entertainmentRisk,
+      createTime: r.createTime,
+      creator: r.creator
+    })
+    row.alignment = { vertical: 'middle', wrapText: true }
+
+    // 嵌入签名图片
+    if (r.files.length > 0) {
+      row.height = 100
+      for (let j = 0; j < r.files.length; j++) {
+        const file = r.files[j]
+        try {
+          const buf = await fetchImageAsBuffer(file.fileUrl)
+          if (buf) {
+            const imageId = workbook.addImage({
+              buffer: buf,
+              extension: getImageExtension(file.fileUrl)
+            })
+            await worksheet.addImage(imageId, {
+              tl: { col: imgStartCol0 + j + 0.1, row: i + 1 + 0.1 },
+              ext: { width: 90, height: 90 }
+            })
+          } else {
+            row.getCell(textColCount + j + 1).value = '图片加载失败'
+          }
+        } catch (e) {
+          console.error('加载签名图片失败:', file.fileUrl, e)
+          row.getCell(textColCount + j + 1).value = '图片加载失败'
+        }
+      }
+    }
+  }
+
+  const buffer = await workbook.xlsx.writeBuffer()
+  const blob = new Blob([buffer], {
+    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+  })
+  saveAs(blob, `安全风险告知书_${new Date().getTime()}.xlsx`)
+}
+
+/**
+ * 获取图片数据为 ArrayBuffer(用于嵌入 Excel)
+ */
+const fetchImageAsBuffer = async (url: string): Promise<ArrayBuffer | null> => {
+  try {
+    const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_BASE_URL}${url}`
+    const response = await fetch(fullUrl, {
+      headers: {
+        Authorization: 'Bearer ' + getAccessToken()
+      }
+    })
+    if (!response.ok) {
+      throw new Error(`HTTP error! status: ${response.status}`)
+    }
+    return await response.arrayBuffer()
+  } catch (e) {
+    console.error('获取图片失败:', url, e)
+    return null
+  }
+}
+
+/**
+ * 根据图片 URL 推断 Excel 支持的图片扩展名
+ */
+const getImageExtension = (url: string): 'png' | 'jpeg' => {
+  const ext = url.split('.').pop()?.toLowerCase()
+  if (ext === 'png') return 'png'
+  return 'jpeg'
+}
+
 /**
  * 根据风险等级获取按钮类型(颜色)
  * @param riskLevel 风险等级:none(无风险)、low(低风险)、medium(中风险)、high(高风险)