xiongxing 2 недель назад
Родитель
Сommit
4539fc1612

+ 37 - 0
src/api/elderly/elder/notice-customer/index.ts

@@ -0,0 +1,37 @@
+import request from '@/config/axios'
+
+/**
+ * 探访公告(notice-customer)
+ *
+ * 说明:每个院区(租户)可自定义公告内容,小程序端通过接口获取并展示。
+ * 接口前缀统一在下方 API_PREFIX 维护,如后端实际路径不同,改这一处即可。
+ */
+const API_PREFIX = 'elderly/notice-customer'
+
+/** 探访公告 VO */
+export interface NoticeCustomerVO {
+  id?: number
+  tenantId?: number
+  /** 公告内容,各条以 \n 换行 */
+  notice?: string
+  createTime?: string
+  updateTime?: string
+  creator?: string
+  updater?: string
+}
+
+/**
+ * 查询指定院区的探访公告
+ * @param tenantId 院区(租户)id
+ */
+export const getNoticeCustomer = async (tenantId: number) => {
+  const data = await request.get({ url: `${API_PREFIX}/get`, params: { tenantId } })
+  return (data || {}) as NoticeCustomerVO
+}
+
+/**
+ * 保存指定院区的探访公告(存在 id 走更新,否则新增)
+ */
+export const saveNoticeCustomer = (data: NoticeCustomerVO) => {
+  return request.post({ url: `${API_PREFIX}/save`, data })
+}

+ 359 - 0
src/api/elderly/elder/notice-customer/notice-customer-api.md

