Jelajahi Sumber

登录页增加记住密码

chenjun 2 minggu lalu
induk
melakukan
e1521d12a9

+ 46 - 0
.workbuddy/memory/2026-09-23.md

@@ -0,0 +1,46 @@
+# 2026-09-23
+
+## 登录页「记住密码」多账号下拉选择(已完成)
+- 需求:登录页勾选记住密码后,把企业名+账号+密码存本地;之后企业名/账号均可下拉选择(同时支持手输新值)
+- 实现:
+  - `src/hooks/web/useCache.ts`:新增 `CACHE_KEY.SavedAccounts = 'savedAccounts'`
+  - `src/utils/auth.ts`:新增 `SavedAccountType` + `getSavedAccounts / addSavedAccount / removeSavedAccount`,密码复用 `encrypt/decrypt`(RSA)加密存 localStorage(web-storage-cache,有效期 365 天,上限 20 条,同 tenantName+username 去重覆盖并置顶)
+  - `src/views/Login/components/LoginForm.vue`:
+    - 企业名、账号两个 `el-input` 改为 `el-select`(`filterable + allow-create + default-first-option`,仍可手输新值)
+    - 账号下拉项显示 `账号(企业名)`,右侧删除图标可单条移除
+    - 选企业名 → 自动带出该企业下最近记住的账号+密码;选账号 → 自动带出企业名+密码(优先匹配当前企业名)
+    - 登录成功:勾选记住 → `addSavedAccount`;未勾选 → 从列表移除该账号
+  - `src/locales/zh-CN.ts` / `en.ts`:新增 `login.rememberPassword`(记住密码)
+- 已跑 `vue-tsc --noEmit`,改动文件无类型报错
+
+## ⚠️ 补做:真正生效的登录页是 `src/views/Login/Login.vue`(不是 components/LoginForm.vue)
+- **事由**:在 `components/LoginForm.vue` 改完后用户截图反馈「没看到新功能」
+- **根因**:本项目真实登录路由渲染的是 `views/Login/Login.vue` 的自定义手写表单(原生 `<input>` + `.inputM/.inputS` 样式 + 「使用谷歌浏览器」页脚);
+  `components/LoginForm.vue` / MobileForm / QrCodeForm / RegisterForm 在 Login.vue 里**全是注释掉的**(`<LoginForm ... />` 注释),改了不生效
+- **排查手法**:用户截图里的特征文案(`账号登录` / `使用谷歌浏览器` / `欢迎来到颐年`)→ 在项目里 Grep,直接定位真正的组件
+- **教训**:改登录页前先确认实际挂载的组件;本项目多个 Login 入口并存,别默认 `LoginForm.vue`
+- Login.vue 侧实现:企业名/账号输入框 `@focus/@input` 弹自绘 `.account-dropdown` 面板(失焦 150ms 延时关闭,选项用 `@mousedown.prevent` 以免 blur 早于 click);
+  密码框下新增「记住密码」el-checkbox(登录按钮 marginTop 由 40px 收为 12px,卡片 500px 高度不变);登录成功分支同样 addSavedAccount / removeSavedAccount
+- 共用同一套 `src/utils/auth.ts` 的 SavedAccounts 存储(跨两个登录页互通)
+
+## 🐛 上述功能上线后两个 BUG 的修复(务必记住这两个坑)
+1. **密码被清空 / 回填后是乱码**
+   - 根因:`src/utils/auth.ts` 的 `setLoginForm()` **原地改写传入对象**(`loginForm.password = encrypt(...)`)。
+     `handleLogin` 里先 `setLoginForm(loginData.loginForm)` 再 `addSavedAccount(...)` → 存进去的是「密文再加密」→ 读时解不开 → 密码空
+   - 修法:① `setLoginForm` 改为 `{ ...loginForm, password: encrypt(...) }` 不再改原对象;
+     ② SavedAccounts 的增删改统一基于**原始密文列表** `getSavedAccountsRaw()`,禁止「先全解密再全加密」(会把解密失败的条目永久写成空);
+     ③ 解密结果仍像密文(≥40 位 base64)时再解一层,自动修复历史二次加密数据
+2. **自绘下拉面板自动关闭**
+   - 根因:面板 `position:absolute` 在 `overflow:auto` 滚动容器内会撑出滚动条→重排→input 失焦→`blur` 关闭;且原本用 blur+150ms 延时关闭太脆
+   - 修法:面板 `Teleport to="body"` + `position:fixed` + 输入框 `getBoundingClientRect()` 定位;
+     **关闭只判 `document.mousedown` 是否落在面板/输入框外**,完全不依赖 focus/blur;scroll/resize 只重新定位
+3. 选中空的记住密码不再覆盖用户已输入的内容(`if (matched.password)` 守卫)
+- `vue-tsc --noEmit` 已过
+
+## 交互约定
+- 回填策略:选企业名 → 带出该企业最近记住的账号+密码;选账号 → 带出企业名+密码;两者都加非空守卫
+
+## 后续微调(同日)
+- 登录下拉改为**打开即展示全部**记住的企业名/账号,不再按输入内容过滤(用户明确要求)
+- 临时医嘱单打印页 `short-doctor-order/Print.vue`:字体全部改黑色(标题/字段标签/表头),红色仅保留线条(标题双红线、表格边框);
+  长期医嘱单 Print.vue 本就是黑色;execute-doctor-order 无红色字体

+ 10 - 0
.workbuddy/memory/MEMORY.md

@@ -3,6 +3,16 @@
 ## 项目身份
 湖北省养老机构系统前端(yudao 框架:Vue3 + TS + Element Plus)。重点模块:九防看板、合同(湖北2024版)、入住登记、长期/临时医嘱单、护士执行医嘱。
 
+## ⚠️ 登录页真实组件是 `src/views/Login/Login.vue`
+- `components/LoginForm.vue`(yudao 原版)在 Login.vue 里**被注释掉**,改它不生效。真实页面用原生 `<input>` + `.inputM/.inputS` 自定义样式,特征文案「账号登录/使用谷歌浏览器」
+- 2026-09-23 已在 Login.vue 实现「记住密码」:企业名/账号下拉(Teleport 到 body + fixed 定位 + 点击外部关闭,不依赖 blur)、密码框下「记住密码」复选框;账号列表存 `src/utils/auth.ts` 的 SavedAccounts(localStorage,RSA 加密,365 天,上限 20 条),两处登录组件共用
+
+## ⚠️ 两个踩过的坑(改登录/记住密码相关代码前必看)
+1. `utils/auth.ts` 的 `setLoginForm()` 曾**原地改写传入对象**(`password = encrypt(...)`)→ 后续再拿同一表单去存账号会**二次加密**导致密码永久丢失。
+   已改成 `{ ...loginForm, password: encrypt(...) }`。**教训:写枚举/工具函数不要就地修改入参**
+2. 自绘下拉面板不要用 `absolute` 放在本项目 `overflow:auto` 容器里(会撑出滚动条触发重排→失焦→面板关闭);
+   用 `Teleport to="body"` + `fixed` + `getBoundingClientRect()` 定位,关闭逻辑用 `document.mousedown` 判定而不是 blur
+
 ## 医嘱模块(2026-08 搭建)
 - 长期医嘱 `long-doctor-order/`:长者 + 多条 entries 子表,条目含 status 启用/停止
 - 临时医嘱 `short-doctor-order/`:一次性,无启停,含 executeTime

+ 15 - 0
src/api/elderly/elder/bed-change/index.ts

@@ -549,6 +549,21 @@ export const getMedCn = (options: MedOption[], val?: string): string => {
   return item ? item.cn : val
 }
 
