Kaynağa Gözat

营养评估增加导出

chenjun 2 hafta önce
ebeveyn
işleme
614555d627

+ 50 - 0
.workbuddy/memory/2026-09-24.md

@@ -0,0 +1,50 @@
+# 2026-09-24
+
+## 数字孪生可行性调研(仅分析,未写代码)
+用户问「养老项目能不能做数字孪生、怎么下手」,做了完整家底盘点。结论:**能做,缺的是「坐标数据」和「表现层」**。
+
+### 已具备(关键发现)
+- **空间骨架**:`src/api/system/badManage/index.ts` 有全套 `build/*` 接口——楼栋 CRUD、房间 CRUD(`createRoom/getRoomByFloorId/editRoomInfo`)、床位(`getBedList/getBedInfoById/checkBedById`)、各层统计(`buildStatistic/getBuildData/getBuildFloorData/getBuildFloorStatistics`)、`getElderByFloorId`、床位计划 `bed-plan-record/*`。楼栋-楼层-房间-床位四层树已成型
+- **实时数据**:`src/utils/useWebSocket.ts` + `src/views/living-home/device-management/home-device/composables/useWebSocket.ts`(含心跳/重连,消息类型:SOS/健康/定位/告警/设备数据/统计更新);IoT 模块 `src/api/iot/{device,product,thinkmodelfunction}` 齐备;设备告警 `device/warn/page`
+- **既有可视化**:echarts 6 + echarts-gl(九防看板 `data-board/nine-safety-measures` 已用 bar3D/grid3D)、高德地图(居家模块户外定位)、`data-board/comprehensive-overview` 大屏
+
+### 缺失
+- 房间/床位/设备**无平面坐标字段**,无平面图底图存储 → 最大拦路虎,需后端补
+- 前端无 3D 引擎(无 three.js/Babylon),无 glTF 模型资产
+- 无室内定位(信标/UWB),居家模块只有户外 GPS;机构侧 WebSocket 是否复用需确认
+
+### 给的三档路线
+- L0 二维平面图(SVG/Canvas 落点 + WS 实时染色 + 下钻)1–2 周,**建议起步**
+- L1 2.5D 立体楼层堆叠(CSS 3D / three 正交相机)2–3 周
+- L2 真三维(three.js + 建模)1.5–3 月
+
+### 建议的下手顺序
+1. 数据盘点确认字段缺口 → 2. 单楼层平面图原型打通 → 3. 平面图后台配置(上传底图 + 拖拽摆点)→ 4. 接实时事件做动态效果 → 5. 再考虑 3D
+
+## 智能设备硬件选型调研(仅分析,未写代码)
+用户补充:「目前没有智能设备,智能设备这块要重做,有推荐厂商吗或其他方案」。
+
+### 给出的三条路线
+- **A 模组自研固件**:用户已有 ESP 自研固件 + EMQX + 设备映射平台,硬件成本最低、掌控力最高,但卡在 3C/SRRC/CCCF 认证与量产售后。只建议用于**非强制认证类**(门磁、SOS 按钮、环境传感器、定位信标)
+- **B 外购设备 + 开放协议直连**(推荐主线):雷达/床垫/护理呼叫等强认证、强责任类设备必须买成熟厂商
+- **C 整包方案商**(涂鸦/运营商等):最快,但会把自有平台架空,只在甲方指定时走
+
+### 关键结论
+- **认证是选型分界线**:消防类要 CCCF、无线类要 SRRC、医疗宣称要械字号 —— 这条线决定自研还是外购
+- **选型池首选工信部+民政部+卫健委《智慧健康养老产品及服务推广目录》**(免费权威,能过滤营销稿厂商)
+- 通信制式:机构内有市电的固定点位走 WiFi/有线(别为省电上 NB-IoT 白交月租);电池供电离散点才用 NB-IoT;单机构自建 LoRa 网关无月租且数据在本地,与自研 EMQX 最搭
+- **别一上来做穿戴**(手环/定位卡),老人不戴是智慧养老项目失败首因;优先非接触式(毫米波雷达/门磁/智能床垫)
+- 室内定位:蓝牙 AoA(0.3–1m,标签几十元、续航 1–2 年)比 UWB(厘米级但基站上万、标签要频繁充电)更适合养老;云里物里/Minew 有 AoA G2 套件
+
+### 提到的厂商(供后续询价,未做商务验证)
+- 非接触感知雷达:清雷科技、兆观传感、希卡立(上海交大孵化)、理工雷科·瑞可安(入选三部委推广目录)、上海三思、迈动健康
+- 模组/方案商(路线 A):移远通信(60GHz 雷达模组+算法)、绿米 Aqara(FP2 存在传感器)
+- 护理呼叫对讲:来邦 lonbon(IP 医护对讲,明确支持对接第三方物联网设备与 HIS)、狄耐克(上市公司)、视达盈、日华电子
+- 室内定位:云里物里 Minew、微能信息、中科劲点(失智专区)
+- 平台 PaaS:涂鸦智能(含 IoT 私有云)、阿里云 IoT、华为 OceanConnect、移动 OneNET
+
+### 给用户的防锁死硬指标
+设备必须支持自定义 MQTT broker 直连或 HTTP webhook 推送;数据归属写入合同;提供设备唯一标识与点位绑定;本地化售后;先买 5–10 台试点单层楼,跑 6–8 周再规模部署。
+
+### 零硬件兜底方案
+预算未到位时先用「二维码/NFC 巡检 + 手动一键告警」占位,事件模型与真设备保持一致,等硬件到位只换数据源 —— 可直接接数字孪生 L0 平面图染色。