@@ -0,0 +1,359 @@
+# 院区「探访公告」后端接口文档
+
+- 版本:v1.0
+- 日期:2026-09-21
+- 状态:**待评审 / 供后端开发**(前端已按此契约完成编码,如路径或字段需调整请同步告知前端)
+- 适用端:Web 管理后台(admin-api) + 家属端小程序(app-api)
+
+---
+
+## 1. 背景与目标
+
+家属端小程序「探访」页面需要展示一段**院区自定义的探访公告**(探视时间、探视须知、注意事项等)。
+
+- 公告内容由**各院区在 Web 管理后台自行维护**,每个院区维护一份。
+- 保存后小程序端实时读取并展示,**无需审核、无历史版本**。
+- 公告为纯文本,按换行符拆分为多条展示。
+- 小程序端公告按 `noticeType` 区分:`APPOINTMENT` 为本文新增的院区自定义公告(后台可维护);`VISIT` 为原有系统固定数据,**本次不改动、保持现状**。
+
+---
+
+## 2. 名词约定
+
+| 名词 | 含义 |
+| --- | --- |
+| 院区 | 系统中的机构/租户,即 `tenant_id`。集团账号下有多个院区,普通院区账号仅有一个 |
+| 探访公告 | 本文描述的业务对象,对应表 `elderly_notice_customer` |
+| 后台接口 | 前缀 `/admin-api`,Web 管理后台调用,需登录鉴权 |
+| C 端接口 | 前缀 `/app-api`,家属端小程序调用 |
+
+---
+
+## 3. 接口清单
+
+| 编号 | 名称 | 方法 | 路径 | 调用方 |
+| --- | --- | --- | --- | --- |
+| API-1 | 查询院区探访公告 | GET | `/admin-api/elderly/notice-customer/get` | Web 后台 |
+| API-2 | 保存院区探访公告(新增/更新) | POST | `/admin-api/elderly/notice-customer/save` | Web 后台 |
+| API-3 | 小程序获取探访公告(按类型) | GET | `/app-api/elderly/notice-customer/get` | 家属端小程序 |
+
+> API-3 通过 `noticeType` 区分场景:
+> - `noticeType=APPOINTMENT`:走院区自定义公告(即本文第 4 章的表数据,由后台维护);
+> - `noticeType=VISIT`:维持现状,返回系统固定数据,不读该表。
+>
+> 接口路径前缀沿用项目现有 elderly 模块风格(如 `/elderly/visit/appointment/page`)。若后端命名规范不同,请统一告知,前端改一处常量即可:`src/api/elderly/elder/notice-customer/index.ts` 中的 `API_PREFIX`。
+
+---
+
+## 4. 数据字典
+
+### 4.1 表结构(MySQL)
+
+```sql
+CREATE TABLE `elderly_notice_customer` (
+  `id`          bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `tenant_id`   bigint       NOT NULL                COMMENT '院区(租户)id',
+  `notice`      text         NULL                    COMMENT '公告内容,纯文本,换行符 \n 分隔多条',
+  `creator`     varchar(64)  DEFAULT ''              COMMENT '创建者',
+  `create_time` datetime     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updater`     varchar(64)  DEFAULT ''              COMMENT '更新者',
+  `update_time` datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted`     bit(1)       DEFAULT b'0'            COMMENT '逻辑删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_tenant_id` (`tenant_id`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='院区探访公告';
+```
+
+### 4.2 字段说明
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `id` | Long | 是 | 主键,保存时为空=新增,有值=更新 |
+| `tenantId` | Long | 是 | 院区(租户)id,唯一约束:一个院区仅一条公告 |
+| `notice` | String | 否 | 公告内容,纯文本,最大 2000 字符;空串表示清空公告。对应 C 端 `noticeType=APPOINTMENT` 返回的内容 |
+| `createTime` | LocalDateTime | - | 创建时间 |
+| `updateTime` | LocalDateTime | - | 更新时间 |
+| `creator` / `updater` | String | - | 创建人 / 更新人 |
+
+---
+
+## 5. 接口详情
+
+### 5.1 API-1 查询院区探访公告
+
+```
+GET /admin-api/elderly/notice-customer/get
+Content-Type: application/x-www-form-urlencoded(query)
+```
+
+**请求参数**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| `tenantId` | query | Long | 是 | 院区 id |
+
+**响应**(`CommonResult<NoticeCustomerRespVO>`)
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | Long | 主键;**该院区未配置公告时为 null** |
+| `tenantId` | Long | 院区 id |
+| `notice` | String | 公告内容;无配置时为 null 或空串 |
+| `createTime` | String | yyyy-MM-dd HH:mm:ss |
+| `updateTime` | String | yyyy-MM-dd HH:mm:ss |
+
+**响应示例(已配置)**
+
+```json
+{
+  "code": 0,
+  "data": {
+    "id": 12,
+    "tenantId": 195,
+    "notice": "探视时间:每日 09:00-11:30、14:30-17:00\n每位老人每日限 2 位访客\n请配合前台登记并佩戴口罩",
+    "createTime": "2026-09-21 10:12:33",
+    "updateTime": "2026-09-21 15:40:02"
+  },
+  "msg": ""
+}
+```
+
+**响应示例(未配置)**
+
+```json
+{ "code": 0, "data": null, "msg": "" }
+```
+
+> 未配置时请返回 `code=0` + `data=null`(或 `data` 中 `notice` 为空),**不要返回业务错误码**,前端会展示空内容等待编辑。
+
+---
+
+### 5.2 API-2 保存院区探访公告
+
+```
+POST /admin-api/elderly/notice-customer/save
+Content-Type: application/json
+```
+
+**请求体**(`NoticeCustomerSaveReqVO`)
+
+| 字段 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| `id` | Long | 否 | - | 有值则更新,无值则按 `tenantId` 做 upsert |
+| `tenantId` | Long | 是 | `@NotNull` | 院区 id |
+| `notice` | String | 是 | `@Size(max = 2000)` | 公告内容;可为空串(清空公告) |
+
+**请求示例**
+
+```json
+{
+  "id": 12,
+  "tenantId": 195,
+  "notice": "探视时间:每日 09:00-11:30、14:30-17:00\n每位老人每日限 2 位访客"
+}
+```
+
+**响应**(`CommonResult<Long>`,返回公告主键 id)
+
+```json
+{ "code": 0, "data": 12, "msg": "" }
+```
+
+---
+
+### 5.3 API-3 小程序获取探访公告
+
+```
+GET /app-api/elderly/notice-customer/get?tenantId=195&noticeType=APPOINTMENT
+```
+
+**请求参数**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| `tenantId` | query | Long | 是 | 院区 id;若小程序已通过登录态确定院区,可改为从 token 中解析(见「待确认」) |
+| `noticeType` | query | String | 是 | 公告类型枚举:`APPOINTMENT` / `VISIT` |
+
+**`noticeType` 枚举与数据来源**
+
+| 值 | 含义 | 数据来源 | 行为 |
+| --- | --- | --- | --- |
+| `APPOINTMENT` | 预约(探访预约)公告 | **院区自定义**:读 `elderly_notice_customer` 表(后台维护) | 按 `tenantId` 查询该院区公告,返回维护内容 |
+| `VISIT` | 探访公告 | **系统固定数据**(后端固定返回,不读该表) | **保持现状不变**,与 `tenantId`、后台维护内容无关 |
+
+**处理逻辑**
+
+1. `noticeType = APPOINTMENT`:按 `tenantId` 查询 `elderly_notice_customer`,返回该院区维护的公告。
+2. `noticeType = VISIT`:**沿用现有固定的返回数据,本次不做任何改动**(不查 `elderly_notice_customer`,忽略院区配置)。
+3. `noticeType` 缺失或非法值:返回 `400` 参数错误。**不要默认按 `APPOINTMENT` 处理**,避免前端漏传时误展示自定义内容。
+
+**响应结构**(两种类型结构一致,仅 `notice` 内容来源不同)
+
+```json
+{
+  "code": 0,
+  "data": {
+    "notice": "探视时间:每日 09:00-11:30、14:30-17:00\n每位老人每日限 2 位访客"
+  },
+  "msg": ""
+}
+```
+
+**响应示例(`noticeType=APPOINTMENT`,院区已配置)**
+
+```json
+{
+  "code": 0,
+  "data": {
+    "notice": "探视时间:每日 09:00-11:30、14:30-17:00\n每位老人每日限 2 位访客"
+  },
+  "msg": ""
+}
+```
+
+**响应示例(`noticeType=APPOINTMENT`,院区未配置)**
+
+```json
+{ "code": 0, "data": { "notice": "" }, "msg": "" }
+```
+
+**响应示例(`noticeType=VISIT`)**
+
+```json
+{
+  "code": 0,
+  "data": {
+    "notice": "(沿用现有固定文案,内容与现状完全一致)"
+  },
+  "msg": ""
+}
+```
+
+> `notice` 为 null 或空串时,小程序端隐藏对应公告模块(`APPOINTMENT` 且院区未配置即为此情况)。
+
+---
+
+## 6. 通用响应结构
+
+沿用项目 yudao/若依约定:
+
+```json
+{ "code": 0, "data": {}, "msg": "" }
+```
+
+`code = 0` 表示成功,非 0 为业务/系统错误。
+
+---
+
+## 7. 业务与校验规则(后端必须实现)
+
+1. **一个院区仅一条公告**:`tenant_id` 唯一索引;`save` 接口实现 **upsert**
+   - `id` 有值 → 按 id 更新(需校验 id 对应记录的 `tenant_id` 与入参一致)
+   - `id` 无值 → 按 `tenantId` 查询,存在则更新,不存在则新增
+2. **越权校验**:`tenantId` 必须在当前登录账号可见的机构范围内(集团账号取其关联机构列表;普通院区账号只能操作自己院区),不在范围内返回 `code=403`「无权操作该院区」。**不可信任前端传参**。
+3. **长度校验**:`notice` 最长 **2000** 字符,超出返回参数错误。
+4. **清空公告**:`notice` 传空串视为清空(前端已强制非空,此处仅说明后端不做非空强校验亦可,请二选一并保持一致)→ **建议后端允许空串**,避免院区无法下架公告。
+5. **展示规则**:公告为纯文本,`\n` 视为一条;小程序端与后台预览均按行拆分渲染,**后端不做富文本解析**。
+6. **租户隔离**:沿用现有租户体系;查询/保存均带租户条件,使用逻辑删除 `deleted`。
+7. **审计字段**:新增写 `creator/createTime`,更新写 `updater/updateTime`。
+8. **无需审批流**:保存即生效,C 端实时读取(如 C 端有缓存,需保证 5 分钟内失效或提供刷新)。
+9. **C 端按 `noticeType` 分支**:
+   - `APPOINTMENT` → 院区自定义公告(本文维护的数据),与后台配置联动;
+   - `VISIT` → 系统固定数据,**保持现状**,不读表、不受后台维护影响;
+   - 后台接口(API-1 / API-2)只维护 `APPOINTMENT` 对应的公告,无需传 `noticeType`。
+
+---
+
+## 8. 权限标识建议
+
+| 标识 | 说明 | 前端位置 |
+| --- | --- | --- |
+| `elderly:notice-customer:query` | 查看院区探访公告 | 页面进入 / 重新加载 |
+| `elderly:notice-customer:save` | 保存院区探访公告 | 「保存」按钮 |
+
+> 若后端不配置权限,前端页面默认对所有可见该菜单的角色开放;如需按钮级控制请回复,前端补充 `v-hasPermi`。
+
+---
+
+## 9. 错误码
+
+| code | 含义 | 触发场景 |
+| --- | --- | --- |
+| 0 | 成功 | - |
+| 400 | 参数错误 | `tenantId` 为空 / `notice` 超 2000 字符 / `noticeType` 缺失或非枚举值 |
+| 401 | 未登录 | token 失效 |
+| 403 | 无权限 | 操作了非本账号可见的院区 |
+| 404 | 数据不存在 | 按 `id` 更新时记录不存在或已删除 |
+| 500 | 系统异常 | - |
+
+---
+
+## 10. 后端实现骨架(yudao 风格,供参考)
+
+```java
+@RestController
+@RequestMapping("/elderly/notice-customer")
+@Validated
+public class NoticeCustomerController {
+
+    @Resource
+    private NoticeCustomerService noticeCustomerService;
+
+    @GetMapping("/get")
+    @PreAuthorize("@ss.hasPermission('elderly:notice-customer:query')")
+    public CommonResult<NoticeCustomerRespVO> getNoticeCustomer(@RequestParam("tenantId") Long tenantId) {
+        // 1. 校验 tenantId 在当前账号可见机构范围内
+        return success(noticeCustomerService.getByTenantId(tenantId));
+    }
+
+    @PostMapping("/save")
+    @PreAuthorize("@ss.hasPermission('elderly:notice-customer:save')")
+    public CommonResult<Long> saveNoticeCustomer(@RequestBody @Valid NoticeCustomerSaveReqVO reqVO) {
+        // 1. 校验 tenantId 可见范围  2. upsert  3. 返回 id
+        return success(noticeCustomerService.saveNoticeCustomer(reqVO));
+    }
+}
+```
+
+```java
+public class NoticeCustomerSaveReqVO {
+    private Long id;
+    @NotNull(message = "院区不能为空")
+    private Long tenantId;
+    @Size(max = 2000, message = "公告内容不能超过 2000 个字符")
+    private String notice;
+}
+
+public class NoticeCustomerRespVO {
+    private Long id;
+    private Long tenantId;
+    private String notice;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}
+```
+
+---
+
+## 11. 前端现状(已按本文实现)
+
+| 内容 | 位置 |
+| --- | --- |
+| 接口文档(本文) | `src/api/elderly/elder/notice-customer/notice-customer-api.md` |
+| 接口封装 | `src/api/elderly/elder/notice-customer/index.ts`(`getNoticeCustomer` / `saveNoticeCustomer`) |
+| 页面 | `src/views/elderly/elder/notice-customer/index.vue`(院区选择 + 公告编辑 + 小程序预览) |
+| 院区选择规则 | 集团账号(`orgTenant.multiple = true`)可切换院区;普通院区账号固定为当前院区,选择器禁用 |
+
+---
+
+## 12. 待确认问题
+
+1. 接口路径与表名:`elderly/notice-customer`、`elderly_notice_customer` 是否 OK?
+2. **公告是否保持纯文本 + 换行展示**,还是未来需要富文本/多条结构化(标题 + 内容)?
+3. C 端接口 `tenantId` 由前端传参,还是由登录态(token)解析?
+4. 是否需要「公告生效时间 / 上下架开关」?
+5. 是否需要操作日志或变更记录?
+6. 权限标识是否按第 8 节配置?
+7. `notice` 是否允许为空串(清空公告)?
+8. `noticeType` 枚举值是否就定为 `APPOINTMENT` / `VISIT`(大小写敏感,需与小程序端完全一致)?
+9. `noticeType=VISIT` 的固定数据目前维护在何处(代码常量 / 配置文件 / 数据字典)?是否也需要后续纳入后台维护?

+ 175 - 0
src/views/elderly/elder/notice-customer/index.vue

@@ -0,0 +1,175 @@
+<template>
+  <ContentWrap>
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      :inline="true"
+      label-width="90px"
+    >
+      <TenantSelect
+        v-model="queryParams.tenantIds"
+        single
+        :disabled="!canSwitchTenant"
+        placeholder="请选择机构名称"
+        prop="tenantIds"
+        @change="handleQuery"
+      />
+      <el-form-item v-if="!canSwitchTenant">
+        <span class="text-12px text-gray-500">当前账号已绑定院区,院区不可切换</span>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleQuery">
+          <Icon icon="ep:search" class="mr-5px" /> 查询
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <div class="mb-15px flex items-center justify-between">
+      <div class="flex items-center">
+        <span class="text-16px font-bold">探访公告内容</span>
+        <span class="ml-10px text-12px text-gray-500">保存后小程序端将展示该内容</span>
+      </div>
+      <div>
+        <el-button @click="handleQuery">
+          <Icon icon="ep:refresh" class="mr-5px" /> 重新加载
+        </el-button>
+        <el-button type="primary" :loading="saveLoading" @click="handleSave">
+          <Icon icon="ep:select" class="mr-5px" /> 保存
+        </el-button>
+      </div>
+    </div>
+
+    <el-input
+      v-loading="loading"
+      v-model="formData.notice"
+      type="textarea"
+      :rows="18"
+      maxlength="2000"
+      show-word-limit
+      placeholder="请输入探访公告内容,每条建议单独一行(换行即视为一条)"
+    />
+  </ContentWrap>
+
+  <ContentWrap>
+    <div class="mb-15px text-16px font-bold">小程序预览</div>
+    <div class="preview-wrap">
+      <div class="preview-title">探访公告</div>
+      <div class="preview-content">
+        <template v-if="previewLines.length">
+          <p v-for="(line, index) in previewLines" :key="index" class="preview-line">
+            {{ line }}
+          </p>
+        </template>
+        <div v-else class="text-gray-400">暂无公告内容</div>
+      </div>
+    </div>
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import { getNoticeCustomer, saveNoticeCustomer } from '@/api/elderly/elder/notice-customer'
+import { useUserStore } from '@/store/modules/user'
+
+defineOptions({ name: 'NoticeCustomer' })
+
+const userStore = useUserStore()
+const message = useMessage()
+
+const loading = ref(false)
+const saveLoading = ref(false)
+
+const queryParams = reactive({
+  tenantIds: userStore.orgTenantId || []
+})
+const formData = reactive({
+  id: undefined as number | undefined,
+  notice: ''
+})
+
+// 预览:按换行拆分为多条
+const previewLines = computed(() =>
+  (formData.notice || '')
+    .split('\n')
+    .map((item) => item.trim())
+    .filter((item) => item)
+)
+
+/** 当前选中院区 */
+const currentTenantId = computed(() => queryParams.tenantIds?.[0])
+
+/** 是否集团账号:集团账号可切换院区,普通院区账号固定为当前院区 */
+const canSwitchTenant = computed(() => !!userStore.getOrgTenant?.multiple)
+
+/** 查询院区公告 */
+const getDetail = async () => {
+  if (!currentTenantId.value) {
+    message.warning('请先选择院区')
+    return
+  }
+  loading.value = true
+  try {
+    const data = await getNoticeCustomer(currentTenantId.value)
+    formData.id = data?.id
+    formData.notice = data?.notice || ''
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 切换院区 / 查询 */
+const handleQuery = () => {
+  getDetail()
+}
+
+/** 保存 */
+const handleSave = async () => {
+  if (!currentTenantId.value) {
+    message.warning('请先选择院区')
+    return
+  }
+  if (!formData.notice || !formData.notice.trim()) {
+    message.warning('公告内容不能为空')
+    return
+  }
+  saveLoading.value = true
+  try {
+    await saveNoticeCustomer({
+      id: formData.id,
+      tenantId: currentTenantId.value,
+      notice: formData.notice
+    })
+    message.success('保存成功')
+    await getDetail()
+  } finally {
+    saveLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+.preview-wrap {
+  width: 320px;
+  padding: 15px;
+  background: #fafafa;
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
+}
+.preview-title {
+  margin-bottom: 10px;
+  font-weight: bold;
+  text-align: center;
+}
+.preview-line {
+  margin: 6px 0;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #333;
+}
+</style>