+/**
+ * 选项值 → 医用简写符号(打印单用,如 po 口服→po、ac 餐前→ac、qd 每日1次→qd)。
+ * - 值本身是合法缩写:直接返回;
+ * - 老数据若误存中文(cn)或"缩写+中文"标签:反查回缩写;
+ * - 都匹配不到:原样返回,避免丢信息。
+ */
+export const getMedAbbr = (options: MedOption[], val?: string): string => {
+  if (!val) return ''
+  const v = String(val).trim()
+  const byValue = options.find((o) => o.value === v)
+  if (byValue) return byValue.value
+  const byCn = options.find((o) => o.cn === v || o.label === v)
+  return byCn ? byCn.value : v
+}
+
 /**
  * 根据频次(frequency)与给药时间(medTime),在指定日期展开"计划执行时点"列表(返回 HH:mm 数组)。
  * 说明:

+ 1 - 0
src/hooks/web/useCache.ts

@@ -21,6 +21,7 @@ export const CACHE_KEY = {
   // 登录表单
   LoginForm: 'loginForm',
   TenantId: 'tenantId',
+  SavedAccounts: 'savedAccounts', // 记住密码保存的账号列表
   // 集团相关
   GROUPINFO: '',
   ISFROM: 'isFrom',

+ 1 - 0
src/locales/en.ts

@@ -128,6 +128,7 @@ export default {
     register: 'Register',
     checkPassword: 'Confirm password',
     remember: 'Remember me',
+    rememberPassword: 'Remember password',
     hasUser: 'Existing account? Go to login',
     forgetPassword: 'Forget password?',
     tenantNamePlaceholder: 'Please Enter Tenant Name',

+ 1 - 0
src/locales/zh-CN.ts

@@ -130,6 +130,7 @@ export default {
     register: '注册',
     checkPassword: '确认密码',
     remember: '记住我',
+    rememberPassword: '记住密码',
     hasUser: '已有账号?去登录',
     forgetPassword: '忘记密码?',
     tenantNamePlaceholder: '请输入租户名称',

+ 81 - 2
src/utils/auth.ts

@@ -62,14 +62,93 @@ export const getLoginForm = () => {
 }
 
 export const setLoginForm = (loginForm: LoginFormType) => {
-  loginForm.password = encrypt(loginForm.password) as string
-  wsCache.set(CACHE_KEY.LoginForm, loginForm, { exp: 30 * 24 * 60 * 60 })
+  // 注意:不能原地改写 loginForm.password,否则会把调用方的明文密码替换成密文
+  // 曾经踩过的坑:登录后紧接着用同一个表单对象去保存账号,导致密码被二次加密、再也解不开
+  wsCache.set(CACHE_KEY.LoginForm, { ...loginForm, password: encrypt(loginForm.password) as string }, { exp: 30 * 24 * 60 * 60 })
 }
 
 export const removeLoginForm = () => {
   wsCache.delete(CACHE_KEY.LoginForm)
 }
 
+// ========== 记住密码:已保存账号列表 ==========
+// 多条账号记录(企业名 + 账号 + 密码),登录页可下拉选择;密码加密存储于 localStorage
+export type SavedAccountType = {
+  tenantName: string
+  username: string
+  password: string // 明文(getSavedAccounts 已解密;如需原密文请用 getSavedAccountsRaw)
+  savedAt: number
+}
+
+const SAVED_ACCOUNTS_MAX = 20
+const SAVED_ACCOUNTS_EXP = 365 * 24 * 60 * 60
+
+// 读取原始(密码仍是密文)列表,增删改均基于它,避免「先解密再加密」导致的二次加密
+const getSavedAccountsRaw = (): SavedAccountType[] => {
+  return wsCache.get(CACHE_KEY.SavedAccounts) || []
+}
+const writeSavedAccounts = (list: SavedAccountType[]) => {
+  wsCache.set(CACHE_KEY.SavedAccounts, list, { exp: SAVED_ACCOUNTS_EXP })
+}
+// 单条解密:解密失败返回空字符串;若结果仍是密文(历史数据曾被二次加密),再解一层还原
+const looksLikeCipher = (txt: string) => /^[A-Za-z0-9+/=]{40,}$/.test(txt.trim()) && !txt.includes(' ')
+const decryptPassword = (cipher: string) => {
+  if (!cipher) {
+    return ''
+  }
+  const plain = decrypt(cipher)
+  const result = typeof plain === 'string' && plain ? plain : ''
+  if (result && looksLikeCipher(result)) {
+    const once = decrypt(result)
+    if (typeof once === 'string' && once) {
+      return once
+    }
+  }
+  return result
+}
+
+export const getSavedAccounts = (): SavedAccountType[] => {
+  return getSavedAccountsRaw().map((item) => ({ ...item, password: decryptPassword(item.password) }))
+}
+
+// 新增/更新一条记住的账号(同 企业名+账号 视为同一条,后存覆盖先存,并移到最前)
+export const addSavedAccount = (account: {
+  tenantName: string
+  username: string
+  password: string
+}) => {
+  if (!account.username) {
+    return
+  }
+  const list = getSavedAccountsRaw()
+  const idx = list.findIndex(
+    (item) => item.tenantName === account.tenantName && item.username === account.username
+  )
+  if (idx > -1) {
+    list.splice(idx, 1)
+  }
+  // 同一条记录更新时,只覆盖密码(避免把密文再加密一遍)
+  const newItem: SavedAccountType = {
+    tenantName: account.tenantName || '',
+    username: account.username,
+    password: encrypt(account.password) as string,
+    savedAt: Date.now()
+  }
+  list.unshift(newItem)
+  while (list.length > SAVED_ACCOUNTS_MAX) {
+    list.pop()
+  }
+  writeSavedAccounts(list)
+}
+
+// 删除一条记住的账号
+export const removeSavedAccount = (tenantName: string, username: string) => {
+  const list = getSavedAccountsRaw().filter(
+    (item) => !(item.tenantName === tenantName && item.username === username)
+  )
+  writeSavedAccounts(list)
+}
+
 // ========== 租户相关 ==========
 
 export const getTenantId = () => {

+ 263 - 5
src/views/Login/Login.vue

@@ -126,6 +126,7 @@
 
                 <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
                   <div
+                    ref="tenantBoxRef"
                     class="row inputM"
                     :style="{ width:   '382px', marginTop: '16px' }"
                   >
@@ -136,17 +137,29 @@
                       style="color: #000000; font-size: 14px; height: 50px;"
                       :style="{ width:   '300px' }"
                       v-model="loginData.loginForm.tenantName"
-                      @focus="focusHd"
+                      @focus="openDropdown('tenant')"
+                      @click="openDropdown('tenant')"
+                      @input="openDropdown('tenant')"
                       class="inputS"
                       @keyup.enter="handleLogin"
                       :placeholder="identityType === 0 ? '请输入企业名' : '请输入集团名'"
                     />
                     <div style="flex-grow: 1"></div>
+                    <el-icon
+                      v-if="tenantOptions.length"
+                      class="input-arrow"
+                      :class="{ 'is-open': dropdownType === 'tenant' }"
+                      color="#c0c4cc"
+                      :size="16"
+                      @mousedown.prevent="toggleDropdown('tenant')"
+                      ><ArrowDown
+                    /></el-icon>
                   </div>
                 </el-form-item>
 
                 <el-form-item prop="username" size="small">
                   <div
+                    ref="accountBoxRef"
                     class="row inputM"
                     :style="{ width:  '382px', marginTop: '-2px' }"
                   >
@@ -157,12 +170,23 @@
                       style="color: #000000; font-size: 14px; height: 50px"
                       :style="{ width:  '300px' }"
                       v-model="loginData.loginForm.username"
-                      @focus="focusHd"
+                      @focus="openDropdown('account')"
+                      @click="openDropdown('account')"
+                      @input="openDropdown('account')"
                       class="inputS"
                       @keyup.enter="handleLogin"
-                      placeholder="请输入账号或手机号"
+                      placeholder="请输入账号或选择已记住的账号"
                     />
                     <div style="flex-grow: 1"></div>
+                    <el-icon
+                      v-if="savedAccounts.length"
+                      class="input-arrow"
+                      :class="{ 'is-open': dropdownType === 'account' }"
+                      color="#c0c4cc"
+                      :size="16"
+                      @mousedown.prevent="toggleDropdown('account')"
+                      ><ArrowDown
+                    /></el-icon>
                   </div>
                 </el-form-item>
 
@@ -219,12 +243,17 @@
 <!--                  </div>-->
 <!--                </div>-->
 
+                <!-- 记住密码 -->
+                <div class="row" style="width: 382px; margin-top: 4px">
+                  <el-checkbox v-model="loginData.loginForm.rememberMe">记住密码</el-checkbox>
+                </div>
+
                 <el-button
                   type="primary"
                   size="large"
                   @click="handleLogin"
                   :loading="loginLoading"
-                  :style="{ width:  '382px', marginTop: '40px' }"
+                  :style="{ width:  '382px', marginTop: '12px' }"
                   style="height: 50px; font-size: 16px"
                   >登录</el-button
                 >
@@ -255,6 +284,48 @@
     <!-- <RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> -->
     <!-- 三方登录 -->
     <!-- <SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> -->
+
+    <!-- 记住密码:账号 / 企业名 下拉面板,Teleport 到 body,避免被滚动容器影响 -->
+    <Teleport to="body">
+      <div
+        v-if="dropdownType === 'tenant' && tenantOptions.length"
+        ref="dropdownRef"
+        class="account-dropdown"
+        :style="{ top: dropdownPos.top + 'px', left: dropdownPos.left + 'px', width: dropdownPos.width + 'px' }"
+      >
+        <div
+          v-for="name in tenantOptions"
+          :key="name"
+          class="account-dropdown-item"
+          @mousedown.prevent="selectTenant(name)"
+        >
+          <span class="account-dropdown-label">{{ name }}</span>
+        </div>
+      </div>
+      <div
+        v-if="dropdownType === 'account' && savedAccounts.length"
+        ref="dropdownRef"
+        class="account-dropdown"
+        :style="{ top: dropdownPos.top + 'px', left: dropdownPos.left + 'px', width: dropdownPos.width + 'px' }"
+      >
+        <div
+          v-for="item in savedAccounts"
+          :key="item.tenantName + '/' + item.username"
+          class="account-dropdown-item"
+          @mousedown.prevent="selectAccount(item)"
+        >
+          <span class="account-dropdown-label">
+            {{ item.username }}
+            <span v-if="item.tenantName" class="account-dropdown-tenant">
+              ({{ item.tenantName }})
+            </span>
+          </span>
+          <span class="account-dropdown-delete" title="删除该账号" @mousedown.prevent.stop="removeAccount(item)"
+            >×</span
+          >
+        </div>
+      </div>
+    </Teleport>
   </div>
 </template>
 
@@ -262,10 +333,11 @@
 import { useDesign } from '@/hooks/web/useDesign'
 import { useAppStore } from '@/store/modules/app'
 import { onMounted } from 'vue'
-import { Hide, View } from '@element-plus/icons-vue'
+import { ArrowDown, Hide, View } from '@element-plus/icons-vue'
 import * as LoginApi from '@/api/login'
 import { ElLoading } from 'element-plus'
 import * as authUtil from '@/utils/auth'
+import type { SavedAccountType } from '@/utils/auth'
 import { useFormValid } from '@/views/Login/components/useLogin'
 import type { RouteLocationNormalizedLoaded } from 'vue-router'
 import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
@@ -342,6 +414,7 @@ onMounted(() => {
   console.log('字体宽度', titleWidth.value)
 
   getLoginFormCache()
+  loadSavedAccounts()
   getTenantByWebsite()
 })
 
@@ -422,8 +495,19 @@ const handleLogin = async (params) => {
     })
     if (loginData.loginForm.rememberMe) {
       authUtil.setLoginForm(loginData.loginForm)
+      // 记住密码:保存/更新到本地账号列表(企业名+账号+加密密码),供下次下拉选择
+      authUtil.addSavedAccount({
+        tenantName: loginData.loginForm.tenantName || '',
+        username: loginData.loginForm.username || '',
+        password: loginData.loginForm.password || ''
+      })
     } else {
       authUtil.removeLoginForm()
+      // 不记住:从本地账号列表中移除该账号
+      authUtil.removeSavedAccount(
+        loginData.loginForm.tenantName || '',
+        loginData.loginForm.username || ''
+      )
     }
     localStorage.setItem("identity", String(identityType.value))
     await userStore.setIsFrom(0) // 直接登录
@@ -478,6 +562,107 @@ const getLoginFormCache = () => {
     }
   }
 }
+
+// ========== 记住密码:本地保存的账号列表,下拉选择 ==========
+const savedAccounts = ref<SavedAccountType[]>([])
+type DropdownType = 'tenant' | 'account' | ''
+const dropdownType = ref<DropdownType>('')
+const tenantBoxRef = ref<HTMLElement>()
+const accountBoxRef = ref<HTMLElement>()
+const dropdownRef = ref<HTMLElement>()
+const dropdownPos = reactive({ top: 0, left: 0, width: 0 })
+// 企业名去重后的选项(下拉打开时展示全部,不做输入过滤)
+const tenantOptions = computed(() => {
+  const names = savedAccounts.value.map((item) => item.tenantName).filter(Boolean)
+  return Array.from(new Set(names))
+})
+const loadSavedAccounts = () => {
+  savedAccounts.value = authUtil.getSavedAccounts()
+}
+// 面板挂到 body 并定位到输入框下方
+const updateDropdownPos = () => {
+  const box: HTMLElement | undefined =
+    dropdownType.value === 'tenant' ? tenantBoxRef.value : accountBoxRef.value
+  if (!box) {
+    return
+  }
+  const rect = box.getBoundingClientRect()
+  dropdownPos.top = rect.bottom + 4
+  dropdownPos.left = rect.left
+  dropdownPos.width = rect.width
+}
+const openDropdown = (type: Exclude<DropdownType, ''>) => {
+  dropdownType.value = type
+  nextTick(() => {
+    updateDropdownPos()
+  })
+}
+const closeDropdown = () => {
+  dropdownType.value = ''
+}
+const toggleDropdown = (type: Exclude<DropdownType, ''>) => {
+  if (dropdownType.value === type) {
+    closeDropdown()
+  } else {
+    openDropdown(type)
+  }
+}
+// 只有点击面板与输入框之外的地方才关闭,避免失焦导致的自动关闭
+const onDocumentMouseDown = (e: MouseEvent) => {
+  const target = e.target as Node
+  if (dropdownRef.value?.contains(target)) {
+    return
+  }
+  const box = dropdownType.value === 'tenant' ? tenantBoxRef.value : accountBoxRef.value
+  if (box?.contains(target)) {
+    return
+  }
+  closeDropdown()
+}
+const onViewChange = () => {
+  // 滚动/缩放时重新定位,跟随输入框
+  updateDropdownPos()
+}
+// 选择企业名:自动带出该企业下最近记住的账号+密码(密码为空则不覆盖已输入内容)
+const selectTenant = (name: string) => {
+  loginData.loginForm.tenantName = name
+  const matched = savedAccounts.value.find((item) => item.tenantName === name)
+  if (matched) {
+    if (matched.username) {
+      loginData.loginForm.username = matched.username
+    }
+    if (matched.password) {
+      loginData.loginForm.password = matched.password
+    }
+  }
+  closeDropdown()
+}
+// 选择账号:自动带出企业名+密码(密码为空则不覆盖已输入内容)
+const selectAccount = (item: SavedAccountType) => {
+  loginData.loginForm.username = item.username
+  if (item.tenantName) {
+    loginData.loginForm.tenantName = item.tenantName
+  }
+  if (item.password) {
+    loginData.loginForm.password = item.password
+  }
+  closeDropdown()
+}
+// 删除一条记住的账号
+const removeAccount = (item: SavedAccountType) => {
+  authUtil.removeSavedAccount(item.tenantName, item.username)
+  loadSavedAccounts()
+}
+onMounted(() => {
+  document.addEventListener('mousedown', onDocumentMouseDown)
+  window.addEventListener('scroll', onViewChange, true)
+  window.addEventListener('resize', onViewChange)
+})
+onUnmounted(() => {
+  document.removeEventListener('mousedown', onDocumentMouseDown)
+  window.removeEventListener('scroll', onViewChange, true)
+  window.removeEventListener('resize', onViewChange)
+})
 </script>
 
 <style lang="scss" scoped>
@@ -614,6 +799,32 @@ $prefix-cls: #{$namespace}-login;
   outline: none;
 }
 
+/* 记住密码:账号下拉面板(Teleport 到 body,按视口坐标固定定位) */
+.account-dropdown {
+  position: fixed;
+  z-index: 3000;
+  max-height: 180px;
+  overflow-y: auto;
+  padding: 4px 0;
+  background-color: #ffffff;
+  border: #dddddd solid 1px;
+  border-radius: 4px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  box-sizing: border-box;
+}
+
+/* 输入框右侧下拉箭头 */
+.input-arrow {
+  flex: none;
+  margin-left: 4px;
+  cursor: pointer;
+  transition: transform 0.2s ease-in-out;
+
+  &.is-open {
+    transform: rotate(180deg);
+  }
+}
+
 input::placeholder {
   color: #a0a0a0; /* 修改placeholder文本的颜色 */
   font-size: 14px; /* 修改placeholder文本的字体大小 */
@@ -634,6 +845,53 @@ input::placeholder {
   margin-top: 20px;
 }
 
+/* 记住密码:账号下拉面板 */
+.account-dropdown {
+  position: absolute;
+  top: 50px;
+  left: 0;
+  z-index: 100;
+  width: 100%;
+  max-height: 180px;
+  overflow-y: auto;
+  background-color: #ffffff;
+  border: #dddddd solid 1px;
+  border-radius: 4px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.account-dropdown-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 12px;
+  height: 36px;
+  font-size: 14px;
+  color: #000000;
+  cursor: pointer;
+
+  &:hover {
+    background-color: #f5f7fa;
+  }
+
+  .account-dropdown-tenant {
+    margin-left: 4px;
+    font-size: 12px;
+    color: #a0a0a0;
+  }
+
+  .account-dropdown-delete {
+    margin-left: 12px;
+    padding: 0 4px;
+    font-size: 16px;
+    color: #c0c4cc;
+
+    &:hover {
+      color: #f56c6c;
+    }
+  }
+}
+
 .aa:hover {
   margin-top: 20px;
   color: #13b4f1;

+ 129 - 7
src/views/Login/components/LoginForm.vue

@@ -17,22 +17,60 @@
       </el-col>
       <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
         <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
-          <el-input
+          <el-select
             v-model="loginData.loginForm.tenantName"
             :placeholder="t('login.tenantNamePlaceholder')"
             :prefix-icon="iconHouse"
-            link
-            type="primary"
-          />
+            allow-create
+            clearable
+            default-first-option
+            filterable
+            @change="handleTenantChange"
+          >
+            <el-option
+              v-for="item in tenantOptions"
+              :key="item"
+              :label="item"
+              :value="item"
+            />
+          </el-select>
         </el-form-item>
       </el-col>
       <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
         <el-form-item prop="username">
-          <el-input
+          <el-select
             v-model="loginData.loginForm.username"
             :placeholder="t('login.usernamePlaceholder')"
             :prefix-icon="iconAvatar"
-          />
+            allow-create
+            clearable
+            default-first-option
+            filterable
+            @change="handleUsernameChange"
+          >
+            <el-option
+              v-for="item in savedAccounts"
+              :key="item.tenantName + '/' + item.username"
+              :label="item.username"
+              :value="item.username"
+            >
+              <div class="saved-account-option">
+                <span class="saved-account-label">
+                  {{ item.username }}
+                  <span v-if="item.tenantName" class="saved-account-tenant">
+                    ({{ item.tenantName }})
+                  </span>
+                </span>
+                <el-icon
+                  class="saved-account-delete"
+                  title="删除该账号"
+                  @click.stop="handleRemoveAccount(item)"
+                >
+                  <Icon icon="ep:delete" />
+                </el-icon>
+              </div>
+            </el-option>
+          </el-select>
         </el-form-item>
       </el-col>
       <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
@@ -55,7 +93,7 @@
           <el-row justify="space-between" style="width: 100%">
             <el-col :span="6">
               <el-checkbox v-model="loginData.loginForm.rememberMe">
-                {{ t('login.remember') }}
+                {{ t('login.rememberPassword') }}
               </el-checkbox>
             </el-col>
             <el-col :offset="6" :span="12">
@@ -158,6 +196,7 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router'
 import { useIcon } from '@/hooks/web/useIcon'
 
 import * as authUtil from '@/utils/auth'
+import type { SavedAccountType } from '@/utils/auth'
 import { usePermissionStore } from '@/store/modules/permission'
 import * as LoginApi from '@/api/login'
 import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
@@ -206,6 +245,50 @@ const socialList = [
   { icon: 'ant-design:alipay-circle-filled', type: 0 }
 ]
 
+// ========== 记住密码:本地保存的账号列表,支持下拉选择 ==========
+const savedAccounts = ref<SavedAccountType[]>([])
+// 企业名去重后的下拉选项
+const tenantOptions = computed(() => {
+  const names = savedAccounts.value.map((item) => item.tenantName).filter(Boolean)
+  return Array.from(new Set(names))
+})
+const loadSavedAccounts = () => {
+  savedAccounts.value = authUtil.getSavedAccounts()
+}
+// 选择企业名:若该企业下有记住的账号,自动带出账号与密码
+const handleTenantChange = (tenantName: string) => {
+  const matched = savedAccounts.value.find((item) => item.tenantName === tenantName)
+  if (matched) {
+    loginData.loginForm.username = matched.username
+    // 密码为空时不覆盖用户已输入的内容
+    if (matched.password) {
+      loginData.loginForm.password = matched.password
+    }
+  }
+}
+// 选择账号:优先匹配当前企业名下的账号,自动带出企业名与密码
+const handleUsernameChange = (username: string) => {
+  const matched =
+    savedAccounts.value.find(
+      (item) => item.username === username && item.tenantName === loginData.loginForm.tenantName
+    ) || savedAccounts.value.find((item) => item.username === username)
+  if (matched) {
+    if (matched.tenantName) {
+      loginData.loginForm.tenantName = matched.tenantName
+    }
+    // 密码为空时不覆盖用户已输入的内容
+    if (matched.password) {
+      loginData.loginForm.password = matched.password
+    }
+  }
+}
+// 删除一条记住的账号
+const handleRemoveAccount = (item: SavedAccountType) => {
+  authUtil.removeSavedAccount(item.tenantName, item.username)
+  loadSavedAccounts()
+  message.success('已删除该记住的账号')
+}
+
 // 获取验证码
 const getCode = async () => {
   // 情况一,未开启:则直接登录
@@ -269,8 +352,19 @@ const handleLogin = async (params: any) => {
     })
     if (loginDataLoginForm.rememberMe) {
       authUtil.setLoginForm(loginDataLoginForm)
+      // 记住密码:保存/更新到本地账号列表(企业名+账号+加密密码),供下拉选择
+      authUtil.addSavedAccount({
+        tenantName: loginDataLoginForm.tenantName || '',
+        username: loginDataLoginForm.username || '',
+        password: loginDataLoginForm.password || ''
+      })
     } else {
       authUtil.removeLoginForm()
+      // 不记住:从本地账号列表中移除该账号
+      authUtil.removeSavedAccount(
+        loginDataLoginForm.tenantName || '',
+        loginDataLoginForm.username || ''
+      )
     }
     authUtil.setToken(res)
     if (!redirect.value) {
@@ -334,11 +428,39 @@ watch(
 )
 onMounted(() => {
   getLoginFormCache()
+  loadSavedAccounts()
   getTenantByWebsite()
 })
 </script>
 
 <style lang="scss" scoped>
+:deep(.el-select) {
+  width: 100%;
+}
+
+// 记住密码下拉选项:账号 + 删除按钮
+:deep(.saved-account-option) {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+
+  .saved-account-tenant {
+    margin-left: 4px;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+  }
+
+  .saved-account-delete {
+    margin-left: 12px;
+    color: var(--el-text-color-placeholder);
+
+    &:hover {
+      color: var(--el-color-danger);
+    }
+  }
+}
+
 :deep(.anticon) {
   &:hover {
     color: var(--el-color-primary) !important;

+ 41 - 1
src/views/elderly/apply/nine-precautions/nutritional-risk/index.vue

@@ -2,8 +2,9 @@
 import ButtonImport from '@/components/ButtonImport/src/ButtonImport.vue'
 import { onMounted, ref, reactive } from 'vue'
 import { ContentWrap } from '@/components/ContentWrap'
-import { nutritionalRiskPage, nutritionalRiskDelete } 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'
 import { useUserStore } from '@/store/modules/user'
 import { tableHeaderColor } from '@/utils/table'
@@ -111,6 +112,42 @@ const handleImportCard = async () => {
   }
 }
 
+// 导出营养表(Word):按当前分页(当前页码+每页条数、含当前筛选条件)导出,
+// 一份文件内写入当前页所有长者的评估数据,每人一页
+const nutritionExportLoading = ref(false)
+const handleExportNutrition = async () => {
+  nutritionExportLoading.value = true
+  try {
+    // 直接使用当前 queryParams(pageNo/pageSize 保持页面现状,即“按当前分页”)
+    const data = await nutritionalRiskPage(queryParams)
+    const rows = data.list || []
+    if (rows.length === 0) {
+      message.warning('当前页暂无数据可导出')
+      return
+    }
+
+    // 详情接口取 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) || ''
+        }
+      })
+    )
+
+    await exportNutritionDocx(records)
+    message.success(`导出成功,共 ${records.length} 位长者`)
+  } catch (error) {
+    console.error('导出营养表失败:', error)
+    message.error('导出营养表失败,请重试')
+  } finally {
+    nutritionExportLoading.value = false
+  }
+}
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
@@ -281,6 +318,9 @@ onMounted(async () => {
     <div class="mb-10px">
       <ButtonAdd @click="openForm(undefined)" />
       <ButtonImport @click="handleImportCard" />
+      <el-button type="warning" plain :loading="nutritionExportLoading" @click="handleExportNutrition">
+        <Icon icon="ep:document" class="mr-5px" />导出营养表
+      </el-button>
     </div>
     <el-table v-loading="loading" :data="list" :header-cell-style="tableHeaderColor">
       <el-table-column header-align="center" align="center" label="序号" width="60">

+ 531 - 0
src/views/elderly/apply/nine-precautions/nutritional-risk/nutrition-docx.ts

@@ -0,0 +1,531 @@
+/**
+ * 老年人营养不良风险评估表 —— Word(.docx) 导出(紧凑段落版,每人一页 A4)
+ *
+ * 格式依据:4.3.18老年人营养评估表-海珠.docx
+ * - 标题「老年人营养不良风险评估表」,右上角为机构名称(仅名称,无“机构名称:”前缀);
+ * - 抬头含 姓名/性别/年龄/床位号/日期/身高/体重/BMI/小腿围/腰围;
+ * - 初筛 6 题 + 评估 14 题,选项以 □/☑ 勾选形式内联排版,与纸质单一致;
+ * - 紧凑排版(8mm 页边距 + 固定行距 + 分档小字号),保证每位长者内容压缩在一张 A4 内,
+ *   下一位长者另起一页。
+ *
+ * 实现:使用项目已有依赖 JSZip 直接组装 OOXML(WordprocessingML),不引入新依赖。
+ */
+import JSZip from 'jszip'
+import { saveAs } from 'file-saver'
+
+/** 导出输入:列表行 + 详情(含 assessData)的合并记录 */
+export interface NutritionExportRecord {
+  /** 机构名称(按 tenantId 解析后的名称) */
+  orgName?: string
+  /** 床位号(列表行 bedName) */
+  bedName?: string
+  /** 详情接口 /elderly-nutritional-risk/get 返回 */
+  detail: any
+}
+
+// ---------------- OOXML 基础工具 ----------------
+
+const FONT_HEI = '黑体'
+const FONT_SONG = '宋体'
+
+/** 字号体系(半磅):标题36 / 机构名24 / 抬头·评分23 / 小节24 / 题目22 / 评定20 */
+const SZ_TITLE = 36
+const SZ_ORG = 24
+const SZ_INFO = 23
+const SZ_SECTION = 24
+const SZ_ITEM = 22
+const SZ_RULE = 20
+
+/** 固定行距(twips,exact 300 ≈ 15pt) */
+const LINE_EXACT = 300
+
+const esc = (s: any): string =>
+  String(s ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+
+interface RunOpts {
+  b?: boolean
+  /** 字号(半磅,如 16 = 8pt) */
+  size?: number
+  font?: string
+}
+
+const run = (text: any, opts: RunOpts = {}): string => {
+  const font = opts.font || FONT_SONG
+  const rPr =
+    `<w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="${font}" w:cs="${font}"/>` +
+    `${opts.b ? '<w:b/><w:bCs/>' : ''}` +
+    `${opts.size ? `<w:sz w:val="${opts.size}"/><w:szCs w:val="${opts.size}"/>` : ''}</w:rPr>`
+  return `<w:r>${rPr}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`
+}
+
+interface ParaOpts {
+  jc?: 'center' | 'left' | 'right'
+  /** 段前(twips,默认0) */
+  before?: number
+  /** 段后(twips,默认0) */
+  after?: number
+  /** 覆盖固定行距(twips);大字号标题需放宽以免 Word 裁切字面 */
+  line?: number
+}
+
+/** 紧凑段落:固定行距、零段间距(可用 before/after 微调) */
+const para = (runsXml: string, opts: ParaOpts = {}): string => {
+  const pPr =
+    `<w:pPr><w:spacing w:before="${opts.before ?? 0}" w:after="${opts.after ?? 0}" ` +
+    `w:line="${opts.line ?? LINE_EXACT}" w:lineRule="exact"/>` +
+    `${opts.jc ? `<w:jc w:val="${opts.jc}"/>` : ''}</w:pPr>`
+  return `<w:p>${pPr}${runsXml}</w:p>`
+}
+
+const textPara = (text: any, opts: RunOpts & ParaOpts = {}): string => {
+  const { jc, before, after, line, ...runOpts } = opts
+  return para(run(text, runOpts), { jc, before, after, line })
+}
+
+/** 勾选符号:选中 ☑,未选 □ */
+const box = (checked: boolean): string => (checked ? '☑ ' : '□ ')
+
+/** 数字格式化(空值安全) */
+const num = (v: any, digits?: number): string => {
+  if (v === '' || v === null || v === undefined) return ''
+  const n = Number(v)
+  if (isNaN(n)) return String(v)
+  return digits !== undefined ? n.toFixed(digits) : String(n)
+}
+
+// ---------------- 题目定义(内联勾选,顺序/文案与参考 docx 一致) ----------------
+
+interface OptionDef {
+  score: number
+  label: string
+}
+interface ItemDef {
+  /** 题号+题干 */
+  name: string
+  options: OptionDef[]
+  /** 取该题已选分值 */
+  selected: (d: AssessData) => number | undefined
+}
+
+interface AssessData {
+  height?: number
+  weight?: number
+  bmi?: number
+  initialScores?: (number | undefined)[]
+  assessScores?: (number | undefined)[]
+  measureScores?: (number | undefined)[]
+  waistScore?: number
+  calfCircumference?: number
+  waistCircumference?: number
+  initialScore?: number
+  assessScore?: number
+  measureScore?: number
+  ageAdjust?: number
+  totalScore?: number
+  [k: string]: any
+}
+
+// 初筛 6 题(分值数组下标沿用前端表单:initialScores[5]=饮食量、[1]=体重、[2]=活动、[3]=牙齿、[4]=精神、[0]=BMI)
+const INITIAL_ITEMS: ItemDef[] = [
+  {
+    name: '1、近三个月有无饮食量变化:',
+    selected: (d) => d.initialScores?.[5],
+    options: [
+      { score: 0, label: '严重增加或严重减少:0分' },
+      { score: 1, label: '增加或减少:1分' },
+      { score: 2, label: '无变化:2分' }
+    ]
+  },
+  {
+    name: '2、近3个月体重变化:',
+    selected: (d) => d.initialScores?.[1],
+    options: [
+      { score: 0, label: '增加或减少>3公斤:0分' },
+      { score: 1, label: '不知道:1分' },
+      { score: 2, label: '1kg≤减少≤3kg或1kg≤增加≤3kg:2分' },
+      { score: 3, label: '0kg<减少<1kg或0kg<增加<1kg:3分' }
+    ]
+  },
+  {
+    name: '3、活动能力:',
+    selected: (d) => d.initialScores?.[2],
+    options: [
+      { score: 0, label: '卧床:0分' },
+      { score: 1, label: '需要依赖工具活动:1分' },
+      { score: 2, label: '独立户外活动:2分' }
+    ]
+  },
+  {
+    name: '4、牙齿状况:',
+    selected: (d) => d.initialScores?.[3],
+    options: [
+      { score: 0, label: '全口/半口缺:0分' },
+      { score: 1, label: '用义齿:1分' },
+      { score: 2, label: '正常:2分' }
+    ]
+  },
+  {
+    name: '5、精神心理问题:',
+    selected: (d) => d.initialScores?.[4],
+    options: [
+      { score: 0, label: '严重认知障碍或抑郁:0分' },
+      { score: 1, label: '轻度认知障碍或抑郁:1分' },
+      { score: 2, label: '无认知障碍或抑郁:2分' }
+    ]
+  },
+  {
+    name: '6、身体质量指数(BMI)(或小腿围):',
+    selected: (d) => d.initialScores?.[0],
+    options: [
+      { score: 0, label: 'BMI<19或BMI>28:0分' },
+      { score: 1, label: '19≤BMI<21或26<BMI≤28:1分' },
+      { score: 2, label: '21≤BMI<23或24<BMI≤26:2分' },
+      { score: 3, label: '23≤BMI≤24:3分' }
+    ]
+  }
+]
+
+// 评估题(顺序按参考 docx;分值数组下标沿用前端表单 assessScores/measureScores/waistScore)
+const ASSESS_ITEMS: ItemDef[] = [
+  {
+    name: '7、患慢性病数>3种:',
+    selected: (d) => d.assessScores?.[0],
+    options: [
+      { score: 0, label: '是:0分' },
+      { score: 1, label: '否:1分' }
+    ]
+  },
+  {
+    name: '8、服药时间在一个月以上的药物种类>3种:',
+    selected: (d) => d.assessScores?.[1],
+    options: [
+      { score: 0, label: '是:0分' },
+      { score: 1, label: '否:1分' }
+    ]
+  },
+  {
+    name: '9、是否独居:',
+    selected: (d) => d.assessScores?.[2],
+    options: [
+      { score: 0, label: '是:0分' },
+      { score: 1, label: '否:1分' }
+    ]
+  },
+  {
+    name: '10、睡眠时间:',
+    selected: (d) => d.assessScores?.[3],
+    options: [
+      { score: 0, label: '<5 h/d:0分' },
+      { score: 1, label: '≥5 h/d:1分' }
+    ]
+  },
+  {
+    name: '11、户外独立活动时间:',
+    selected: (d) => d.assessScores?.[4],
+    options: [
+      { score: 0, label: '<1 h/d:0分' },
+      { score: 1, label: '≥1 h/d:1分' }
+    ]
+  },
+  {
+    name: '12、每天摄入奶类;每天摄入豆制品;每天摄入鱼/肉/禽/蛋类食品:',
+    selected: (d) => d.assessScores?.[9],
+    options: [
+      { score: 0, label: '0-1项:0分' },
+      { score: 0.5, label: '2项:0.5分' },
+      { score: 1, label: '3项:1分' }
+    ]
+  },
+  {
+    name: '13、文化程度:',
+    selected: (d) => d.assessScores?.[5],
+    options: [
+      { score: 0, label: '小学及以下:0分' },
+      { score: 1, label: '中学及以上:1分' }
+    ]
+  },
+  {
+    name: '14、进食能力:',
+    selected: (d) => d.assessScores?.[7],
+    options: [
+      { score: 0, label: '依靠别人:0分' },
+      { score: 1, label: '自行进食稍有困难:1分' },
+      { score: 2, label: '自行进食:2分' }
+    ]
+  },
+  {
+    name: '15、一天餐次:',
+    selected: (d) => d.assessScores?.[8],
+    options: [
+      { score: 0, label: '1次:0分' },
+      { score: 1, label: '2次:1分' },
+      { score: 2, label: '3次及以上:2分' }
+    ]
+  },
+  {
+    name: '16、自我感觉经济状况:',
+    selected: (d) => d.assessScores?.[6],
+    options: [
+      { score: 0, label: '差:0分' },
+      { score: 0.5, label: '一般:0.5分' },
+      { score: 1, label: '良好:1分' }
+    ]
+  },
+  {
+    name: '17、每天烹调油摄入量:',
+    selected: (d) => d.assessScores?.[10],
+    options: [
+      { score: 0, label: '>25g:0分' },
+      { score: 1, label: '≤25g:1分' }
+    ]
+  },
+  {
+    name: '18、是否每天吃蔬菜水果500g及以上:',
+    selected: (d) => d.measureScores?.[0],
+    options: [
+      { score: 0, label: '否:0分' },
+      { score: 1, label: '是:1分' }
+    ]
+  },
+  {
+    name: '19、小腿围:',
+    selected: (d) => d.measureScores?.[1],
+    options: [
+      { score: 0, label: '<31cm:0分' },
+      { score: 1, label: '≥31cm:1分' }
+    ]
+  }
+]
+
+const RESULT_OPTIONS = [
+  { level: 'good', label: '营养正常' },
+  { level: 'at_risk', label: '存在营养不良的风险' },
+  { level: 'malnutrition', label: '营养不良' }
+]
+
+const RESULT_RULES = [
+  'a) 若初筛总分≥12分提示无营养不良风险,无需评估;',
+  'b) 若初筛总分<12分提示有营养不良风险,继续评估;',
+  'c) 若营养不良风险评估总分(初筛+评估)≥24分,表示营养状况良好;',
+  'd) 若营养不良风险评估总分(初筛+评估)<24分,当BMI≥24(或男性腰围≥90cm,女性腰围≥80cm)时,提示可能是肥胖/超重型营养不良或有营养不良风险;',
+  'e) 若营养不良风险评估总分(初筛+评估)17分~24分,表示有营养不良风险;',
+  'f) 若营养不良风险评估总分(初筛+评估)≤17分,表示有营养不良。'
+]
+
+// ---------------- 单组长者内容渲染(紧凑段落式) ----------------
+
+const normSex = (v: any): string => {
+  if (v === 1 || v === '1') return '男'
+  if (v === 2 || v === '2') return '女'
+  return String(v ?? '')
+}
+
+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('')
+  return para(runs)
+}
+
+const renderElderBlock = (record: NutritionExportRecord): string => {
+  const res: any = record.detail || {}
+  let d: AssessData = {}
+  try {
+    d = res.assessData ? JSON.parse(res.assessData) : {}
+  } catch (e) {
+    d = {}
+  }
+
+  const initialScore = d.initialScore ?? res.initialScore ?? 0
+  const assessScore = d.assessScore ?? res.assessScore ?? 0
+  const measureScore = d.measureScore ?? res.measureScore ?? 0
+  const ageAdjust = d.ageAdjust ?? res.ageAdjust ?? 0
+  const totalScore = d.totalScore ?? res.assessScore ?? 0
+  const riskLevel = res.riskLevel || d.riskLevel || ''
+
+  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('老年人营养不良风险评估表', {
+      font: FONT_HEI,
+      b: true,
+      size: SZ_TITLE,
+      jc: 'center',
+      after: 60,
+      line: 520
+    })
+  )
+
+  // 抬头信息(两行排满,压缩纵向空间;测量值有值才带单位)
+  parts.push(
+    para(
+      run(
+        `姓名:${res.elderName || ''} 性别:${normSex(res.elderSex)} 年龄:${res.elderAge ?? ''} 床位号:${record.bedName || ''}`,
+        { size: SZ_INFO }
+      )
+    )
+  )
+  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.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 !== ''
+      ? `腰围:${num(d.waistCircumference, 1)}cm`
+      : ''
+  ]
+    .filter(Boolean)
+    .join(' ')
+  if (measureLine) parts.push(para(run(measureLine, { size: SZ_INFO }), { after: 40 }))
+
+  // 初筛
+  parts.push(textPara('初筛:', { font: FONT_HEI, b: true, size: SZ_SECTION, before: 30 }))
+  INITIAL_ITEMS.forEach((item) => parts.push(itemPara(item, d)))
+  parts.push(
+    textPara(
+      '总分14分,<12分提示有营养不良风险,继续以下评估;≥12分提示无营养不良风险,无需以下评估。',
+      { size: SZ_ITEM, after: 30 }
+    )
+  )
+
+  // 评估
+  parts.push(textPara('评估:', { font: FONT_HEI, b: true, size: SZ_SECTION, before: 30 }))
+  ASSESS_ITEMS.forEach((item) => parts.push(itemPara(item, d)))
+
+  // 腰围(男女同行,waistScore:0=男>90 / 1=男≤90 / 2=女>80 / 3=女≤80)
+  const w = d.waistScore
+  parts.push(
+    para(
+      run('20、腰围:', { b: true, size: SZ_ITEM }) +
+        run(
+          `男性 ${box(w === 0)}>90cm:0分 ${box(w === 1)}≤90cm:1分  女性 ${box(w === 2)}>80cm:0分 ${box(w === 3)}≤80cm:1分`,
+          { size: SZ_ITEM }
+        )
+    )
+  )
+
+  // 评分说明
+  parts.push(
+    textPara(
+      '评估总分30分,≤17分提示营养不良;17-24分提示有营养不良的风险;≥24分营养正常。',
+      { size: SZ_ITEM, before: 30 }
+    )
+  )
+  parts.push(
+    textPara(
+      `年龄超过70岁总分加1分,即年龄调整增加的分值:${ageAdjust}分,年龄<70岁:0分,年龄≥70岁:1分`,
+      { size: SZ_ITEM }
+    )
+  )
+
+  // 评估人 / 总分
+  parts.push(
+    para(
+      run(
+        `评估人:${res.assessor || ''}  初筛分:${initialScore}分 评估分:${num(
+          Number(assessScore) + Number(measureScore || 0)
+        )}分 年龄调整:${ageAdjust}分 `,
+        { b: true, size: SZ_INFO }
+      ) + run(`总分:${totalScore}分`, { b: true, font: FONT_HEI, size: SZ_INFO }),
+      { before: 30 }
+    )
+  )
+
+  // 评估结果勾选
+  parts.push(
+    para(
+      run('评估结果:', { b: true, size: SZ_INFO }) +
+        RESULT_OPTIONS.map((o) => run(`${box(riskLevel === o.level)}${o.label} `, { size: SZ_INFO })).join(''),
+      { before: 20 }
+    )
+  )
+
+  // 结果评定(最小字号,保证整页放得下)
+  parts.push(textPara('结果评定:', { b: true, font: FONT_HEI, size: SZ_RULE, before: 30 }))
+  RESULT_RULES.forEach((r) => parts.push(textPara(r, { size: SZ_RULE })))
+
+  return parts.join('')
+}
+
+// ---------------- docx 打包 ----------------
+
+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"/>
+<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"/>
+</Types>`
+
+const ROOT_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/officeDocument" Target="word/document.xml"/>
+</Relationships>`
+
+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"/>
+</Relationships>`
+
+const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+<w:docDefaults>
+<w:rPrDefault><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体"/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr></w:rPrDefault>
+<w:pPrDefault><w:pPr><w:spacing w:before="0" w:after="0" w:line="${LINE_EXACT}" w:lineRule="exact"/></w:pPr></w:pPrDefault>
+</w:docDefaults>
+<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
+</w:styles>`
+
+/** 分页符段落(除第一组外,每组长者之前插入——每人一张 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 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"/>` +
+    `</w:sectPr>`
+  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+<w:body>${body}</w:body>
+</w:document>`
+}
+
+/**
+ * 导出多组长者营养评估数据到一份 .docx(每组一页 A4)。
+ */
+export const exportNutritionDocx = async (records: NutritionExportRecord[]): Promise<void> => {
+  if (!records.length) throw new Error('没有可导出的评估数据')
+
+  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('styles.xml', STYLES)
+  word.folder('_rels')!.file('document.xml.rels', DOC_RELS)
+
+  const blob = await zip.generateAsync({
+    type: 'blob',
+    mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+  })
+
+  const now = new Date()
+  const pad = (n: number) => String(n).padStart(2, '0')
+  const dateStr = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
+  saveAs(blob, `老年人营养评估表_${dateStr}.docx`)
+}