+ 10 - 5
src/api/elderly/apply/check-in.ts

@@ -35,7 +35,7 @@ export const getCheckInPage = (params) => {
 // 获取详情
 export const getCheckInRecordId = (id, status) => {
   return request.get({
-    url: `elderlyInfo/getCheckInDetail?checkInRecordId=${id}&status=${status}`,
+    url: `elderlyInfo/getCheckInDetail?checkInRecordId=${id}&status=${status}`
   })
 }
 // 根据合同ID获取办理入住详情
@@ -45,8 +45,6 @@ export const getCheckInDetailByContractId = (id) => {
   })
 }
 
-
-
 // 获取服务安全风险知情告知书列表
 export const getSafetyRiskNoticePage = (params) => {
   return request.get({
@@ -90,6 +88,11 @@ export const exportCustomer = async (params: any) => {
   return await request.download({ url: `/bpm/process-instance/nurse-change-export-excel`, params })
 }
 
+// 导出办理入住流程实例(分页全量数据)
+export const exportCheckIn = async (params: any) => {
+  return await request.download({ url: `/bpm/process-instance/check-in-export-excel`, params })
+}
+
 // 导出
 export const exportCustomerWait = async (params: any) => {
   return await request.download({ url: `/bpm/process-instance/check-in-wait-export-excel`, params })
@@ -106,6 +109,8 @@ export const exportCustomerBed = async (params: any) => {
 
 // 导出
 export const exportCustomerFood = async (params: any) => {
-  return await request.download({ url: `/bpm/process-instance/catering-change-export-excel`, params })
+  return await request.download({
+    url: `/bpm/process-instance/catering-change-export-excel`,
+    params
+  })
 }
-

+ 39 - 18
src/views/elderly/apply/check-in/index.vue

@@ -56,6 +56,9 @@
         >
           <Icon icon="ep:download" class="mr-5px" /> 导入一次性费用
         </el-button>
+        <el-button type="success" plain :loading="exportLoading" @click="handleExport">
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
       </template>
     </TabBarBtn>
 
@@ -107,22 +110,21 @@
         <el-button link type="primary" @click="openForm(scope)" v-hasPermi="['check-in:upload']">
           上传体检报告
         </el-button>
-<!--        <el-dropdown-->
-<!--          v-show="scope.inStatusType == 1 && (scope.bpmStatus == 1 || scope.bpmStatus == 5)"-->
-<!--          trigger="hover"-->
-<!--          @command="(cmd) => openContractForm(scope, cmd)"-->
-<!--        >-->
-<!--          <span class="el-dropdown-link" style="color:#409eff;cursor:pointer;">-->
-<!--            完善合同信息<el-icon class="el-icon&#45;&#45;arrowdown"><arrow-down /></el-icon>-->
-<!--          </span>-->
-<!--          <template #dropdown>-->
-<!--            <el-dropdown-menu>-->
-<!--              <el-dropdown-item command="edit">编辑模式</el-dropdown-item>-->
-<!--              <el-dropdown-item command="view">详情模式(可打印)</el-dropdown-item>-->
-<!--            </el-dropdown-menu>-->
-<!--          </template>-->
-<!--        </el-dropdown>-->
-
+        <!--        <el-dropdown-->
+        <!--          v-show="scope.inStatusType == 1 && (scope.bpmStatus == 1 || scope.bpmStatus == 5)"-->
+        <!--          trigger="hover"-->
+        <!--          @command="(cmd) => openContractForm(scope, cmd)"-->
+        <!--        >-->
+        <!--          <span class="el-dropdown-link" style="color:#409eff;cursor:pointer;">-->
+        <!--            完善合同信息<el-icon class="el-icon&#45;&#45;arrowdown"><arrow-down /></el-icon>-->
+        <!--          </span>-->
+        <!--          <template #dropdown>-->
+        <!--            <el-dropdown-menu>-->
+        <!--              <el-dropdown-item command="edit">编辑模式</el-dropdown-item>-->
+        <!--              <el-dropdown-item command="view">详情模式(可打印)</el-dropdown-item>-->
+        <!--            </el-dropdown-menu>-->
+        <!--          </template>-->
+        <!--        </el-dropdown>-->
       </template>
       <template #next="{ scope }">
         <el-button
@@ -162,8 +164,9 @@
 </template>
 <script lang="ts" setup>
 import Import from '@/components/ImportFile/index.vue'
+import download from '@/utils/download'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
-import { getCheckInPage } from '@/api/elderly/apply/check-in'
+import { getCheckInPage, exportCheckIn } from '@/api/elderly/apply/check-in'
 import { CheckInColumns } from '../column'
 import { cancelprocessInstance } from '@/api/elderly/common'
 import { useUserStore } from '@/store/modules/user'
@@ -222,7 +225,6 @@ const openForm = (row: any = {}) => {
   formRef.value.open(row)
 }
 
-
 // 入住
 const checkRef = ref()
 const handleCheck = async (row: Object) => {
@@ -260,6 +262,25 @@ const handleImport = () => {
   importRef.value.open()
 }
 
+const exportLoading = ref(false)
+/** 导出按钮操作:按当前筛选条件导出全量(pageSize 取接口上限 99999) */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    exportLoading.value = true
+    const data = await exportCheckIn({
+      ...queryParams,
+      pageNo: 1,
+      pageSize: 99999
+    })
+    download.excel(data, '办理入住.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
 const route = useRoute()
 /** 初始化 **/
 onMounted(() => {

BIN
src/views/elderly/apply/nine-precautions/nutritional-risk/assets/nutrition-bottom.png


BIN
src/views/elderly/apply/nine-precautions/nutritional-risk/assets/nutrition-brand.png


BIN
src/views/elderly/apply/nine-precautions/nutritional-risk/assets/nutrition-org-logo.png


BIN
src/views/elderly/apply/nine-precautions/nutritional-risk/assets/nutrition-top.jpeg


+ 27 - 14
src/views/elderly/apply/nine-precautions/nutritional-risk/index.vue

@@ -2,7 +2,11 @@
 import ButtonImport from '@/components/ButtonImport/src/ButtonImport.vue'
 import { onMounted, ref, reactive } from 'vue'
 import { ContentWrap } from '@/components/ContentWrap'
-import { nutritionalRiskPage, nutritionalRiskDelete, nutritionalRiskGetById } from '@/api/social-work'
+import {
+  nutritionalRiskPage,
+  nutritionalRiskDelete,
+  nutritionalRiskGetById
+} from '@/api/social-work'
 import { exportWithExpandedObjectArrays } from '@/utils/excel-export'
 import { exportNutritionDocx } from './nutrition-docx'
 import AddForm from './AddForm.vue'
@@ -37,15 +41,8 @@ const exportLoading = ref(false)
 const handleImportCard = async () => {
   exportLoading.value = true
   try {
-    const queryP = {
-      pageNo: 1,
-      pageSize: 9999,
-      elderName: queryParams.elderName,
-      assessor: queryParams.assessor,
-      assessDate: queryParams.assessDate,
-      tenantIds: queryParams.tenantIds
-    }
-    const data = await nutritionalRiskPage(queryP)
+    // 按当前筛选条件 + 当前分页(pageNo/pageSize 与列表一致)导出
+    const data = await nutritionalRiskPage(queryParams)
     const exportData = data.list || []
 
     if (exportData.length === 0) {
@@ -126,19 +123,30 @@ const handleExportNutrition = async () => {
       return
     }
 
-    // 详情接口取 assessData(勾选/测量值),列表行补充床位号;机构名按 tenantId 解析
+    // 详情接口取 assessData(勾选/测量值),列表行补充床位号;机构名按 tenantId 解析(兜底用)
     const records = await Promise.all(
       rows.map(async (row: any) => {
         const detail = await nutritionalRiskGetById(row.id)
         return {
           detail,
           bedName: row.bedName || detail?.bedName || '',
-          orgName: findLabelById(userStore.getOrgTenant.options, row.tenantId || detail?.tenantId) || ''
+          orgName:
+            findLabelById(userStore.getOrgTenant.options, row.tenantId || detail?.tenantId) || ''
         }
       })
     )
 
-    await exportNutritionDocx(records)
+    // 当前机构名称:筛选条件中只选了一个机构时取所选机构名,否则取登录机构名(再兜底第一条记录所属机构)
+    const tenantIds = queryParams.tenantIds || []
+    let currentOrgName = ''
+    if (tenantIds.length === 1) {
+      currentOrgName = findLabelById(userStore.getOrgTenant.options, tenantIds[0]) || ''
+    }
+    if (!currentOrgName) {
+      currentOrgName = userStore.getTenantName() || records[0]?.orgName || ''
+    }
+
+    await exportNutritionDocx(records, currentOrgName)
     message.success(`导出成功,共 ${records.length} 位长者`)
   } catch (error) {
     console.error('导出营养表失败:', error)
@@ -318,7 +326,12 @@ onMounted(async () => {
     <div class="mb-10px">
       <ButtonAdd @click="openForm(undefined)" />
       <ButtonImport @click="handleImportCard" />
-      <el-button type="warning" plain :loading="nutritionExportLoading" @click="handleExportNutrition">
+      <el-button
+        type="warning"
+        plain
+        :loading="nutritionExportLoading"
+        @click="handleExportNutrition"
+      >
         <Icon icon="ep:document" class="mr-5px" />导出营养表
       </el-button>
     </div>

+ 150 - 23
src/views/elderly/apply/nine-precautions/nutritional-risk/nutrition-docx.ts

@@ -2,16 +2,22 @@
  * 老年人营养不良风险评估表 —— Word(.docx) 导出(紧凑段落版,每人一页 A4)
  *
  * 格式依据:4.3.18老年人营养评估表-海珠.docx
- * - 标题「老年人营养不良风险评估表」,右上角为机构名称(仅名称,无“机构名称:”前缀);
+ * - 标题「老年人营养不良风险评估表」;
  * - 抬头含 姓名/性别/年龄/床位号/日期/身高/体重/BMI/小腿围/腰围;
  * - 初筛 6 题 + 评估 14 题,选项以 □/☑ 勾选形式内联排版,与纸质单一致;
- * - 紧凑排版(8mm 页边距 + 固定行距 + 分档小字号),保证每位长者内容压缩在一张 A4 内,
+ * - 紧凑排版(固定行距 + 分档小字号),保证每位长者内容压缩在一张 A4 内,
  *   下一位长者另起一页。
+ * - 页眉/页脚沿用模板视觉:顶部彩色飘带 + 当前机构名称(替换模板中原海珠区老人公寓 logo)
+ *   + 颐年医养品牌 logo,底部橙绿飘带;每页重复。
  *
  * 实现:使用项目已有依赖 JSZip 直接组装 OOXML(WordprocessingML),不引入新依赖。
  */
 import JSZip from 'jszip'
 import { saveAs } from 'file-saver'
+import topRibbonUrl from './assets/nutrition-top.jpeg?url'
+import bottomRibbonUrl from './assets/nutrition-bottom.png?url'
+import brandLogoUrl from './assets/nutrition-brand.png?url'
+import orgLogoUrl from './assets/nutrition-org-logo.png?url'
 
 /** 导出输入:列表行 + 详情(含 assessData)的合并记录 */
 export interface NutritionExportRecord {
@@ -28,9 +34,8 @@ export interface NutritionExportRecord {
 const FONT_HEI = '黑体'
 const FONT_SONG = '宋体'
 
-/** 字号体系(半磅):标题36 / 机构名24 / 抬头·评分23 / 小节24 / 题目22 / 评定20 */
+/** 字号体系(半磅):标题36 / 抬头·评分23 / 小节24 / 题目22 / 评定20 */
 const SZ_TITLE = 36
-const SZ_ORG = 24
 const SZ_INFO = 23
 const SZ_SECTION = 24
 const SZ_ITEM = 22
@@ -328,7 +333,9 @@ const itemPara = (item: ItemDef, d: AssessData): string => {
   const selected = item.selected(d)
   const runs =
     run(item.name, { b: true, size: SZ_ITEM }) +
-    item.options.map((o) => run(`${box(selected === o.score)}${o.label} `, { size: SZ_ITEM })).join('')
+    item.options
+      .map((o) => run(`${box(selected === o.score)}${o.label} `, { size: SZ_ITEM }))
+      .join('')
   return para(runs)
 }
 
@@ -350,10 +357,7 @@ const renderElderBlock = (record: NutritionExportRecord): string => {
 
   const parts: string[] = []
 
-  // 机构名称:右上角仅显示名称(无“机构名称:”前缀),为空则不占位
-  if (record.orgName) {
-    parts.push(textPara(record.orgName, { font: FONT_HEI, b: true, size: SZ_ORG, jc: 'right', line: 360 }))
-  }
+  // 机构名称在页眉处展示(当前机构名称,每页重复),正文直接从标题开始
   // 标题
   parts.push(
     textPara('老年人营养不良风险评估表', {
@@ -377,13 +381,19 @@ const renderElderBlock = (record: NutritionExportRecord): string => {
   )
   const measureLine = [
     res.assessDate ? `日期:${res.assessDate}` : '',
-    d.height !== undefined && d.height !== null && d.height !== '' ? `身高:${num(d.height, 2)}m` : '',
-    d.weight !== undefined && d.weight !== null && d.weight !== '' ? `体重:${num(d.weight, 1)}kg` : '',
+    d.height !== undefined && d.height !== null && d.height !== ''
+      ? `身高:${num(d.height, 2)}m`
+      : '',
+    d.weight !== undefined && d.weight !== null && d.weight !== ''
+      ? `体重:${num(d.weight, 1)}kg`
+      : '',
     d.bmi ? `BMI:${num(d.bmi, 1)}kg/㎡` : '',
     d.calfCircumference !== undefined && d.calfCircumference !== null && d.calfCircumference !== ''
       ? `小腿围:${num(d.calfCircumference, 1)}cm`
       : '',
-    d.waistCircumference !== undefined && d.waistCircumference !== null && d.waistCircumference !== ''
+    d.waistCircumference !== undefined &&
+    d.waistCircumference !== null &&
+    d.waistCircumference !== ''
       ? `腰围:${num(d.waistCircumference, 1)}cm`
       : ''
   ]
@@ -419,10 +429,10 @@ const renderElderBlock = (record: NutritionExportRecord): string => {
 
   // 评分说明
   parts.push(
-    textPara(
-      '评估总分30分,≤17分提示营养不良;17-24分提示有营养不良的风险;≥24分营养正常。',
-      { size: SZ_ITEM, before: 30 }
-    )
+    textPara('评估总分30分,≤17分提示营养不良;17-24分提示有营养不良的风险;≥24分营养正常。', {
+      size: SZ_ITEM,
+      before: 30
+    })
   )
   parts.push(
     textPara(
@@ -448,7 +458,9 @@ const renderElderBlock = (record: NutritionExportRecord): string => {
   parts.push(
     para(
       run('评估结果:', { b: true, size: SZ_INFO }) +
-        RESULT_OPTIONS.map((o) => run(`${box(riskLevel === o.level)}${o.label} `, { size: SZ_INFO })).join(''),
+        RESULT_OPTIONS.map((o) =>
+          run(`${box(riskLevel === o.level)}${o.label} `, { size: SZ_INFO })
+        ).join(''),
       { before: 20 }
     )
   )
@@ -466,8 +478,11 @@ const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
 <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
 <Default Extension="xml" ContentType="application/xml"/>
+<Default Extension="png" ContentType="image/png"/>
+<Default Extension="jpeg" ContentType="image/jpeg"/>
 <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
 <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
+<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
 </Types>`
 
 const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -478,6 +493,15 @@ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 const DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
+<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>
+</Relationships>`
+
+const HEADER_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
+<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image2.jpeg"/>
+<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image3.png"/>
+<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image4.png"/>
 </Relationships>`
 
 const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -489,35 +513,138 @@ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
 </w:styles>`
 
+// ---------------- 页眉/页脚装饰(坐标取自模板 4.3.18老年人营养评估表-海珠.docx,EMU,相对页面) ----------------
+// 顶部彩色飘带(image2):左上角起,全页宽
+const POS_TOP = { x: -6985, y: -5715, cx: 7566660, cy: 208280, rid: 'rId2', z: 251659264 }
+// 左上机构 logo(圆形徽章,位于机构名称左侧)
+const POS_ORG_LOGO = { x: 120000, y: 330000, cx: 480000, cy: 480000, rid: 'rId3', z: 251659267 }
+// 当前机构名称:文本框高度=单行行高,纵向中心线与 logo 中心线(y=570000)严格对齐
+const ORG_LINE_EMU = 254000 // 20pt 行高 = 400 twips
+const POS_ORG = {
+  x: 680000,
+  y: 570000 - ORG_LINE_EMU / 2,
+  cx: 2300000,
+  cy: ORG_LINE_EMU,
+  z: 251659266
+}
+// 颐年医养品牌 logo(image4,右上)
+const POS_BRAND = { x: 5825490, y: 349885, cx: 1487805, cy: 431165, rid: 'rId4', z: 251659262 }
+// 底部橙绿飘带(image1)
+const POS_BOTTOM = { x: 7620, y: 10001885, cx: 7552055, cy: 563245, rid: 'rId1', z: 251660288 }
+
+/** 浮动图片锚点(相对页面四角定位,不影响正文排版) */
+const picAnchorXml = (
+  id: number,
+  name: string,
+  p: { x: number; y: number; cx: number; cy: number; rid: string; z: number }
+): string =>
+  `<wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="${p.z}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">` +
+  `<wp:simplePos x="0" y="0"/>` +
+  `<wp:positionH relativeFrom="page"><wp:posOffset>${p.x}</wp:posOffset></wp:positionH>` +
+  `<wp:positionV relativeFrom="page"><wp:posOffset>${p.y}</wp:posOffset></wp:positionV>` +
+  `<wp:extent cx="${p.cx}" cy="${p.cy}"/>` +
+  `<wp:effectExtent l="0" t="0" r="0" b="0"/>` +
+  `<wp:wrapNone/>` +
+  `<wp:docPr id="${id}" name="${name}"/>` +
+  `<wp:cNvGraphicFramePr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>` +
+  `<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">` +
+  `<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">` +
+  `<pic:nvPicPr><pic:cNvPr id="${id}" name="${name}"/><pic:cNvPicPr><a:picLocks noChangeAspect="1"/></pic:cNvPicPr></pic:nvPicPr>` +
+  `<pic:blipFill><a:blip r:embed="${p.rid}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
+  `<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${p.cx}" cy="${p.cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>` +
+  `</pic:pic></a:graphicData></a:graphic></wp:anchor>`
+
+/** 浮动文本框(当前机构名称,黑体加粗,位置同模板左上海珠 logo) */
+const orgTextAnchorXml = (orgName: string): string =>
+  `<wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="${POS_ORG.z}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">` +
+  `<wp:simplePos x="0" y="0"/>` +
+  `<wp:positionH relativeFrom="page"><wp:posOffset>${POS_ORG.x}</wp:posOffset></wp:positionH>` +
+  `<wp:positionV relativeFrom="page"><wp:posOffset>${POS_ORG.y}</wp:posOffset></wp:positionV>` +
+  `<wp:extent cx="${POS_ORG.cx}" cy="${POS_ORG.cy}"/>` +
+  `<wp:effectExtent l="0" t="0" r="0" b="0"/>` +
+  `<wp:wrapNone/>` +
+  `<wp:docPr id="11" name="当前机构名称"/>` +
+  `<wp:cNvGraphicFramePr/>` +
+  `<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">` +
+  `<wps:wsp xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">` +
+  `<wps:cNvSpPr txBox="1"/>` +
+  `<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${POS_ORG.cx}" cy="${POS_ORG.cy}"/></a:xfrm>` +
+  `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></wps:spPr>` +
+  `<wps:txbx><w:txbxContent>` +
+  `<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:pPr><w:spacing w:before="0" w:after="0" w:line="400" w:lineRule="exact"/><w:jc w:val="left"/></w:pPr>` +
+  `<w:r><w:rPr><w:rFonts w:ascii="${FONT_HEI}" w:hAnsi="${FONT_HEI}" w:eastAsia="${FONT_HEI}" w:cs="${FONT_HEI}"/><w:b/><w:bCs/><w:color w:val="000000"/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr><w:t xml:space="preserve">${esc(orgName)}</w:t></w:r>` +
+  `</w:p></w:txbxContent></wps:txbx>` +
+  `<wps:bodyPr rot="0" vertOverflow="clip" horzOverflow="clip" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"/>` +
+  `</wps:wsp></a:graphicData></a:graphic></wp:anchor>`
+
+const buildHeaderXml = (orgName: string): string =>
+  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
+<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"/></w:pPr>` +
+  `<w:r><w:drawing>${picAnchorXml(10, '顶部飘带', POS_TOP)}</w:drawing></w:r>` +
+  `<w:r><w:drawing>${picAnchorXml(14, '机构logo', POS_ORG_LOGO)}</w:drawing></w:r>` +
+  `<w:r><w:drawing>${orgTextAnchorXml(orgName)}</w:drawing></w:r>` +
+  `<w:r><w:drawing>${picAnchorXml(12, '颐年医养logo', POS_BRAND)}</w:drawing></w:r>` +
+  `<w:r><w:drawing>${picAnchorXml(13, '底部飘带', POS_BOTTOM)}</w:drawing></w:r>` +
+  `</w:p>
+</w:hdr>`
+
 /** 分页符段落(除第一组外,每组长者之前插入——每人一张 A4) */
-const PAGE_BREAK = '<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="160" w:lineRule="exact"/></w:pPr><w:r><w:br w:type="page"/></w:r></w:p>'
+const PAGE_BREAK =
+  '<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="160" w:lineRule="exact"/></w:pPr><w:r><w:br w:type="page"/></w:r></w:p>'
 
 const buildDocumentXml = (records: NutritionExportRecord[]): string => {
   const body =
     records.map((r, i) => (i > 0 ? PAGE_BREAK : '') + renderElderBlock(r)).join('') +
-    // A4 竖版;页边距 10mm(约567 twips),配合固定行距把内容控制在一页内
-    `<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>` +
-    `<w:pgMar w:top="567" w:right="567" w:bottom="567" w:left="567" w:header="0" w:footer="0" w:gutter="0"/>` +
+    // A4 竖版;页眉每页重复(顶部飘带/机构名/logo、底部飘带),上下页边距避让装饰带
+    `<w:sectPr><w:headerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2" w:type="default"/>` +
+    `<w:pgSz w:w="11906" w:h="16838"/>` +
+    `<w:pgMar w:top="1296" w:right="567" w:bottom="1134" w:left="567" w:header="720" w:footer="720" w:gutter="0"/>` +
     `</w:sectPr>`
   return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
 <w:body>${body}</w:body>
 </w:document>`
 }
 
+const fetchAsset = async (url: string): Promise<ArrayBuffer> => {
+  const res = await fetch(url)
+  if (!res.ok) throw new Error(`背景素材加载失败: ${url}`)
+  return await res.arrayBuffer()
+}
+
 /**
  * 导出多组长者营养评估数据到一份 .docx(每组一页 A4)。
+ * @param records 评估记录
+ * @param orgName 当前机构名称(页眉展示)
  */
-export const exportNutritionDocx = async (records: NutritionExportRecord[]): Promise<void> => {
+export const exportNutritionDocx = async (
+  records: NutritionExportRecord[],
+  orgName = ''
+): Promise<void> => {
   if (!records.length) throw new Error('没有可导出的评估数据')
 
+  const [topBuf, bottomBuf, brandBuf, orgLogoBuf] = await Promise.all([
+    fetchAsset(topRibbonUrl),
+    fetchAsset(bottomRibbonUrl),
+    fetchAsset(brandLogoUrl),
+    fetchAsset(orgLogoUrl)
+  ])
+
   const zip = new JSZip()
   zip.file('[Content_Types].xml', CONTENT_TYPES)
   zip.folder('_rels')!.file('.rels', ROOT_RELS)
   const word = zip.folder('word')!
   word.file('document.xml', buildDocumentXml(records))
+  word.file('header1.xml', buildHeaderXml(orgName))
   word.file('styles.xml', STYLES)
   word.folder('_rels')!.file('document.xml.rels', DOC_RELS)
+  word.folder('_rels')!.file('header1.xml.rels', HEADER_RELS)
+  const media = word.folder('media')!
+  media.file('image1.png', bottomBuf)
+  media.file('image2.jpeg', topBuf)
+  media.file('image3.png', orgLogoBuf)
+  media.file('image4.png', brandBuf)
 
   const blob = await zip.generateAsync({
     type: 'blob',