|
|
@@ -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',
|