+ 24 - 21
src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue

@@ -1,6 +1,8 @@
 <template>
   <Dialog v-model="dialogVisible" title="长期医嘱单" width="95%" scroll>
     <div class="long-order-print" id="longOrderPrintArea" v-loading="loading">
+      <!-- 机构名称抬头:取当前登录租户名称,非写死 -->
+      <h1 v-if="tenantName" class="org-title">{{ tenantName }}</h1>
       <h2 class="doc-title">长期医嘱单</h2>
 
       <!-- 基本信息:参照纸质长期医嘱单(住院号、科室不打印) -->
@@ -32,7 +34,7 @@
         每行 = 一条常规执行记录(doctorOrderExecution,execType=1、已执行):
           开始时间 = 实际执行时间(execTime),医生=开嘱医生,护士=执行护士(execNurseSign);
         同一种医嘱(orderEntryId 相同)的多条执行记录归为一组:
-          医嘱序号 / 内容 / 用法只在组内首行展示,停止信息只在组内最后一行显示(不合并单元格,保留行线)。
+          停止信息只在组内最后一行显示(不合并单元格,保留行线)。
         停止信息(execType=2 停止确认任务):
           停止时间 = 护士确认停止的执行时间(execTime),停止护士 = 确认护士(execNurseSign),
           停止医生 = 医嘱条目上的 stopDoctorSign;
@@ -75,10 +77,9 @@
             </td>
             <td class="sign-cell">{{ item.doctorSign || '' }}</td>
             <td class="sign-cell">{{ item.nurseSign || '' }}</td>
-            <!-- 长期医嘱内容(同一种医嘱组内仅首行显示序号;每行都带备注位) -->
+            <!-- 长期医嘱内容(每行都带备注位,不加序号) -->
             <td class="content-cell">
               <div class="oe-line">
-                <span v-if="item.orderNo" class="oe-no">{{ item.orderNo }}</span>
                 <span class="oe-text">{{ item.orderContent || '' }}</span>
                 <span class="oe-meta">{{ buildMeta(item) }}</span>
               </div>
@@ -110,7 +111,7 @@ import dayjs from 'dayjs'
 import {
   getLongDoctorOrder,
   getDoctorOrderExecutionPage,
-  getMedCn,
+  getMedAbbr,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
@@ -152,6 +153,9 @@ const ageText = computed(() => {
   return `${age}岁`
 })
 
+/** 当前登录机构(租户)名称,作为打印单抬头 */
+const tenantName = computed(() => userStore.tenant?.name || '')
+
 /** 打印页最少展示 6 行,不足补空行 */
 const MIN_ROWS = 6
 
@@ -167,8 +171,6 @@ interface PrintRow {
   medTime?: string
   route?: string
   remark?: string
-  /** 医嘱序号:仅同组首行有值 */
-  orderNo: number | ''
   /** 停止信息:仅同组最后一行有值,其余行留空(保证行分割线完整) */
   stopTime?: string
   stopDoctorSign?: string
@@ -178,16 +180,16 @@ interface PrintRow {
 const renderRows = ref<PrintRow[]>([])
 
 /**
- * 拼接纸质单“长期医嘱”栏右侧的用法信息:途径 + 给药时间 + 频次(缩写),
- * 如“静脉滴注 立即执行 tid”;无值的要素不显示。
+ * 拼接纸质单“长期医嘱”栏右侧的用法信息:途径 + 给药时间 + 频次,
+ * 统一用医用简写符号(如 po 口服、ac 餐前、qd 每日1次);无值的要素不显示。
  */
 const buildMeta = (item: { frequency?: string; medTime?: string; route?: string }): string => {
   const parts: string[] = []
-  const route = getMedCn(MED_ROUTE_OPTIONS, item.route)
-  const medTime = getMedCn(MED_TIME_OPTIONS, item.medTime)
+  const route = getMedAbbr(MED_ROUTE_OPTIONS, item.route)
+  const medTime = getMedAbbr(MED_TIME_OPTIONS, item.medTime)
   if (route) parts.push(route)
   if (medTime) parts.push(medTime)
-  if (item.frequency) parts.push(item.frequency)
+  if (item.frequency) parts.push(getMedAbbr(MED_FREQUENCY_OPTIONS, item.frequency))
   return parts.join(' ')
 }
 
@@ -320,9 +322,7 @@ const open = async (row: any = {}) => {
 
     // records 已按时间排序,Map 插入顺序即各组首条执行时间先后
     const rows: PrintRow[] = []
-    let orderNo = 0
     groupMap.forEach((group) => {
-      orderNo += 1
       const entry: LongDoctorOrderEntry | undefined = group[0]?._entry
       const entryId: number | undefined = group[0]?._entryId
 
@@ -356,7 +356,6 @@ const open = async (row: any = {}) => {
           medTime: r.medTime,
           route: r.route,
           remark: r.remark || '',
-          orderNo: i === 0 ? orderNo : '',
           stopTime: isLast ? stopTime : '',
           stopDoctorSign: isLast ? stopDoctorSign : '',
           stopNurseSign: isLast ? stopNurseSign : ''
@@ -366,7 +365,7 @@ const open = async (row: any = {}) => {
 
     // 不足最小行数补空行(独立 7 列空行)
     for (let i = rows.length; i < MIN_ROWS; i++) {
-      rows.push({ orderNo: '' })
+      rows.push({})
     }
     renderRows.value = rows
   } catch (e) {
@@ -402,7 +401,15 @@ const print = {
   width: 100%;
   margin: 0;
   box-sizing: border-box;
-  font-family: 'SimSun', '宋体', 'Songti SC', serif;
+  font-family: 'SimHei', '黑体', 'Heiti SC', 'Microsoft YaHei', sans-serif;
+
+  .org-title {
+    text-align: center;
+    font-size: 30px;
+    font-weight: bold;
+    letter-spacing: 4px;
+    margin: 0 0 2px;
+  }
 
   .doc-title {
     text-align: center;
@@ -410,7 +417,7 @@ const print = {
     font-weight: bold;
     letter-spacing: 10px;
     text-indent: 10px; // 抵消末字间距,保持视觉居中
-    margin: 6px 0 14px;
+    margin: 2px 0 14px;
   }
 
   /* 基本信息:纸质单风格,值带下划线 */
@@ -507,10 +514,6 @@ const print = {
         align-items: baseline;
         gap: 8px;
       }
-      .oe-no {
-        font-weight: bold;
-        flex: 0 0 auto;
-      }
       .oe-text {
         flex: 1 1 auto;
         text-align: left;

+ 301 - 118
src/views/elderly/elder/doctor-orders/short-doctor-order/Print.vue

@@ -1,67 +1,83 @@
 <template>
   <Dialog v-model="dialogVisible" title="临时医嘱单" width="95%" scroll>
     <div class="short-order-print" id="shortOrderPrintArea" v-loading="loading">
+      <!-- 机构名称抬头:取当前登录租户名称,非写死 -->
+      <h1 v-if="tenantName" class="org-title">{{ tenantName }}</h1>
       <h2 class="doc-title">临时医嘱单</h2>
 
-      <!-- 基本信息 -->
+      <!-- 基本信息:参照纸质临时医嘱单(系统暂无入院编号数据,保留空位打印后手填) -->
       <div class="info-row">
-        <div class="info-cell"><span class="lbl">姓名:</span>{{ form.elderName || '' }}</div>
-        <div class="info-cell"><span class="lbl">性别:</span>{{ genderText }}</div>
-        <div class="info-cell"><span class="lbl">年龄:</span>{{ form.elderAge || '' }}</div>
-        <div class="info-cell"><span class="lbl">床号:</span>{{ form.bedName || '' }}</div>
         <div class="info-cell">
-          <span class="lbl">护理等级:</span>{{ form.nurseLevelName || '' }}
+          <span class="lbl">床位号:</span><span class="val">{{ form.bedName || '' }}</span>
+        </div>
+        <div class="info-cell">
+          <span class="lbl">姓名:</span><span class="val">{{ form.elderName || '' }}</span>
+        </div>
+        <div class="info-cell">
+          <span class="lbl">性别:</span><span class="val">{{ genderText }}</span>
+        </div>
+        <div class="info-cell">
+          <span class="lbl">年龄:</span><span class="val">{{ ageText }}</span>
+        </div>
+        <div class="info-cell">
+          <span class="lbl">入院编号:</span><span class="val">{{ admissionNo }}</span>
         </div>
       </div>
 
-      <!-- 医嘱表(A4 竖版:弹填 dialog 95%,按 194mm 内容宽分配:
-           医嘱时间18/医嘱75/频次10/给药时间10/途径11/医生签名18/备注34
-           = 9.28/38.66/5.15/5.15/5.67/9.28/9.28/17.53 % ) -->
+      <!--
+        数据口径(每行 = 一条临时医嘱条目 shortDoctorOrderEntry):
+          起始时间 = 条目 orderTime(医嘱开立时间),拆"日期/时间"两个独立单元格;
+          医师签名 = 条目 doctorSign;
+          执行时间/护士签名 = 关联的执行记录(doctorOrderExecution,orderType=2 已执行)
+            的 execTime / execNurseSign(执行护士真实签名);未执行时留空,待护士打印后手签;
+          用法三要素(途径/给药时间/频次)拼在"临时医嘱"内容右侧,无值不显示。
+        6 列:日期 | 时间 | 临时医嘱 | 医师签名 | 执行时间 | 护士签名
+      -->
       <table class="order-table">
         <colgroup>
-          <col style="width: 9.28%" />
-          <col style="width: 38.66%" />
-          <col style="width: 5.15%" />
-          <col style="width: 5.15%" />
-          <col style="width: 5.67%" />
-          <col style="width: 14%" />
-          <col style="width: 22.09%" />
+          <col style="width: 12%" />
+          <col style="width: 8%" />
+          <col style="width: 42%" />
+          <col style="width: 11%" />
+          <col style="width: 15%" />
+          <col style="width: 12%" />
         </colgroup>
         <thead>
           <tr>
-            <th>医嘱时间</th>
-            <th>医嘱</th>
-            <th>频次</th>
-            <th>给药时间</th>
-            <th>途径</th>
-            <th>医生<br />签名</th>
-            <th>备注</th>
+            <th class="group-head" colspan="2">起始时间</th>
+            <th class="order-head" rowspan="2">临时医嘱</th>
+            <th class="sign-head" rowspan="2">医师<br />签名</th>
+            <th class="sign-head" rowspan="2">执行<br />时间</th>
+            <th class="sign-head" rowspan="2">护士<br />签名</th>
+          </tr>
+          <tr>
+            <th class="sub-head">日期</th>
+            <th class="sub-head">时间</th>
           </tr>
         </thead>
         <tbody>
-          <tr v-for="(item, idx) in paddedEntries" :key="idx">
-            <td>
-              <div class="datetime">
-                <div class="d-line">{{ formatDateTimeSplit(item.orderTime).date }}</div>
-                <div class="t-line">{{ formatDateTimeSplit(item.orderTime).time }}</div>
+          <tr v-for="(item, idx) in paddedRows" :key="idx">
+            <!-- 起始时间:日期、时间两个独立单元格 -->
+            <td class="time-cell">{{ formatDateTimeSplit(item.startTime).date }}</td>
+            <td class="time-cell">{{ formatDateTimeSplit(item.startTime).time }}</td>
+            <!-- 临时医嘱内容(用法拼在内容右侧;备注在内容下方小字) -->
+            <td class="content-cell">
+              <div class="oe-line">
+                <span class="oe-text">{{ item.orderContent || '' }}</span>
+                <span class="oe-meta">{{ buildMeta(item) }}</span>
               </div>
+              <div v-if="item.remark" class="oe-remark">备注:{{ item.remark }}</div>
             </td>
-            <td>
-              <span class="cell-value pre">{{ item.orderContent || '' }}</span>
-            </td>
-            <td>
-              <span class="cell-value">{{ getMedCn(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
-            </td>
-            <td>
-              <span class="cell-value">{{ getMedCn(MED_TIME_OPTIONS, item.medTime) }}</span>
-            </td>
-            <td>
-              <span class="cell-value">{{ getMedCn(MED_ROUTE_OPTIONS, item.route) }}</span>
-            </td>
-            <td>{{ item.doctorSign || '' }}</td>
-            <td>
-              <span class="cell-value pre">{{ item.remark || '' }}</span>
+            <td class="sign-cell">{{ item.doctorSign || '' }}</td>
+            <!-- 执行时间:单列内日期/时间上下两行 -->
+            <td class="time-cell">
+              <div class="datetime">
+                <div class="d-line">{{ formatDateTimeSplit(item.execTime).date }}</div>
+                <div class="t-line">{{ formatDateTimeSplit(item.execTime).time }}</div>
+              </div>
             </td>
+            <!-- 护士签名:取执行护士签名 execNurseSign -->
+            <td class="sign-cell">{{ item.nurseSign || '' }}</td>
           </tr>
         </tbody>
       </table>
@@ -75,18 +91,25 @@
 </template>
 
 <script lang="ts" setup>
+import dayjs from 'dayjs'
 import {
   getShortDoctorOrder,
-  getMedCn,
+  getDoctorOrderExecutionPage,
+  getMedAbbr,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
-  type ShortDoctorOrderEntry
+  type ShortDoctorOrderEntry,
+  type DoctorOrderExecution
 } from '@/api/elderly/elder/bed-change'
 import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+import { useUserStore } from '@/store/modules/user'
 
 defineOptions({ name: 'ShortDoctorOrderPrint' })
 
+const userStore = useUserStore()
+const message = useMessage()
+
 const dialogVisible = ref(false)
 const loading = ref(false)
 
@@ -102,33 +125,71 @@ const defaultForm = {
 }
 const form = reactive({ ...defaultForm })
 
+/** 当前登录机构(租户)名称,作为打印单抬头 */
+const tenantName = computed(() => userStore.tenant?.name || '')
+
 const genderText = computed(() =>
   form.gender === '' || form.gender === undefined || form.gender === null
     ? ''
     : getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender)
 )
 
-/** 打印页最少展示 6 行,不足补空行(与截图默认空白行一致) */
-const MIN_ROWS = 6
-const paddedEntries = computed<ShortDoctorOrderEntry[]>(() => {
-  const list = form.entries || []
-  const padding: ShortDoctorOrderEntry[] = Array.from(
-    { length: Math.max(0, MIN_ROWS - list.length) },
-    () => ({
-      orderTime: '',
-      orderContent: '',
-      frequency: '',
-      medTime: '',
-      route: '',
-      doctorSign: '',
-      remark: ''
-    })
+const ageText = computed(() => {
+  const age = form.elderAge
+  if (age === '' || age === null || age === undefined) return ''
+  return `${age}岁`
+})
+
+/** 入院编号:纸质单字段,系统暂无该数据,保留空位打印后手填;后端若返回则直接展示 */
+const admissionNo = computed(() => (form as any).admissionNo || '')
+
+/** 打印页最少展示行数,不足补空行(参照纸质临时医嘱单的空白行) */
+const MIN_ROWS = 12
+
+interface PrintRow {
+  /** 起始时间(条目开立时间 orderTime) */
+  startTime?: string
+  orderContent?: string
+  frequency?: string
+  medTime?: string
+  route?: string
+  /** 医师签名(开嘱医生) */
+  doctorSign?: string
+  /** 实际执行时间(执行记录 execTime) */
+  execTime?: string
+  /** 护士签名(执行护士 execNurseSign) */
+  nurseSign?: string
+  remark?: string
+}
+
+const renderRows = ref<PrintRow[]>([])
+
+const paddedRows = computed<PrintRow[]>(() => {
+  const padding: PrintRow[] = Array.from(
+    { length: Math.max(0, MIN_ROWS - renderRows.value.length) },
+    () => ({})
   )
-  return [...list, ...padding]
+  return [...renderRows.value, ...padding]
 })
 
+/**
+ * 拼接"临时医嘱"栏右侧的用法信息:途径 + 给药时间 + 频次,
+ * 统一用医用简写符号(如 po 口服、ac 餐前、qd 每日1次);无值的要素不显示。
+ */
+const buildMeta = (item: { frequency?: string; medTime?: string; route?: string }): string => {
+  const parts: string[] = []
+  const route = getMedAbbr(MED_ROUTE_OPTIONS, item.route)
+  const medTime = getMedAbbr(MED_TIME_OPTIONS, item.medTime)
+  if (route) parts.push(route)
+  if (medTime) parts.push(medTime)
+  if (item.frequency) parts.push(getMedAbbr(MED_FREQUENCY_OPTIONS, item.frequency))
+  return parts.join(' ')
+}
+
 /**
  * 把时间拆成 { date: 'YYYY-MM-DD', time: 'HH:mm' } 两段用于上下两行展示
+ * - 空值返回 { date:'', time:'' }
+ * - 兼容字符串/数字/已有格式化串
  */
 const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
   if (val === null || val === undefined || val === '') return { date: '', time: '' }
@@ -139,7 +200,9 @@ const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
     const d = new Date(val)
     if (isNaN(d.getTime())) return { date: '', time: '' }
     const pad = (n: number) => String(n).padStart(2, '0')
-    str = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+    str = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
+      d.getHours()
+    )}:${pad(d.getMinutes())}`
   }
   const m = str.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
   if (m) return { date: m[1], time: m[2] }
@@ -147,38 +210,89 @@ const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
 }
 
 /**
- * 标准格式化时间为 YYYY-MM-DD HH:mm
- * - 字符串日期直接匹配;如已是标准格式则原样返回,否则尽量截前 16 位
- * - 数字时间戳按本地时区解析
- * - 空值/无效返回空串
+ * 打开打印(详情页)
+ * 数据组装:
+ * 1. 临时医嘱单详情:表头信息 + 各条目(起始时间 orderTime / 医师签名 doctorSign);
+ * 2. 执行记录分页(orderType=2 临时,时间窗取足够大的历史区间 ~ 今天),
+ *    按 orderEntryId 关联到条目,优先取已执行(status=1)的一条,
+ *    得到执行时间 execTime 与执行护士签名 execNurseSign;
+ * 3. execution 表为引用型设计,缺失字段用医嘱条目兜底回填;
+ * 4. 按条目起始时间升序排列;不足最小行数补空行。
  */
-const formatFullTime = (val?: any): string => {
-  if (val === null || val === undefined || val === '') return ''
-  if (typeof val === 'string') {
-    const m = val.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/)
-    if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
-    return val
-  }
-  if (typeof val === 'number') {
-    const d = new Date(val)
-    if (isNaN(d.getTime())) return ''
-    const pad = (n: number) => String(n).padStart(2, '0')
-    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
-  }
-  return ''
-}
-
-/** 打开打印(详情页) */
 const open = async (row: any = {}) => {
   dialogVisible.value = true
   loading.value = true
+  renderRows.value = []
   try {
     Object.assign(form, defaultForm, { entries: [] })
-    if (row?.id) {
-      const data: any = await getShortDoctorOrder(row.id)
-      Object.assign(form, defaultForm, data)
-      form.entries = (data.entries || []) as ShortDoctorOrderEntry[]
+    if (!row?.id) {
+      message.error('缺少医嘱单信息,无法打印')
+      return
     }
+
+    const detail: any = await getShortDoctorOrder(row.id)
+    Object.assign(form, defaultForm, detail)
+    form.entries = (detail.entries || []) as ShortDoctorOrderEntry[]
+
+    const elderId = detail.elderId ?? row.elderId
+    let execRecords: any[] = []
+    if (elderId) {
+      const execPage = await getDoctorOrderExecutionPage({
+        pageNo: 1,
+        pageSize: 3000,
+        tenantIds: userStore.orgTenantId,
+        elderId,
+        orderType: 2,
+        planTime: ['2000-01-01', dayjs().format('YYYY-MM-DD')]
+      })
+      execRecords = ((execPage?.list || []) as any[]).filter(
+        (r: any) =>
+          r.elderId !== undefined && r.elderId !== null && Number(r.elderId) === Number(elderId)
+      )
+    }
+
+    // 执行记录按医嘱条目建索引:优先取已执行(status=1,有实际执行时间)的一条
+    const execMap = new Map<number, DoctorOrderExecution>()
+    execRecords
+      .filter(
+        (r: any) =>
+          Number(r.execType) !== 2 && r.orderEntryId !== undefined && r.orderEntryId !== null
+      )
+      .forEach((r: any) => {
+        const key = Number(r.orderEntryId)
+        const prev = execMap.get(key)
+        if (!prev || (prev.status !== 1 && Number(r.status) === 1)) execMap.set(key, r)
+      })
+
+    // 每行 = 一条临时医嘱条目,执行时间/护士签名取关联执行记录
+    const rows: PrintRow[] = (form.entries || []).map((e: ShortDoctorOrderEntry) => {
+      const exec = e.id !== undefined && e.id !== null ? execMap.get(Number(e.id)) : undefined
+      return {
+        startTime: e.orderTime || exec?.planTime || '',
+        orderContent: e.orderContent || exec?.orderContent || '',
+        frequency: e.frequency || exec?.frequency || '',
+        medTime: e.medTime || exec?.medTime || '',
+        route: e.route || exec?.route || '',
+        doctorSign: e.doctorSign || exec?.doctorSign || '',
+        // 执行时间/护士签名以执行记录为准,老数据回退条目上的 executeTime/nurseSign
+        execTime: exec?.execTime || e.executeTime || '',
+        nurseSign: exec?.execNurseSign || e.nurseSign || '',
+        remark: e.remark || exec?.remark || ''
+      }
+    })
+
+    // 按起始时间升序(同时间保持原顺序)
+    rows.sort((a, b) => {
+      const ta = String(a.startTime || '')
+      const tb = String(b.startTime || '')
+      if (ta !== tb) return ta < tb ? -1 : 1
+      return 0
+    })
+
+    renderRows.value = rows
+  } catch (e) {
+    console.error('[临时医嘱单] 打印数据加载失败', e)
+    message.error('临时医嘱单数据加载失败,请稍后重试')
   } finally {
     loading.value = false
   }
@@ -188,6 +302,7 @@ defineExpose({ open })
 const handleClosed = () => {
   dialogVisible.value = false
   Object.assign(form, defaultForm, { entries: [] })
+  renderRows.value = []
 }
 
 // 打印配置(vue3-print-nb)
@@ -210,26 +325,54 @@ const print = {
   width: 100%;
   margin: 0;
   box-sizing: border-box;
+  font-family: 'SimHei', '黑体', 'Heiti SC', 'Microsoft YaHei', sans-serif;
+
+  /* 全黑:字体与线条(标题下划线、表格边框)一律黑色 */
+  $print-ink: #000;
+
+  .org-title {
+    text-align: center;
+    font-size: 30px;
+    font-weight: bold;
+    letter-spacing: 4px;
+    margin: 0 0 2px;
+  }
 
   .doc-title {
     text-align: center;
-    font-size: 20px;
+    font-size: 28px;
     font-weight: bold;
-    margin: 4px 0 12px;
+    letter-spacing: 12px;
+    text-indent: 12px; // 抵消末字间距,保持视觉居中
+    color: #000;
+    padding-bottom: 4px;
+    margin: 2px 0 16px;
+    border-bottom: 3px double $print-ink; // 标题下双线(黑色)
   }
 
+  /* 基本信息:5 项均匀分布(参照纸质单,值位置留空可手填) */
   .info-row {
     display: flex;
     justify-content: space-between;
-    margin-bottom: 8px;
-    font-size: 13px;
+    margin-bottom: 10px;
+    font-size: 14px;
+    padding: 0 2px;
 
     .info-cell {
       flex: 1;
       min-width: 0;
-      padding: 0 8px;
+      line-height: 1.4;
+
       .lbl {
         font-weight: bold;
+        color: #000;
+      }
+      .val {
+        display: inline-block;
+        min-width: 48px;
+        /* 床位号等值过长时在单元格内换行,避免溢出压到相邻字段 */
+        word-break: break-all;
+        white-space: normal;
       }
     }
   }
@@ -238,12 +381,13 @@ const print = {
     width: 100%;
     border-collapse: collapse;
     table-layout: fixed;
-    font-size: 12px;
+    font-size: 13px;
+    border: 2px solid $print-ink;
 
     th,
     td {
-      border: 1px solid #555;
-      padding: 4px 4px;
+      border: 1px solid $print-ink;
+      padding: 4px 6px;
       text-align: center;
       vertical-align: middle;
       word-break: break-all;
@@ -252,34 +396,73 @@ const print = {
     thead th {
       background: #fff;
       font-weight: bold;
+      color: #000;
+      padding: 5px 4px;
     }
-
-    tbody td {
-      height: 28px;
+    .group-head {
+      font-size: 15px;
+      letter-spacing: 2px;
     }
-    .cell-value {
-      display: inline-block;
-      width: 100%;
-      text-align: center;
+    .order-head {
+      font-size: 18px;
+      letter-spacing: 6px;
+      text-indent: 6px;
     }
-    .cell-value.pre {
-      white-space: pre-wrap;
-      text-align: left;
-      word-break: break-word;
+    .sign-head {
+      font-size: 14px;
+      line-height: 1.5;
+    }
+    .sub-head {
+      font-size: 14px;
+    }
+
+    tbody td {
+      height: 34px;
     }
-    /* 日期 + 时间上下两行展示(医嘱时间/执行时间列) */
-    .datetime {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      line-height: 1.2;
-      font-size: 11px;
+
+    .time-cell {
+      font-size: 12px;
+      .datetime {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        line-height: 1.35;
+      }
+      .datetime .t-line {
+        color: #000;
+      }
     }
-    .datetime .d-line {
-      font-weight: 500;
+    .sign-cell {
+      font-size: 13px;
     }
-    .datetime .t-line {
-      color: #444;
+    .content-cell {
+      padding: 4px 8px;
+      text-align: left;
+
+      .oe-line {
+        display: flex;
+        align-items: baseline;
+        gap: 8px;
+      }
+      .oe-text {
+        flex: 1 1 auto;
+        text-align: left;
+        white-space: pre-wrap;
+        word-break: break-word;
+        line-height: 1.5;
+      }
+      .oe-meta {
+        flex: 0 0 auto;
+        white-space: nowrap;
+        letter-spacing: 1px;
+      }
+      .oe-remark {
+        margin-top: 2px;
+        padding-left: 20px;
+        text-align: left;
+        font-size: 11px;
+        color: #000;
+      }
     }
   }
 }
@@ -288,7 +471,7 @@ const print = {
 <!--
   @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
   A4 竖版 210×297mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
-  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - thead { display: table-header-group }:浏览器自动在每页重复两行表头
   - tr { page-break-inside: avoid }:单条医嘱行不被切断
   - 表格整体允许跨页(默认行为)
 -->

+ 84 - 52
src/views/operation-analysis/occupancy/bed-utilization/index.vue

@@ -100,9 +100,7 @@
           <el-empty
             v-else
             :description="
-              trendReady
-                ? '所选院区暂无趋势数据'
-                : '暂无数据:月度趋势接口待后端提供(check-trend)'
+              trendReady === false ? '月度趋势数据加载失败,请稍后刷新重试' : '所选院区暂无趋势数据'
             "
             :image-size="90"
             class="chart-empty"
@@ -114,7 +112,7 @@
     <ContentWrap class="table-wrap">
       <div class="panel-title">
         床位利用明细
-        <span class="title-tip">院区 → 楼栋 → 楼层逐级展开,展开时实时统计</span>
+        <span class="title-tip">院区 → 楼栋 → 楼层逐级展开(楼栋/楼层来自统计聚合接口)</span>
       </div>
       <el-table
         v-if="tableRows.length"
@@ -159,14 +157,18 @@
 <script lang="ts" setup>
 import { Refresh } from '@element-plus/icons-vue'
 import Echart from '@/components/Echart/src/Echart.vue'
-import { getBuildList, getBuildData, getBuildFloorData } from '@/api/system/badManage'
 import {
   fetchOverviewDynamics,
   getCampusOptions,
   type CampusBedStat,
   type OverviewDynamics
 } from '../../overview/campus-data'
-import { fetchCheckTrend, type CheckTrendItem } from '../occupancy-api'
+import {
+  fetchBuildStatistics,
+  fetchCheckTrend,
+  type BuildStatisticsItem,
+  type CheckTrendItem
+} from '../occupancy-api'
 
 defineOptions({ name: 'OaOccupancyBedUtilization' })
 
@@ -175,6 +177,8 @@ const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
 const loading = ref(false)
 const updateTime = ref('')
 const dynamics = ref<OverviewDynamics | null>(null)
+/** 接口 A:楼栋/楼层统计聚合,随院区筛选一次加载,树表展开直接读缓存 */
+const buildStats = ref<BuildStatisticsItem[]>([])
 const trendItems = ref<CheckTrendItem[]>([])
 const trendReady = ref<boolean | null>(null)
 
@@ -256,8 +260,6 @@ interface TreeRow {
   occupancyRate: number | null
   utilizationRate: number | null
   hasChildren: boolean
-  /** 楼栋行缓存楼层(getList 已带回) */
-  floorList?: { id: number; floorName: string }[]
 }
 
 const buildStatRow = (
@@ -270,9 +272,11 @@ const buildStatRow = (
     usedBedCount?: number | null
     unusedBedCount?: number | null
     temporaryOccupationCount?: number | null
+    /** 后端已算好的比率,传入时直接采用 */
+    occupancyRate?: number | null
+    utilizationRate?: number | null
   },
-  hasChildren: boolean,
-  floorList?: TreeRow['floorList']
+  hasChildren: boolean
 ): TreeRow => {
   const totalBedCount = Number(stat.totalBedCount) || 0
   const occupiedBedCount = Number(stat.usedBedCount) || 0
@@ -286,13 +290,19 @@ const buildStatRow = (
     occupiedBedCount,
     emptyBedCount: Number(stat.unusedBedCount) || 0,
     temporaryOccupationCount,
-    occupancyRate: totalBedCount > 0 ? (occupiedBedCount / totalBedCount) * 100 : null,
+    occupancyRate:
+      stat.occupancyRate !== undefined
+        ? stat.occupancyRate
+        : totalBedCount > 0
+          ? (occupiedBedCount / totalBedCount) * 100
+          : null,
     utilizationRate:
-      totalBedCount > 0
-        ? ((occupiedBedCount + temporaryOccupationCount) / totalBedCount) * 100
-        : null,
-    hasChildren,
-    floorList
+      stat.utilizationRate !== undefined
+        ? stat.utilizationRate
+        : totalBedCount > 0
+          ? ((occupiedBedCount + temporaryOccupationCount) / totalBedCount) * 100
+          : null,
+    hasChildren
   }
 }
 
@@ -314,44 +324,57 @@ const tableRows = computed<TreeRow[]>(() =>
   )
 )
 
-/** 懒加载:院区→楼栋(逐楼统计),楼栋→楼层(逐层统计) */
-const loadChildren = async (
-  row: TreeRow,
-  _treeNode: unknown,
-  resolve: (data: TreeRow[]) => void
-) => {
-  try {
-    if (row.level === 'campus') {
-      const tenantId = Number(row.rowKey.replace('campus-', ''))
-      const buildings = await getBuildList({ tenantIds: tenantId })
-      const rows = await Promise.all(
-        (buildings || []).map(async (build: any) => {
-          const stat = await getBuildData(build.id).catch(() => ({}))
-          return buildStatRow(
-            'building',
-            `building-${build.id}`,
-            build.buildName,
-            stat || {},
-            (build.floorList || []).length > 0,
-            (build.floorList || []).map((f: any) => ({ id: f.id, floorName: f.floorName }))
-          )
-        })
+/** 懒加载:楼栋/楼层数据均来自接口 A 的一次聚合结果,展开时只做本地过滤 */
+const loadChildren = (row: TreeRow, _treeNode: unknown, resolve: (data: TreeRow[]) => void) => {
+  if (row.level === 'campus') {
+    const tenantId = Number(row.rowKey.replace('campus-', ''))
+    const buildings = buildStats.value.filter(
+      (item) => item.level === 1 && item.tenantId === tenantId
+    )
+    resolve(
+      buildings.map((b) =>
+        buildStatRow(
+          'building',
+          `building-${b.buildId}`,
+          b.buildName,
+          {
+            totalRoomCount: b.totalRoomCount,
+            totalBedCount: b.totalBedCount,
+            usedBedCount: b.occupiedBedCount,
+            unusedBedCount: b.emptyBedCount,
+            temporaryOccupationCount: b.temporaryOccupationCount,
+            occupancyRate: b.occupancyRate,
+            utilizationRate: b.utilizationRate
+          },
+          buildStats.value.some((item) => item.level === 2 && item.buildId === b.buildId)
+        )
       )
-      resolve(rows)
-      return
-    }
-    if (row.level === 'building') {
-      const rows = await Promise.all(
-        (row.floorList || []).map(async (floor) => {
-          const stat = await getBuildFloorData(floor.id).catch(() => ({}))
-          return buildStatRow('floor', `floor-${floor.id}`, floor.floorName, stat || {}, false)
-        })
+    )
+    return
+  }
+  if (row.level === 'building') {
+    const buildId = Number(row.rowKey.replace('building-', ''))
+    const floors = buildStats.value.filter((item) => item.level === 2 && item.buildId === buildId)
+    resolve(
+      floors.map((f) =>
+        buildStatRow(
+          'floor',
+          `floor-${f.floorId}`,
+          f.floorName || '-',
+          {
+            totalRoomCount: f.totalRoomCount,
+            totalBedCount: f.totalBedCount,
+            usedBedCount: f.occupiedBedCount,
+            unusedBedCount: f.emptyBedCount,
+            temporaryOccupationCount: f.temporaryOccupationCount,
+            occupancyRate: f.occupancyRate,
+            utilizationRate: f.utilizationRate
+          },
+          false
+        )
       )
-      resolve(rows)
-      return
-    }
-  } catch {
-    // 单层统计失败不影响其它层级
+    )
+    return
   }
   resolve([])
 }
@@ -489,6 +512,7 @@ const pad2 = (n: number) => String(n).padStart(2, '0')
 const loadAll = async () => {
   if (!selectedCampusIds.value.length) {
     dynamics.value = null
+    buildStats.value = []
     trendItems.value = []
     trendReady.value = null
     return
@@ -497,6 +521,14 @@ const loadAll = async () => {
   try {
     const [overview] = await Promise.all([
       fetchOverviewDynamics(selectedCampusIds.value),
+      fetchBuildStatistics(selectedCampusIds.value)
+        .then((items) => {
+          buildStats.value = items
+        })
+        .catch(() => {
+          // 楼栋聚合失败不影响院区级卡片与图表
+          buildStats.value = []
+        }),
       fetchCheckTrend({ tenantIds: selectedCampusIds.value, granularity: 'month', months: 12 })
         .then((items) => {
           trendItems.value = items

+ 1 - 10
src/views/operation-analysis/occupancy/checkin-trend/index.vue

@@ -71,15 +71,6 @@
         </el-form-item>
       </el-form>
 
-      <el-alert
-        v-if="ready === false"
-        type="warning"
-        :closable="false"
-        show-icon
-        class="api-tip"
-        title="趋势接口(/operation-analysis/overview/check-trend)待后端提供;当前可先查看页面下方的近 12 个月退住趋势(现网真实数据)。"
-      />
-
       <!-- 区间 KPI -->
       <div class="kpi-grid">
         <div v-for="card in kpiCards" :key="card.label" class="kpi-card" :class="card.theme">
@@ -103,7 +94,7 @@
       <Echart v-if="ready && items.length" :options="mainChartOption" height="380px" />
       <el-empty
         v-else
-        :description="ready ? '所选区间暂无数据' : '暂无数据:趋势接口待后端提供(check-trend)'"
+        :description="ready === false ? '数据加载失败,请稍后重试' : '所选区间暂无数据'"
         :image-size="90"
         class="chart-empty"
       />

+ 130 - 240
src/views/operation-analysis/occupancy/elder-structure/index.vue

@@ -1,9 +1,5 @@
 <template>
-  <div
-    v-loading="loading"
-    class="oa-page"
-    element-loading-text="正在加载在院长者档案,数据较多请稍候…"
-  >
+  <div v-loading="loading" class="oa-page" element-loading-text="正在加载长者结构统计…">
     <ContentWrap>
       <div class="page-header">
         <div class="header-title">
@@ -42,12 +38,7 @@
         <el-form-item>
           <el-button type="primary" :icon="Search" @click="loadAll">查询</el-button>
           <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
-          <el-button
-            type="success"
-            :icon="Download"
-            :disabled="!elders.length"
-            @click="handleExport"
-          >
+          <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
             导出汇总
           </el-button>
         </el-form-item>
@@ -58,7 +49,7 @@
         type="warning"
         :closable="false"
         show-icon
-        title="长者档案数据加载失败,请稍后刷新重试"
+        title="长者结构数据加载失败,请稍后刷新重试"
         class="api-tip"
       />
 
@@ -83,7 +74,7 @@
         <ContentWrap>
           <div class="panel-title">性别结构</div>
           <div class="donut-wrap">
-            <Echart v-if="elders.length" :options="sexOption" height="340px" />
+            <Echart v-if="hasData" :options="sexOption" height="340px" />
             <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
           </div>
         </ContentWrap>
@@ -91,7 +82,7 @@
       <el-col :xs="24" :lg="15">
         <ContentWrap>
           <div class="panel-title">年龄段分布</div>
-          <Echart v-if="elders.length" :options="ageOption" height="340px" />
+          <Echart v-if="hasData" :options="ageOption" height="340px" />
           <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
         </ContentWrap>
       </el-col>
@@ -104,14 +95,14 @@
             护理等级分布 TOP10
             <span class="title-tip">按在院长者当前护理等级名称统计</span>
           </div>
-          <Echart v-if="elders.length" :options="nurseLevelOption" height="380px" />
+          <Echart v-if="hasData" :options="nurseLevelOption" height="380px" />
           <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
         </ContentWrap>
       </el-col>
       <el-col :xs="24" :lg="10">
         <ContentWrap>
           <div class="panel-title">入住时长分布</div>
-          <Echart v-if="elders.length" :options="stayOption" height="380px" />
+          <Echart v-if="hasData" :options="stayOption" height="380px" />
           <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
         </ContentWrap>
       </el-col>
@@ -121,14 +112,14 @@
       <el-col :xs="24" :lg="12">
         <ContentWrap>
           <div class="panel-title">文化程度分布</div>
-          <Echart v-if="elders.length" :options="educationOption" height="340px" />
+          <Echart v-if="hasData" :options="educationOption" height="340px" />
           <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
         </ContentWrap>
       </el-col>
       <el-col :xs="24" :lg="12">
         <ContentWrap>
           <div class="panel-title">缴费方式分布</div>
-          <Echart v-if="elders.length" :options="paymentOption" height="340px" />
+          <Echart v-if="hasData" :options="paymentOption" height="340px" />
           <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
         </ContentWrap>
       </el-col>
@@ -183,7 +174,12 @@ import Echart from '@/components/Echart/src/Echart.vue'
 import { exportWithCustomHeaders } from '@/utils/excel-export'
 import { getStrDictOptions, getDictLabel, DICT_TYPE } from '@/utils/dict'
 import { getCampusOptions } from '../../overview/campus-data'
-import { fetchInHospitalElders, type InHospitalElderVO } from '../occupancy-api'
+import {
+  fetchElderStructure,
+  type DictBucket,
+  type ElderStructureData,
+  type StructureBucket
+} from '../occupancy-api'
 
 defineOptions({ name: 'OaOccupancyElderStructure' })
 
@@ -194,122 +190,76 @@ const loading = ref(false)
 const loadFailed = ref(false)
 const updateTime = ref('')
 const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
-const elders = ref<InHospitalElderVO[]>([])
+const data = ref<ElderStructureData | null>(null)
+const hasData = computed(() => (data.value?.total ?? 0) > 0)
 
 const educationDict = getStrDictOptions(DICT_TYPE.DEGREE_EDUCATION_ARR)
 const paymentDict = getStrDictOptions(DICT_TYPE.PAYMENT_METHOD_ARR)
 
-const dictLabel = (dict: any[], value: number | null) =>
-  value === null || value === undefined ? '未设置' : getDictLabel(dict, String(value)) || '未设置'
-
-const pad2 = (n: number) => String(n).padStart(2, '0')
-
-/* ---------------- 聚合 ---------------- */
-
-const countBy = (fn: (e: InHospitalElderVO) => string) => {
-  const map = new Map<string, number>()
-  elders.value.forEach((e) => {
-    const key = fn(e)
-    map.set(key, (map.get(key) || 0) + 1)
-  })
-  return map
-}
-
-const average = (nums: number[]) =>
-  nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10 : null
-
-const stats = computed(() => {
-  const list = elders.value
-  const total = list.length
-  const male = list.filter((e) => Number(e.elderSex) === 1).length
-  const ages = list.map((e) => Number(e.elderAge)).filter((v) => !Number.isNaN(v) && v > 0)
-  const now = Date.now()
-  const stayMonthsList = list
-    .filter((e) => e.checkInTime && e.checkInTime > 0)
-    .map((e) => (now - (e.checkInTime as number)) / 1000 / 60 / 60 / 24 / 30.4375)
-  return {
-    total,
-    male,
-    female: list.filter((e) => Number(e.elderSex) === 2).length,
-    sexUnknown: total - male - list.filter((e) => Number(e.elderSex) === 2).length,
-    avgAge: average(ages),
-    over90: list.filter((e) => Number(e.elderAge) >= 90).length,
-    avgStayMonths: average(stayMonthsList)
-  }
+/** 分段标签颜色(按顺序循环) */
+const AGE_COLORS = ['#95d4ff', '#67b8ff', '#409eff', '#36a866', '#e6a23c', '#f56c6c', '#909399']
+
+/* ---------------- KPI ---------------- */
+
+const kpiCards = computed(() => {
+  const d = data.value
+  const total = d?.total ?? 0
+  const male = d?.maleCount ?? 0
+  const female = d?.femaleCount ?? 0
+  return [
+    { label: '在院长者', value: total, unit: '人', icon: 'ep:user', theme: 'blue' },
+    {
+      label: '平均年龄',
+      value: d?.avgAge ?? '-',
+      unit: '岁',
+      icon: 'ep:calendar',
+      theme: 'cyan'
+    },
+    {
+      label: '男性',
+      value: male,
+      unit: '人',
+      sub: total ? `占比 ${((male / total) * 100).toFixed(1)}%` : '',
+      icon: 'ep:male',
+      theme: 'green'
+    },
+    {
+      label: '女性',
+      value: female,
+      unit: '人',
+      sub: total ? `占比 ${((female / total) * 100).toFixed(1)}%` : '',
+      icon: 'ep:female',
+      theme: 'pink'
+    },
+    {
+      label: '90岁及以上',
+      value: d?.over90Count ?? 0,
+      unit: '人',
+      icon: 'ep:histogram',
+      theme: 'orange'
+    },
+    {
+      label: '平均入住时长',
+      value: d?.avgStayMonths ?? '-',
+      unit: '月',
+      icon: 'ep:timer',
+      theme: 'purple'
+    }
+  ]
 })
 
-const kpiCards = computed(() => [
-  { label: '在院长者', value: stats.value.total, unit: '人', icon: 'ep:user', theme: 'blue' },
-  {
-    label: '平均年龄',
-    value: stats.value.avgAge ?? '-',
-    unit: '岁',
-    icon: 'ep:calendar',
-    theme: 'cyan'
-  },
-  {
-    label: '男性',
-    value: stats.value.male,
-    unit: '人',
-    sub: stats.value.total
-      ? `占比 ${((stats.value.male / stats.value.total) * 100).toFixed(1)}%`
-      : '',
-    icon: 'ep:male',
-    theme: 'green'
-  },
-  {
-    label: '女性',
-    value: stats.value.female,
-    unit: '人',
-    sub: stats.value.total
-      ? `占比 ${((stats.value.female / stats.value.total) * 100).toFixed(1)}%`
-      : '',
-    icon: 'ep:female',
-    theme: 'pink'
-  },
-  {
-    label: '90岁及以上',
-    value: stats.value.over90,
-    unit: '人',
-    icon: 'ep:histogram',
-    theme: 'orange'
-  },
-  {
-    label: '平均入住时长',
-    value: stats.value.avgStayMonths ?? '-',
-    unit: '月',
-    icon: 'ep:timer',
-    theme: 'purple'
-  }
-])
-
-/** 年龄段(养老行业通用分段) */
-const AGE_BUCKETS = [
-  { label: '60岁以下', test: (a: number) => a < 60 },
-  { label: '60-69岁', test: (a: number) => a >= 60 && a < 70 },
-  { label: '70-79岁', test: (a: number) => a >= 70 && a < 80 },
-  { label: '80-89岁', test: (a: number) => a >= 80 && a < 90 },
-  { label: '90-99岁', test: (a: number) => a >= 90 && a < 100 },
-  { label: '100岁及以上', test: (a: number) => a >= 100 }
-]
-
-/** 入住时长分段(年) */
-const STAY_BUCKETS = [
-  { label: '1年以内', test: (m: number) => m < 12 },
-  { label: '1-3年', test: (m: number) => m >= 12 && m < 36 },
-  { label: '3-5年', test: (m: number) => m >= 36 && m < 60 },
-  { label: '5-10年', test: (m: number) => m >= 60 && m < 120 },
-  { label: '10年及以上', test: (m: number) => m >= 120 }
-]
-
 /* ---------------- 图表 ---------------- */
 
 const sexOption = computed(() => {
-  const s = stats.value
-  const data = [
-    { name: '男', value: s.male, itemStyle: { color: '#409eff' } },
-    { name: '女', value: s.female, itemStyle: { color: '#ec5f86' } }
-  ].filter((item) => item.value > 0)
+  const buckets = data.value?.sexBuckets ?? []
+  const colorMap: Record<string, string> = {
+    男: '#409eff',
+    女: '#ec5f86',
+    未设置: '#909399'
+  }
+  const chartData = buckets
+    .filter((b) => b.count > 0)
+    .map((b) => ({ name: b.label, value: b.count, itemStyle: { color: colorMap[b.label] } }))
   return {
     tooltip: { trigger: 'item', formatter: '{b}:{c} 人({d}%)' },
     legend: { bottom: 0, icon: 'circle' },
@@ -319,50 +269,43 @@ const sexOption = computed(() => {
         radius: ['50%', '70%'],
         center: ['50%', '46%'],
         label: { formatter: '{b}\n{c} 人' },
-        data
+        data: chartData
       }
     ]
   }
 })
 
-const ageOption = computed(() => {
-  const counts = AGE_BUCKETS.map(
-    (b) => elders.value.filter((e) => Number(e.elderAge) > 0 && b.test(Number(e.elderAge))).length
-  )
-  return {
-    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
-    grid: { left: 48, right: 24, top: 24, bottom: 30 },
-    xAxis: { type: 'category', data: AGE_BUCKETS.map((b) => b.label) },
-    yAxis: { type: 'value', name: '人', minInterval: 1 },
-    series: [
-      {
-        type: 'bar',
-        barWidth: 28,
-        data: counts,
-        itemStyle: {
-          borderRadius: [4, 4, 0, 0],
-          color: (p: any) =>
-            ['#95d4ff', '#67b8ff', '#409eff', '#36a866', '#e6a23c', '#f56c6c'][p.dataIndex]
-        },
-        label: { show: true, position: 'top' }
-      }
-    ]
-  }
+const barOption = (buckets: StructureBucket[], colors: string[]) => ({
+  tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
+  grid: { left: 48, right: 24, top: 24, bottom: 30 },
+  xAxis: { type: 'category', data: buckets.map((b) => b.label) },
+  yAxis: { type: 'value', name: '人', minInterval: 1 },
+  series: [
+    {
+      type: 'bar',
+      barWidth: 28,
+      data: buckets.map((b, i) => ({
+        value: b.count,
+        itemStyle: { borderRadius: [4, 4, 0, 0], color: colors[i % colors.length] }
+      })),
+      label: { show: true, position: 'top' }
+    }
+  ]
 })
 
+const ageOption = computed(() => barOption(data.value?.ageBuckets ?? [], AGE_COLORS))
+const stayOption = computed(() => barOption(data.value?.stayBuckets ?? [], ['#1fa9bf']))
+
 const nurseLevelOption = computed(() => {
-  const map = countBy((e) => e.nurseLevelName || '未设置')
-  const list = Array.from(map.entries())
-    .map(([name, count]) => ({ name, count }))
-    .sort((a, b) => a.count - b.count)
-    .slice(-10)
+  // 后端按人数降序返回;echart 类目轴第一项在底部,反转让人数最多的显示在顶部
+  const list = [...(data.value?.nurseLevelBuckets ?? [])].slice(0, 10).reverse()
   return {
     tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
     grid: { left: 130, right: 48, top: 10, bottom: 10, containLabel: true },
     xAxis: { type: 'value', minInterval: 1 },
     yAxis: {
       type: 'category',
-      data: list.map((i) => i.name),
+      data: list.map((i) => i.nurseLevelName || '未设置'),
       axisLabel: { width: 120, overflow: 'truncate' }
     },
     series: [
@@ -377,42 +320,16 @@ const nurseLevelOption = computed(() => {
   }
 })
 
-const stayOption = computed(() => {
-  const now = Date.now()
-  const monthsOf = (e: InHospitalElderVO) =>
-    e.checkInTime ? (now - e.checkInTime) / 1000 / 60 / 60 / 24 / 30.4375 : null
-  const counts = STAY_BUCKETS.map(
-    (b) =>
-      elders.value.filter((e) => {
-        const m = monthsOf(e)
-        return m !== null && b.test(m)
-      }).length
-  )
-  return {
-    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
-    grid: { left: 48, right: 24, top: 24, bottom: 30 },
-    xAxis: { type: 'category', data: STAY_BUCKETS.map((b) => b.label) },
-    yAxis: { type: 'value', name: '人', minInterval: 1 },
-    series: [
-      {
-        type: 'bar',
-        barWidth: 24,
-        data: counts,
-        itemStyle: { borderRadius: [4, 4, 0, 0], color: '#1fa9bf' },
-        label: { show: true, position: 'top' }
-      }
-    ]
-  }
-})
-
-const distributionOption = (
-  dict: any[],
-  valueOf: (e: InHospitalElderVO) => number | null,
-  color: string
-) => {
-  const map = countBy((e) => dictLabel(dict, valueOf(e)))
-  const list = Array.from(map.entries())
-    .map(([name, count]) => ({ name, count }))
+/** 字典类分布:后端返回字典原值,前端翻译展示 */
+const dictDistributionOption = (buckets: DictBucket[], dict: any[], color: string) => {
+  const list = buckets
+    .map((b) => ({
+      name:
+        b.value === null || b.value === undefined || b.value === ''
+          ? '未设置'
+          : getDictLabel(dict, String(b.value)) || '未设置',
+      count: b.count
+    }))
     .sort((a, b) => b.count - a.count)
   return {
     tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
@@ -436,77 +353,50 @@ const distributionOption = (
 }
 
 const educationOption = computed(() =>
-  distributionOption(educationDict, (e) => e.degreeEducation, '#36a866')
+  dictDistributionOption(data.value?.educationBuckets ?? [], educationDict, '#36a866')
 )
 const paymentOption = computed(() =>
-  distributionOption(paymentDict, (e) => e.paymentMethod, '#f5851d')
+  dictDistributionOption(data.value?.paymentBuckets ?? [], paymentDict, '#f5851d')
 )
 
 /* ---------------- 院区明细表 ---------------- */
 
-const campusNameOf = (e: InHospitalElderVO) => {
-  const opt = campusOptions.find((item) => item.value === Number(e.tenantId))
-  return opt?.label || e.tenantName || `院区${e.tenantId ?? '-'}`
-}
-
-const campusRows = computed(() => {
-  const map = new Map<string, InHospitalElderVO[]>()
-  elders.value.forEach((e) => {
-    const name = campusNameOf(e)
-    const arr = map.get(name) || []
-    arr.push(e)
-    map.set(name, arr)
-  })
-  return Array.from(map.entries())
-    .map(([campusName, list]) => {
-      const male = list.filter((e) => Number(e.elderSex) === 1).length
-      const ages = list.map((e) => Number(e.elderAge)).filter((v) => v > 0)
-      const now = Date.now()
-      const stays = list
-        .filter((e) => e.checkInTime)
-        .map((e) => (now - (e.checkInTime as number)) / 1000 / 60 / 60 / 24 / 30.4375)
-      const nurseMap = new Map<string, number>()
-      list.forEach((e) => {
-        const name = e.nurseLevelName || '未设置'
-        nurseMap.set(name, (nurseMap.get(name) || 0) + 1)
-      })
-      const main = Array.from(nurseMap.entries()).sort((a, b) => b[1] - a[1])[0]
-      return {
-        campusName,
-        total: list.length,
-        male,
-        female: list.length - male,
-        maleRate: list.length ? ((male / list.length) * 100).toFixed(1) + '%' : '-',
-        avgAge: average(ages),
-        over80: list.filter((e) => Number(e.elderAge) >= 80).length,
-        avgStayMonths: average(stays),
-        mainNurseLevel: main ? `${main[0]}(${main[1]}人)` : ''
-      }
-    })
+const campusRows = computed(() =>
+  [...(data.value?.campusStats ?? [])]
+    .map((c) => ({
+      campusName: c.tenantName || `院区${c.tenantId}`,
+      total: c.total,
+      male: c.maleCount,
+      female: c.femaleCount,
+      maleRate: c.total ? ((c.maleCount / c.total) * 100).toFixed(1) + '%' : '-',
+      avgAge: c.avgAge,
+      over80: c.over80Count,
+      avgStayMonths: c.avgStayMonths,
+      mainNurseLevel: c.mainNurseLevelName || ''
+    }))
     .sort((a, b) => b.total - a.total)
-})
+)
 
 /* ---------------- 加载与导出 ---------------- */
 
+const pad2 = (n: number) => String(n).padStart(2, '0')
+
 const loadAll = async () => {
+  // 空选 = 下拉范围内全部院区
   const ids = selectedCampusIds.value.length
     ? selectedCampusIds.value
     : campusOptions.map((item) => item.value)
-  if (!ids.length) {
-    elders.value = []
-    return
-  }
   loading.value = true
   loadFailed.value = false
   try {
-    elders.value = await fetchInHospitalElders(ids)
+    data.value = await fetchElderStructure(ids)
     const now = new Date()
     updateTime.value = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${pad2(
       now.getHours()
     )}:${pad2(now.getMinutes())}`
   } catch {
     loadFailed.value = true
-    elders.value = []
+    data.value = null
   } finally {
     loading.value = false
   }

+ 129 - 120
src/views/operation-analysis/occupancy/empty-bed/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div v-loading="loading" class="oa-page">
+  <div v-loading="loading" class="oa-page" element-loading-text="正在查询空床明细…">
     <ContentWrap>
       <div class="page-header">
         <div class="header-title">
@@ -31,7 +31,7 @@
         </el-form-item>
         <el-form-item label="楼栋">
           <el-select
-            v-model="queryBuildKeys"
+            v-model="queryBuildIds"
             multiple
             collapse-tags
             collapse-tags-tooltip
@@ -42,9 +42,9 @@
           >
             <el-option
               v-for="item in buildOptions"
-              :key="item.key"
+              :key="item.buildId"
               :label="item.label"
-              :value="item.key"
+              :value="item.buildId"
             />
           </el-select>
         </el-form-item>
@@ -67,30 +67,17 @@
         <el-form-item>
           <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
           <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
-          <el-button
-            type="success"
-            :icon="Download"
-            :disabled="!filteredRows.length"
-            @click="handleExport"
-          >
+          <el-button type="success" :icon="Download" :loading="exporting" @click="handleExport">
             导出 Excel
           </el-button>
         </el-form-item>
       </el-form>
 
-      <el-alert
-        type="info"
-        :closable="false"
-        show-icon
-        class="api-tip"
-        title="当前数据来自现有床位接口(按院区实时统计):房间区域/朝向/房型/房间配置等筛选与服务端分页导出待后端接口增强,详见《入住运营报表-设计与后端接口需求》接口 B。"
-      />
-
-      <!-- 汇总条 -->
+      <!-- 汇总条(恒按空床口径,来自 summary 接口,不受床位状态筛选影响) -->
       <div class="summary-grid">
         <div class="summary-card blue">
           <div class="s-label"><Icon icon="ep:moon" />空床总数</div>
-          <div class="s-value">{{ summary.emptyCount }}<span class="s-unit">张</span></div>
+          <div class="s-value">{{ summary.emptyBedCount }}<span class="s-unit">张</span></div>
         </div>
         <div class="summary-card cyan">
           <div class="s-label"><Icon icon="ep:office-building" />涉及空床房间</div>
@@ -102,7 +89,7 @@
         </div>
         <div class="summary-card orange">
           <div class="s-label"><Icon icon="ep:files" />当前筛选结果</div>
-          <div class="s-value">{{ filteredRows.length }}<span class="s-unit">条</span></div>
+          <div class="s-value">{{ total }}<span class="s-unit">条</span></div>
         </div>
       </div>
     </ContentWrap>
@@ -112,7 +99,7 @@
         床位明细
         <span class="title-tip">按 院区 / 楼栋 / 楼层 / 房间 排序,默认仅展示空床</span>
       </div>
-      <el-table :data="pagedRows" border stripe v-loading="loading">
+      <el-table :data="rows" border stripe v-loading="loading">
         <el-table-column type="index" label="序号" width="60" align="center" />
         <el-table-column prop="tenantName" label="机构" min-width="140" show-overflow-tooltip />
         <el-table-column prop="buildName" label="楼栋" min-width="130" show-overflow-tooltip />
@@ -120,12 +107,12 @@
         <el-table-column prop="roomName" label="房间号" min-width="100" />
         <el-table-column prop="bedName" label="床位号" min-width="90" />
         <el-table-column label="房间床位" width="90" align="center">
-          <template #default="{ row }">{{ roomInfo(row).used }}/{{ roomInfo(row).total }}</template>
+          <template #default="{ row }">{{ row.roomUsedCount }}/{{ row.roomBedCount }}</template>
         </el-table-column>
         <el-table-column label="状态" width="90" align="center">
           <template #default="{ row }">
-            <el-tag :type="row.status === 0 ? 'warning' : 'success'" effect="light">
-              {{ row.status === 0 ? '空床' : '在住' }}
+            <el-tag :type="row.bedStatus === 0 ? 'warning' : 'success'" effect="light">
+              {{ row.bedStatus === 0 ? '空床' : '在住' }}
             </el-tag>
           </template>
         </el-table-column>
@@ -135,9 +122,11 @@
           v-model:current-page="pageNo"
           v-model:page-size="pageSize"
           :page-sizes="[20, 50, 100, 200]"
-          :total="filteredRows.length"
+          :total="total"
           layout="total, sizes, prev, pager, next, jumper"
           background
+          @current-change="loadPage"
+          @size-change="handleSizeChange"
         />
       </div>
     </ContentWrap>
@@ -149,7 +138,12 @@ import { Download, RefreshLeft, Search } from '@element-plus/icons-vue'
 import { getBuildList } from '@/api/system/badManage'
 import { exportWithCustomHeaders } from '@/utils/excel-export'
 import { getCampusOptions } from '../../overview/campus-data'
-import { fetchEmptyBedRows, type EmptyBedRow } from '../occupancy-api'
+import {
+  fetchEmptyBedPage,
+  fetchEmptyBedSummary,
+  type EmptyBedPageRow,
+  type EmptyBedSummary
+} from '../occupancy-api'
 
 defineOptions({ name: 'OaOccupancyEmptyBed' })
 
@@ -157,17 +151,34 @@ const message = useMessage()
 const campusOptions = getCampusOptions()
 
 const loading = ref(false)
+const exporting = ref(false)
 const queryCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
-const queryBuildKeys = ref<string[]>([])
-const queryStatus = ref(0)
+const queryBuildIds = ref<number[]>([])
+const queryStatus = ref<number>(0)
 const queryBedName = ref('')
 
-const allRows = ref<EmptyBedRow[]>([])
-const buildOptions = ref<{ key: string; label: string }[]>([])
+const rows = ref<EmptyBedPageRow[]>([])
+const total = ref(0)
+const summary = ref<EmptyBedSummary>({
+  emptyBedCount: 0,
+  emptyRoomCount: 0,
+  fullEmptyRoomCount: 0
+})
+const buildOptions = ref<{ buildId: number; label: string }[]>([])
 const pageNo = ref(1)
 const pageSize = ref(20)
 
-/** 楼栋下拉:所选院区的全部楼栋(key=机构id::楼栋名) */
+/** 当前筛选条件(院区为空 = 按登录人机构范围) */
+const buildFilterParams = () => {
+  const ids = queryCampusIds.value.length ? queryCampusIds.value : []
+  return {
+    tenantIds: ids.length ? ids : undefined,
+    buildIds: queryBuildIds.value.length ? queryBuildIds.value : undefined,
+    bedName: queryBedName.value.trim() || undefined
+  }
+}
+
+/** 楼栋下拉:所选院区的全部楼栋(getBuildList 项主键为 id,与明细行 buildId 同源) */
 const loadBuildOptions = async () => {
   const ids = queryCampusIds.value.length
     ? queryCampusIds.value
@@ -178,7 +189,7 @@ const loadBuildOptions = async () => {
       try {
         const list = await getBuildList({ tenantIds: id })
         return (list || []).map((b: any) => ({
-          key: `${id}::${b.buildName}`,
+          buildId: Number(b.id ?? b.buildId),
           label: `${nameMap.get(id) || id} / ${b.buildName}`
         }))
       } catch {
@@ -186,111 +197,109 @@ const loadBuildOptions = async () => {
       }
     })
   )
-  buildOptions.value = groups.flat()
-  queryBuildKeys.value = queryBuildKeys.value.filter((key) =>
-    buildOptions.value.some((item) => item.key === key)
+  buildOptions.value = groups.flat().filter((item) => !Number.isNaN(item.buildId))
+  queryBuildIds.value = queryBuildIds.value.filter((id) =>
+    buildOptions.value.some((item) => item.buildId === id)
   )
 }
 
-/** 行 → 所属房间 key */
-const roomKeyOf = (row: EmptyBedRow) =>
-  `${row.tenantId}::${row.buildName}::${row.floorName}::${row.roomName}`
-
-/** 房间床位占用情况(基于空+在住全量行计算) */
-const roomStatMap = computed(() => {
-  const map = new Map<string, { total: number; used: number }>()
-  allRows.value.forEach((row) => {
-    const key = roomKeyOf(row)
-    const stat = map.get(key) || { total: 0, used: 0 }
-    stat.total += 1
-    if (row.status === 1) stat.used += 1
-    map.set(key, stat)
-  })
-  return map
-})
-
-const roomInfo = (row: EmptyBedRow) =>
-  roomStatMap.value.get(roomKeyOf(row)) || { total: 0, used: 0 }
-
-/** 当前筛选条件下的行(状态/楼栋在前端过滤) */
-const filteredRows = computed(() => {
-  let list = allRows.value
-  if (queryStatus.value !== -1) list = list.filter((row) => row.status === queryStatus.value)
-  if (queryBuildKeys.value.length) {
-    const keySet = new Set(queryBuildKeys.value)
-    list = list.filter((row) => keySet.has(`${row.tenantId}::${row.buildName}`))
-  }
-  return [...list].sort((a, b) =>
-    `${a.tenantName}${a.buildName}${a.floorName}${a.roomName}${a.bedName}`.localeCompare(
-      `${b.tenantName}${b.buildName}${b.floorName}${b.roomName}${b.bedName}`
-    )
-  )
-})
-
-const pagedRows = computed(() =>
-  filteredRows.value.slice((pageNo.value - 1) * pageSize.value, pageNo.value * pageSize.value)
-)
-
-/** 汇总恒以“空床”口径统计,不受床位状态筛选影响 */
-const summary = computed(() => {
-  const emptyRows = allRows.value.filter((row) => row.status === 0)
-  const emptyRooms = new Set<string>()
-  const fullEmptyRooms = new Set<string>()
-  emptyRows.forEach((row) => {
-    const key = roomKeyOf(row)
-    emptyRooms.add(key)
-    const info = roomStatMap.value.get(key)
-    if (info && info.used === 0) fullEmptyRooms.add(key)
-  })
-  return {
-    emptyCount: emptyRows.length,
-    emptyRoomCount: emptyRooms.size,
-    fullEmptyRoomCount: fullEmptyRooms.size
-  }
-})
-
-const handleQuery = async () => {
+/** 查询明细分页(服务端分页) */
+const loadPage = async () => {
   loading.value = true
-  pageNo.value = 1
   try {
-    const ids = queryCampusIds.value.length
-      ? queryCampusIds.value
-      : campusOptions.map((item) => item.value)
-    allRows.value = await fetchEmptyBedRows(ids, queryBedName.value.trim())
+    const res = await fetchEmptyBedPage({
+      pageNo: pageNo.value,
+      pageSize: pageSize.value,
+      ...buildFilterParams(),
+      // 全部状态不传 status
+      status: queryStatus.value === -1 ? '' : queryStatus.value
+    })
+    rows.value = res.list
+    total.value = res.total
   } finally {
     loading.value = false
   }
 }
 
+/** 汇总恒以“空床”口径统计,不随床位状态筛选变化 */
+const loadSummary = async () => {
+  try {
+    summary.value = await fetchEmptyBedSummary(buildFilterParams())
+  } catch {
+    // 汇总失败不阻断明细浏览
+  }
+}
+
+const handleQuery = () => {
+  pageNo.value = 1
+  loadPage()
+  loadSummary()
+}
+
+const handleSizeChange = () => {
+  pageNo.value = 1
+  loadPage()
+}
+
 const handleReset = () => {
   queryCampusIds.value = campusOptions.map((item) => item.value)
-  queryBuildKeys.value = []
+  queryBuildIds.value = []
   queryStatus.value = 0
   queryBedName.value = ''
   loadBuildOptions()
   handleQuery()
 }
 
-const handleExport = () => {
-  exportWithCustomHeaders(
-    filteredRows.value.map((row) => ({
-      ...row,
-      statusText: row.status === 0 ? '空床' : '在住',
-      roomBedText: `${roomInfo(row).used}/${roomInfo(row).total}`
-    })),
-    [
-      { key: 'tenantName', title: '机构', width: 18 },
-      { key: 'buildName', title: '楼栋', width: 16 },
-      { key: 'floorName', title: '楼层', width: 10 },
-      { key: 'roomName', title: '房间号', width: 12 },
-      { key: 'bedName', title: '床位号', width: 10 },
-      { key: 'roomBedText', title: '房间床位(在住/总数)', width: 16 },
-      { key: 'statusText', title: '状态', width: 8 }
-    ],
-    `空床明细_${new Date().toISOString().slice(0, 10)}.xlsx`,
-    '空床明细'
-  )
-  message.success('导出成功')
+/** 导出:按当前筛选(含状态)拉全量后前端生成 Excel */
+const handleExport = async () => {
+  exporting.value = true
+  try {
+    const all: EmptyBedPageRow[] = []
+    const pageSizeForExport = 200
+    let page = 1
+    // 服务端单页上限 200,循环拉取全部筛选结果
+    for (;;) {
+      const res = await fetchEmptyBedPage({
+        pageNo: page,
+        pageSize: pageSizeForExport,
+        ...buildFilterParams(),
+        status: queryStatus.value === -1 ? '' : queryStatus.value
+      })
+      all.push(...res.list)
+      if (all.length >= res.total || res.list.length === 0) break
+      page += 1
+      if (page > 100) break
+    }
+    if (!all.length) {
+      message.warning('当前筛选条件下暂无数据可导出')
+      return
+    }
+    exportWithCustomHeaders(
+      all.map((row) => ({
+        tenantName: row.tenantName,
+        buildName: row.buildName,
+        floorName: row.floorName,
+        roomName: row.roomName,
+        bedName: row.bedName,
+        roomBedText: `${row.roomUsedCount}/${row.roomBedCount}`,
+        statusText: row.bedStatus === 0 ? '空床' : '在住'
+      })),
+      [
+        { key: 'tenantName', title: '机构', width: 18 },
+        { key: 'buildName', title: '楼栋', width: 16 },
+        { key: 'floorName', title: '楼层', width: 10 },
+        { key: 'roomName', title: '房间号', width: 12 },
+        { key: 'bedName', title: '床位号', width: 10 },
+        { key: 'roomBedText', title: '房间床位(在住/总数)', width: 16 },
+        { key: 'statusText', title: '状态', width: 8 }
+      ],
+      `空床明细_${new Date().toISOString().slice(0, 10)}.xlsx`,
+      '空床明细'
+    )
+    message.success('导出成功')
+  } finally {
+    exporting.value = false
+  }
 }
 
 watch(queryCampusIds, () => loadBuildOptions())

+ 205 - 132
src/views/operation-analysis/occupancy/occupancy-api.ts

@@ -1,85 +1,153 @@
 import request from '@/config/axios'
-import { getCampusOptions } from '../overview/campus-data'
 
 /**
- * 入住运营报表共享数据层
- * - 空床明细:复用现有 build/getBedList(2026-09-20 集团账号实测契约见下)
- * - 入退趋势:待后端实现 /operation-analysis/overview/check-trend(契约见同目录 md)
- * - 长者结构:复用现有 elderlyInfo/findPage 全量在院长者前端聚合
+ * 入住运营报表共享数据层(2026-09-23 后端接口 A~D 已上线)
+ * - 接口 A:GET /build/statistics 楼栋/楼层床位统计聚合
+ * - 接口 B:GET /build/empty-bed/page 空床明细分页、/empty-bed/summary 汇总
+ * - 接口 C:GET /operation-analysis/overview/check-trend 入住/退住趋势
+ * - 接口 D:GET /operation-analysis/overview/elder-structure 长者结构聚合
+ *
+ * 注意:后端 List 入参只认重复键(tenantIds=1&tenantIds=2),项目 axios 默认
+ * qs indices 形式(tenantIds[0]=)会报参数缺失,因此本文件统一手动拼查询串。
  */
 
-/** build/getBedList 单条原始记录(实测:扁平数组,无分页,仅 5 个字段) */
-export interface BedListRawVO {
-  buildName: string
-  buildFloorName: string
-  buildRoomName: string
-  buildBedId: number
-  buildBedName: string
+/** 拼查询串:数组按重复键展开,null/undefined/空串跳过 */
+const buildQuery = (params: Record<string, any>): string => {
+  const parts: string[] = []
+  Object.entries(params).forEach(([key, val]) => {
+    if (val === null || val === undefined || val === '') return
+    if (Array.isArray(val)) {
+      val.forEach((v) => {
+        if (v !== null && v !== undefined && v !== '') parts.push(`${key}=${encodeURIComponent(v)}`)
+      })
+    } else {
+      parts.push(`${key}=${encodeURIComponent(String(val))}`)
+    }
+  })
+  return parts.length ? `?${parts.join('&')}` : ''
 }
 
-/** 空床明细行(前端补齐机构名与状态后供表格使用) */
-export interface EmptyBedRow {
+/* ============================== 接口 A:楼栋/楼层聚合 ============================== */
+
+/** 楼栋/楼层床位统计行(level=1 楼栋,level=2 楼层) */
+export interface BuildStatisticsItem {
   tenantId: number
   tenantName: string
+  buildId: number
   buildName: string
-  floorName: string
-  roomName: string
+  floorId?: number
+  floorName?: string
+  /** 1=楼栋行,2=楼层行 */
+  level: 1 | 2
+  totalRoomCount: number
+  /** 整间空房数(房内床位全部空闲) */
+  emptyRoomCount: number
+  totalBedCount: number
+  occupiedBedCount: number
+  emptyBedCount: number
+  temporaryOccupationCount: number
+  /** 入住率 % */
+  occupancyRate: number | null
+  /** 利用率 %=(在住+临时)/总床 */
+  utilizationRate: number | null
+}
+
+/**
+ * 接口 A:楼栋/楼层床位统计聚合
+ * @param tenantIds 机构 id 集合;空数组=按登录人机构范围
+ */
+export const fetchBuildStatistics = async (
+  tenantIds: number[] = []
+): Promise<BuildStatisticsItem[]> => {
+  const data = await request.get({
+    url: `/build/statistics${buildQuery({ orgType: 1, tenantIds })}`
+  })
+  return Array.isArray(data?.items) ? data.items : []
+}
+
+/* ============================== 接口 B:空床明细分页 ============================== */
+
+/** 空床明细行(一张床一行,房间属性冗余在行上) */
+export interface EmptyBedPageRow {
   bedId: number
   bedName: string
   /** 床位状态:0=空床,1=在住 */
-  status: number
+  bedStatus: number
+  roomId: number
+  roomName: string
+  /** 区域(字典 room_area_arr,可能为 null) */
+  roomArea: string | null
+  /** 朝向(字典 room_orientation) */
+  orientation: string | null
+  /** 房间配置 */
+  roomSettings: string | null
+  /** 房型(字典 elderly_health_status) */
+  roomUsage: string | null
+  roomBedCount: number
+  roomUsedCount: number
+  floorId: number
+  floorName: string
+  buildId: number
+  buildName: string
+  tenantId: number
+  tenantName: string
+  remark?: string | null
 }
 
-/**
- * 空床/床位明细:
- *
- * 现有 GET /build/getBedList 实测行为(2026-09-20,集团账号 tenant-id=1):
- * - 必填 bedName(可空串)与 tenantIds;支持多个 tenantIds;
- * - status=0 空床 / 1 在住 / 不传全部;pageNo、pageSize 不生效,直接返回全量扁平数组;
- * - 每条仅 5 个字段(楼栋/楼层/房间名称 + 床id/床名),无状态、无机构 id/名称、无房间属性;
- * 因此这里按机构分别请求、按 status=0/1 请求两次,在前端补上机构名与状态。
- *
- * @param tenantIds 机构 id 集合
- * @param bedName 房号/床号关键字(后端模糊匹配,可空)
- */
-export const fetchEmptyBedRows = async (
-  tenantIds: number[],
-  bedName = ''
-): Promise<EmptyBedRow[]> => {
-  const nameMap = new Map(getCampusOptions().map((item) => [item.value, item.label]))
-  // 注意:该接口后端用 @RequestParam List 接收,实测只认重复键(tenantIds=1&tenantIds=2),
-  // qs 默认的 tenantIds[0]= 形式会报“请求参数缺失:tenantIds”,因此手动拼 URL。
-  const buildUrl = (tenantId: number, status: number) =>
-    `/build/getBedList?bedName=${encodeURIComponent(bedName)}&status=${status}&tenantIds=${tenantId}&orgType=1`
-  const tasks: Promise<EmptyBedRow[]>[] = []
-  tenantIds.forEach((tenantId) => {
-    ;[0, 1].forEach((status) => {
-      tasks.push(
-        request
-          .get({
-            url: buildUrl(tenantId, status)
-          })
-          .then((list: BedListRawVO[]) =>
-            (Array.isArray(list) ? list : []).map((item) => ({
-              tenantId,
-              tenantName: nameMap.get(tenantId) || `院区${tenantId}`,
-              buildName: item.buildName || '',
-              floorName: item.buildFloorName || '',
-              roomName: item.buildRoomName || '',
-              bedId: item.buildBedId,
-              bedName: item.buildBedName || '',
-              status
-            }))
-          )
-          .catch(() => [])
-      )
-    })
+export interface EmptyBedPageParams {
+  pageNo: number
+  pageSize: number
+  tenantIds?: number[]
+  buildIds?: number[]
+  floodIds?: number[]
+  /** 0=空床(默认),1=在住,不传=全部 */
+  status?: number | ''
+  roomAreaArray?: (string | number)[]
+  elderTypeArray?: (string | number)[]
+  orientationArray?: (string | number)[]
+  roomSettingsArray?: (string | number)[]
+  bedName?: string
+}
+
+/** 接口 B:空床明细分页 */
+export const fetchEmptyBedPage = async (
+  params: EmptyBedPageParams
+): Promise<{ total: number; list: EmptyBedPageRow[] }> => {
+  const data = await request.get({
+    url: `/build/empty-bed/page${buildQuery({ orgType: 1, ...params })}`
   })
-  const groups = await Promise.all(tasks)
-  return groups.flat()
+  return {
+    total: Number(data?.total) || 0,
+    list: Array.isArray(data?.list) ? data.list : []
+  }
 }
 
-/** 入退趋势一行(契约同运营总览二期 check-trend) */
+export interface EmptyBedSummary {
+  /** 空床总数 */
+  emptyBedCount: number
+  /** 涉及空床的房间数 */
+  emptyRoomCount: number
+  /** 整间空房数 */
+  fullEmptyRoomCount: number
+}
+
+/** 接口 B:空床汇总(入参同筛选;status 默认空床口径) */
+export const fetchEmptyBedSummary = async (
+  params: Omit<EmptyBedPageParams, 'pageNo' | 'pageSize'>
+): Promise<EmptyBedSummary> => {
+  const data = await request.get({
+    url: `/build/empty-bed/summary${buildQuery({ orgType: 1, ...params })}`
+  })
+  return {
+    emptyBedCount: Number(data?.emptyBedCount) || 0,
+    emptyRoomCount: Number(data?.emptyRoomCount) || 0,
+    fullEmptyRoomCount: Number(data?.fullEmptyRoomCount) || 0
+  }
+}
+
+/* ============================== 接口 C:入退趋势 ============================== */
+
+/** 入退趋势一行 */
 export interface CheckTrendItem {
   /** yyyy-MM(月)或 yyyy-MM-dd(日) */
   period: string
@@ -102,80 +170,85 @@ export interface CheckTrendParams {
   months?: number
 }
 
-/**
- * 入住/退住/净增趋势:GET /operation-analysis/overview/check-trend
- * 后端尚未实现(2026-09-20),失败由调用方按“待后端接口”空态处理。
- */
+/** 接口 C:入住/退住/净增趋势 */
 export const fetchCheckTrend = async (params: CheckTrendParams): Promise<CheckTrendItem[]> => {
   const data = await request.get({
-    url: '/operation-analysis/overview/check-trend',
-    params: { orgType: 1, ...params }
+    url: `/operation-analysis/overview/check-trend${buildQuery({ orgType: 1, ...params })}`
   })
   return Array.isArray(data?.items) ? data.items : []
 }
 
-/** 在院长者档案行(findPage 字段裁剪,仅保留结构分析所需字段) */
-export interface InHospitalElderVO {
-  id: number
-  elderName: string
-  /** 1=男,2=女 */
-  elderSex: number | null
-  elderAge: number | null
-  nurseLevelId: number | null
-  nurseLevelName: string | null
-  /** 文化程度字典 degree_education_arr 值 */
-  degreeEducation: number | null
-  /** 缴费方式字典 payment_method_arr 值 */
-  paymentMethod: number | null
-  /** 照护类型字典 care_type 值 */
-  careType: number | null
-  /** 入住时间(毫秒时间戳) */
-  checkInTime: number | null
-  tenantId: number | null
-  tenantName: string | null
+/* ============================== 接口 D:长者结构聚合 ============================== */
+
+/** 通用分段 {标签,人数} */
+export interface StructureBucket {
+  label: string
+  count: number
+}
+/** 字典值分段(value=字典原值,由前端翻译;null=未设置) */
+export interface DictBucket {
+  value: number | string | null
+  label: string
+  count: number
+}
+/** 护理等级分段 */
+export interface NurseLevelBucket {
+  nurseLevelId: number
+  nurseLevelName: string
+  count: number
+}
+/** 每院区结构明细 */
+export interface CampusStructureStat {
+  tenantId: number
+  tenantName: string
+  total: number
+  maleCount: number
+  femaleCount: number
+  avgAge: number | null
+  over80Count: number
+  avgStayMonths: number | null
+  mainNurseLevelName: string | null
 }
 
-/**
- * 拉取所选机构的全部在院长者(inStatus=1),用于长者结构分析前端聚合。
- *
- * 现有 GET /elderlyInfo/findPage 实测(2026-09-20 集团账号):
- * - tenantIds 支持多个机构(与 getBedList 一样用重复键拼 URL);
- * - pageSize=500 生效但单页较重(500 条约 8.5s,返回字段多),故首页拿 total 后并发拉剩余页;
- * - 字段齐全:elderSex/elderAge/nurseLevelName/degreeEducation/paymentMethod/checkInTime 等。
- * 待后端提供聚合接口(文档接口 D)后可替换为一次请求。
- */
-export const fetchInHospitalElders = async (tenantIds: number[]): Promise<InHospitalElderVO[]> => {
-  const tenantPart = tenantIds.map((id) => `tenantIds=${id}`).join('&')
-  const PAGE_SIZE = 500
-  const urlOf = (pageNo: number) =>
-    `/elderlyInfo/findPage?pageNo=${pageNo}&pageSize=${PAGE_SIZE}&inStatus=1&${tenantPart}&orgType=1`
-
-  const first: any = await request.get({ url: urlOf(1) })
-  const total: number = Number(first?.total) || 0
-  const firstList: any[] = Array.isArray(first?.list) ? first.list : []
-  if (!total || firstList.length === 0) return []
-
-  const pageCount = Math.ceil(total / PAGE_SIZE)
-  const rest: any[][] = await Promise.all(
-    Array.from({ length: pageCount - 1 }, (_, idx) =>
-      request
-        .get({ url: urlOf(idx + 2) })
-        .then((res: any) => (Array.isArray(res?.list) ? res.list : []))
-        .catch(() => [])
-    )
-  )
-  return [...firstList, ...rest.flat()].map((r: any) => ({
-    id: r.id,
-    elderName: r.elderName || '',
-    elderSex: r.elderSex ?? null,
-    elderAge: r.elderAge ?? null,
-    nurseLevelId: r.nurseLevelId ?? null,
-    nurseLevelName: r.nurseLevelName || '',
-    degreeEducation: r.degreeEducation ?? null,
-    paymentMethod: r.paymentMethod ?? null,
-    careType: r.careType ?? null,
-    checkInTime: r.checkInTime ?? null,
-    tenantId: r.tenantId ?? null,
-    tenantName: r.tenantName || ''
-  }))
+/** 接口 D 返回体 */
+export interface ElderStructureData {
+  total: number
+  maleCount: number
+  femaleCount: number
+  avgAge: number | null
+  over80Count: number
+  over90Count: number
+  avgStayMonths: number | null
+  sexBuckets: StructureBucket[]
+  ageBuckets: StructureBucket[]
+  stayBuckets: StructureBucket[]
+  nurseLevelBuckets: NurseLevelBucket[]
+  educationBuckets: DictBucket[]
+  paymentBuckets: DictBucket[]
+  campusStats: CampusStructureStat[]
+}
+
+/** 接口 D:长者结构聚合(1 次轻请求替代 findPage 全量翻页) */
+export const fetchElderStructure = async (
+  tenantIds: number[] = []
+): Promise<ElderStructureData> => {
+  const data = await request.get({
+    url: `/operation-analysis/overview/elder-structure${buildQuery({ orgType: 1, tenantIds })}`
+  })
+  return {
+    total: Number(data?.total) || 0,
+    maleCount: Number(data?.maleCount) || 0,
+    femaleCount: Number(data?.femaleCount) || 0,
+    avgAge: data?.avgAge ?? null,
+    over80Count: Number(data?.over80Count) || 0,
+    over90Count: Number(data?.over90Count) || 0,
+    avgStayMonths: data?.avgStayMonths ?? null,
+    sexBuckets: Array.isArray(data?.sexBuckets) ? data.sexBuckets : [],
+    ageBuckets: Array.isArray(data?.ageBuckets) ? data.ageBuckets : [],
+    stayBuckets: Array.isArray(data?.stayBuckets) ? data.stayBuckets : [],
+    nurseLevelBuckets: Array.isArray(data?.nurseLevelBuckets) ? data.nurseLevelBuckets : [],
+    educationBuckets: Array.isArray(data?.educationBuckets) ? data.educationBuckets : [],
+    paymentBuckets: Array.isArray(data?.paymentBuckets) ? data.paymentBuckets : [],
+    campusStats: Array.isArray(data?.campusStats) ? data.campusStats : []
+  }
 }

+ 28 - 0
src/views/operation-analysis/occupancy/入住运营报表-设计与后端接口需求.md

@@ -144,3 +144,31 @@ floorId/floorName、buildId/buildName、tenantId/tenantName、remark
 - 实测聚合:平均 80.7 岁、男 1127(36.8%)/女 1933、90+ 501 人、平均入住 26.4 月;颐年天河 727 人(男 235/女 492、81.3 岁、主要等级一级护理 452 人)。
 
 **全集团对账**:总床位 4306 / 在住 3062 / 空闲 1244 / 入住率 71.1%;空床涉及房间 656、整间空房 358(前端按 roomKey 计算)。
+
+---
+
+## 附录二:接口 A~D 实测(2026-09-23,集团账号 tenant-id=1,生产库,前端已全部切换)
+
+通用:四接口均已上线且需 `orgType=1`;List 入参**只认重复键**(`tenantIds=196&tenantIds=197`),前端在 occupancy-api.ts 统一手动拼查询串。
+
+**A `GET /build/statistics`**:辖区 15 机构返回 230 行 = 63 个楼栋行(level=1)+楼层行(level=2);字段与契约一致(含整间空房 emptyRoomCount、occupancyRate/utilizationRate,无床时比率为 null);支持 tenantIds。
+
+**B `GET /build/empty-bed/page`**:标准 `{total,list[]}`,行 17 字段(bedId/bedName/bedStatus/roomId/roomName/roomArea/orientation/roomSettings/roomUsage/roomBedCount/roomUsedCount/floorId/floorName/buildId/buildName/tenantId/tenantName,无 remark,房间属性多为 null);分页生效(实测 200/页),status 0空/1在/不传全部,bedName 模糊可用;越权 tenantIds 不报错、返回 total=0。
+`/empty-bed/summary` 返回 `{emptyBedCount,emptyRoomCount,fullEmptyRoomCount}`,入参同筛选;注意 **summary.emptyRoomCount=涉及空床房间数,fullEmptyRoomCount 才是整间空房**,与 A 的 emptyRoomCount(整间空房)同名不同义。
+`/empty-bed/export-excel` 返回有效 xlsx(27KB,PK 头),但**无 Content-Type / Content-Disposition 响应头**,前端暂继续本地导出(按筛选循环 pageSize=200 拉全量后生成)。
+
+**C `GET /operation-analysis/overview/check-trend`**:granularity=month/day、startDate/endDate、months 均可用;items 升序。近 12 月(2025-10 ~ 2026-09)恒等式逐月成立:首月 2025-10 期初2463/入76/出81/期末2458,末月 2026-09 期初3410/入112/出47/净增65/期末3475;历史月 endOccupancyRate=null,当月 2026-09=71.8。
+
+**D `GET /operation-analysis/overview/elder-structure`**:辖区 15 机构 total=**3505**(男1292/女2213,avgAge 80.6,80+ 1884,90+ 580,avgStayMonths 23.3);各 bucket 求和均=3505。
+年龄:<60:110 / 60-69:291 / 70-79:768 / 80-89:1304 / 90-99:562 / ≥100:18 / 未设置:452;
+时长:<1年:1542 / 1-3:1259 / 3-5:300 / 5-10:346 / ≥10:48 / 未设置:10;
+文化程度:null 2138 / 值7:1264 / 6:38 / 10:33 / 5:26 / 3:6(value 为字典原值,前端翻译,null 归"未设置");
+缴费方式:null 1976 / 1:1512 / 2:17;campusStats 返回 29 个院区行(含辖区外/无床机构),含 mainNurseLevelName(仅名称,无人数字)。
+
+**口径差异(待后端确认)**:
+
+1. 机构 197 存在"院外"楼栋(24 床 / 空 14),A 与 summary 将其计入,但 empty-bed/page 与 overview/dynamics 不含:196+197 summary 空床 41 vs page(status=0) 27;集团 summary 1349 vs dynamics 空闲 1334(dynamics 集团口径:总床 4838 / 在住 3504 / 空 1334,29 个机构中 18 个有床)。
+2. export-excel 响应缺 Content-Type / Content-Disposition,浏览器无法自动定名,建议补齐。
+3. check-trend 的 day 粒度在缺 startDate/endDate、超 366 天等边界下的报错/补零策略未完整校验,建议后端明确。
+
+**前端切换记录**:occupancy-api.ts 新增 fetchBuildStatistics / fetchEmptyBedPage / fetchEmptyBedSummary / fetchElderStructure 及 VO 类型,fetchCheckTrend 改为手动拼重复键;报表 2 改服务端分页+summary 卡片(删除 30 次 getBedList 请求与前端 roomStatMap);报表 1 树表展开改为读接口 A 一次聚合缓存(删除逐楼/逐层统计请求);报表 3 删除"接口待提供"空态;报表 4 以 1 次 D 接口替代 findPage 全量翻页与全部前端聚合。