feat(api): 完善家谱系统API客户端契约

- 实现家谱管理相关方法,包括创建、详情、概览、我的家谱和选项查询
- 添加家谱加入申请功能,支持申请、审核、取消和待审核列表操作
- 集成通知详情获取方法和通知ID安全验证机制
- 完善功德记录、谱文、相册、视频、祭祀活动的完整CRUD操作契约
- 实现家谱成员管理功能,包含成员列表、更新、移除和转让所有者操作
- 优化路径ID验证逻辑,拒绝不安全的数值ID并提供明确错误提示
- 更新测试用例以验证所有新增API方法的路径和请求体白名单机制
This commit is contained in:
fizzleaf
2026-07-29 16:57:27 +08:00
parent 59a72fb22b
commit fb1743aa2a
74 changed files with 13166 additions and 1320 deletions
+498 -51
View File
@@ -156,13 +156,39 @@
| C11 | `completed` 只有 string 类型,无枚举 | 不能决定复选框提交 `0/1`、true/false 或其他值 | Apifox 增加枚举和中文含义 |
| C12 | `feedType``recordType``registerSource` 等无枚举 | 无法判断文本输入还是选择项 | 明确为自由文本,或补全枚举 |
| C13 | 多个日期字段没有 format/时区规则 | 页面值和后端解析可能不一致 | 明确 date 或 date-time,并统一时区 |
| C14 | 只有配额,没有我的家谱/成员入口 | 无法取得 `genealogyId`、成员或受邀用户选项 | 增加 PC 家谱列表/选项和成员选项接口,或明确外部上下文契约 |
| C14 | 已由后端 PC 实链接决 | `GET /genealogy/pc/genealogies/mine``/options` 返回完整 `AppGenealogyVo``genealogyId` 从响应取得 | 前端只从真实列表选择,不允许手填 ID |
| C15 | `bindingMode=SPECIFIED` 需要 `appUserId`,但无业务用户选项接口 | 不能提供合法选择器 | 增加家谱成员/业务用户选项接口 |
| C16 | 邀约的 `inviteeUserIds` 没有选项接口 | 不能让管理员选择受邀人 | 增加同一家谱成员选项接口 |
| C17 | 视频、成长、亲友、备忘、功德、通知响应未展开 | 无法安全展示、编辑或单条操作 | 分别增加资源 View 和列表/详情响应 |
| C18 | 更新接口未说明缺省、null、空串语义 | 可选字段可能无法清空 | Apifox 对每个可清空字段写明更新规则 |
| C19 | 多数 View 没有 `canEdit` / `canDelete` 等权限字段 | 页面无法可靠决定按钮可见性 | 增加能力字段,或提供明确且可实现的角色规则 |
| C20 | 文章、相册、祭祀只有删除操作 | 没有真实 ID 来源和完整资源流程 | 补全 PC 列表/详情/新增/修改后才开放页面 |
| C21 | 家族圈动态只返回 `mediaOssIds`,PC 没有 OSS ID 批量解析/访问 URL 接口 | 已上传图片只能统计数量,不能在列表和详情安全回显缩略图 | PC 增加按 OSS ID 返回授权 URL、文件名和媒体类型的接口,或让动态 View 直接返回媒体对象数组 |
| C22 | 停用动态不会出现在普通列表,普通详情接口也拒绝读取 | 管理员可以停用,但停用后无法从 PC 页面重新读取、恢复或验证最终状态 | 增加包含停用记录的管理列表/详情,或增加独立的状态恢复接口 |
| C23 | `VideoVo` 只返回 `coverOssId` / `videoOssId`,没有 PC 文件访问 URL 或解析接口 | 可以维护视频记录,但不能安全播放视频或回显封面 | 视频 View 直接返回授权 URL,或增加按 OSS ID 获取文件访问地址的 PC 接口 |
| C24 | 视频列表只返回正常记录,详情拒绝读取停用记录 | `status=1` 后无法从页面重新读取和恢复 | 增加视频 management 列表/详情,或增加独立状态恢复接口 |
| C25 | `GrowthRecordVo` 只返回附件 `mediaOssIds`,没有 PC 文件访问 URL 或解析接口 | 可以维护附件引用,但不能安全预览、下载或回显文件名 | 成长记录 View 返回附件对象,或增加按 OSS ID 获取授权地址、文件名和类型的 PC 接口 |
| C26 | 成长记录列表只返回正常记录,详情拒绝读取停用记录 | `status=1` 后无法写后重读、恢复或再次编辑 | 增加成长记录 management 列表/详情,或增加独立状态恢复接口 |
| C27 | `giftAmount` 只有 `number` / `BigDecimal` 类型,没有精度、范围和正负规则 | 前端不能自行限定两位小数、最大金额或禁止负数 | Apifox 明确 scale、precision、minimum/maximum 和负数业务含义 |
| C28 | `RelativeRecordVo` 只返回附件 `mediaOssIds`,没有 PC 文件访问 URL 或解析接口 | 可以维护附件引用,但不能安全预览、下载或回显文件名 | 亲友记录 View 返回附件对象,或增加按 OSS ID 获取授权地址、文件名和类型的 PC 接口 |
| C29 | 亲友记录列表只返回正常记录,详情拒绝读取停用记录 | `status=1` 后无法写后重读、恢复或再次编辑 | 增加亲友记录 management 列表/详情,或增加独立状态恢复接口 |
| C30 | `MemoVo` 只返回附件 `mediaOssIds`,没有 PC 文件访问 URL 或解析接口 | 可以维护附件引用,但不能安全预览、下载或回显文件名 | 备忘录 View 返回附件对象,或增加按 OSS ID 获取授权地址、文件名和类型的 PC 接口 |
| C31 | 备忘录列表只返回正常记录,详情拒绝读取停用记录 | `status=1` 后无法写后重读、恢复或再次编辑 | 增加备忘录 management 列表/详情,或增加独立状态恢复接口 |
| C32 | 功德金额只有 `number` / `BigDecimal` 类型,没有精度、范围和正负规则 | 前端不能自行限定两位小数、最大金额或禁止负数 | Apifox 明确 scale、precision、minimum/maximum 和负数业务含义 |
| C33 | 功德记录列表只返回正常记录,详情拒绝读取停用记录 | `status=1` 后无法写后重读、恢复或再次编辑 | 增加功德记录 management 列表/详情,或增加独立状态恢复接口 |
| C34 | YAML `NotificationView` 声明了家谱编号/名称、发送人昵称/手机号和业务摘要,但 PC `toVo` 未填充这些字段 | 页面可能长期显示空白,导出契约与真实 PC 响应不一致 | 后端补齐字段,或从 PC Schema 删除不会返回的字段并明确隐私策略 |
| C35 | PC 谱文后端列表支持可选 `categoryId`,YAML 未声明该 query,且没有文章分类选项接口 | 前端无法可靠提供分类筛选或编辑分类 | YAML 补齐 query,并提供当前家谱文章分类选项接口 |
| C36 | `ArticleVo` 只返回 `coverOssId`,没有 PC 文件访问 URL | 谱文可维护封面引用,但不能安全回显封面图片 | Article View 返回封面 URL,或增加 OSS ID 授权解析接口 |
| C37 | 谱文列表只返回正常记录,详情拒绝读取停用记录 | `status=1` 后无法写后重读或恢复 | 增加谱文 management 列表/详情,或独立状态恢复接口 |
| C38 | `AlbumVo` / `AlbumPhotoVo` 只返回 `coverOssId` / `ossId`,没有 PC 文件访问 URL | 相册和照片记录可维护,但不能安全回显封面或照片内容 | View 返回授权 URL,或增加 OSS ID 授权解析接口 |
| C39 | 相册和照片列表只返回正常记录,且没有停用记录的 management 入口 | `status=1` 后无法从页面重读、恢复或再次编辑 | 增加相册/照片 management 列表,或独立状态恢复接口 |
| C40 | `CeremonyVo` 只返回 `coverOssId`,没有 PC 文件访问 URL | 活动记录可维护,但不能安全回显封面 | View 返回封面授权 URL,或增加 OSS ID 授权解析接口 |
| C41 | 活动列表和详情只允许读取正常记录 | `status=1` 后无法从页面重读、恢复或再次编辑 | 增加活动 management 列表/详情,或独立状态恢复接口 |
| C42 | `CeremonyGiftBody.giftAmount` 的 YAML 没有精度和最大值,后端实际拒绝负数 | 页面可按后端约束拒绝负数,但不能自行限定小数位或最大金额 | YAML 补充 minimum、scale、precision 和 maximum |
| C43 | YAML 成员更新角色枚举含 `owner/visitor`PC 后端 DTO 只接受 `admin/editor/member` | 按 YAML 展示会产生必然失败的写入 | YAML 收紧枚举;本轮前端按 PC 后端校验冻结为三种可分配角色 |
| C44 | `GenealogyMemberVo` 声明昵称、手机号、家谱和世系人物名称等字段,PC `toVo` 实际只填充 ID、成员名、角色、关系、来源和时间 | 列表部分说明可能长期为空,手机号还有隐私风险 | 后端补齐非敏感展示字段并删除手机号,或从 PC Schema 删除不会返回的字段 |
| C45 | PC 没有世系关系解除接口 | 页面无法合法解除父母、配偶、子女或兄弟姐妹关系 | 后端增加带权限和关系完整性校验的解除接口 |
| C46 | 成员更新只在 `lineagePersonId != null` 时写入,没有解除成员与世系人物绑定的语义 | 下拉留空只能表示“不修改”,不能完成解绑 | 增加明确解绑接口或约定可验证的 null/空值语义 |
当前复核状态:
@@ -175,8 +201,28 @@
| C01/C08 | 前端按后端 PC 实链解决 | 只调用 `/genealogy/pc/region/*`;列表按 `RegionSelectVo``regionCode/regionName/regionLevel` 读取,不保留公共路径 fallback |
| C09 | 前端按后端 PC 实链解决 | `SysOssResumableInitVo` 明确提供 `uploadId/instant/ossId/url/fileName/uploadedChunks`,完成响应使用 `SysOssUploadVo` |
| C10 | 前端边界解决 | 后端初始化 `ossId` 为 Long、完成响应为 string;浏览器统一转十进制字符串,隐藏回填且不转 `Number` |
| C11(备忘录) | 本轮按后端 PC 实链解决 | YAML 仍只有 stringPC `AppMemoServiceImpl` 明确校验 `completed` 只能为 `0/1`,含义为否/是,前端冻结为未完成/已完成 |
| C13 | 后续日期模块仍阻断 | 本阶段上传与区划响应不消费日期;其他模块仍需逐字段核验 Java 日期类型 |
| C17(成长记录) | 本轮按后端 PC 实链解决 | YAML 响应仍为泛型;PC `PcGrowthRecordController` 明确返回 `GrowthRecordVo`,前端只冻结该 PC 响应,不读取 APP 路径 |
| C17(亲友记录) | 本轮按后端 PC 实链解决 | YAML 响应仍为泛型;PC `PcRelativeRecordController` 明确返回 `RelativeRecordVo`,前端只冻结该 PC 响应,不读取 APP 路径 |
| C17(备忘录) | 本轮按后端 PC 实链解决 | YAML 响应仍为泛型;PC `PcMemoController` 复用的 PC 支持层明确返回 `MemoVo`,前端只冻结该 PC 响应,不读取 APP 路径 |
| C17(功德记录) | 本轮按后端 PC 实链解决 | YAML 响应仍为泛型;PC `PcMeritRecordController` 复用的 PC 支持层明确返回 `MeritRecordVo`,前端只冻结该 PC 响应,不读取 APP 路径 |
| C17(消息通知) | 本轮按 YAML 详情 DTO 和后端 PC 实链解决 | 详情为完整 `NotificationView`;列表导出仍是泛型数组,但 PC 控制器实际返回同一 `NotificationVo`。前端只接受直接数组,不读取 APP 路径 |
| C20(谱文) | 本轮按后端 PC 实链解决 | PC 控制器实际具备列表、详情、新增、修改、删除并返回 `ArticleVo`;页面不再只开放删除 |
| C18 | 阻断 | 未说明可选更新字段的省略、`null`、空串语义 |
| C34 | 阻断非核心展示 | PC 实际未填充 `genealogyNo/genealogyName/senderNickName/senderPhone/bizSummary`;页面兼容空值并始终隐藏手机号,核心通知读取和已读闭环不依赖这些字段 |
| C35 | 阻断分类能力 | 本轮不发送 YAML 未声明的 `categoryId` query,也不提供手填分类 ID |
| C36/C37 | 阻断非核心展示/停用管理 | 本轮不猜封面 URL,并固定提交 `status=0`,避免产生不可重读谱文 |
| C20(相册/照片) | 本轮按后端 PC 实链接决 | PC 控制器具备相册列表、新增、修改、删除和照片列表、新增、删除;页面只从响应取得稳定 ID |
| C38/C39 | 阻断非核心展示/停用管理 | 本轮不猜文件 URL,并固定提交 `status=0`,避免产生不可重读的相册或照片 |
| C20(祭祀活动/祭品) | 本轮按后端 PC 实链接决 | PC 控制器具备活动完整 CRUD 和祭品列表、新增、删除;页面只使用响应中的稳定 ID |
| C16 | 本轮按后端 PC 实链接决 | `members/options` 返回同一家谱成员及真实 `appUserId`;管理员邀约名单只允许选择正常且已绑定账号的成员 |
| C15 | 本轮按后端 PC 实链接决 | `members/options` 是当前家谱真实业务账号的唯一选择源;`SPECIFIED` 只提交正常且已绑定账号成员的 `appUserId` |
| C40/C41 | 阻断非核心展示/停用管理 | 本轮不猜封面 URL,并固定提交 `status=0`,避免产生不可重读活动 |
| C42 | 阻断金额精度/上限 | 前端只执行后端已确认的非负校验,不自行限制小数位和最大金额 |
| C43 | 前端按后端 PC 实链收紧 | 成员角色选择器只允许 `admin/editor/member`;不发送 YAML 中后端拒绝的 `owner/visitor` |
| C44 | 阻断非核心 enrichment | 页面兼容未填充名称,始终隐藏成员、邀请人手机号和内部用户 ID;核心成员管理不依赖这些字段 |
| C45/C46 | 阻断 | 页面不提供世系关系解除,也不把空 `lineagePersonId` 解释为解绑 |
阶段 0 验收标准:
@@ -376,12 +422,13 @@
`-1` 显示为“不限”,不能参与普通减法或显示负数。
配额响应不包含 `genealogyId`,不能替代“我的家谱”。在 C14 完成前
配额响应不包含 `genealogyId`,不能替代“我的家谱”。当前由 `GET /genealogy/pc/genealogies/mine` 返回完整 `AppGenealogyVo`
- 所有家谱内页面缺少真实 `genealogyId` 时必须阻止请求
- 不显示静态家谱卡片作为真实数据
- 不从 APP 接口获取家谱
- 不允许用户手工输入家谱 ID
- `profile-families.html` 从真实列表生成家谱入口
- `genealogyId` 在浏览器边界保持字符串,并由 `profile-common.js` 统一读取和传播
- 所有家谱内页面缺少真实 `genealogyId` 时必须阻止请求并回到家谱选择入口
-显示静态家谱卡片,不从 APP 接口获取家谱,不允许用户手工输入家谱 ID
- 切换家谱通过进入新的带 `genealogyId` URL 完成整页导航,旧页面模块状态不会复用。
### 7.5 行政区划
@@ -455,6 +502,17 @@
4. 点赞、取消、评论、删除成功后以接口返回和重新读取结果校正计数。
5. 删除有回复的评论后保留占位,不从 DOM 直接移除整棵回复。
2026-07-28 家族圈实施状态:
- `ApiClient` 的 14 个家族圈 PC 操作已由契约测试锁定;页面首屏只调用 `/feeds/page`,评论和回复分别调用各自的 `/page`
- 已新增 `profile-feed-detail.html`,以 `genealogyId + feedId` 形成稳定详情深链;列表、详情和编辑链接都保留字符串 ID。
- 列表和详情消费 `AppFamilyFeedVo` 的发布人、点赞状态与计数、评论计数、置顶、状态和时间字段;评论消费 `FamilyFeedCommentVo` 的归属、父评论、层级、删除占位、回复计数和时间字段。
- `GET /genealogy/pc/genealogies/{genealogyId}``canEditContent/canManage` 控制编辑和高级设置,`GET /genealogy/pc/auth/profile``userId` 结合响应中的发布人/评论人 ID 控制本人删除按钮;后端仍执行最终权限校验。
- 发布图片只允许文件选择;`mediaOssIds` 为隐藏的上传派生字段,多文件结果使用英文逗号连接,不允许手填 OSS ID。
- 新增、修改、点赞、取消点赞、评论和删除均有同资源动作锁;成功后重新读取详情、分页列表或评论分页以校正页面状态。
- `status=1` 使用后端定义的“停用”语义,不再显示为“草稿”。C21、C22 未解决前,图片缩略图回显和停用记录恢复仍属于后端阻断,不宣称这两项闭环完成。
- 已在真实 Chrome 登录态验证:当前账号的 `/genealogies/mine` 返回空列表;无家谱上下文时家族圈不发业务请求,所有发布入口统一改写到 `profile-families.html?next=profile-feed-edit.html`。发布表单实测为多文件选择、隐藏 OSS ID、普通用户隐藏且禁用管理字段。由于账号没有家谱,本轮不能进行真实动态列表/详情读取或写操作联调。
### 7.7 字辈谱
`profile-generation.html` 同时提供“单条维护”和“批量维护”。
@@ -486,6 +544,14 @@
普通展示列表使用正常字辈接口;管理页面使用 management 接口以包含停用项。
当前实现状态:
- 页面先读取家谱详情,以 `canEditContent` 决定调用正常列表还是 management 列表;无维护权限时不渲染新增、编辑、停用和批量维护控件,后端继续执行最终权限校验。
- `genealogyId` 只来自家谱上下文,`poemId` 只来自列表响应;所有业务 ID 在浏览器边界保持字符串,不提供手工 ID 输入。
- 单条新增、修改和状态切换成功后重新读取列表;批量维护严格执行 preview → 当前请求体签名确认 → save → 重新读取列表。
- 批量预览逐项校验 `action`、状态、必需的新旧文本和内部 ID,并核对四类汇总计数;勾选 `disableMissing` 时保存确认明确说明只停用后续世代、不删除历史。
- 已在真实 Chrome 登录态验证无家谱上下文分支:不发送字辈业务请求,新增按钮和批量面板隐藏,家谱上下文入口统一返回家谱选择页。当前账号家谱列表为空,因此正常/management 列表、单条写入和批量写入仍缺少真实家谱数据联调证据。
### 7.8 世系人物
全部操作集中在 `profile-tree.html`,详情和编辑可使用抽屉/弹窗,但所有字段都要有明确控件。
@@ -493,7 +559,7 @@
| 字段 | 必填性 | 类型 | 页面处理 |
| --- | --- | --- | --- |
| `bindingMode` | 必填 | S | NONE 不绑定、SELF 当前账号、SPECIFIED 指定账号 |
| `appUserId` | SPECIFIED 时必填 | S/I | 从真实业务用户选项选择;没有接口时禁用 SPECIFIED,禁止文本 ID |
| `appUserId` | SPECIFIED 时必填 | S/I | 从当前家谱 `members/options` 的正常且已绑定账号成员中选择;禁止文本 ID |
| `personNo` | 可选 | U/A | 高级设置可填;留空由服务端生成 |
| `name` | 必填 | U | 姓名 |
| `aliasName` | 可选 | U | 别名/曾用名 |
@@ -543,6 +609,19 @@
`LineagePersonTreeView` 补全前,不得继续依靠多个字段 fallback 猜树结构。
当前实现状态:
- 已接入 PC 世系树、普通列表、分页列表、人物选项、详情、新增、修改、逻辑停用,以及父母、子女、兄弟姐妹、配偶四类关系新增;成功写入后统一重新读取树、列表、分页和选项,返回稳定 `personId` 时再读取详情。
- 页面先读取家谱详情,`canEditContent` 只控制新增、编辑、停用和关系维护;树、列表、详情、筛选和分页保持只读可用,后端继续执行最终查看和编辑权限校验。
- `LineagePersonBody` 的全部字段均有明确来源:世代从正常字辈列表选择并自动回填字辈,父母从世系人物选项选择,头像通过统一上传组件产生隐藏 `avatarOssId`,所有业务 ID 在浏览器边界保持字符串且不可手填。
- 新增和编辑人物开放 `NONE``SELF``SPECIFIED``SPECIFIED` 选择器只消费当前家谱 `members/options`,过滤未绑定账号、停用成员和当前账号,不显示手机号,不允许手填 `appUserId`
- 编辑已绑定其他账号的人物时,用详情响应的 `appUserId` 精确匹配同一成员选项;真实选项不存在时不伪造回填,保存会被前端校验阻止。
- 分页查询已接入 `keyword``generation``personStatus``pageNum/pageSize`;详情展示家谱、绑定账号、父母、配偶、出生、逝世、安葬、人物状态、简介和备注等响应字段。
- DELETE 确认明确使用“停用”语义,并提示存在正常子女时后端会拒绝;页面没有伪造解除关系或物理删除。
- 已在真实 Chrome 登录态验证无家谱上下文分支:不发送世系业务请求,读区域显示选择家谱提示,新增、关系维护和完整表单隐藏,分页与配偶专用字段不误显示,所有家谱链接返回家谱选择页。当前账号家谱列表为空,因此真实树、列表、详情和写操作仍缺少有数据账号联调证据。
- C18 仍适用于本模块:可选字段清空语义未明确,本轮仅提交非空值,不宣称可选字段清空闭环。
- `SPECIFIED` 账号绑定相关聚焦测试 42/42 通过;真实账号页面验证统一留到阶段 6 收尾执行。
### 7.9 视频
计划新增 `profile-video-edit.html``profile-video.html` 负责列表。
@@ -560,7 +639,25 @@
流程:选视频 → 分片上传 → 得到 `videoOssId` → 可选封面上传 → 填标题说明 → 保存 → 重新读取详情。
当前视频列表、详情、新增和修改均返回泛型对象/列表。C17 完成前可以完成静态表单布局和 `ApiClient` 契约测试,但不得把真实 CRUD 页面标记为已完成。
YAML 的视频响应仍使用泛型 `ObjectResult` / `ListResult`,但后端已存在独立 `PcVideoController`,其 PC method/path 与 YAML 一致,并明确返回 `VideoVo`。根据本轮“后端源码可作为可靠补充契约”的约定,页面响应字段由该 PC 控制器及 `VideoVo` 冻结:
- I`videoId``genealogyId``coverOssId``videoOssId``publisherUserId`
- R`genealogyNo``genealogyName``surname``publisherNickName``publisherPhone``publishTime``viewCount``remark`
- U/R`videoTitle``videoDesc`
- F/A/R`durationSeconds`
- U/S/R`sortOrder`
- A/R`status`,当前固定提交 `0`
当前接入状态:
- `profile-video.html` 已接入正常视频列表、详情读取、权限化编辑/删除入口、空错状态和无家谱上下文分支;
- 新增 `profile-video-edit.html`,新增和修改只提交 `VideoBody` 七个字段;视频与封面都通过统一分片上传产生隐藏 OSS ID,不允许手填;
- 选择视频文件后由浏览器媒体元数据自动读取时长,`durationSeconds` 只读;
- 保存成功后使用稳定 `videoId` 重新读取详情,再返回列表;删除确认明确会释放视频和封面文件引用,删除后重读列表;
- 后端 `BigNumberSerializer` 对超出 JavaScript 安全整数范围的 `Long` 输出字符串,对安全整数输出 number;前端接受两种输入并统一保留为字符串;
- C23 未解决前只显示文件“已上传”,不猜测 OSS URL,不宣称播放或封面预览完成;
- C24 未解决前不开放 `status=1`,避免产生无法重新读取和恢复的停用视频;
- 2026-07-29 已在真实登录 Chrome 验证无家谱上下文:视频列表不发业务请求,发布入口及编辑表单保持隐藏,所有上下文链接返回家谱选择页,控制台无错误。当前账号没有家谱,真实列表、详情、上传和 CRUD 写入仍待有数据账号验证。
### 7.10 贺礼邀约与祭祀
@@ -583,6 +680,8 @@
“我的邀请”接口有完整响应,可独立实现接受/拒绝流程。管理端替换受邀人依赖 C16,且必须先有真实活动 ID。
当前接入状态:`profile-gift.html` 已使用 `GET /genealogy/pc/genealogies/ceremony-invitations/mine` 展示当前账号的邀请列表和响应内详情;仅 `PENDING` 邀请显示接受/拒绝,并向 `PUT /genealogy/pc/genealogies/{genealogyId}/ceremonies/{ceremonyId}/invitations/me` 提交唯一字段 `inviteStatus`,成功后重读列表。内部 ID 和坐标不直接展示,坐标仅用于生成地图链接。2026-07-29 使用真实账号联调,PC “我的邀请”接口成功返回空数组,页面空状态和鉴权正常;由于当前账号没有待响应邀请,真实接受/拒绝写入仍待有邀请数据时复核。后端仓库中的同组 `AppCeremonyController` 仍以 `/genealogy/app/genealogies` 为基础路径,与冻结 YAML 的 `/genealogy/pc` 不一致,但线上 PC mine 路径已实际可用,前端继续以 YAML 为唯一契约,不回退 APP 路径。
当前没有祭祀活动列表、详情、新增、修改和献礼列表/新增接口。现有 `profile-gift-edit.html` 中的 `ceremonyType``ceremonyTitle``ceremonyTime``location``ceremonyDesc``coverOssId` 不属于任何现有 PC 写入 DTO,必须继续保持禁用,不得向猜测路径提交。
两条删除接口只能在后端补齐列表和详情、产生稳定 ID 后开放:
@@ -605,9 +704,27 @@
| `remindTime` | 可选 | S | 日期时间控件 |
| `mediaOssIds` | 可选 | F/I | 多附件上传 |
| `sortOrder` | 可选 | U/S | 管理高级设置 |
| `status` | 可选 | S | 0 正常、1 停用 |
| `status` | 可选 | A | 当前固定提交 0 正常;C26 解决前不开放 1 停用 |
列表、详情、新增和修改响应仍是泛型对象。补齐 `GrowthRecordView` 前不得展示原始 JSON,也不得猜 `recordId` 开启编辑和删除。
YAML 的列表、详情、新增和修改响应仍是泛型对象,但后端已存在独立 `PcGrowthRecordController`,其 PC method/path 与 YAML 一致,并明确返回 `GrowthRecordVo`。根据本轮“后端源码可作为可靠补充契约”的约定,页面响应字段由该 PC 控制器及 `GrowthRecordVo` 冻结:
- I`recordId``genealogyId``appUserId``lineagePersonId``mediaOssIds`
- R`genealogyNo``genealogyName``surname``appUserNickName``appUserPhone``lineagePersonNo``lineagePersonName``remark`
- U/R`recordType``recordTitle``recordContent`
- S/R`recordDate``remindTime`
- U/S/R`sortOrder`
- A/R`status`,当前固定提交 `0`
当前接入状态:
- `profile-growth.html` 已接入当前账号成长记录列表、详情、编辑和删除入口;列表接口不传后端额外的 `all` 参数,只消费当前 YAML 声明的请求;
- `profile-growth-edit.html` 已接入新增和修改;`lineagePersonId` 只从真实世系人物选项接口选择,`recordId` 只来自列表响应或 URL,均按字符串处理且不能手填;
- `recordType` 后端 DTO 和服务均为不带枚举的自由字符串,因此保留可选文本输入;`recordDate` 提交 `yyyy-MM-dd``remindTime``datetime-local` 转为后端 `Date` 接受的 `yyyy-MM-dd HH:mm:ss`
- 多附件通过统一分片上传产生隐藏 `mediaOssIds`,支持保留、追加和清除附件选择,不显示 OSS ID;
- 新增和修改成功后必须使用稳定 `recordId` 重读同一条详情并校验身份,再返回列表;删除成功后重读列表,确认文案明确逻辑删除且会释放附件引用;
- C18 未解决前,标题以外可选文本、人物和日期字段清空不宣称闭环;前端对空可选字段保持省略,附件清除使用后端已明确的空引用处理;
- C25 未解决前只显示附件数量,不猜测 OSS URL、文件名或下载地址;C26 未解决前固定 `status=0`,不产生无法重新读取和恢复的停用记录;
- 2026-07-29 已在真实登录 Chrome 验证无家谱上下文:列表和详情不发成长记录业务请求,编辑表单及页头保存按钮隐藏,入口统一返回家谱选择页。当前账号没有家谱,真实列表、详情、世系选项、上传和 CRUD 写入仍待有数据账号验证。
### 7.12 亲友记录
@@ -619,13 +736,33 @@
| `relationName` | 可选 | U | 关系名称 |
| `eventName` | 可选 | U | 事件名称 |
| `eventTime` | 可选 | S | 日期时间选择器 |
| `giftAmount` | 可选 | U | 金额输入,明确精度和非负规则后校验 |
| `giftAmount` | 可选 | U | 金额输入;只提交 JSON 序列化后数值不变的有限数字,C27 解决前不猜精度、范围或非负规则 |
| `recordContent` | 可选 | U | 内容 |
| `mediaOssIds` | 可选 | F/I | 多附件上传 |
| `sortOrder` | 可选 | U/S | 管理高级设置 |
| `status` | 可选 | S | 0 正常、1 停用 |
| `status` | 可选 | A | 当前固定提交 0 正常;C29 解决前不开放 1 停用 |
补齐 `RelativeRecordView` 前只完成页面字段布局和请求契约,不开放真实列表、编辑和删除。
YAML 的列表、详情、新增和修改响应仍是泛型对象,但后端已存在独立 `PcRelativeRecordController`,其 PC method/path 与 YAML 一致,并明确返回 `RelativeRecordVo`。根据本轮“后端源码可作为可靠补充契约”的约定,页面响应字段由该 PC 控制器及 `RelativeRecordVo` 冻结:
- I`relativeId``genealogyId``appUserId``mediaOssIds`
- R`genealogyNo``genealogyName``surname``appUserNickName``appUserPhone``remark`
- U/R`relativeName``relationName``eventName``recordContent`
- S/R`eventTime`
- U/R`giftAmount`,后端 `BigDecimal` 响应按字符串保留;
- U/S/R`sortOrder`
- A/R`status`,当前固定提交 `0`
当前接入状态:
- `profile-relative.html` 已接入当前账号亲友记录列表、详情、编辑和删除入口;列表接口不传后端额外的 `all` 参数,只消费当前 YAML 声明的请求;
- `profile-relative-edit.html` 已接入新增和修改;`relativeId` 只来自列表响应或 URL,按字符串处理且不能手填;
- `eventTime` 使用 `datetime-local`,提交前转为后端 `Date` 接受的 `yyyy-MM-dd HH:mm:ss`,并校验真实月日和时分秒;
- `giftAmount` 请求按 YAML 的 `number` 提交;提交前比较用户十进制输入与 `JSON.stringify(Number(value))` 的精确数值,序列化会改值的超大或高精度金额直接拒绝。后端 `BigDecimal` 响应按原字符串展示,不转回 `Number`
- 多附件通过统一分片上传产生隐藏 `mediaOssIds`,支持保留、追加和清除附件选择,不显示 OSS ID;
- 新增和修改成功后必须使用稳定 `relativeId` 重读同一条详情并校验身份,再返回列表;删除成功后重读列表,确认文案明确逻辑删除且会释放附件引用;
- C18 未解决前,可选文本、时间和金额字段的清空不宣称闭环;附件清除使用后端已明确的空引用处理;
- C27 未解决前不增加两位小数、最大金额或非负限制;C28 未解决前只显示附件数量;C29 未解决前固定 `status=0`,并拒绝编辑停用响应,避免意外恢复;
- 2026-07-29 已在真实登录 Chrome 验证无家谱上下文:列表和详情不发亲友记录业务请求,编辑表单及页头保存按钮隐藏,入口统一返回家谱选择页。当前账号没有家谱,真实列表、详情、上传和 CRUD 写入仍待有数据账号验证。
### 7.13 备忘录
@@ -635,13 +772,31 @@
| --- | --- | --- | --- |
| `memoTitle` | 必填 | U | 标题 |
| `memoContent` | 可选 | U | 内容 |
| `remindTime` | 可选 | S | 日期时间 |
| `completed` | 可选 | S | 应为开关;C11 明确提交值前保持禁用 |
| `mediaOssIds` | 可选 | F/I | 多附件上传 |
| `sortOrder` | 可选 | U/S | 管理高级设置 |
| `status` | 可选 | S | 0 正常、1 停用 |
| `remindTime` | 可选 | S | `datetime-local` 选择,提交为 `yyyy-MM-dd HH:mm:ss` 并校验真实日期 |
| `completed` | 可选 | S | PC 后端已核验:`0` 未完成、`1` 已完成;创建默认 `0` |
| `mediaOssIds` | 可选 | F/I | 多附件上传自动产生;隐藏保存,允许追加和清除,不允许手填 |
| `sortOrder` | 可选 | U/S | 安全整数;创建默认 `0` |
| `status` | 可选 | I/A | 页面固定隐藏提交 `0`;不开放不可重读的停用状态 |
`completed` 表示业务完成状态,`status` 表示记录正常/停用,两者不能合并。补齐 `MemoView` 前不得猜 `memoId` 响应字段
PC `MemoVo` 响应字段及页面用途:
| 字段 | 类型 | 标记 | 页面处理 |
| --- | --- | --- | --- |
| `memoId``genealogyId``appUserId` | Long | I | 仅从 PC 响应取得并按十进制字符串保存;用于详情、编辑、删除和写后重读,不显示、不手填 |
| `genealogyNo``genealogyName``surname` | string | R | 家谱上下文只读信息;当前备忘录页不重复展示 |
| `appUserNickName` | string | R | 详情显示记录人 |
| `appUserPhone` | string | I | 隐私字段,不渲染 |
| `memoTitle``memoContent``remindTime``completed` | string | R | 列表摘要、详情展示和编辑回填 |
| `mediaOssIds` | string | I | 只统计附件数量并回填隐藏上传字段,不暴露原始 OSS ID |
| `sortOrder` | Long | I | 编辑回填;列表顺序由后端负责 |
| `status` | string | I | 仅接受正常记录 `0`;停用响应拒绝进入编辑器 |
| `remark` | string | R | 详情只读展示,MemoBody 不含此字段,因此不提交 |
`completed` 表示业务完成状态,`status` 表示记录正常/停用,两者不能合并。列表、详情、新增、修改和删除只调用 `/genealogy/pc/genealogies/{genealogyId}/memos` 及其 `/{memoId}` 子路径;列表不发送后端管理专用的 `all` 查询。新增/修改成功后使用稳定 `memoId` 重读同一详情,删除后重读列表。
- C18 未解决前,可选内容和提醒时间的清空不宣称闭环;附件清除使用后端已明确的空引用处理;
- C30 未解决前只显示附件数量;C31 未解决前固定 `status=0`,拒绝编辑停用响应,避免意外恢复;
- 2026-07-29 已在真实登录 Chrome 验证无家谱上下文:列表和详情显示明确提示,编辑表单及页头保存按钮隐藏,控制台无错误。当前账号没有可用家谱,真实列表、详情、上传和 CRUD 写入仍待有数据账号验证。
### 7.14 功德记录
@@ -653,12 +808,33 @@
| `meritType` | 可选 | S | donation 捐赠、repair 修祠、public 公益、other 其他 |
| `meritTitle` | 必填 | U | 标题 |
| `meritContent` | 可选 | U | 内容 |
| `amount` | 可选 | U | 金额 |
| `meritTime` | 可选 | S | date-time |
| `sortOrder` | 可选 | U/S | 管理高级设置 |
| `status` | 可选 | S | 0 正常、1 停用 |
| `amount` | 可选 | U | YAML `number`;提交前拒绝 JSON 序列化会改变精确数值的输入,不自行限制小数位、范围或正负 |
| `meritTime` | 可选 | S | `datetime-local` 选择,提交为后端示例和 `Date` 接受的 `yyyy-MM-dd HH:mm:ss` |
| `sortOrder` | 可选 | U/S | 安全整数;创建默认 `0` |
| `status` | 可选 | I/A | 页面固定隐藏提交 `0`;不开放不可重读的停用状态 |
现有页面错误地使用了 `service`,但契约枚举中不存在该值;必须删除 `service`,增加 `repair``public`。现有页面还缺少 `sortOrder``status`
PC `MeritRecordVo` 响应字段及页面用途:
| 字段 | 类型 | 标记 | 页面处理 |
| --- | --- | --- | --- |
| `meritId``genealogyId``appUserId` | Long | I | 仅从 PC 响应取得并按十进制字符串保存;用于详情、编辑、删除和写后重读,不显示、不手填 |
| `genealogyNo``genealogyName``surname` | string | R | 家谱上下文只读信息;当前功德页不重复展示 |
| `appUserNickName` | string | R | 详情显示记录人 |
| `appUserPhone` | string | I | 隐私字段,不渲染 |
| `donorName``meritType``meritTitle``meritContent` | string | R | 列表摘要、详情展示和编辑回填;HTML 内容转义后展示 |
| `amount` | BigDecimal | R | 按响应原始字符串展示,不转回 `Number` |
| `meritTime` | Date | R | 列表、详情展示和 `datetime-local` 编辑回填 |
| `sortOrder` | Long | I | 编辑回填;列表顺序由后端负责 |
| `status` | string | I | 仅接受正常记录 `0`;停用响应拒绝进入编辑器 |
| `remark` | string | R | 详情只读展示,`MeritRecordBody` 不含此字段,因此不提交 |
现有页面中的契约外 `service` 已删除,补齐 `repair``public`;由于 `MeritRecordBody` 没有附件字段,功德页不借用其他模块的 `mediaOssIds` 或上传控件。ApiClient 的新增和修改方法再次按 8 个 YAML 字段白名单过滤,调用者额外传入 `appUserId``mediaOssIds` 等字段也不会发送。
- 列表、详情、新增、修改和删除只调用 `/genealogy/pc/genealogies/{genealogyId}/merit-records` 及其 `/{meritId}` 子路径;
- 新增/修改成功后使用稳定 `meritId` 重读同一详情,删除成功后重读列表;
- C18 未解决前,可选内容、金额和时间的清空不宣称闭环;C32 未解决前不增加两位小数、最大金额或非负限制;C33 未解决前固定 `status=0` 并拒绝编辑停用响应;
- PC 写操作最终由 `assertCanEditContent` 鉴权;当前 View 没有 `canEdit/canDelete`,页面保留操作入口并明确处理 403,不猜角色字段;
- 2026-07-29 已在真实登录 Chrome 验证无家谱上下文:列表和详情显示明确提示,编辑表单及页头保存按钮隐藏,契约外类型和附件控件不存在,控制台无错误。当前账号没有可用家谱,真实列表、详情和 CRUD 写入仍待有数据账号验证。
列表、详情、新增和修改仍是泛型响应。补齐 `MeritRecordView` 后再开放完整 CRUD。
@@ -666,28 +842,276 @@
页面:`profile-messages.html`
| 字段 | 必填性 | 类型 | 页面处理 |
接口冻结:
- `GET /genealogy/pc/notifications`:可选 query `readStatus=0|1`,返回非分页直接数组;全部通知时省略 query。
- `GET /genealogy/pc/notifications/unread-count`:返回非负 int64 未读数量。
- `GET /genealogy/pc/notifications/{notificationId}`:读取当前账号可见的正常通知详情。
- `POST /genealogy/pc/notifications/{notificationId}/read`:将当前账号的一条正常未读通知标记已读。
- `POST /genealogy/pc/notifications/read-all`:将当前账号全部未读通知标记已读。
- 五个接口均由 `ApiClient` 自动携带 Bearer 和 `clientid`;页面不采集 Header。YAML 未声明业务错误码;前端统一处理未登录/401、403、404、业务失败和网络失败。
| 字段 | 必填性 / 类型 | 标记 | 页面用途、来源与提交时机 |
| --- | --- | --- | --- |
| `readStatus` | 可选 query | S | 全部=省略、未读=0、已读=1 |
| `notificationId` | 单条已读必填 path | I/A | 从被点击通知记录带入 |
| 未读数量 `data` | 响应 | R | 顶部徽标,int64 按安全数值/字符串处理 |
| query `readStatus` | 可选 string;枚举 `0/1` | S | 消息页筛选选择;全部=省略、未读=0、已读=1;切换筛选时提交 |
| path `notificationId` | 详情/单条已读必填 int64 | I/A | 从列表响应的稳定十进制字符串取得;点击详情或“标记已读”时自动进入 path,不允许手填或转 `Number` |
| `notificationId` | 响应 int64 | I | 列表行内部关联详情和已读动作;不在可见页面展示 |
| `genealogyId` | 响应 int64,可空 | I | 仅保留为响应内部上下文;不展示、不提交、不允许手填 |
| `genealogyNo` | 响应 string | R | 详情只读候选字段;当前 PC 未填充,不作为功能依赖 |
| `genealogyName` | 响应 string | R | 详情只读展示;当前 PC 可能为空 |
| `senderUserId` | 响应 int64,可空 | I | 内部关联字段;不展示、不提交 |
| `senderNickName` | 响应 string | R | 详情只读展示;当前 PC 可能为空 |
| `senderPhone` | 响应 string | I | 隐私字段,页面始终隐藏 |
| `noticeType` | 响应 string,无枚举/默认值 | R | 列表和详情只读展示;不据此猜测跳转 |
| `noticeTitle` | 响应 string | R | 列表和详情标题,转义后展示 |
| `noticeContent` | 响应 string | R | 列表摘要和详情正文,转义后展示 |
| `bizType` | 响应 string,无枚举/默认值 | R | 详情只读展示;不据此拼业务 URL |
| `bizId` | 响应 int64,可空 | I | 内部业务关联字段;PC 未提供目标路由契约前不展示、不跳转 |
| `bizSummary` | 响应 string | R | 详情只读展示;当前 PC 可能为空 |
| `publishTime` | 响应 string `date-time` | R | 列表和详情只读展示,不由页面提交 |
| `readStatus` | 响应 string;枚举 `0/1` | R | 0=未读、1=已读;仅正常未读记录显示单条已读按钮 |
| `readTime` | 响应 string `date-time`,可空 | R | 详情只读展示,未读时为空 |
| `status` | 响应 string;枚举 `0/1` | I | 仅用于确认正常状态 `0` 才能执行单条已读,不提供页面编辑 |
| `remark` | 响应 string | R | 详情只读展示 |
| 未读数量 `data` | 响应 int64 | R | 顶部计数;只接受非负安全整数 |
通知列表当前是 `ListResult<object>`,没有通知 ID、标题、正文、类型、时间、已读状态和目标深链。补齐 `NotificationView`
响应和权限边界
- 可以调用未读数量和全部已读;
- 不得展示原始对象;
- 不得猜 `notificationId` 开放单条已读;
- 不得根据未声明字段拼详情跳转
- YAML 的详情对象没有声明字段级 `required`、长度、范围或默认值;页面不补造限制,并兼容可选展示字段为空。
- PC 后端按当前 `APP_USER` 登录账号隔离通知,只返回 `status=0` 的正常通知;列表按接收记录倒序,非分页。
- 详情和单条已读必须命中当前账号的接收记录;重复单条已读保持已读状态,页面仍做写操作防重复。
- C34 未解决前,`genealogyNo/genealogyName/senderNickName/senderPhone/bizSummary` 不能被视为稳定可用;手机号即使后端补齐也不展示
- 页面不展示原始 JSON,不使用 APP 接口,不开放删除,不根据 `bizType/bizId` 猜业务深链。
- 2026-07-29 已在真实登录 Chrome 验证当前账号:未读数为 0,全部和仅未读筛选均成功同步,空列表/空详情状态正确。账号暂无通知,因此有数据详情和单条/全部已读真实写入仍待有数据时复核。
### 7.16 当前只有删除能力的模块
### 7.16 谱文
| 模块 | 当前操作 | 页面策略 |
页面:`profile-article.html``profile-article-edit.html`
| 请求字段 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `categoryId` | 可选 int64 | I/S | C35 未解决前省略;没有真实分类选项时不提供输入框 |
| `articleTitle` | 必填 string | U | 编辑页标题;保存时提交 |
| `articleSummary` | 可选 string | U | 编辑页摘要;非空时提交 |
| `coverOssId` | 可选 string/int64 | F/I | 选择封面并完成统一上传后自动产生;隐藏保存,禁止手填 |
| `articleContent` | 必填 string | U | wangEditor 正文;同步到 textarea 后提交 |
| `authorName` | 可选 string | U | 编辑页落款;非空时提交 |
| `sortOrder` | 可选 int64 | U/S | 编辑页安全整数;非空时提交 |
| `status` | 可选 string `0/1` | A/I | C37 未解决前固定隐藏提交 `0` |
| `genealogyId``articleId` | path int64 | I/A | 家谱上下文和列表/详情响应;按十进制字符串处理,不显示、不手填 |
PC `ArticleVo` 响应字段及页面用途:
| 字段 | 类型 | 标记 | 页面处理 |
| --- | --- | --- | --- |
| `articleId``genealogyId` | Long | I | 列表、详情、编辑、删除和写后重读的稳定 ID |
| `genealogyNo``genealogyName``surname` | string | R | 家谱只读上下文 |
| `categoryId` | Long,可空 | I | 仅内部保留;无分类选项源时不回填为可编辑 ID |
| `categoryName``categoryCode` | string | R | 详情分类只读展示 |
| `articleTitle``articleSummary``articleContent``authorName` | string | R/U | 列表、详情展示和编辑回填;可见内容均转义 |
| `coverOssId` | Long,可空 | I | 编辑隐藏回填;C36 未解决前不拼封面 URL |
| `publishTime``viewCount` | Date/Long | R | 详情发布时间和浏览量 |
| `sortOrder` | Long | I/U | 编辑回填;列表顺序由后端负责 |
| `status` | string `0/1` | I | 只允许正常记录进入编辑器 |
| `remark` | string | R | 详情只读展示,不进入 ArticleBody |
接入边界:
- 列表、详情、新增、修改和删除只调用 `/genealogy/pc/genealogies/{genealogyId}/articles` 及其 `/{articleId}` 子路径;
- YAML 响应仍为泛型包装,但 PC 控制器明确返回 `ArticleVo`,前端严格校验该 VO,不读取 APP 路径;
- 新增/修改成功后使用稳定 `articleId` 重读同一详情;删除后重读列表;所有写操作防重复;
- 页面先读取家谱详情,只有 `canEditContent/canManage` 显示新增、编辑和删除;后端继续执行最终权限校验;
- C35 未解决前不开放分类筛选/编辑;C36 未解决前不猜封面地址;C37 未解决前不开放停用;
- 相关聚焦测试 49/49 通过;真实账号页面验证统一留到阶段 6 收尾执行。
### 7.17 相册与照片
页面:`profile-album.html``profile-album-edit.html``profile-album-detail.html`
相册请求字段:
| 请求字段 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `albumName` | 必填 string | U | 编辑页相册名称;保存时提交 |
| `albumDesc` | 可选 string | U | 编辑页描述;非空时提交 |
| `coverOssId` | 可选 string/int64 | F/I | 选择封面并完成统一上传后自动产生;隐藏保存,禁止手填 |
| `sortOrder` | 可选 int64 | U/S | 编辑页安全整数;非空时提交 |
| `status` | 可选 string `0/1` | A/I | C39 未解决前固定隐藏提交 `0` |
| `genealogyId``albumId` | path int64 | I/A | 家谱上下文及相册响应;按十进制字符串处理,不显示、不手填 |
照片请求字段:
| 请求字段 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `ossId` | 必填 string/int64 | F/I | 选择照片并完成统一上传后自动产生;隐藏保存,禁止手填 |
| `photoTitle``photoDesc``photographer` | 可选 string | U | 照片表单;非空时提交 |
| `shootTime` | 可选 date | S | 日期选择器;非空时提交 `YYYY-MM-DD` |
| `sortOrder` | 可选 int64 | U/S | 照片表单安全整数;非空时提交 |
| `status` | 可选 string `0/1` | A/I | C39 未解决前固定隐藏提交 `0` |
| `albumId``photoId` | path int64 | I/A | 相册和照片响应;按十进制字符串处理,不显示、不手填 |
PC 响应字段及页面用途:
| 对象 | 字段 | 标记 | 页面处理 |
| --- | --- | --- | --- |
| `AlbumVo` | `albumId``genealogyId` | I | 列表、编辑、详情、删除和写后重读的稳定 ID |
| `AlbumVo` | `genealogyNo``genealogyName``surname` | R | 家谱只读上下文 |
| `AlbumVo` | `albumName``albumDesc` | R/U | 列表/详情展示和编辑回填;可见内容均转义 |
| `AlbumVo` | `coverOssId` | F/I | 编辑隐藏回填;C38 未解决前不拼封面 URL |
| `AlbumVo` | `photoCount` | R | 列表和详情照片数量 |
| `AlbumVo` | `sortOrder``status` | I/U | 编辑回填;只允许正常记录进入编辑器 |
| `AlbumVo` | `remark` | R | 详情只读展示,不进入请求 Body |
| `AlbumPhotoVo` | `photoId``genealogyId``albumId` | I | 照片删除和写后重读的稳定 ID |
| `AlbumPhotoVo` | `genealogyNo``genealogyName``surname``albumName` | R | 家谱与相册只读上下文 |
| `AlbumPhotoVo` | `ossId` | F/I | 文件引用;C38 未解决前不拼照片 URL |
| `AlbumPhotoVo` | `photoTitle``photoDesc``photographer``shootTime` | R/U | 照片列表展示;可见内容均转义 |
| `AlbumPhotoVo` | `sortOrder``status` | I | 列表顺序和正常状态校验 |
| `AlbumPhotoVo` | `remark` | R | 只读展示,不进入请求 Body |
接入边界:
- 相册只调用 `/genealogy/pc/genealogies/{genealogyId}/albums` 及其 `/{albumId}` 子路径;照片只调用该相册下的 `/photos``/{photoId}`
- 后端没有单独的相册详情接口,详情和编辑页必须重读相册列表并用响应中的稳定 `albumId` 精确匹配;
- YAML 响应仍为泛型包装,前端只接受后端 PC 控制器实际返回的 `AlbumVo` / `AlbumPhotoVo` 直接数组,不读取 APP 路径;
- 新增/修改相册后重读相册列表;新增/删除照片后重读照片列表;所有写操作防重复;
- 页面先读取家谱详情,只有 `canEditContent/canManage` 显示新增、编辑、删除和照片维护;
- C38 未解决前不猜文件地址;C39 未解决前不开放停用;
- 相关聚焦测试 49/49 通过;真实账号页面验证统一留到阶段 6 收尾执行。
### 7.18 祭祀活动、祭品与管理员受邀名单
页面:`profile-ceremony.html``profile-gift-edit.html``profile-ceremony-detail.html`;当前账号收到的邀请继续由 `profile-gift.html` 负责。
活动请求字段:
| 请求字段 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `ceremonyType` | 必填 string,无枚举 | U | 编辑页活动类型;保存时提交,不借用其他端枚举 |
| `ceremonyTitle` | 必填 string | U | 编辑页标题;保存时提交 |
| `ceremonyDesc` | 可选 string | U | 编辑页说明;非空时提交 |
| `ceremonyTime` | 可选 date-time | S | 日期时间选择器;转换为后端 Date 可解析格式后提交 |
| `location` | 可选 string | U | 编辑页地点名称;非空时提交 |
| `locationAddress` | 可选 string,最长 300 | U | 编辑页详细地址;非空时提交 |
| `longitude` | 可选 number`[-180, 180]` | U/S | 与纬度同时填写并保存 |
| `latitude` | 可选 number`[-90, 90]` | U/S | 与经度同时填写并保存 |
| `coverOssId` | 可选 string/int64 | F/I | 选择封面并完成统一上传后自动产生;隐藏保存,禁止手填 |
| `sortOrder` | 可选 int64 | U/S | 编辑页安全整数;非空时提交 |
| `status` | 可选 string `0/1` | A/I | C41 未解决前固定隐藏提交 `0` |
| `genealogyId``ceremonyId` | path int64 | I/A | 家谱上下文和活动响应;按十进制字符串处理,不显示、不手填 |
祭品与受邀名单请求字段:
| 请求字段 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `giverName` | 可选 string | U | 祭品表单赠送人姓名;非空时提交 |
| `giftAmount` | 必填 number,后端要求非负 | U | 祭品表单金额;添加祭品时提交;C42 未解决前不限制精度和最大值 |
| `giftMessage` | 可选 string | U | 祭品留言;非空时提交 |
| `inviteeUserIds` | 必填、唯一 int64 数组,可为空 | S/I | 从 `members/options` 的正常且已绑定账号成员多选产生;更新名单时提交完整数组,禁止手填 |
| `giftId``invitationId``inviteeUserId` | path/response int64 | I/A | 只从 PC 响应取得;用于删除、名单匹配和写后重读,不显示原值 |
PC 响应字段及页面用途:
| 对象 | 字段 | 标记 | 页面处理 |
| --- | --- | --- | --- |
| `CeremonyVo` | `ceremonyId``genealogyId``sponsorUserId` | I | 活动 CRUD、详情和写后重读的稳定 ID |
| `CeremonyVo` | `genealogyNo``genealogyName``surname` | R | 家谱只读上下文 |
| `CeremonyVo` | `sponsorNickName` | R | 发起人只读展示 |
| `CeremonyVo` | `sponsorPhone` | I | 隐私字段,始终不展示 |
| `CeremonyVo` | `ceremonyType``ceremonyTitle``ceremonyDesc``ceremonyTime``location``locationAddress` | R/U | 列表/详情展示和编辑回填;可见内容均转义 |
| `CeremonyVo` | `longitude``latitude` | I/S | 仅用于生成安全地图链接,不直接展示数值 |
| `CeremonyVo` | `coverOssId` | F/I | 编辑隐藏回填;C40 未解决前不拼封面 URL |
| `CeremonyVo` | `giftCount``giftAmount` | R | 详情祭品数量与礼金合计 |
| `CeremonyVo` | `sortOrder``status` | I/U | 编辑回填;只允许正常记录进入编辑器 |
| `CeremonyVo` | `remark` | R | 详情只读展示,不进入请求 Body |
| `CeremonyGiftVo` | `giftId``genealogyId``ceremonyId``giverUserId` | I | 祭品删除、关联和写后重读的稳定 ID |
| `CeremonyGiftVo` | `genealogyNo``genealogyName``surname``ceremonyTitle` | R | 家谱与活动只读上下文 |
| `CeremonyGiftVo` | `giverNickName``giverName``giftAmount``giftMessage``giftTime` | R | 祭品列表展示;可见内容均转义 |
| `CeremonyGiftVo` | `giverPhone` | I | 隐私字段,始终不展示 |
| `CeremonyGiftVo` | `status``remark` | R/I | 正常状态校验和只读备注 |
| `CeremonyInvitationVo` | 全部 ID、版本 | I | 邀请状态匹配和更新后的重读校验,不显示原值 |
| `CeremonyInvitationVo` | `inviteStatus`、投递/阅读/响应时间 | R | 管理员邀请列表只读展示 |
| `GenealogyMemberVo` 选项 | `appUserId` | I/S | 受邀人的唯一真实业务用户 ID 来源 |
| `GenealogyMemberVo` 选项 | `memberName``appUserNickName` | R/S | 受邀人选择器标签;手机号不展示 |
接入边界:
- 活动只调用 `/genealogy/pc/genealogies/{genealogyId}/ceremonies``/{ceremonyId}`;祭品只调用其 `/gifts` 子路径;
- 管理员邀请列表和替换只调用同一活动下的 `/invitations``/invitees`,受邀人来源只调用当前家谱 `/members/options`
- 新增/修改活动后重读同一详情,新增/删除祭品后重读祭品列表,更新受邀人后重读邀请列表;所有写操作防重复;
- 页面先读取家谱详情,只有 `canEditContent/canManage` 显示活动、祭品和受邀名单维护;
- 空数组允许取消全部尚未响应邀请;响应过的历史邀请由后端保留状态,前端不伪造删除;
- C40 未解决前不猜封面地址;C41 未解决前不开放停用;C42 未解决前不猜金额精度和上限;
- 相关聚焦测试 56/56 通过;真实账号页面验证统一留到阶段 6 收尾执行。
### 7.19 家谱成员管理、退出与谱主转移
页面:`profile-family-admin.html`
成员修改请求字段:
| 请求字段 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `memberName` | 可选 string,最长 50 | U | 管理员编辑表单;非空时提交 |
| `relationName` | 可选 string,最长 100 | U | 管理员编辑表单;后端明确接收空串时可清空 |
| `roleType` | 可选 string`admin/editor/member` | S | 按当前管理者和目标角色过滤;保存时提交,不发送 `owner/visitor` |
| `lineagePersonId` | 可选 int64 | S/I | 从当前家谱世系人物选项选择;非空时提交,留空表示不修改而不是解绑 |
| `genealogyId``memberId` | path int64 | I/A | 家谱上下文和成员列表响应;按字符串处理,不显示、不手填 |
退出与谱主转移:
| 字段/操作 | 必填性 / 类型 | 标记 | 页面来源与提交时机 |
| --- | --- | --- | --- |
| `DELETE /members/me` | 无 Body | S/A | 当前正常成员二次确认后提交;谱主不显示退出按钮 |
| `targetMemberId` | 必填 int64 | S/I | 谱主从当前正常成员列表选择新谱主;禁止选择自己和手填 ID |
| `DELETE /members/{memberId}` | path int64 | S/I | 管理员从真实成员行执行“移出家谱”;谱主不可被移出 |
PC `GenealogyMemberVo` 响应字段及页面用途:
| 字段 | 标记 | 页面处理 |
| --- | --- | --- |
| 内容文章 | 删除文章 | `profile-article*` 保持设计;没有列表/详情时不开放删除 |
| 相册 | 删除相册、删除照片 | `profile-album.html` 保持设计;不使用静态 ID 调删除 |
| 祭祀 | 删除活动、删除祭品 | 活动 CRUD 和礼物列表补齐前保持禁用 |
| `memberId``genealogyId``appUserId``lineagePersonId``inviterUserId` | I | 当前成员匹配、修改、移出、转让和世系绑定的稳定 ID;不显示原值 |
| `genealogyNo``genealogyName``surname` | R | C44 未解决前允许为空的家谱只读上下文 |
| `appUserNickName``lineagePersonNo``lineagePersonName``memberName` | R/U | 成员列表标签和编辑回填;可见内容均转义 |
| `appUserPhone``inviterPhone` | I | 隐私字段,始终不展示 |
| `roleType` | R/S | 响应可含 `owner/admin/editor/member/visitor`;修改只提交三种后端可分配角色 |
| `relationName` | R/U | 列表展示和编辑回填 |
| `joinSource``inviterNickName``joinTime` | R | 加入来源只读说明;C44 未填充时允许为空 |
| `status` | I | 列表只接受后端返回的正常状态 `0` |
删除按钮必须由真实响应中的 ID 和权限字段产生。禁止用 DOM 序号、示例 ID 或 URL 猜测资源 ID。
权限与状态边界:
- 所有账号可按家谱查看权限读取正常成员;只有家谱详情 `canManage=true` 时显示修改和移出;
- 谱主不能直接修改或移出;只有谱主可管理管理员、分配 `admin` 或转让谱主;管理员只能管理非管理员成员并分配 `editor/member`
- 普通成员、编辑和管理员可退出家谱;谱主必须先转让谱主。退出和移出只停用成员关系,不删除世系人物;
- 更新后重读成员列表并核对同一 `memberId`;移出后核对该成员不再出现在正常列表;谱主转移后重读家谱详情和成员列表;
- 页面没有直接新增成员接口;新增成员必须通过后端已有的加入/邀请业务闭环;
- C44 未解决前不依赖 enrichment 字段并隐藏手机号;C45/C46 未解决前不伪造关系解除或成员-世系人物解绑;
- 相关聚焦测试 49/49 通过;真实账号页面验证统一留到阶段 6 收尾执行。
### 7.20 家谱创建与加入申请契约
页面:`profile-create-family.html`;加入申请和审核页面留到下一批开放。
`AppGenealogyCreateBody`
| 字段 | 类型 | 必填 | 来源 | 页面与提交时机 |
| --- | --- | --- | --- | --- |
| `genealogyName` | string | 是 | U | 创建页谱名;提交时发送 |
| `surname` | string | 是 | U | 创建页姓氏;提交时发送 |
| `regionCode` | string | 是 | S | 省市区选择器自动取最后一级;提交时发送 |
| `ancestralHall``originPlace``addressDetail``intro` | string | 否 | U | 创建页可选输入;非空时发送 |
| `coverOssId` | int64/string | 否 | F | 封面上传成功后自动产生;不允许手填 |
| `visibility` | string enum `0/1/2` | 否 | S | 私密/公开/成员可见 |
| `joinMode` | string enum `0/1/2` | 否 | S | 关闭/审核/邀请模式 |
实现边界:
- 创建只调用 `POST /genealogy/pc/genealogies`,提交前读取 `GET /genealogy/pc/genealogies/quota`
- 地区选项复用 `/genealogy/pc/region/children?parentCode=...`,不手写地区编码;
- 创建成功后使用响应中的稳定 `genealogyId` 重读同一家谱,匹配后进入家谱主页;
- ApiClient 已同时冻结加入申请、我的申请、待审核、审核和撤销的 PC path/body 白名单,但本批未提前开放加入 UI;
- `inviterUserId` 没有安全 PC 邀请来源,本轮加入申请契约不发送该字段;
- 创建页面无家谱 ID、申请 ID、用户 ID 或 OSS ID 文本输入,所有写操作防重复。
## 8. 分阶段执行顺序
@@ -769,6 +1193,8 @@
3. 世系人物;
4. 我的贺礼邀请。
当前进度:阶段 4 已按顺序完成。家族圈的列表、正常状态详情、新增、修改、删除、点赞、评论、回复、权限显隐、空/错状态、防重复和写后重读已接入,C21 图片 URL 回显和 C22 停用记录管理继续阻断;字辈谱的正常/management 权限分流、单条新增修改和状态切换、批量预览保存、响应校验、防重复、写后重读及无家谱上下文分支已接入;世系人物的树、列表、分页、详情、完整字段表单、权限显隐、四类关系新增、逻辑停用、防重复和写后重读已接入,C15 继续阻断新增 `SPECIFIED` 账号绑定,C18 继续阻断可选字段清空闭环;我的贺礼邀请已接入当前账号列表、响应内详情、PENDING 接受/拒绝、长 ID 校验、权限/空错状态、防重复和写后重读,C16 继续阻断管理员替换受邀人。真实账号已验证邀请读取和空状态;该账号没有待响应邀请,接受/拒绝真实写入仍待有数据时复核。下一阶段为阶段 5,按顺序先核对视频 DTO 是否已完整。
每个模块采用同一闭环:
1. `ApiClient` 方法和契约测试;
@@ -792,19 +1218,36 @@
任何模块的 View/List DTO 未补齐时,只完成表单布局和契约测试,不宣称真实功能完成。
### 阶段 6:后端补齐后再开放的模块
当前进度:阶段 5 已按顺序完成。视频、成长记录、亲友记录、备忘录、功德记录和消息通知均已按对应 PC 契约开放;各模块的文件 URL、停用记录、可选字段清空和金额业务边界仍按 C18、C23–C34 保持阻断,不通过 APP 接口或猜测字段补齐。
范围:
### 阶段 6:当前 PC 契约可闭环模块
- 文章;
- 相册/照片;
- 祭祀活动/祭品;
- 管理员邀约名单;
- 指定账号绑定世系人物;
- 家谱和成员管理;
- 世系关系解除。
已完成:
这些模块不允许通过 APP 接口或猜测路径提前实现。
- 谱文列表、详情、新增、修改和删除;
- 相册列表、新增、修改、删除,以及照片列表、新增和删除;
- 祭祀活动列表、详情、新增、修改和删除,祭品列表、新增和删除;
- 管理员受邀名单读取和完整替换,当前账号邀请读取与响应;
- 从同家谱正常成员选项中选择账号完成 `SPECIFIED` 世系人物绑定;
- 家谱成员列表、修改、移出、当前成员退出和谱主转移;
- 个人中心、家谱首页和家谱内容页的稳定入口。
契约收口:
- `replaceCeremonyInvitees(genealogyId, ceremonyId, inviteeUserIds)` 只接收真实成员选项产生的 ID 数组,`utils/ApiClient.js` 唯一封装 `{ inviteeUserIds }`
- `/members/options` 严格按 YAML 不发送 Query;后端存在但 YAML 未定义的 `keyword` 不进入前端契约;
- 成员角色构造边界只接受 `admin/editor/member``owner/visitor` 在请求构造前失败;
- 所有业务 ID 来自 URL、上下文或响应选项,页面不提供手填 ID/OSS ID。
仍阻断:
- C35–C46 中记录的分类选项、文件 URL、停用记录、金额业务精度/上限、响应 enrichment 与解绑契约;
- 世系关系解除没有 PC 接口;成员移出/退出不会伪造成世系关系解除或成员与世系人物解绑;
- 真实账号 `19181970173` 已验证登录和无家谱上下文分支:阶段 6 页面均阻止业务请求、隐藏管理操作并返回家谱选择页,浏览器控制台无错误。该账号当前无家谱数据,因此列表有数据态、详情以及真实写操作仍待有数据账号复核,未制造家谱或业务记录。
### 阶段 7:剩余 PC 页面分批开放
首批已完成家谱生命周期 ApiClient 契约和创建家谱页面:额度、三级地区、封面上传派生、严格 DTO 校验、防重复与创建后重读均已接入。加入申请/审核、反馈/工单、VIP 和家谱主页按独立后续批次实施;成员邀请、分享和资料完善提醒没有 PC Controller,继续阻断。
## 9. 文件级施工建议
@@ -822,9 +1265,13 @@
| `public/js/auth-pages.js` / `security-pages.js` | 认证业务流程 |
| `public/js/feed-pages.js` | 家族圈 |
| `public/js/generation-pages.js` | 字辈 |
| `public/js/lineage-pages.js` | 世系 |
| `public/js/lineage-pages.js` | 世系与指定账号绑定 |
| `public/js/video-pages.js` | 视频列表、详情、编辑、上传元数据和 CRUD |
| `public/js/growth-pages.js``relative-pages.js``memo-pages.js` | 对应族务记录 |
| 新增专用视频/功德/邀约脚本 | 仅在 DTO 完整后新增 |
| `public/js/ceremony-pages.js` | 当前用户贺礼邀请列表、响应内详情和接受/拒绝 |
| `public/js/article-pages.js``album-pages.js` | 谱文、相册和照片 |
| `public/js/ceremony-admin-pages.js` | 祭祀活动、祭品与管理员邀约 |
| `public/js/member-admin-pages.js` | 成员修改、移出、退出和谱主转移 |
不得创建一个包揽所有业务的巨型页面脚本,也不得为了单一页面引入通用框架。
@@ -33,7 +33,7 @@
- `auditGenealogyJoinApply(genealogyId, applyId, body)`
- `cancelGenealogyJoinApply(applyId)`
- [ ] **Step 1: 写失败的路径和 body 白名单测试**
- [x] **Step 1: 写失败的路径和 body 白名单测试**
测试固定以下请求:
@@ -69,7 +69,7 @@ await client.auditGenealogyJoinApply(genealogyId, applyId, {
- 审核 body 只保留 `status/auditRemark`
- 所有 path ID 拒绝不安全数字。
- [ ] **Step 2: 运行 RED**
- [x] **Step 2: 运行 RED**
Run:
@@ -79,11 +79,11 @@ node --test --test-name-pattern "genealogy lifecycle" tests/api-client-contract.
Expected: FAIL,新方法尚不存在。
- [ ] **Step 3: 最小实现方法和白名单**
- [x] **Step 3: 最小实现方法和白名单**
使用现有 `request()``pickDefined()``toRequiredPathId()`;不增加兼容别名或 APP fallback。
- [ ] **Step 4: 运行 GREEN**
- [x] **Step 4: 运行 GREEN**
Run:
@@ -110,7 +110,7 @@ Expected: PASS。
- `normalizeCreatedGenealogy(item)`
- `buildCreatedGenealogyUrl(item)`
- [ ] **Step 1: 写失败的创建行为测试**
- [x] **Step 1: 写失败的创建行为测试**
```js
assert.deepEqual(GenealogyEntryPages.buildGenealogyCreateBody({
@@ -141,7 +141,7 @@ assert.deepEqual(GenealogyEntryPages.buildGenealogyCreateBody({
- 创建响应必须有稳定 `genealogyId`、谱名和姓氏。
- 跳转地址只能由响应 ID 产生:`profile-family-home.html?genealogyId=...`
- [ ] **Step 2: 运行 RED**
- [x] **Step 2: 运行 RED**
Run:
@@ -151,11 +151,11 @@ node --test tests/genealogy-entry-pages.test.js
Expected: FAIL,纯函数不存在。
- [ ] **Step 3: 最小实现纯函数**
- [x] **Step 3: 最小实现纯函数**
严格构造 `AppGenealogyCreateBody`,可选空值省略,不读取页面外字段。
- [ ] **Step 4: 运行 GREEN**
- [x] **Step 4: 运行 GREEN**
Run:
@@ -182,7 +182,7 @@ Expected: PASS。
- Consumes: Task 1 和 Task 2、`RegionPages``UploadPages`
- Produces: `initGenealogyCreatePage()`
- [ ] **Step 1: 写失败的页面测试**
- [x] **Step 1: 写失败的页面测试**
断言页面:
@@ -193,7 +193,7 @@ Expected: PASS。
- 表单只包含后端创建 DTO 字段和地区选择辅助字段。
- 创建按钮、状态区域和额度区域可由脚本控制。
- [ ] **Step 2: 运行 RED**
- [x] **Step 2: 运行 RED**
Run:
@@ -203,7 +203,7 @@ node --test tests/genealogy-entry-pages.test.js tests/pending-pages.test.js test
Expected: FAIL,页面仍 pending 且缺上传/初始化行为。
- [ ] **Step 3: 实现页面初始化和提交**
- [x] **Step 3: 实现页面初始化和提交**
初始化顺序:
@@ -218,7 +218,7 @@ Expected: FAIL,页面仍 pending 且缺上传/初始化行为。
401 清登录态;403 保留登录态;业务错误显示在表单状态区域。
- [ ] **Step 4: 运行 GREEN**
- [x] **Step 4: 运行 GREEN**
Run:
@@ -237,11 +237,11 @@ Expected: PASS。
- Modify: `docs/PC接口对接规划.md`
- Modify: `docs/superpowers/plans/2026-07-29-genealogy-create-first-batch.md`
- [ ] **Step 1: 更新契约和阶段状态**
- [x] **Step 1: 更新契约和阶段状态**
记录 `AppGenealogyCreateBody``AppGenealogyVo`、额度、权限、上传派生字段、加入申请 ApiClient 契约及仍未开放的加入 UI。
- [ ] **Step 2: 聚焦和全量验证**
- [x] **Step 2: 聚焦和全量验证**
Run:
@@ -253,6 +253,6 @@ git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
Expected: 全部 PASS。
- [ ] **Step 3: 浏览器验证并报告**
- [x] **Step 3: 浏览器验证并报告**
使用真实账号验证创建页登录态、额度、地区加载、无手填 ID、控制台错误和提交前校验。不得为测试消耗真实创建额度;除非用户明确授权,不执行最终创建写操作。
@@ -0,0 +1,509 @@
# 阶段 6 PC 功能页面 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Project rules reserve code edits for the primary agent; subagents may only perform read-only exploration and review.
**Goal:** 实现所有当前 PC 接口已具备闭环但前端缺失或未开放的谱文、相册/照片、祭祀/祭品、管理员邀约、指定账号绑定和家谱成员管理页面。
**Architecture:** `utils/ApiClient.js` 唯一拥有 PC method/path/query/body;每个业务使用独立 UMD 页面脚本,页面只消费脚本导出的规范化、校验、渲染和初始化接口。`profile-common.js` 提供家谱上下文,`upload-pages.js` 产生 OSS ID,所有写操作使用稳定字符串 ID、防重复提交并在成功后重读。
**Tech Stack:** 静态 HTML、原生 JavaScript UMD、Axios 请求封装、Node `node:test`、现有 profile CSS、wangEditor v5、统一分片上传。
## Global Constraints
- 后端目录 `D:/WorkSpace/Java/Genealogy` 严格只读。
- 只使用 `/genealogy/pc/**`,不得使用 APP 接口或旧路径 fallback。
- 不允许手填业务 ID、用户 ID 或 OSS ID;int64 在浏览器边界保存为十进制字符串。
- 普通列表无法重读停用记录的模块固定提交 `status=0`
- 每个模块严格执行 RED → GREEN → 聚焦测试 → 真实账号非破坏性验证 → 规划更新。
- 当前工作树已有前几阶段改动;不得重置、覆盖或提交无关文件。除非用户另行要求,本计划不创建 Git commit。
---
### Task 1: 扩展 PC ApiClient 契约
**Files:**
- Modify: `utils/ApiClient.js`
- Modify: `tests/api-client-contract.test.js`
**Interfaces:**
- Produces:
- `articles(genealogyId)`, `articleDetail(genealogyId, articleId)`, `createArticle(genealogyId, body)`, `updateArticle(genealogyId, articleId, body)`
- `albums(genealogyId)`, `createAlbum(genealogyId, body)`, `updateAlbum(genealogyId, albumId, body)`, `albumPhotos(genealogyId, albumId)`, `createAlbumPhoto(genealogyId, albumId, body)`
- `ceremonies(genealogyId)`, `ceremonyDetail(genealogyId, ceremonyId)`, `createCeremony`, `updateCeremony`, `ceremonyGifts`, `createCeremonyGift`
- `replaceCeremonyInvitees(genealogyId, ceremonyId, inviteeUserIds)`
- `genealogyMembers(genealogyId)`, `genealogyMemberOptions(genealogyId)`, `updateGenealogyMember`, `removeGenealogyMember`, `leaveGenealogy`, `transferGenealogyOwner`
- [x] **Step 1: 写失败的路径和 body 白名单测试**
```js
await client.createArticle('9007199254740993001', {
articleTitle: '族史',
articleContent: '<p>正文</p>',
coverOssId: '9007199254740993002',
appUserId: 'must-drop',
status: '0'
});
assert.deepEqual(calls[0].data, {
articleTitle: '族史',
articleContent: '<p>正文</p>',
coverOssId: '9007199254740993002',
status: '0'
});
await client.replaceCeremonyInvitees(
'9007199254740993001',
'9007199254740993003',
['9007199254740993004']
);
assert.deepEqual(calls.at(-1).data, {
inviteeUserIds: ['9007199254740993004']
});
```
- [x] **Step 2: 运行 RED**
Run: `node --test --test-name-pattern "article|album|ceremony admin|genealogy member" tests/api-client-contract.test.js`
Expected: FAIL,原因是新方法不存在或仍只有删除方法。
- [x] **Step 3: 最小实现所有方法和白名单**
```js
var ARTICLE_FIELDS = [
'categoryId', 'articleTitle', 'articleSummary', 'coverOssId',
'articleContent', 'authorName', 'sortOrder', 'status'
];
var ALBUM_FIELDS = ['albumName', 'albumDesc', 'coverOssId', 'sortOrder', 'status'];
var ALBUM_PHOTO_FIELDS = [
'ossId', 'photoTitle', 'photoDesc', 'photographer',
'shootTime', 'sortOrder', 'status'
];
var CEREMONY_FIELDS = [
'ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'ceremonyTime',
'location', 'locationAddress', 'longitude', 'latitude',
'coverOssId', 'sortOrder', 'status'
];
var CEREMONY_GIFT_FIELDS = ['giverName', 'giftAmount', 'giftMessage'];
var MEMBER_UPDATE_FIELDS = ['memberName', 'relationName', 'roleType', 'lineagePersonId'];
```
每个方法只通过既有 `request()``pickDefined()``toRequiredPathId()` 和路径 builder 发请求;成员 options 按 YAML 不发送 Query。
- [x] **Step 4: 运行 GREEN 和语法检查**
Run: `node --test tests/api-client-contract.test.js`
Run: `node --check utils/ApiClient.js`
Expected: PASS。
---
### Task 2: 谱文列表、详情、创建、修改和删除
**Files:**
- Create: `public/js/article-pages.js`
- Create: `tests/article-pages.test.js`
- Modify: `profile-article.html`
- Modify: `profile-article-edit.html`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Consumes: Task 1 article ApiClient methods、`ProfileUI.requireGenealogyContext()``ProfileUpload.bindUploadField()``RichEditorPages`
- Produces: `buildArticleBody`, `normalizeArticle`, `normalizeArticles`, `matchesArticle`, `renderArticleList`, `renderArticleDetail`, `initArticleListPage`, `initArticleEditPage`
- [x] **Step 1: 写失败的谱文行为测试**
```js
assert.deepEqual(ArticlePages.buildArticleBody({
articleTitle: '族史',
articleContent: '<p>正文</p>',
coverOssId: '9007199254740993002',
categoryId: '',
status: '1'
}), {
articleTitle: '族史',
articleContent: '<p>正文</p>',
coverOssId: '9007199254740993002',
status: '0'
});
assert.equal(
ArticlePages.normalizeArticle({ articleId: Number.MAX_SAFE_INTEGER + 1 }),
null
);
assert.doesNotMatch(
ArticlePages.renderArticleDetail({
articleId: '9007199254740993003',
articleTitle: '<img src=x onerror=alert(1)>',
articleContent: '<script>alert(1)</script>',
status: '0'
}),
/<script|onerror/
);
```
页面测试必须断言两个 HTML 不再 pending、加载 `article-pages.js`、没有手填 `articleId/categoryId/coverOssId/status`,列表页存在详情区域,编辑页存在标题/摘要/正文/作者/封面上传。
- [x] **Step 2: 运行 RED**
Run: `node --test tests/article-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js tests/pc-scope.test.js`
Expected: FAIL,原因是模块不存在且页面仍为 pending。
- [x] **Step 3: 实现谱文脚本和页面**
```js
function buildArticleBody(values) {
return compact({
articleTitle: requiredText(values.articleTitle, '请输入谱文标题'),
articleSummary: optionalText(values.articleSummary),
coverOssId: normalizeNullableId(values.coverOssId),
articleContent: requiredText(values.articleContent, '请输入谱文正文'),
authorName: optionalText(values.authorName),
sortOrder: normalizeOptionalSafeInteger(values.sortOrder),
status: '0'
});
}
```
列表从直接数组读取;详情按 `articleId` 匹配;列表没有独立分类选项源时不渲染 `categoryId` 输入。新增/修改成功后用返回 ID 或 URL ID 重读详情,删除后重读列表。所有可见文本转义,正文使用现有安全富文本展示规则。
- [x] **Step 4: 运行 GREEN**
Run: `node --test tests/article-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js tests/pc-scope.test.js`
Run: `node --check public/js/article-pages.js`
Expected: PASS。
- [x] **Step 5: 更新规划并报告谱文阶段**
`docs/PC接口对接规划.md` 记录 ArticleBody/ArticleVo 字段、C20 谱文状态、`categoryId` query 差异、封面 URL 和停用记录阻断。
---
### Task 3: 相册列表、相册编辑和照片管理
**Files:**
- Create: `profile-album-edit.html`
- Create: `profile-album-detail.html`
- Create: `public/js/album-pages.js`
- Create: `tests/album-pages.test.js`
- Modify: `profile-album.html`
- Modify: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: Task 1 album ApiClient methods、ProfileUI、ProfileUpload
- Produces: `buildAlbumBody`, `buildAlbumPhotoBody`, `normalizeAlbum`, `normalizeAlbumPhoto`, `findAlbumById`, `renderAlbumList`, `renderPhotoList`, three page initializers
- [x] **Step 1: 写失败的相册/照片测试**
```js
assert.deepEqual(AlbumPages.buildAlbumPhotoBody({
ossId: '9007199254740993010',
photoTitle: '祠堂合影',
shootTime: '2026-07-29T10:30',
status: '1'
}), {
ossId: '9007199254740993010',
photoTitle: '祠堂合影',
shootTime: '2026-07-29 10:30:00',
status: '0'
});
assert.equal(AlbumPages.findAlbumById(
[{ albumId: '9007199254740993011', albumName: '旧影', status: '0' }],
'9007199254740993011'
).albumName, '旧影');
```
页面测试断言 OSS ID 只能是 hidden/upload target,列表链接传播 `genealogyId + albumId`,详情页具有照片上传和真实删除入口。
- [x] **Step 2: 运行 RED**
Run: `node --test tests/album-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js`
Expected: FAIL,原因是脚本和新增页面不存在。
- [x] **Step 3: 实现三个页面和脚本**
```js
function buildAlbumBody(values) {
return compact({
albumName: requiredText(values.albumName, '请输入相册名称'),
albumDesc: optionalText(values.albumDesc),
coverOssId: normalizeNullableId(values.coverOssId),
sortOrder: normalizeOptionalSafeInteger(values.sortOrder),
status: '0'
});
}
```
相册编辑/详情通过重新读取相册列表并按稳定 `albumId` 匹配;照片列表调用 `/photos`。照片新增后重读照片列表;删除照片后重读并更新相册计数;删除相册后返回并重读相册列表。
- [x] **Step 4: 运行 GREEN**
Run: `node --test tests/album-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js`
Run: `node --check public/js/album-pages.js`
Expected: PASS。
- [x] **Step 5: 更新规划并报告相册阶段**
记录 AlbumBody、AlbumPhotoBody、AlbumVo、AlbumPhotoVo、文件 URL 缺口和停用记录阻断。
---
### Task 4: 祭祀活动、祭品和管理员邀约
**Files:**
- Create: `profile-ceremony.html`
- Create: `profile-ceremony-detail.html`
- Create: `public/js/ceremony-admin-pages.js`
- Create: `tests/ceremony-admin-pages.test.js`
- Modify: `profile-gift-edit.html`
- Modify: `profile-gift.html`
- Modify: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: Task 1 ceremony/member ApiClient methods、ProfileUI、ProfileUpload
- Produces: `buildCeremonyBody`, `buildCeremonyGiftBody`, `normalizeCeremony`, `normalizeCeremonyGift`, `normalizeInviteeOption`, `buildInviteeBody`, list/detail/edit initializers
- [x] **Step 1: 写失败的祭祀测试**
```js
assert.deepEqual(CeremonyAdminPages.buildCeremonyGiftBody({
giverName: '宗亲',
giftAmount: '88.50',
giftMessage: '敬献'
}), {
giverName: '宗亲',
giftAmount: 88.5,
giftMessage: '敬献'
});
assert.throws(() => CeremonyAdminPages.buildCeremonyGiftBody({
giftAmount: '-1'
}));
assert.deepEqual(CeremonyAdminPages.buildInviteeBody([
'9007199254740993020',
'9007199254740993020',
'9007199254740993021'
]), {
inviteeUserIds: ['9007199254740993020', '9007199254740993021']
});
```
页面测试断言个人邀请脚本和管理员脚本职责分离;活动编辑没有手填封面 ID;详情页有祭品和受邀成员选择器。
- [x] **Step 2: 运行 RED**
Run: `node --test tests/ceremony-admin-pages.test.js tests/ceremony-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js`
Expected: FAIL,原因是管理模块和页面不存在。
- [x] **Step 3: 实现活动、祭品和邀约管理**
```js
function buildInviteeBody(ids) {
var values = Array.from(new Set(ids.map(normalizeId).filter(Boolean)));
return { inviteeUserIds: values };
}
```
活动固定 `status=0`;经纬度必须成对且范围合法;祭品金额非负并通过精确 JSON 数值检查。管理员候选过滤空 `appUserId`,提交空数组时显示取消全部待响应邀请确认。各写操作完成后重读活动详情、祭品或邀请名单。
- [x] **Step 4: 运行 GREEN**
Run: `node --test tests/ceremony-admin-pages.test.js tests/ceremony-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js`
Run: `node --check public/js/ceremony-admin-pages.js`
Expected: PASS。
- [x] **Step 5: 更新规划并报告祭祀阶段**
记录 CeremonyBody、CeremonyGiftBody、CeremonyInviteesBody、权限与金额/文件/停用阻断。
---
### Task 5: 启用 SPECIFIED 世系账号绑定
**Files:**
- Modify: `public/js/lineage-pages.js`
- Modify: `profile-tree.html`
- Modify: `tests/lineage-pages.test.js`
**Interfaces:**
- Consumes: Task 1 `genealogyMemberOptions(genealogyId)`
- Produces: `normalizeBindingMemberOption`, `renderBindingMemberOptions`;扩展现有表单初始化和 `buildLineagePersonBody`
- [x] **Step 1: 写失败的绑定测试**
```js
assert.deepEqual(LineagePages.normalizeBindingMemberOption({
memberId: '9007199254740993030',
appUserId: '9007199254740993031',
memberName: '族员甲',
appUserNickName: '账号甲',
status: '0'
}), {
value: '9007199254740993031',
label: '族员甲(账号甲)'
});
assert.equal(LineagePages.normalizeBindingMemberOption({
memberId: '9007199254740993032',
appUserId: null,
status: '0'
}), null);
```
页面测试断言 `SPECIFIED` 不再 disabled、只显示成员选择器且不存在 appUserId 文本输入。
- [x] **Step 2: 运行 RED**
Run: `node --test tests/lineage-pages.test.js tests/api-client-contract.test.js`
Expected: FAIL,原因是选项仍禁用且规范化函数不存在。
- [x] **Step 3: 最小启用成员选项绑定**
只在 `canManage` 时显示 `SPECIFIED`;选择后内部保存字符串 `appUserId``NONE/SELF` 必须删除 body 中的 `appUserId``SPECIFIED` 必须选择有效 option;403 作为权限错误处理,不清登录态。
- [x] **Step 4: 运行 GREEN**
Run: `node --test tests/lineage-pages.test.js tests/api-client-contract.test.js`
Run: `node --check public/js/lineage-pages.js`
Expected: PASS。
- [x] **Step 5: 更新规划并报告绑定阶段**
把 C15 更新为“由同家谱正常成员 options 解决;未绑定账号的成员不可选”,保留没有任意 AppUser 搜索能力的边界。
---
### Task 6: 家谱成员管理、退出和所有权转移
**Files:**
- Create: `public/js/member-admin-pages.js`
- Create: `tests/member-admin-pages.test.js`
- Modify: `profile-family-admin.html`
- Modify: `tests/pending-pages.test.js`
**Interfaces:**
- Consumes: Task 1 member ApiClient methods、`lineagePersonOptions`、ProfileUI
- Produces: `normalizeMember`, `normalizeMemberOptions`, `buildMemberUpdateBody`, `canEditMember`, `canRemoveMember`, `canTransferOwner`, `renderMemberList`, `initMemberAdminPage`
- [x] **Step 1: 写失败的成员管理测试**
```js
assert.deepEqual(MemberAdminPages.buildMemberUpdateBody({
memberName: '族员甲',
relationName: '侄',
roleType: 'editor',
lineagePersonId: '9007199254740993040',
appUserId: 'must-drop'
}), {
memberName: '族员甲',
relationName: '侄',
roleType: 'editor',
lineagePersonId: '9007199254740993040'
});
assert.throws(() => MemberAdminPages.buildMemberUpdateBody({
roleType: 'owner'
}));
```
渲染测试断言手机号和内部 ID 不可见;owner 不显示移除按钮;当前 owner 不显示退出按钮而显示所有权转移。
- [x] **Step 2: 运行 RED**
Run: `node --test tests/member-admin-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js`
Expected: FAIL,原因是脚本不存在且页面 pending。
- [x] **Step 3: 实现成员管理页面**
成员列表支持关键词刷新;编辑只提交 `memberName/relationName/roleType/lineagePersonId`。角色选择只有 `admin/editor/member`。移除、退出、转让都二次确认并使用响应中的 memberId;成功后重读成员列表,退出成功后清除当前家谱上下文并返回家谱选择页。
- [x] **Step 4: 运行 GREEN**
Run: `node --test tests/member-admin-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js`
Run: `node --check public/js/member-admin-pages.js`
Expected: PASS。
- [x] **Step 5: 更新规划并报告成员阶段**
记录成员更新字段、角色差异、退出/移除/转让权限与世系关系解除继续阻断。
---
### Task 7: 导航开放、真实验证和阶段收尾
**Files:**
- Modify: `profile.html`
- Modify: `profile-content.html`
- Modify: `profile-families.html`
- Modify: `docs/PC接口对接规划.md`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Consumes: Tasks 16 completed pages
- Produces: 所有功能的稳定导航入口和阶段 6 完成记录
- [x] **Step 1: 写失败的入口行为测试**
```js
for (const page of [
'profile-article.html',
'profile-album.html',
'profile-ceremony.html',
'profile-family-admin.html'
]) {
assert.match(navigationHtml, new RegExp(page.replace('.', '\\.')));
}
```
测试同时断言新增/开放页面不加载 `pending-pages.js`,不存在 APP path、手填 ID 或原始 JSON。
- [x] **Step 2: 运行 RED**
Run: `node --test tests/pending-pages.test.js tests/pc-scope.test.js`
Expected: FAIL,原因是导航尚未开放。
- [x] **Step 3: 开放入口并更新规划**
个人中心和家谱内容入口链接到谱文、相册和祭祀;家谱管理入口链接到成员管理。规划逐模块记录字段表、验证结果、契约差异和唯一未实现的世系关系解除。
- [x] **Step 4: 聚焦和全量自动验证**
Run:
```powershell
node --test tests/article-pages.test.js tests/album-pages.test.js
node --test tests/ceremony-admin-pages.test.js tests/ceremony-pages.test.js
node --test tests/lineage-pages.test.js tests/member-admin-pages.test.js
node --test tests/api-client-contract.test.js tests/pending-pages.test.js tests/pc-scope.test.js
npm test
node --check public/js/article-pages.js
node --check public/js/album-pages.js
node --check public/js/ceremony-admin-pages.js
node --check public/js/member-admin-pages.js
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 全部 PASS。
- [x] **Step 5: 真实账号浏览器验证**
逐页验证登录态、家谱上下文、无数据空状态、筛选、隐私隐藏和控制台错误。真实账号没有家谱时,确认业务请求被阻止且所有入口回到 `profile-families.html?next=...`;不得制造家谱或业务记录。
- [x] **Step 6: 独立只读复核和最终报告**
安排默认子代理只读检查所有阶段 6 文件,主代理按 file:line 抽查。报告使用 `Changed / Verified / Blocked / Next`,明确世系关系解除、文件 URL、停用记录和缺真实数据写入验证的剩余风险。
@@ -0,0 +1,119 @@
# 阶段 6 PC 功能页面设计
## 目标
把当前 PC YAML 与只读后端已经具备完整业务闭环、但前端页面缺失或仍处于待开发状态的功能全部落到现有个人中心中。不得使用 APP 接口、静态业务 ID、手填用户 ID/OSS ID、猜测响应字段或不可重读的停用流程。
## 实施范围
### 1. 谱文
- 开放 `profile-article.html`,提供正常谱文列表、分类筛选、详情和权限化操作入口。
- 开放 `profile-article-edit.html`,提供新增与修改。
- 新增成功后使用响应中的稳定 `articleId` 重读详情;修改后重读同一详情;删除后重读列表。
- `categoryId` 只能来自真实文章分类响应;如果 PC 没有分类列表/选项源,则分类保持省略,不能输入 ID。
- 封面只能通过文件选择和上传产生 `coverOssId`
- 普通列表和详情只读取正常状态,因此创建、修改固定提交 `status=0`,不开放停用。
### 2. 相册与照片
- 开放 `profile-album.html` 作为相册列表入口。
- 新增 `profile-album-edit.html` 负责相册新增、修改。
- 新增 `profile-album-detail.html` 负责相册详情、照片列表、照片上传和照片删除。
- 相册封面与照片文件都必须通过统一分片上传产生 OSS ID。
- 相册或照片写入后重新读取相册/照片列表;删除后重新读取并校正数量。
- 普通接口只返回正常记录,因此不开放相册或照片停用状态。
- 在没有 PC 文件访问 URL 时,只显示上传状态、标题和业务元数据,不拼接 OSS 地址。
### 3. 祭祀活动与祭品
- 保留 `profile-gift.html` 的“我的邀请”能力,并增加进入祭祀活动管理的入口。
- 开放 `profile-gift-edit.html`,负责祭祀活动新增、修改。
- 新增 `profile-ceremony.html`,负责活动列表和详情。
- 新增 `profile-ceremony-detail.html`,负责活动详情、祭品列表、新增祭品、删除祭品和管理员邀约名单。
- 活动封面通过统一上传产生 `coverOssId`
- 祭品金额按 YAML `number` 和后端 `BigDecimal` 边界处理;拒绝负数以及 JSON 数值序列化会改变输入值的金额。
- 活动与祭品写操作成功后重新读取对应详情/列表。
- 普通活动列表只返回正常记录,因此不开放活动停用。
### 4. 管理员邀约名单
- 邀约管理放在 `profile-ceremony-detail.html`,不与当前用户“我的邀请”响应流程混合。
- 候选人来自 `/genealogy/pc/genealogies/{genealogyId}/members/options`
- 只允许选择拥有稳定 `appUserId` 的同家谱正常成员;当前登录人由后端拒绝,前端不猜测替代账号。
- 保存时一次提交完整 `inviteeUserIds`;空数组表示取消所有仍未响应邀请,提交前必须二次确认。
- 保存成功后重新读取活动邀请名单。
### 5. 指定账号绑定世系人物
-`profile-tree.html` 启用 `SPECIFIED`,仅对具有家谱管理权限的用户开放。
- 账号候选来自家谱成员选项;过滤没有 `appUserId` 的成员。
- 用户看到成员昵称/成员名称,不看到或手填 `appUserId`
- `NONE``SELF``SPECIFIED` 继续遵守互斥请求规则;后端负责最终租户、账号状态和重复绑定校验。
### 6. 家谱成员管理
- 开放 `profile-family-admin.html`
- 提供成员列表、关键词搜索、成员详情摘要、成员资料/角色修改、成员移除、当前用户退出家谱和所有权转移。
- 角色编辑只发送后端实际接受的 `admin/editor/member`,不允许把 `owner/visitor` 作为更新值。
- 世系人物关联只能来自真实世系人物选项。
- 所有权转移只能从当前正常成员记录中选择稳定 `memberId`
- 移除、退出和所有权转移必须有明确影响说明与二次确认;成功后重新读取成员列表和家谱上下文。
## 明确保留的阻断
- PC 没有世系关系解除接口,不创建“解除父母/配偶/兄弟关系”按钮。
- PC 没有统一 OSS ID 访问地址解析能力;谱文封面、相册照片、祭祀封面只显示后端已直接返回的 URL,否则不猜 URL。
- 普通谱文、相册、照片和祭祀列表过滤停用记录,详情也不能稳定读取停用项;前端固定提交正常状态。
- 谱文 YAML 遗漏后端可选 `categoryId` 列表 query,且文章分类选项来源需要单独核实;未确认前不发送该 query。
- YAML 中部分 OSS ID 为 string、Java DTO 为 Long;浏览器始终保存十进制字符串,上传响应是唯一来源。
- YAML 的文章、相册和祭祀响应仍为泛型包装;页面只消费对应 PC 控制器明确返回的 `ArticleVo``AlbumVo``AlbumPhotoVo``CeremonyVo``CeremonyGiftVo`,不读取 APP 路径。
## 前端结构
- `utils/ApiClient.js` 是所有 method/path/query/body 的唯一 owner,并为每个 Body 使用字段白名单。
- 每个业务模块使用独立脚本:
- `public/js/article-pages.js`
- `public/js/album-pages.js`
- `public/js/ceremony-admin-pages.js`
- `public/js/member-admin-pages.js`
- `public/js/ceremony-pages.js` 继续只负责当前账号的邀请读取与接受/拒绝,避免管理端与个人端状态混在一起。
- `public/js/profile-common.js` 继续作为 `genealogyId` 的唯一上下文 owner。
- `public/js/upload-pages.js` 继续作为 OSS ID 的唯一前端产生来源。
## 数据流与状态
1. 页面从 URL 和 `profile-common.js` 取得真实家谱上下文。
2. 页面先读取家谱详情,按 `canView/canEditContent/canManage` 控制可见入口;后端继续执行最终权限校验。
3. 列表记录返回稳定字符串 ID,用户点击后进入详情或编辑。
4. 表单只提交 YAML/后端 PC DTO 声明的字段;可选空值默认省略。
5. 写操作使用模块级提交锁,完成后重新读取服务端状态。
6. 页面分别展示加载、空、校验失败、403、404、业务失败和网络失败状态。
7. 所有响应文本经过转义;手机号、内部 ID、OSS ID 和原始 JSON不进入可见页面。
## 页面入口
- 个人中心和家谱管理导航开放谱文、相册、祭祀活动、成员管理入口。
- 页面缺少 `genealogyId` 时返回家谱选择页,并通过 `next` 保留目标页面。
- 已有待开发页面在功能完成并通过测试后移除 `data-feature-status="pending"``pending-pages.js`
- 新增页面沿用现有个人中心头部、侧边栏、按钮、卡片和移动端断点,不引入新的前端框架。
## 测试与验收
每个模块单独执行:
1. 先新增 `ApiClient` 路径、query、body 白名单和长 ID 失败测试并观察 RED。
2. 新增规范化、权限、渲染转义、写后重读、防重复和页面字段测试并观察 RED。
3. 最小实现后运行模块聚焦测试直到 GREEN。
4. 使用真实登录账号验证读取、空状态和非破坏性筛选。
5. 有真实业务数据时验证详情和写操作;没有数据时明确记录未覆盖项,不制造示例数据。
6. 每个模块完成后运行相关测试并更新 `docs/PC接口对接规划.md`
7. 阶段完成后运行 `npm test`、JavaScript 语法检查、`git diff --check` 和凭据扫描。
## 完成标准
- 所有当前 PC 可闭环功能都有可访问页面和导航入口。
- 所有请求字段有真实页面来源,所有 ID 均由响应、选择器或上传产生。
- 不存在 APP 路径、旧路径 fallback、手填业务 ID、原始 JSON或静态业务数据。
- 所有写操作防重复并在成功后重新读取。
- 仍缺后端能力的功能保持明确禁用并写入规划阻断项。
+5 -11
View File
@@ -18,17 +18,11 @@
>
<div class="eyebrow">Join Family</div>
<h1 id="join-title">加入家谱</h1>
<p class="lead">输入亲人分享的家谱ID或从详情页带入的链接,提交后等待管理员审核</p>
<form class="form-grid" data-genealogy-form="join" data-success-url="profile-join-family.html">
<div class="invite-box">
<input class="input" name="genealogyId" inputmode="numeric" placeholder="请输入家谱ID或邀请码" />
</div>
<input class="input" name="applicantName" autocomplete="name" placeholder="你的姓名" />
<input class="input" name="phone" autocomplete="tel" placeholder="手机号" />
<input class="input" name="relationDesc" placeholder="与家族关系说明" />
<textarea class="input" name="applyReason" rows="4" placeholder="申请说明(选填)"></textarea>
<button class="btn primary" type="submit">提交申请</button>
</form>
<p class="lead">请从家谱广场或亲人分享的家谱详情进入申请流程,家谱编号由系统自动带入</p>
<div class="form-grid">
<a class="btn primary" href="plaza.html">前往家谱广场</a>
<a class="btn ghost" href="profile-families.html">返回我的家谱</a>
</div>
</section>
</main>
<script src="config.js"></script>
+120
View File
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>相册详情 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-album-detail-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-album.html">相册</a>
</nav>
<div class="nav-actions">
<a href="profile-album.html" data-genealogy-context-link>返回相册</a>
</div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Album Detail</div>
<div class="module-title-row">
<div>
<h1 data-album-detail-title>相册详情</h1>
<p>查看相册信息和照片;当前 PC 未返回文件访问 URL 时只展示业务元数据。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a>
<a href="profile-article.html">谱文</a>
<a class="active" href="profile-album.html">相册</a>
<a href="profile-video.html">视频</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>相册信息</h2>
<div data-album-detail>
<div class="api-empty">相册加载中...</div>
</div>
</section>
<section class="module-panel">
<h2>照片列表</h2>
<div class="module-list" data-album-photo-list>
<div class="api-empty">照片加载中...</div>
</div>
</section>
<section class="module-panel" data-album-photo-editor hidden>
<h2>添加照片</h2>
<form class="editor-form" data-album-photo-form>
<div class="editor-two">
<div class="editor-field">
<label for="albumPhotoFile">照片文件</label>
<input id="albumPhotoOssId" name="ossId" type="hidden" />
<label class="upload-control" for="albumPhotoFile">选择照片</label>
<input id="albumPhotoFile" class="upload-input" type="file" accept="image/*" data-upload-target="#albumPhotoOssId" data-upload-status="#albumPhotoUploadStatus" />
<p id="albumPhotoUploadStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-field">
<label for="albumPhotoTitle">照片标题</label>
<input id="albumPhotoTitle" name="photoTitle" type="text" placeholder="请输入照片标题" />
</div>
</div>
<div class="editor-field">
<label for="albumPhotoDesc">照片说明</label>
<textarea id="albumPhotoDesc" name="photoDesc" placeholder="请输入照片说明"></textarea>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="albumPhotographer">拍摄人</label>
<input id="albumPhotographer" name="photographer" type="text" placeholder="请输入拍摄人" />
</div>
<div class="editor-field">
<label for="albumShootTime">拍摄时间</label>
<input id="albumShootTime" name="shootTime" type="datetime-local" />
</div>
</div>
<div class="editor-field">
<label for="albumPhotoSortOrder">排序</label>
<input id="albumPhotoSortOrder" name="sortOrder" type="number" step="1" placeholder="数字越小越靠前" />
</div>
<input name="status" type="hidden" value="0" />
<p class="api-status" data-album-photo-status></p>
<div class="bottom-actions">
<button class="btn primary" type="submit">添加照片</button>
</div>
</form>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/FormUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/album-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>编辑相册 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-album-edit-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-album.html">相册</a>
</nav>
<div class="nav-actions">
<a href="profile-album.html" data-genealogy-context-link>返回相册</a>
<button class="btn primary magnetic" type="submit" form="album-edit-form" data-album-editor-action hidden>保存</button>
</div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Album Editor</div>
<div class="module-title-row">
<div>
<h1 data-album-editor-title>新建相册</h1>
<p>维护相册名称、说明和封面;封面编号由上传自动产生。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a>
<a href="profile-article.html">谱文</a>
<a class="active" href="profile-album.html">相册</a>
<a href="profile-video.html">视频</a>
</aside>
<div class="module-main">
<section class="module-panel">
<div class="api-empty" data-album-editor-placeholder>正在读取家谱权限...</div>
<form id="album-edit-form" class="editor-form" data-album-form data-album-editor hidden>
<div class="editor-field">
<label for="albumName">相册名称</label>
<input id="albumName" name="albumName" type="text" required placeholder="请输入相册名称" />
</div>
<div class="editor-field">
<label for="albumDesc">相册说明</label>
<textarea id="albumDesc" name="albumDesc" placeholder="请输入相册说明"></textarea>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="albumCoverFile">封面文件</label>
<input id="albumCoverOssId" name="coverOssId" type="hidden" />
<label class="upload-control" for="albumCoverFile">选择封面图片</label>
<input id="albumCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#albumCoverOssId" data-upload-status="#albumCoverStatus" />
<p id="albumCoverStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-field">
<label for="albumSortOrder">排序</label>
<input id="albumSortOrder" name="sortOrder" type="number" step="1" placeholder="数字越小越靠前" />
</div>
</div>
<input name="status" type="hidden" value="0" />
<p class="api-status" data-album-form-status></p>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存相册</button>
<a class="btn ghost" href="profile-album.html" data-genealogy-context-link>取消</a>
</div>
</form>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/FormUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/album-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+17 -84
View File
@@ -8,20 +8,21 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-album-page data-feature-status="pending" data-feature-message="家族相册服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-album-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a
>
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-album.html">相册</a
><a href="profile-video.html">视频</a>
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-album.html">相册</a>
<a href="profile-video.html">视频</a>
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a
><a class="btn primary magnetic" href="#album-form">新建相册</a>
<a href="profile-content.html">返回发布中心</a>
<a class="btn primary magnetic" href="profile-album-edit.html" data-album-create-link hidden>新建相册</a>
</div>
</div>
</header>
@@ -32,9 +33,7 @@
<div class="module-title-row">
<div>
<h1>家族相册</h1>
<p>
管理相册名称、封面、描述和照片,作为个人中心内的独立相册管理页。
</p>
<p>查看和管理家谱相册;照片文件通过上传自动关联,不展示 OSS ID。</p>
</div>
</div>
</div>
@@ -42,82 +41,19 @@
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a href="profile-article.html">谱文</a
><a class="active" href="profile-album.html">相册</a
><a href="profile-video.html">视频</a
><a href="profile-family-home.html">家谱主页</a>
<a href="profile-content.html">内容发布</a>
<a href="profile-article.html">谱文</a>
<a class="active" href="profile-album.html">相册</a>
<a href="profile-video.html">视频</a>
<a href="profile-family-home.html">家谱主页</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>相册列表</h2>
<!-- 相册列表由 /albums 接口渲染,点击后加载照片 -->
<div class="module-list" data-album-list>
<div class="api-empty">相册加载中...</div>
</div>
</section>
<section class="module-panel">
<h2>新建 / 编辑相册</h2>
<form id="album-form" class="editor-form" data-album-form>
<div class="editor-field">
<label for="albumName">相册名称</label>
<input id="albumName" name="albumName" type="text" placeholder="请输入相册名称" required />
</div>
<div class="editor-field">
<label for="albumDesc">相册描述</label>
<textarea id="albumDesc" name="albumDesc" placeholder="请输入相册描述"></textarea>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="albumCoverFile">封面文件</label>
<input id="coverOssId" name="coverOssId" type="hidden" />
<label class="upload-control" for="albumCoverFile">选择封面图片</label>
<input id="albumCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#coverOssId" data-upload-status="#albumCoverStatus" />
<p id="albumCoverStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-field">
<label for="albumStatus">状态</label>
<select id="albumStatus" name="status">
<option value="0">启用</option>
<option value="1">停用</option>
</select>
</div>
</div>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存相册</button>
<button class="btn ghost" type="reset">清空</button>
</div>
</form>
</section>
<section class="module-panel">
<h2>照片管理</h2>
<!-- 选择相册后由 /albums/{albumId}/photos 接口渲染 -->
<div class="module-list" data-album-photo-list>
<div class="api-empty">请选择相册查看照片</div>
</div>
<form class="editor-form album-photo-form" data-album-photo-form>
<div class="editor-two">
<div class="editor-field">
<label for="albumPhotoFile">照片文件</label>
<input id="photoOssId" name="ossId" type="hidden" required />
<label class="upload-control" for="albumPhotoFile">选择照片</label>
<input id="albumPhotoFile" class="upload-input" type="file" accept="image/*" data-upload-target="#photoOssId" data-upload-status="#albumPhotoStatus" />
<p id="albumPhotoStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-field">
<label for="photoTitle">照片标题</label>
<input id="photoTitle" name="photoTitle" type="text" placeholder="请输入照片标题" />
</div>
</div>
<div class="editor-field">
<label for="photoDesc">照片说明</label>
<textarea id="photoDesc" name="photoDesc" placeholder="请输入照片说明"></textarea>
</div>
<div class="bottom-actions">
<button class="btn primary" type="submit">添加照片</button>
</div>
</form>
</section>
</div>
</div>
</section>
@@ -128,13 +64,10 @@
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/FormUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/album-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+18 -11
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="public/css/profile-module.css" />
<link rel="stylesheet" href="public/js/wangeditor5/css/style.css" />
</head>
<body class="page-profile-module" data-article-edit-page data-feature-status="pending" data-feature-message="谱文服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-article-edit-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-article.html">返回谱文</a
><button class="btn primary magnetic" type="submit" form="article-edit-form">保存</button>
><button class="btn primary magnetic" type="submit" form="article-edit-form" data-article-editor-action hidden>保存</button>
</div>
</div>
</header>
@@ -31,7 +31,7 @@
<div class="module-kicker">Article Editor</div>
<div class="module-title-row">
<div>
<h1>编辑谱文</h1>
<h1 data-article-editor-title>新建谱文</h1>
<p>
使用富文本编辑器撰写谱文、谱序、谱论和恩荣录,支持图片、表格与排版。
</p>
@@ -50,7 +50,8 @@
<div class="module-main">
<section class="module-panel">
<h2>谱文内容</h2>
<form id="article-edit-form" class="editor-form layui-form" data-article-form>
<div class="api-empty" data-article-editor-placeholder>正在读取家谱权限...</div>
<form id="article-edit-form" class="editor-form layui-form" data-article-form data-article-editor hidden>
<div class="editor-field">
<label for="articleTitle">谱文标题</label
><input
@@ -62,12 +63,6 @@
/>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="articleCategory">谱文类型</label
><select id="articleCategory" name="categoryId" data-article-category>
<option value="">分类加载中...</option>
</select>
</div>
<div class="editor-field">
<label for="articleSignature">落款</label
><input
@@ -77,6 +72,16 @@
placeholder="请输入落款"
/>
</div>
<div class="editor-field">
<label for="articleSortOrder">排序</label
><input
id="articleSortOrder"
name="sortOrder"
type="number"
step="1"
placeholder="数字越小越靠前"
/>
</div>
</div>
<div class="editor-field">
<label for="articleContent">正文</label
@@ -99,6 +104,8 @@
<input id="articleCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#articleCoverOssId" data-upload-status="#articleCoverStatus" />
<p id="articleCoverStatus" class="upload-status">未选择文件</p>
</div>
<input name="status" type="hidden" value="0" />
<p class="api-status" data-article-form-status></p>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button
><a class="btn ghost magnetic" href="profile-article.html"
@@ -125,7 +132,7 @@
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/article-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+6 -24
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-article-page data-feature-status="pending" data-feature-message="谱文服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-article-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -20,7 +20,7 @@
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a
><a class="btn primary magnetic" href="profile-article-edit.html"
><a class="btn primary magnetic" href="profile-article-edit.html" data-article-create-link hidden
>添加谱文</a
>
</div>
@@ -51,32 +51,14 @@
<div class="module-main">
<section class="module-panel">
<h2>谱文列表</h2>
<!-- 谱文列表由 /articles 接口渲染 -->
<div class="module-list" data-article-list>
<div class="api-empty">谱文加载中...</div>
</div>
</section>
<section class="module-panel">
<h2>新建谱文</h2>
<div class="form-like">
<p>
<span>谱文标题</span><b>待输入</b
><a class="link-btn" href="profile-article-edit.html">填写</a>
</p>
<p>
<span>谱文内容</span><b>待输入</b
><a class="link-btn" href="profile-article-edit.html">编辑</a>
</p>
<p>
<span>图片质量</span><b>原图</b
><a
class="link-btn"
href="#"
data-layer-select="原图|高清|普通"
data-select-title="选择图片质量"
>选择</a
>
</p>
<h2>谱文详情</h2>
<div data-article-detail>
<div class="api-empty">请选择一篇谱文查看详情</div>
</div>
</section>
</div>
@@ -92,7 +74,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/article-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>活动详情 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-ceremony-detail-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a>
<nav class="nav-links"><a href="profile-content.html">内容发布</a><a class="active" href="profile-ceremony.html">祭祀活动</a></nav>
<div class="nav-actions"><a href="profile-ceremony.html">返回活动列表</a><a class="btn primary magnetic" href="profile-gift-edit.html" data-ceremony-editor-action hidden>编辑活动</a></div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container"><div class="module-kicker">Ceremony Detail</div><div class="module-title-row"><div><h1 data-ceremony-detail-title>活动详情</h1><p>活动信息、祭品记录与管理员受邀名单。</p></div></div></div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav"><a href="profile-content.html">内容发布</a><a href="profile-gift.html">我的邀请</a><a class="active" href="profile-ceremony.html">祭祀活动</a><a href="profile-merit.html">功德录</a></aside>
<div class="module-main">
<section class="module-panel"><h2>活动信息</h2><div data-ceremony-detail aria-live="polite"><div class="api-empty">正在加载活动详情…</div></div></section>
<section class="module-panel">
<h2>祭品记录</h2>
<div class="module-list" data-ceremony-gift-list aria-live="polite"><div class="api-empty">正在加载祭品…</div></div>
<form class="editor-form" data-ceremony-gift-form data-ceremony-admin-editor hidden>
<div class="editor-two">
<div class="editor-field"><label for="ceremonyGiverName">赠送人姓名</label><input id="ceremonyGiverName" name="giverName" type="text" placeholder="可选" /></div>
<div class="editor-field"><label for="ceremonyGiftAmount">礼金金额</label><input id="ceremonyGiftAmount" name="giftAmount" type="number" min="0" step="any" required /></div>
</div>
<div class="editor-field"><label for="ceremonyGiftMessage">祭品留言</label><textarea id="ceremonyGiftMessage" name="giftMessage" placeholder="可选"></textarea></div>
<p class="upload-status" data-ceremony-gift-status aria-live="polite"></p>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">添加祭品</button></div>
</form>
</section>
<section class="module-panel" data-ceremony-admin-editor hidden>
<h2>受邀名单</h2>
<div class="module-list" data-ceremony-invitation-list aria-live="polite"><div class="api-empty">正在加载邀请记录…</div></div>
<form class="editor-form" data-ceremony-invitee-form>
<p>仅显示当前家谱中已绑定业务账号的正常成员;提交完整勾选名单,取消勾选会撤销尚未响应的邀请。</p>
<div class="editor-choice-list" data-ceremony-invitee-options><div class="api-empty">正在加载成员选项…</div></div>
<p class="upload-status" data-ceremony-invitee-status aria-live="polite"></p>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">更新受邀名单</button></div>
</form>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/ceremony-admin-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>祭祀活动 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-ceremony-list-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a>
<nav class="nav-links"><a href="profile-content.html">内容发布</a><a class="active" href="profile-ceremony.html">祭祀活动</a></nav>
<div class="nav-actions"><a href="profile-content.html">返回发布中心</a><a class="btn primary magnetic" href="profile-gift-edit.html" data-ceremony-create-link data-genealogy-context-link hidden>创建活动</a></div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Ceremony</div>
<div class="module-title-row"><div><h1>祭祀活动</h1><p>查看当前家谱的活动;具备内容维护权限时可创建、编辑和删除。</p></div></div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav"><a href="profile-content.html">内容发布</a><a href="profile-gift.html">我的邀请</a><a class="active" href="profile-ceremony.html">祭祀活动</a><a href="profile-merit.html">功德录</a></aside>
<div class="module-main">
<section class="module-panel">
<div class="module-panel-head"><h2>活动列表</h2><a class="pill" href="profile-gift-edit.html" data-ceremony-create-link data-genealogy-context-link hidden>创建活动</a></div>
<div class="module-list" data-ceremony-list aria-live="polite"><div class="api-empty">正在加载祭祀活动…</div></div>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/ceremony-admin-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+9 -13
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-content-page data-feature-status="pending" data-feature-message="除家族圈动态外,其他内容服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-content-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -42,7 +42,7 @@
家谱主页里的谱文、视频、相册、功德录、贺礼邀请、成长日志、动态和备忘录。
</p>
</div>
<a class="btn primary magnetic" href="profile-article.html"
<a class="btn primary magnetic" href="profile-article.html" data-feature-link="available" data-genealogy-context-link
>添加谱文</a
>
</div>
@@ -62,27 +62,27 @@
<section class="module-panel">
<h2>发布入口</h2>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-article.html"
<a class="module-card tilt-card" href="profile-article.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>谱文</h3>
<p>添加谱文标题、内容、落款和图片质量设置。</p></a
>
<a class="module-card tilt-card" href="profile-album.html"
<a class="module-card tilt-card" href="profile-album.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>家族相册</h3>
<p>新建相册、编辑封面、上传照片。</p></a
>
<a class="module-card tilt-card" href="profile-video.html"
<a class="module-card tilt-card" href="profile-video.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>家族视频</h3>
<p>发布视频、编辑家族视频和视频描述。</p></a
>
<a class="module-card tilt-card" href="profile-merit.html"
<a class="module-card tilt-card" href="profile-merit.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>功德录</h3>
<p>添加功德人,记录事迹和时间。</p></a
>
<a class="module-card tilt-card" href="profile-gift.html"
<a class="module-card tilt-card" href="profile-gift.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>贺礼邀请</h3>
<p>创建婚礼、升学、生日等贺礼邀请。</p></a
@@ -110,11 +110,8 @@
</div>
</section>
<section class="module-panel">
<h2>最近内容</h2>
<!-- 最近内容先接谱文接口,其他内容类型按后续编号逐步接入 -->
<div class="module-list" data-recent-content>
<div class="api-empty">最近内容加载中...</div>
</div>
<h2>使用说明</h2>
<div class="api-empty">请先选择家谱,再进入对应模块查看和维护真实内容。</div>
</section>
</div>
</div>
@@ -129,7 +126,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+23 -5
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-feature-status="pending" data-feature-message="创建家谱服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-genealogy-create-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -48,8 +48,8 @@
<div class="module-main">
<section class="module-panel">
<h2>基础信息</h2>
<!-- 个人中心创建家谱复用创建接口,字段对应 GenealogyCreateBody。 -->
<form class="editor-form profile-create-family-form" data-genealogy-form="create" data-success-url="profile-families.html">
<p class="upload-status" data-genealogy-create-quota>正在读取家谱额度…</p>
<form class="editor-form profile-create-family-form" data-genealogy-create-form>
<div class="editor-two">
<div class="editor-field">
<label for="familySurname">姓氏</label>
@@ -93,6 +93,20 @@
<label for="intro">家谱简介</label>
<textarea id="intro" name="intro"></textarea>
</div>
<div class="editor-field">
<label for="familyCoverFile">家谱封面</label>
<input id="familyCoverOssId" name="coverOssId" type="hidden" />
<label class="upload-control" for="familyCoverFile">选择封面图片</label>
<input
id="familyCoverFile"
class="upload-input"
type="file"
accept="image/*"
data-upload-target="#familyCoverOssId"
data-upload-status="#familyCoverStatus"
/>
<p id="familyCoverStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="visibility">可见范围</label>
@@ -111,8 +125,9 @@
</select>
</div>
</div>
<p class="upload-status" data-genealogy-create-status aria-live="polite"></p>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">确定创建</button>
<button class="btn primary magnetic" type="submit" data-genealogy-create-submit>确定创建</button>
<a class="btn ghost magnetic" href="profile-families.html">返回列表</a>
</div>
</form>
@@ -130,8 +145,11 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/region-pages.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/genealogy-entry-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+4 -4
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-feature-status="pending" data-feature-message="家谱列表服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -38,7 +38,7 @@
<div>
<h1>我的家谱</h1>
<p>
这里进入的家谱。当前 PC 只提供家谱额度查询,列表和详情入口等待后端补齐后再开放
真实列表选择要进入的家谱,后续页面会持续使用同一个家谱上下文
</p>
</div>
<a class="btn primary magnetic" href="profile-create-family.html"
@@ -61,8 +61,9 @@
<section class="module-panel">
<h2>家谱入口</h2>
<p>对应 PC 的“我的家谱”入口;进入具体业务前必须取得真实家谱编号。</p>
<p class="api-status" data-genealogy-entry-status>正在读取我的家谱…</p>
<div class="module-list" data-genealogy-list="mine">
<div class="api-empty" data-genealogy-entry-status>正在读取家谱入口状态</div>
<div class="api-empty">正在加载列表</div>
</div>
</section>
<section class="module-panel">
@@ -95,7 +96,6 @@
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/genealogy-entry-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="public/js/page-effects.js"></script>
</body>
+47 -77
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
@@ -8,26 +8,12 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-member-admin-page data-feature-status="pending" data-feature-message="家谱成员与权限服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-member-admin-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" /></a
>
<nav class="nav-links">
<a href="profile.html">首页</a
><a class="active" href="profile-families.html">数字家谱</a
><a href="plaza.html">家谱广场</a
><a href="profile-content.html">家族文化</a
><a href="surname.html">姓氏百科</a><a href="app.html">应用下载</a
><a href="help.html">帮助中心</a><a href="about.html">关于我们</a>
</nav>
<div class="nav-actions">
<a href="profile.html">个人中心</a
><a class="btn primary magnetic" href="profile-create-family.html"
>创建家谱</a
>
</div>
<a class="brand magnetic" href="profile.html"><img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" /></a>
<nav class="nav-links"><a href="profile.html">首页</a><a class="active" href="profile-families.html">数字家谱</a><a href="profile-content.html">家族文化</a></nav>
<div class="nav-actions"><a href="profile.html">个人中心</a><a class="btn primary magnetic" href="profile-tree.html" data-genealogy-context-link>世系人物</a></div>
</div>
</header>
<main>
@@ -35,74 +21,58 @@
<div class="container">
<div class="module-kicker">Family Admin</div>
<div class="module-title-row">
<div>
<h1 data-member-title>家族管理</h1>
<p>
承接 APP
里的世代管理、管理员权限、入谱审核和邀请绑定等后台能力。
</p>
</div>
<a class="btn primary magnetic" href="profile-data.html"
>添加成员</a
>
<div><h1 data-member-title>家族管理</h1><p>查看家谱成员;具备管理权限时可调整成员信息、角色和世系人物绑定。</p></div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-families.html">我的家谱</a
><a href="profile-messages.html">消息中心</a
><a class="active" href="profile-family-admin.html">家族管理</a
><a href="profile-content.html">内容发布</a
><a href="profile-data.html">个人资料</a
><a href="profile-services.html">帮助与服务</a>
</aside>
<aside class="module-nav"><a href="profile-families.html">我的家谱</a><a href="profile-messages.html">消息中心</a><a class="active" href="profile-family-admin.html" data-genealogy-context-link>家族管理</a><a href="profile-tree.html" data-genealogy-context-link>世系图</a><a href="profile-generation.html" data-genealogy-context-link>字辈谱</a></aside>
<div class="module-main">
<section class="module-panel">
<h2>管理入口</h2>
<p data-member-overview>家谱概览加载中...</p>
<h2>家谱概览</h2>
<p data-member-overview>正在加载家谱和当前权限…</p>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-tree.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>世系图</h3>
<p>查看树谱模式、搜索成员、进入个人资料</p></a
>
<a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>字辈谱</h3>
<p>维护字辈和各世代人数。</p></a
>
<a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>设置世代</h3>
<p>新增、编辑、调整始祖世代。</p></a
>
<a class="module-card tilt-card" href="profile-messages.html"
><span class="icon"></span>
<h3>入谱审核</h3>
<p>审核加入家谱与绑定账号申请。</p></a
>
<a
class="module-card tilt-card"
href="profile-admin-permissions.html"
><span class="icon"></span>
<h3>管理员权限</h3>
<p>分配全权、全系、支系管理员。</p></a
>
<a class="module-card tilt-card" href="profile-invite.html"
><span class="icon"></span>
<h3>邀请家人</h3>
<p>生成邀请入口,让亲人加入协作。</p></a
>
<a class="module-card tilt-card" href="profile-tree.html" data-feature-link="available" data-genealogy-context-link><span class="icon"></span><h3>世系图</h3><p>查看或维护世系人物与亲属关系。</p></a>
<a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link><span class="icon"></span><h3>字辈谱</h3><p>查看或维护家谱字辈。</p></a>
<a class="module-card tilt-card" href="profile-ceremony.html" data-genealogy-context-link><span class="icon"></span><h3>祭祀活动</h3><p>维护活动、祭品与受邀名单。</p></a>
<a class="module-card tilt-card" href="profile-article.html" data-genealogy-context-link><span class="icon"></span><h3>谱文</h3><p>查看或维护当前家谱谱文</p></a>
</div>
</section>
<section class="module-panel">
<h2>当前管理</h2>
<!-- 成员列表由 /members 接口渲染,权限按钮会提交 /members/{memberId} -->
<div class="module-list" data-member-list>
<div class="api-empty">成员加载中...</div>
</div>
<h2>正常成</h2>
<p>成员新增必须通过后端已有的加入或邀请流程;本页不伪造直接新增成员。</p>
<div class="module-list" data-member-list aria-live="polite"><div class="api-empty">成员加载中…</div></div>
</section>
<section class="module-panel" data-member-management hidden>
<h2 data-member-form-title>编辑成员</h2>
<form class="editor-form" data-member-form hidden>
<div class="editor-two">
<div class="editor-field"><label for="memberAdminName">成员名称</label><input id="memberAdminName" name="memberName" type="text" maxlength="50" /></div>
<div class="editor-field"><label for="memberAdminRelation">与家谱关系</label><input id="memberAdminRelation" name="relationName" type="text" maxlength="100" /></div>
</div>
<div class="editor-two">
<div class="editor-field"><label for="memberAdminRole">成员角色</label><select id="memberAdminRole" name="roleType" data-member-role-options></select></div>
<div class="editor-field"><label for="memberAdminLineage">绑定世系人物</label><select id="memberAdminLineage" name="lineagePersonId" data-member-lineage-options><option value="">不修改世系人物绑定</option></select></div>
</div>
<p class="upload-status">当前 PC 更新接口不提供解除世系人物绑定;留空表示不修改,不会发送空 ID。</p>
<p class="upload-status" data-member-form-status aria-live="polite"></p>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存成员</button><button class="btn ghost" type="reset">取消</button></div>
</form>
</section>
<section class="module-panel" data-owner-transfer-section hidden>
<h2>转让谱主</h2>
<form class="editor-form" data-owner-transfer-form>
<div class="editor-field"><label for="ownerTransferTarget">新谱主</label><select id="ownerTransferTarget" name="targetMemberId" data-owner-transfer-target><option value="">请选择新谱主</option></select></div>
<p>只能选择当前正常成员。转让后当前账号变为管理员,新谱主受其账号家谱拥有额度约束。</p>
<p class="upload-status" data-owner-transfer-status aria-live="polite"></p>
<div class="bottom-actions"><button class="btn ghost" type="submit">确认转让谱主</button></div>
</form>
</section>
<section class="module-panel" data-member-leave-section hidden>
<h2>退出家谱</h2>
<p>退出只停用当前账号的成员关系,不删除世系人物资料;谱主必须先转让谱主。</p>
<div class="bottom-actions"><button class="btn ghost" type="button" data-member-leave>退出当前家谱</button></div>
</section>
</div>
</div>
@@ -117,7 +87,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/member-admin-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+12 -4
View File
@@ -49,7 +49,7 @@
<aside class="module-nav">
<a class="active" href="profile-family-home.html" data-genealogy-context-link>家谱主页</a
><a href="profile-tree.html" data-genealogy-context-link>世系图</a
><a href="profile-album.html">相册</a
><a href="profile-album.html" data-feature-link="available" data-genealogy-context-link>相册</a
><a href="profile-content.html">内容发布</a
><a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a
><a href="profile-messages.html">消息中心</a>
@@ -84,18 +84,26 @@
<section class="module-panel">
<h2>常用入口</h2>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-article.html"
<a class="module-card tilt-card" href="profile-article.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>谱文</h3>
<p>管理谱文、谱序和族史。</p></a
><a class="module-card tilt-card" href="profile-video.html"
><a class="module-card tilt-card" href="profile-album.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>相册</h3>
<p>维护家族相册和照片。</p></a
><a class="module-card tilt-card" href="profile-video.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>视频</h3>
<p>发布和编辑家族视频。</p></a
><a class="module-card tilt-card" href="profile-merit.html"
><a class="module-card tilt-card" href="profile-merit.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>功德录</h3>
<p>记录功德人和事迹。</p></a
><a class="module-card tilt-card" href="profile-ceremony.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>祭祀活动</h3>
<p>维护活动、祭品和受邀名单。</p></a
>
</div>
</section>
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>动态详情 - 家族圈 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-feed-detail-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a
>
<nav class="nav-links">
<a href="profile-family-home.html">家谱主页</a
><a class="active" href="profile-feed.html" data-feed-list-link data-genealogy-context-link>家族圈</a>
</nav>
<div class="nav-actions">
<a href="profile-feed.html" data-feed-list-link data-genealogy-context-link>返回动态列表</a>
</div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Feed Detail</div>
<div class="module-title-row">
<div>
<h1>动态详情</h1>
<p>查看动态、评论与回复;本页链接可用于通知跳转和分享。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-feed.html" data-feed-list-link data-genealogy-context-link>家族圈</a
><a href="profile-album.html">相册</a
><a href="profile-video.html">视频</a>
</aside>
<div class="module-main">
<section class="module-panel" data-feed-detail>
<div class="api-empty">动态详情加载中...</div>
</section>
<section class="module-panel">
<h2>评论与回复</h2>
<div data-feed-detail-comments>
<div class="api-empty">评论加载中...</div>
</div>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/feed-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+11 -12
View File
@@ -20,7 +20,7 @@
><a class="active" href="profile-feed.html">发布动态</a>
</nav>
<div class="nav-actions">
<a href="profile-feed.html" data-feed-list-link>返回动态</a
<a href="profile-feed.html" data-feed-list-link data-genealogy-context-link>返回动态</a
><button class="btn primary magnetic" type="submit" form="feed-edit-form">发布</button>
</div>
</div>
@@ -61,29 +61,28 @@
></textarea>
</div>
<div class="editor-field">
<label for="feedMedia">图片 OSS ID</label>
<input id="feedMedia" name="mediaOssIds" type="text" placeholder="多个图片 ID 用英文逗号分隔" />
<!-- 当前上传接口一次只接收一个文件;多张图片请使用逗号分隔 OSS ID。 -->
<label for="feedMediaFile">动态图片</label>
<input id="feedMedia" name="mediaOssIds" type="hidden" />
<label class="upload-control" for="feedMediaFile">选择动态图片</label>
<input id="feedMediaFile" class="upload-input" type="file" accept="image/*" data-upload-target="#feedMedia" data-upload-status="#feedMediaStatus" />
<p id="feedMediaStatus" class="upload-status">未选择文件</p>
<input id="feedMediaFile" class="upload-input" type="file" accept="image/*" multiple data-upload-multiple="true" data-upload-target="#feedMedia" data-upload-status="#feedMediaStatus" />
<p id="feedMediaStatus" class="upload-status">未选择图片</p>
</div>
<div class="editor-two">
<div class="editor-two" data-feed-advanced hidden>
<div class="editor-field">
<label for="feedStatus">状态</label>
<select id="feedStatus" name="status">
<option value="0">发布</option>
<option value="1">草稿</option>
<select id="feedStatus" name="status" disabled>
<option value="0">正常</option>
<option value="1">停用</option>
</select>
</div>
<div class="editor-field">
<label for="feedSortOrder">排序值</label>
<input id="feedSortOrder" name="sortOrder" type="number" min="0" value="0" />
<input id="feedSortOrder" name="sortOrder" type="number" min="0" value="0" disabled />
</div>
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">发布</button
><a class="btn ghost magnetic" href="profile-feed.html" data-feed-list-link
><a class="btn ghost magnetic" href="profile-feed.html" data-feed-list-link data-genealogy-context-link
>取消</a
>
</div>
+17 -12
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>发布动态 - 个人中心 - 代代相传</title>
<title>家族圈 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
@@ -17,11 +17,11 @@
<nav class="nav-links">
<a href="profile-family-home.html">家谱主页</a
><a href="profile-content.html">内容发布</a
><a class="active" href="profile-feed.html">发布动态</a>
><a class="active" href="profile-feed.html">家族圈</a>
</nav>
<div class="nav-actions">
<a href="profile-family-home.html">返回家谱主页</a
><a class="btn primary magnetic" href="profile-feed-edit.html" data-feed-create-link
><a class="btn primary magnetic" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link
>发布</a
>
</div>
@@ -33,7 +33,7 @@
<div class="module-kicker">Family Feed</div>
<div class="module-title-row">
<div>
<h1>发布动态</h1>
<h1>家族圈</h1>
<p>向家族圈发布文字和图片动态,与家人交流近况。</p>
</div>
</div>
@@ -43,31 +43,31 @@
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-feed.html">发布动态</a
><a class="active" href="profile-feed.html">家族圈</a
><a href="profile-album.html">相册</a
><a href="profile-video.html">视频</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>动态发布</h2>
<h2>发布动态</h2>
<div class="form-like">
<p>
<span>内容</span><b>在发布页填写</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>编辑</a>
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>编辑</a>
</p>
<p>
<span>图片</span><b>添加图片 OSS ID</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>添加</a
<span>图片</span><b>直接选择并上传多张图片</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>添加</a
>
</p>
<p>
<span>动态类型</span><b>图文动态</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>编辑</a
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>编辑</a
>
</p>
<p>
<span>发布状态</span><b>发布或草稿</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link>设置</a
<span>发布状态</span><b>正常或停用</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>设置</a
>
</p>
</div>
@@ -78,6 +78,11 @@
<div class="module-list" data-feed-list>
<div class="api-empty">动态加载中...</div>
</div>
<div class="row-actions">
<button class="pill" type="button" data-feed-page-prev disabled>上一页</button>
<span data-feed-page-label>第 1 页</span>
<button class="pill" type="button" data-feed-page-next disabled>下一页</button>
</div>
</section>
</div>
</div>
+4 -1
View File
@@ -37,6 +37,9 @@
class="btn primary magnetic"
type="button"
data-generation-add
data-generation-management
hidden
disabled
>新增字辈</button>
</div>
</div>
@@ -49,7 +52,7 @@
><a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a>
</aside>
<div id="generation-content" class="module-main">
<section class="module-panel generation-batch-panel">
<section class="module-panel generation-batch-panel" data-generation-management hidden>
<h2>批量字辈</h2>
<!-- 批量字辈表单:字段对应 Apifox 的 GenerationPoemBatchBody。 -->
<form class="editor-form generation-batch-form" data-generation-batch-form>
+31 -17
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="public/css/profile-module.css" />
<link rel="stylesheet" href="public/js/wangeditor5/css/style.css" />
</head>
<body class="page-profile-module" data-ceremony-edit-page data-feature-status="pending" data-feature-message="当前 PC 未提供活动创建或编辑接口,页面保持设计预览。">
<body class="page-profile-module" data-ceremony-edit-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -31,8 +31,8 @@
<div class="module-kicker">Gift Editor</div>
<div class="module-title-row">
<div>
<h1>创建贺礼</h1>
<p>维护贺礼类型、标题、时间地点、通知人员和邀请正文</p>
<h1 data-ceremony-editor-title>创建祭祀活动</h1>
<p>维护活动类型、标题、时间地点和说明;受邀名单在活动详情中选择家谱成员</p>
</div>
</div>
</div>
@@ -47,25 +47,20 @@
</aside>
<div class="module-main">
<section class="module-panel">
<h2>贺礼信息</h2>
<form id="ceremony-edit-form" class="editor-form layui-form" data-ceremony-form>
<h2>活动信息</h2>
<div class="api-empty" data-ceremony-editor-placeholder>正在加载活动编辑信息…</div>
<form id="ceremony-edit-form" class="editor-form layui-form" data-ceremony-form data-ceremony-editor hidden>
<div class="editor-two">
<div class="editor-field">
<label for="giftType">贺礼类型</label
><select id="giftType" name="ceremonyType">
<option value="wedding">婚礼</option>
<option value="education">升学</option>
<option value="birthday">生日</option>
<option value="ancestor">祭祀</option>
<option value="other">其他</option>
</select>
<label for="giftType">活动类型</label
><input id="giftType" name="ceremonyType" type="text" placeholder="请输入后端约定的活动类型" required />
</div>
<div class="editor-field">
<label for="giftDate">举办时间</label
><input
id="giftDate"
name="ceremonyTime"
type="text"
type="datetime-local"
placeholder="请选择时间"
/>
</div>
@@ -83,12 +78,25 @@
placeholder="请输入地点"
/>
</div>
<div class="editor-field">
<label for="giftLocationAddress">详细地址</label
><input id="giftLocationAddress" name="locationAddress" type="text" maxlength="300" placeholder="最多 300 个字符" />
</div>
<div class="editor-two">
<div class="editor-field">
<label for="giftLongitude">经度</label
><input id="giftLongitude" name="longitude" type="number" min="-180" max="180" step="any" placeholder="与纬度同时填写" />
</div>
<div class="editor-field">
<label for="giftLatitude">纬度</label
><input id="giftLatitude" name="latitude" type="number" min="-90" max="90" step="any" placeholder="与经度同时填写" />
</div>
</div>
<div class="editor-field">
<label for="giftContent">贺礼内容</label
><textarea
><textarea class="js-rich-editor"
id="giftContent"
name="ceremonyDesc"
class="js-rich-editor"
placeholder="请输入贺礼内容"
></textarea>
</div>
@@ -99,6 +107,12 @@
<input id="giftCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#giftCoverOssId" data-upload-status="#giftCoverStatus" />
<p id="giftCoverStatus" class="upload-status">未选择文件</p>
</div>
<div class="editor-field">
<label for="giftSortOrder">排序值</label
><input id="giftSortOrder" name="sortOrder" type="number" step="1" placeholder="可选,数值越小越靠前" />
</div>
<input name="status" type="hidden" value="0" />
<p class="upload-status" data-ceremony-form-status aria-live="polite"></p>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">确定创建</button
><a class="btn ghost magnetic" href="profile-gift.html"
@@ -125,7 +139,7 @@
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/ceremony-admin-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+16 -34
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-ceremony-page data-feature-status="pending" data-feature-message="当前 PC 仅提供活动邀约接口,缺少活动列表、创建和详情,页面保持设计预览。">
<body class="page-profile-module" data-ceremony-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -19,10 +19,7 @@
><a class="active" href="profile-gift.html">贺礼邀请</a>
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a
><a class="btn primary magnetic" href="profile-gift-edit.html"
>创建贺礼</a
>
<a href="profile-content.html">返回发布中心</a>
</div>
</div>
</header>
@@ -34,7 +31,7 @@
<div>
<h1>贺礼邀请</h1>
<p>
当前仅核验活动邀约、响应及“我的邀请”;活动列表、创建和详情接口尚未提供
查看发给当前账号的活动邀请,并直接接受或拒绝;具备权限时可进入活动管理
</p>
</div>
</div>
@@ -50,37 +47,22 @@
</aside>
<div class="module-main">
<section class="module-panel">
<h2>邀请列表</h2>
<!-- 当前 PC 未提供活动列表接口,保持待开发状态。 -->
<div class="module-list" data-ceremony-list>
<div class="api-empty">贺礼邀请加载中...</div>
<h2>我的邀请</h2>
<div class="module-list" data-my-invitation-list aria-live="polite">
<div class="api-empty">正在加载我的邀请…</div>
</div>
</section>
<section class="module-panel">
<h2>献礼记录</h2>
<!-- 当前 PC 未提供活动详情或献礼记录接口,保持待开发状态。 -->
<div class="module-list" data-ceremony-gift-list>
<div class="api-empty">请选择贺礼查看献礼记录</div>
<div class="module-panel-head">
<h2>活动入口</h2>
<a class="pill" href="profile-ceremony.html" data-genealogy-context-link>查看祭祀活动</a>
</div>
</section>
<section class="module-panel">
<h2>邀请详情</h2>
<div data-my-invitation-detail aria-live="polite">
<div class="api-empty">请选择一条邀请查看详情</div>
</div>
<form class="editor-form ceremony-gift-form" data-ceremony-gift-form>
<div class="editor-two">
<div class="editor-field">
<label for="giftGiverName">献礼人</label>
<input id="giftGiverName" name="giverName" type="text" placeholder="请输入献礼人姓名" />
</div>
<div class="editor-field">
<label for="giftAmount">礼金金额</label>
<input id="giftAmount" name="giftAmount" type="number" step="0.01" placeholder="请输入金额" />
</div>
</div>
<div class="editor-field">
<label for="giftMessage">献礼留言</label>
<textarea id="giftMessage" name="giftMessage" placeholder="请输入留言"></textarea>
</div>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存献礼</button>
</div>
</form>
</section>
</div>
</div>
@@ -95,7 +77,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/ceremony-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+16 -16
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-growth.html" data-genealogy-context-link>返回记录</a
><button class="btn primary magnetic" type="submit" form="growth-edit-form">保存</button>
><button class="btn primary magnetic" type="submit" form="growth-edit-form" data-growth-editor-action hidden>保存</button>
</div>
</div>
</header>
@@ -31,8 +31,8 @@
<div class="module-kicker">Growth Record</div>
<div class="module-title-row">
<div>
<h1>新建成长记录</h1>
<p>仅提交 Apifox PC 的 GrowthRecordBody 字段</p>
<h1 data-growth-editor-title>新建成长记录</h1>
<p>人物从当前家谱世系中选择,附件由上传组件自动生成内部编号</p>
</div>
</div>
</div>
@@ -48,16 +48,17 @@
<div class="module-main">
<section class="module-panel">
<h2>记录内容</h2>
<form id="growth-edit-form" class="editor-form layui-form" data-growth-form>
<form id="growth-edit-form" class="editor-form" data-growth-form data-growth-editor hidden>
<div class="editor-two">
<div class="editor-field">
<label for="growthPersonId">世系人物 ID</label
><input
<label for="growthPersonId">关联世系人物</label
><select
id="growthPersonId"
name="lineagePersonId"
type="text"
placeholder="可选;请输入世系人物 ID"
/>
data-growth-lineage-person
>
<option value="">正在加载世系人物…</option>
</select>
</div>
<div class="editor-field">
<label for="growthDate">日期</label
@@ -90,25 +91,23 @@
</div>
<div class="editor-field">
<label for="growthRemindTime">提醒时间</label>
<input id="growthRemindTime" name="remindTime" type="text" placeholder="可选;使用带时区的 ISO 8601 时间" />
<input id="growthRemindTime" name="remindTime" type="datetime-local" />
</div>
<div class="editor-two">
<div class="editor-field">
<label for="growthMediaFiles">附件文件</label>
<input id="growthMediaOssIds" name="mediaOssIds" type="hidden" />
<label class="upload-control" for="growthMediaFiles">选择附件</label>
<input id="growthMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#growthMediaOssIds" data-upload-status="#growthMediaStatus" />
<p id="growthMediaStatus" class="upload-status">未选择文件</p>
<input id="growthMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#growthMediaOssIds" data-upload-status="#growthMediaStatus" data-growth-media-input />
<p id="growthMediaStatus" class="upload-status" data-growth-media-status>未选择文件</p>
<button class="pill" type="button" data-growth-clear-media>清除附件</button>
</div>
<div class="editor-field">
<label for="growthSortOrder">排序值</label>
<input id="growthSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" />
</div>
</div>
<div class="editor-field">
<label for="growthStatus">状态</label>
<input id="growthStatus" name="status" type="text" placeholder="可选;按后端定义填写状态" />
</div>
<input id="growthStatus" name="status" type="hidden" value="0" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button
><a class="btn ghost magnetic" href="profile-growth.html" data-genealogy-context-link
@@ -117,6 +116,7 @@
<span class="form-status" data-growth-form-status></span>
</div>
</form>
<div class="api-empty" data-growth-editor-placeholder>正在加载成长记录编辑信息…</div>
</section>
</div>
</div>
+7 -6
View File
@@ -33,7 +33,7 @@
<div class="module-title-row">
<div>
<h1>成长记录</h1>
<p>记录成员成长节点、日期、提醒和附件 OSS ID</p>
<p>记录家族成员成长节点、日期、提醒和附件。</p>
</div>
</div>
</div>
@@ -49,14 +49,15 @@
<div class="module-main">
<section class="module-panel">
<h2>记录列表</h2>
<!-- 成长记录由 ApiClient.growthRecords 渲染。 -->
<div class="module-list" data-growth-list>
<div class="api-empty">成长记录加载中...</div>
<div class="module-list" data-growth-list aria-live="polite">
<div class="api-empty">正在加载成长记录…</div>
</div>
</section>
<section class="module-panel">
<h2>接口状态</h2>
<p>Apifox 当前未展开成长记录列表和详情的元素 DTO,页面不猜测 <code>recordId</code>、标题或权限字段,因此不开放编辑和删除入口。</p>
<h2>记录详情</h2>
<div data-growth-detail aria-live="polite">
<div class="api-empty">请选择一条成长记录查看详情</div>
</div>
</section>
</div>
</div>
+8 -7
View File
@@ -13,12 +13,12 @@
<div class="container nav">
<a class="brand magnetic" href="profile.html"><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a>
<nav class="nav-links"><a href="profile-content.html">内容发布</a><a class="active" href="profile-memo.html" data-genealogy-context-link>备忘录</a></nav>
<div class="nav-actions"><a href="profile-memo.html" data-genealogy-context-link>返回记录</a><button class="btn primary magnetic" type="submit" form="memo-edit-form">保存</button></div>
<div class="nav-actions"><a href="profile-memo.html" data-genealogy-context-link>返回记录</a><button class="btn primary magnetic" type="submit" form="memo-edit-form" data-memo-editor-action hidden>保存</button></div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container"><div class="module-kicker">Memo Editor</div><div class="module-title-row"><div><h1>新建备忘录</h1><p>仅提交 Apifox PC 已核验的请求体字段</p></div></div></div>
<div class="container"><div class="module-kicker">Memo Editor</div><div class="module-title-row"><div><h1 data-memo-editor-title>新建备忘录</h1><p>附件由上传组件自动生成内部编号,页面不允许手工填写</p></div></div></div>
</section>
<section class="section">
<div class="container module-layout">
@@ -26,20 +26,21 @@
<div class="module-main">
<section class="module-panel">
<h2>备忘内容</h2>
<form id="memo-edit-form" class="editor-form layui-form" data-memo-form>
<form id="memo-edit-form" class="editor-form" data-memo-form data-memo-editor hidden>
<div class="editor-field"><label for="memoTitle">备忘标题</label><input id="memoTitle" name="memoTitle" type="text" required /></div>
<div class="editor-field"><label for="memoContent">备忘内容</label><textarea id="memoContent" name="memoContent" placeholder="可选"></textarea></div>
<div class="editor-two">
<div class="editor-field"><label for="memoRemindTime">提醒时间</label><input id="memoRemindTime" name="remindTime" type="text" placeholder="可选;按后端定义填写" /></div>
<div class="editor-field"><label for="memoCompleted">是否已完成</label><input id="memoCompleted" name="completed" type="text" placeholder="可选;按后端定义填写" /></div>
<div class="editor-field"><label for="memoRemindTime">提醒时间</label><input id="memoRemindTime" name="remindTime" type="datetime-local" /></div>
<div class="editor-field"><label for="memoCompleted">完成状态</label><select id="memoCompleted" name="completed"><option value="0">未完成</option><option value="1">已完成</option></select></div>
</div>
<div class="editor-two">
<div class="editor-field"><label for="memoMediaFiles">附件文件</label><input id="memoMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="memoMediaFiles">选择附件</label><input id="memoMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#memoMediaOssIds" data-upload-status="#memoMediaStatus" /><p id="memoMediaStatus" class="upload-status">未选择文件</p></div>
<div class="editor-field"><label for="memoMediaFiles">附件文件</label><input id="memoMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="memoMediaFiles">选择附件</label><input id="memoMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#memoMediaOssIds" data-upload-status="#memoMediaStatus" data-memo-media-input /><p id="memoMediaStatus" class="upload-status" data-memo-media-status>未选择文件</p><button class="pill" type="button" data-memo-clear-media>清除附件</button></div>
<div class="editor-field"><label for="memoSortOrder">排序值</label><input id="memoSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" /></div>
</div>
<div class="editor-field"><label for="memoStatus">状态</label><input id="memoStatus" name="status" type="text" placeholder="可选;按后端定义填写" /></div>
<input name="status" type="hidden" value="0" />
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存</button><a class="btn ghost magnetic" href="profile-memo.html" data-genealogy-context-link>取消</a><span class="form-status" data-memo-form-status></span></div>
</form>
<div class="api-empty" data-memo-editor-placeholder>正在加载备忘录编辑信息…</div>
</section>
</div>
</div>
+7 -4
View File
@@ -29,12 +29,15 @@
<div class="module-main">
<section class="module-panel">
<h2>备忘列表</h2>
<!-- 备忘录由 ApiClient.memos 渲染。 -->
<div class="module-list" data-memo-list><div class="api-empty">备忘录加载中...</div></div>
<div class="module-list" data-memo-list aria-live="polite">
<div class="api-empty">正在加载备忘录…</div>
</div>
</section>
<section class="module-panel">
<h2>接口状态</h2>
<p>Apifox 当前未展开备忘录列表和详情的元素 DTO,页面不猜测 <code>memoId</code>、标题或权限字段,因此不开放编辑和删除入口。</p>
<h2>备忘详情</h2>
<div data-memo-detail aria-live="polite">
<div class="api-empty">请选择一条备忘录查看详情</div>
</div>
</section>
</div>
</div>
+37 -48
View File
@@ -3,25 +3,26 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>添加功德人 - 个人中心 - 代代相传</title>
<title>新增功德记录 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
<link rel="stylesheet" href="public/js/wangeditor5/css/style.css" />
</head>
<body class="page-profile-module" data-merit-edit-page data-feature-status="pending" data-feature-message="功德录服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-merit-edit-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a
>
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-merit.html">功德录</a>
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-merit.html" data-genealogy-context-link>功德录</a>
</nav>
<div class="nav-actions">
<a href="profile-merit.html">返回功德录</a
><button class="btn primary magnetic" type="submit" form="merit-edit-form">保存</button>
<a href="profile-merit.html" data-genealogy-context-link>返回功德录</a>
<button class="btn primary magnetic" type="submit" form="merit-edit-form" data-merit-editor-action hidden>保存</button>
</div>
</div>
</header>
@@ -31,10 +32,8 @@
<div class="module-kicker">Merit Editor</div>
<div class="module-title-row">
<div>
<h1>添加功德人</h1>
<p>
记录功德人姓名、事迹、时间和图片资料,正文使用富文本编辑器。
</p>
<h1 data-merit-editor-title>新增功德记录</h1>
<p>功德类型严格使用 PC 契约枚举,金额按后端未限定的数值规则提交。</p>
</div>
</div>
</div>
@@ -42,34 +41,23 @@
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-merit.html">功德录</a
><a href="profile-gift.html">贺礼邀请</a
><a href="profile-growth.html">成长日志</a>
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-merit.html" data-genealogy-context-link>功德录</a>
<a href="profile-gift.html" data-genealogy-context-link>贺礼邀请</a>
<a href="profile-growth.html" data-genealogy-context-link>成长日志</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>功德资料</h2>
<form id="merit-edit-form" class="editor-form layui-form" data-merit-form>
<form id="merit-edit-form" class="editor-form" data-merit-form data-merit-editor hidden>
<div class="editor-two">
<div class="editor-field">
<label for="meritName">姓名</label
><input
id="meritName"
name="donorName"
type="text"
placeholder="请输入姓名"
required
/>
<label for="meritName">功德人姓名</label>
<input id="meritName" name="donorName" type="text" placeholder="请输入功德人姓名" required />
</div>
<div class="editor-field">
<label for="meritDate">日期</label
><input
id="meritDate"
name="meritTime"
type="text"
placeholder="请选择日期"
/>
<label for="meritTime">功德时间</label>
<input id="meritTime" name="meritTime" type="datetime-local" />
</div>
</div>
<div class="editor-field">
@@ -77,35 +65,36 @@
<input id="meritTitle" name="meritTitle" type="text" placeholder="请输入功德标题" required />
</div>
<div class="editor-field">
<label for="meritContent">事迹内容</label
><textarea
id="meritContent"
name="meritContent"
class="js-rich-editor"
placeholder="请输入内容"
></textarea>
<label for="meritContent">事迹内容</label>
<textarea id="meritContent" name="meritContent" class="js-rich-editor" placeholder="可选,记录功德事迹"></textarea>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="meritType">功德类型</label>
<select id="meritType" name="meritType">
<option value="donation">捐赠</option>
<option value="service">义务服务</option>
<option value="repair">修缮</option>
<option value="public">公益</option>
<option value="other">其他</option>
</select>
</div>
<div class="editor-field">
<label for="meritAmount">金额</label>
<input id="meritAmount" name="amount" type="number" step="0.01" placeholder="请输入金额" />
<input id="meritAmount" name="amount" type="number" step="any" placeholder="可选;后端未限定精度、范围或正负" />
</div>
</div>
<div class="editor-field">
<label for="meritSortOrder">排序值</label>
<input id="meritSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" />
</div>
<input name="status" type="hidden" value="0" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button
><a class="btn ghost magnetic" href="profile-merit.html"
>取消</a
>
<button class="btn primary magnetic" type="submit">保存</button>
<a class="btn ghost magnetic" href="profile-merit.html" data-genealogy-context-link>取消</a>
<span class="form-status" data-merit-form-status></span>
</div>
</form>
<div class="api-empty" data-merit-editor-placeholder>正在加载功德记录编辑信息…</div>
</section>
</div>
</div>
@@ -116,13 +105,13 @@
<script src="public/js/lay-config.js"></script>
<script src="public/js/wangeditor5/index.js"></script>
<script src="public/js/rich-editor.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/merit-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+23 -41
View File
@@ -8,21 +8,20 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-merit-page data-feature-status="pending" data-feature-message="功德录服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-merit-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a
>
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-merit.html">功德录</a>
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-merit.html" data-genealogy-context-link>功德录</a>
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a
><a class="btn primary magnetic" href="profile-merit-edit.html"
>添加功德人</a
>
<a href="profile-content.html">返回发布中心</a>
<a class="btn primary magnetic" href="profile-merit-edit.html" data-genealogy-context-link>新增功德记录</a>
</div>
</div>
</header>
@@ -33,7 +32,7 @@
<div class="module-title-row">
<div>
<h1>功德录</h1>
<p>记录功德人、事迹、时间和相关图片,沉淀家族贡献。</p>
<p>记录功德人、事迹、时间和金额,沉淀家族贡献。</p>
</div>
</div>
</div>
@@ -41,40 +40,23 @@
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-merit.html">功德录</a
><a href="profile-gift.html">贺礼邀请</a
><a href="profile-growth.html">成长日志</a
><a href="profile-memo.html">备忘录</a>
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-merit.html" data-genealogy-context-link>功德录</a>
<a href="profile-gift.html" data-genealogy-context-link>贺礼邀请</a>
<a href="profile-growth.html" data-genealogy-context-link>成长日志</a>
<a href="profile-memo.html" data-genealogy-context-link>备忘录</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>功德</h2>
<!-- 功德记录由 /merit-records 接口渲染 -->
<div class="module-list" data-merit-list>
<div class="api-empty">功德记录加载中...</div>
<h2>功德记录</h2>
<div class="module-list" data-merit-list aria-live="polite">
<div class="api-empty">正在加载功德记录…</div>
</div>
</section>
<section class="module-panel">
<h2>添加功德人</h2>
<div class="form-like">
<p>
<span>姓名</span><b>待输入</b
><a class="link-btn" href="profile-merit-edit.html">填写</a>
</p>
<p>
<span>内容</span><b>待输入</b
><a class="link-btn" href="profile-merit-edit.html">编辑</a>
</p>
<p>
<span>附件</span><b>可添加图片</b
><a
class="link-btn"
href="#"
data-layer-msg="请选择要上传的附件"
>添加</a
>
</p>
<h2>记录详情</h2>
<div data-merit-detail aria-live="polite">
<div class="api-empty">请选择一条功德记录查看详情</div>
</div>
</section>
</div>
@@ -84,13 +66,13 @@
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/merit-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+46 -30
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
@@ -11,22 +11,22 @@
<body class="page-profile-module">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" /></a
>
<a class="brand magnetic" href="profile.html">
<img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" />
</a>
<nav class="nav-links">
<a href="profile.html">首页</a
><a href="profile-families.html">数字家谱</a
><a href="plaza.html">家谱广场</a
><a href="profile-content.html">家族文化</a
><a href="surname.html">姓氏百科</a><a href="app.html">应用下载</a
><a href="help.html">帮助中心</a><a href="about.html">关于我们</a>
<a href="profile.html">首页</a>
<a href="profile-families.html">数字家谱</a>
<a href="plaza.html">家谱广场</a>
<a href="profile-content.html">家族文化</a>
<a href="surname.html">姓氏百科</a>
<a href="app.html">应用下载</a>
<a href="help.html">帮助中心</a>
<a href="about.html">关于我们</a>
</nav>
<div class="nav-actions">
<a class="active" href="profile.html">个人中心</a
><a class="btn primary magnetic" href="profile-create-family.html"
>创建家谱</a
>
<a class="active" href="profile.html">个人中心</a>
<a class="btn primary magnetic" href="profile-create-family.html">创建家谱</a>
</div>
</div>
</header>
@@ -37,9 +37,7 @@
<div class="module-title-row">
<div>
<h1>消息中心</h1>
<p>
查看当前账号的消息通知,并同步未读数量。
</p>
<p>查看当前账号的通知详情,并管理未读状态。</p>
</div>
<a class="btn ghost magnetic" href="profile.html">返回个人中心</a>
</div>
@@ -48,34 +46,52 @@
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-families.html">我的家谱</a
><a class="active" href="profile-messages.html">消息中心</a
><a href="profile-family-admin.html">家族管理</a
><a href="profile-content.html">内容发布</a
><a href="profile-data.html">个人资料</a
><a href="profile-services.html">帮助与服务</a>
<a href="profile-families.html">我的家谱</a>
<a class="active" href="profile-messages.html">消息中心</a>
<a href="profile-family-admin.html">家族管理</a>
<a href="profile-content.html">内容发布</a>
<a href="profile-data.html">个人资料</a>
<a href="profile-services.html">帮助与服务</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>通知列表</h2>
<div class="module-title-row">
<div>
<h2>通知列表</h2>
<p class="module-note">未读数量:<b data-notification-unread-count>0</b></p>
</div>
<label class="editor-field">
<span>阅读状态</span>
<select data-notification-filter>
<option value="">全部通知</option>
<option value="0">仅未读</option>
<option value="1">仅已读</option>
</select>
</label>
</div>
<p class="api-empty" data-notification-status>正在加载消息…</p>
<p class="module-note">未读数量:<b data-notification-unread-count>0</b></p>
<div class="module-list" data-notification-list>
<div class="module-list" data-notification-list aria-live="polite">
<div class="api-empty">正在加载消息…</div>
</div>
</section>
<section class="module-panel">
<h2>系统通知</h2>
<h2>通知详情</h2>
<div data-notification-detail aria-live="polite">
<div class="api-empty">请选择一条通知查看详情</div>
</div>
</section>
<section class="module-panel">
<h2>阅读管理</h2>
<div class="module-grid two">
<button class="module-card module-action-card" type="button" data-notification-read-all>
<span class="icon"></span>
<h3>全部标记已读</h3>
<p>使用当前 PC 的全部已读接口</p>
<p>将当前账号所有未读通知标记为已读</p>
</button>
<button class="module-card module-action-card" type="button" data-notification-refresh>
<span class="icon"></span>
<h3>刷新消息</h3>
<p>重新读取通知列表与未读数量。</p>
<p>重新读取通知列表、详情与未读数量。</p>
</button>
</div>
</section>
@@ -86,7 +102,7 @@
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
+116 -21
View File
@@ -3,33 +3,128 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新建亲友往来 - 个人中心 - 代代相传</title>
<title>新建亲友记录 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-relative-edit-page>
<header class="site-header"><div class="container nav">
<a class="brand magnetic" href="profile.html"><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a>
<nav class="nav-links"><a href="profile-content.html">内容发布</a><a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a></nav>
<div class="nav-actions"><a href="profile-relative.html" data-genealogy-context-link>返回记录</a><button class="btn primary magnetic" type="submit" form="relative-edit-form">保存</button></div>
</div></header>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a>
</nav>
<div class="nav-actions">
<a href="profile-relative.html" data-genealogy-context-link>返回记录</a>
<button class="btn primary magnetic" type="submit" form="relative-edit-form" data-relative-editor-action hidden>保存</button>
</div>
</div>
</header>
<main>
<section class="module-hero"><div class="container"><div class="module-kicker">Relative Record</div><div class="module-title-row"><div><h1>新建亲友往来</h1><p>仅提交 Apifox PC 的 RelativeRecordBody 字段。</p></div></div></div></section>
<section class="section"><div class="container module-layout">
<aside class="module-nav"><a href="profile-content.html">内容发布</a><a href="profile-growth.html" data-genealogy-context-link>成长记录</a><a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a><a href="profile-memo.html">备忘录</a></aside>
<div class="module-main"><section class="module-panel"><h2>记录内容</h2>
<form id="relative-edit-form" class="editor-form layui-form" data-relative-form>
<div class="editor-two"><div class="editor-field"><label for="relativeName">亲友姓名</label><input id="relativeName" name="relativeName" type="text" required /></div><div class="editor-field"><label for="relationName">关系名称</label><input id="relationName" name="relationName" type="text" placeholder="可选" /></div></div>
<div class="editor-two"><div class="editor-field"><label for="eventName">事件名称</label><input id="eventName" name="eventName" type="text" placeholder="可选" /></div><div class="editor-field"><label for="eventTime">事件时间</label><input id="eventTime" name="eventTime" type="text" placeholder="可选;使用带时区的 ISO 8601 时间" /></div></div>
<div class="editor-two"><div class="editor-field"><label for="giftAmount">礼金金额</label><input id="giftAmount" name="giftAmount" type="number" step="any" placeholder="可选" /></div><div class="editor-field"><label for="relativeSortOrder">排序值</label><input id="relativeSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" /></div></div>
<div class="editor-field"><label for="relativeContent">记录内容</label><textarea id="relativeContent" name="recordContent" placeholder="可选"></textarea></div>
<div class="editor-two"><div class="editor-field"><label for="relativeMediaFiles">附件文件</label><input id="relativeMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="relativeMediaFiles">选择附件</label><input id="relativeMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#relativeMediaOssIds" data-upload-status="#relativeMediaStatus" /><p id="relativeMediaStatus" class="upload-status">未选择文件</p></div><div class="editor-field"><label for="relativeStatus">状态</label><input id="relativeStatus" name="status" type="text" placeholder="可选;按后端定义填写" /></div></div>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存</button><a class="btn ghost magnetic" href="profile-relative.html" data-genealogy-context-link>取消</a><span class="form-status" data-relative-form-status></span></div>
</form>
</section></div>
</div></section>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Relative Record</div>
<div class="module-title-row">
<div>
<h1 data-relative-editor-title>新建亲友记录</h1>
<p>附件由上传组件自动生成内部编号,页面不允许手工填写。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a>
<a href="profile-growth.html" data-genealogy-context-link>成长记录</a>
<a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a>
<a href="profile-memo.html">备忘录</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>记录内容</h2>
<form id="relative-edit-form" class="editor-form" data-relative-form data-relative-editor hidden>
<div class="editor-two">
<div class="editor-field">
<label for="relativeName">亲友姓名</label>
<input id="relativeName" name="relativeName" type="text" placeholder="请输入亲友姓名" required />
</div>
<div class="editor-field">
<label for="relationName">关系名称</label>
<input id="relationName" name="relationName" type="text" placeholder="可选,例如长辈、同学" />
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="eventName">事件名称</label>
<input id="eventName" name="eventName" type="text" placeholder="可选,例如寿宴、婚礼" />
</div>
<div class="editor-field">
<label for="eventTime">事件时间</label>
<input id="eventTime" name="eventTime" type="datetime-local" />
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="giftAmount">礼金金额</label>
<input id="giftAmount" name="giftAmount" type="number" step="any" placeholder="可选;后端未限定精度或正负" />
</div>
<div class="editor-field">
<label for="relativeSortOrder">排序值</label>
<input id="relativeSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" />
</div>
</div>
<div class="editor-field">
<label for="relativeContent">记录内容</label>
<textarea id="relativeContent" name="recordContent" placeholder="可选,记录往来详情"></textarea>
</div>
<div class="editor-field">
<label for="relativeMediaFiles">附件文件</label>
<input id="relativeMediaOssIds" name="mediaOssIds" type="hidden" />
<label class="upload-control" for="relativeMediaFiles">选择附件</label>
<input
id="relativeMediaFiles"
class="upload-input"
type="file"
multiple
data-upload-multiple="true"
data-upload-target="#relativeMediaOssIds"
data-upload-status="#relativeMediaStatus"
data-relative-media-input
/>
<p id="relativeMediaStatus" class="upload-status" data-relative-media-status>未选择文件</p>
<button class="pill" type="button" data-relative-clear-media>清除附件</button>
</div>
<input name="status" type="hidden" value="0" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button>
<a class="btn ghost magnetic" href="profile-relative.html" data-genealogy-context-link>取消</a>
<span class="form-status" data-relative-form-status></span>
</div>
</form>
<div class="api-empty" data-relative-editor-placeholder>正在加载亲友记录编辑信息…</div>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script><script src="public/layui/layui.js"></script><script src="public/js/lay-config.js"></script><script src="public/js/profile-common.js?v=20260725-entry"></script><script src="config.js"></script><script src="utils/StorageUtil.js"></script><script src="utils/axios.js"></script><script src="utils/AxiosRequestUtil.js"></script><script src="utils/ApiClient.js"></script><script src="public/js/md5.js"></script><script src="public/js/upload-pages.js"></script><script src="public/js/relative-pages.js"></script><script src="public/js/page-effects.js"></script>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/relative-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+62 -14
View File
@@ -9,21 +9,69 @@
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-relative-page>
<header class="site-header"><div class="container nav">
<a class="brand magnetic" href="profile.html"><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a>
<nav class="nav-links"><a href="profile-content.html">内容发布</a><a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a></nav>
<div class="nav-actions"><a href="profile-content.html">返回发布中心</a><a class="btn primary magnetic" href="profile-relative-edit.html" data-genealogy-context-link>新建记录</a></div>
</div></header>
<main>
<section class="module-hero"><div class="container"><div class="module-kicker">Relative Records</div><div class="module-title-row"><div><h1>亲友往来</h1><p>记录亲友关系、事项、时间、礼金和附件 OSS ID。</p></div></div></div></section>
<section class="section"><div class="container module-layout">
<aside class="module-nav"><a href="profile-content.html">内容发布</a><a href="profile-growth.html" data-genealogy-context-link>成长记录</a><a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a><a href="profile-memo.html">备忘录</a></aside>
<div class="module-main">
<section class="module-panel"><h2>记录列表</h2><div class="module-list" data-relative-list><div class="api-empty">亲友往来加载中...</div></div></section>
<section class="module-panel"><h2>接口状态</h2><p>Apifox 未展开列表和详情元素 DTO,页面不猜测 <code>relativeId</code>、展示字段或权限字段,因此不开放编辑和删除入口。</p></section>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a>
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a>
<a class="btn primary magnetic" href="profile-relative-edit.html" data-genealogy-context-link>新建记录</a>
</div>
</div></section>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Relative Records</div>
<div class="module-title-row">
<div>
<h1>亲友往来</h1>
<p>记录亲友关系、往来事项、时间、礼金和附件。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a>
<a href="profile-growth.html" data-genealogy-context-link>成长记录</a>
<a class="active" href="profile-relative.html" data-genealogy-context-link>亲友往来</a>
<a href="profile-memo.html">备忘录</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>记录列表</h2>
<div class="module-list" data-relative-list aria-live="polite">
<div class="api-empty">正在加载亲友记录…</div>
</div>
</section>
<section class="module-panel">
<h2>记录详情</h2>
<div data-relative-detail aria-live="polite">
<div class="api-empty">请选择一条亲友记录查看详情</div>
</div>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script><script src="public/layui/layui.js"></script><script src="public/js/lay-config.js"></script><script src="public/js/profile-common.js?v=20260725-entry"></script><script src="config.js"></script><script src="utils/StorageUtil.js"></script><script src="utils/axios.js"></script><script src="utils/AxiosRequestUtil.js"></script><script src="utils/ApiClient.js"></script><script src="public/js/relative-pages.js"></script><script src="public/js/page-effects.js"></script>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/relative-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+123 -21
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-family-home.html" data-genealogy-context-link>返回家谱主页</a
><a class="btn primary magnetic" href="#lineage-member-form">添加成员</a>
><a class="btn primary magnetic" href="#lineage-member-form" data-lineage-management hidden>添加成员</a>
</div>
</div>
</header>
@@ -53,7 +53,7 @@
<div class="module-main">
<section class="module-panel">
<h2>树谱操作</h2>
<div class="module-grid">
<div class="module-grid" data-lineage-management hidden>
<button class="module-card module-action-card tilt-card" type="button" data-lineage-relation="parents"
><span class="icon"></span>
<h3>添加父亲 / 母亲</h3>
@@ -91,8 +91,17 @@
</section>
<section class="module-panel">
<h2>成员搜索</h2>
<div class="lineage-toolbar">
<div class="lineage-toolbar lineage-filter-toolbar">
<input type="search" data-lineage-keyword placeholder="输入姓名、别名或人物编号搜索" />
<select data-lineage-generation-filter aria-label="按世代筛选">
<option value="">全部世代</option>
</select>
<select data-lineage-status-filter aria-label="按人物状态筛选">
<option value="">全部人物状态</option>
<option value="0">健在</option>
<option value="1">已故</option>
<option value="2">未知</option>
</select>
<button class="btn primary" type="button" data-lineage-search>搜索</button>
</div>
<p class="form-status" data-lineage-list-summary></p>
@@ -113,9 +122,40 @@
<div class="api-empty">请选择一个成员查看资料</div>
</div>
</section>
<section class="module-panel">
<h2>新增成员</h2>
<section class="module-panel" data-lineage-management hidden>
<h2>新增 / 编辑成员</h2>
<form id="lineage-member-form" class="editor-form" data-lineage-form>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-binding-mode">账号绑定</label>
<select id="lineage-binding-mode" name="bindingMode" required>
<option value="NONE" selected>不绑定账号</option>
<option value="SELF">绑定当前登录账号</option>
<option value="SPECIFIED">指定其他账号</option>
</select>
<select name="appUserId" data-lineage-member-options hidden>
<option value="">请选择已绑定账号的家谱成员</option>
</select>
<p class="upload-status" data-lineage-binding-status>指定账号仅可从当前家谱正常成员中选择。</p>
</div>
<div class="editor-field">
<label for="lineage-avatar-file">头像</label>
<input id="lineage-avatar-oss-id" name="avatarOssId" type="hidden" />
<label class="upload-control" for="lineage-avatar-file">选择头像文件</label>
<input
id="lineage-avatar-file"
class="upload-input"
type="file"
accept="image/*"
data-upload-target="#lineage-avatar-oss-id"
data-upload-status="#lineage-avatar-status"
data-upload-mode="resumable"
data-upload-biz-type="avatar"
data-upload-usage-scene="lineage_avatar"
/>
<p id="lineage-avatar-status" class="upload-status">请选择头像文件</p>
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-person-name">姓名</label>
@@ -126,51 +166,112 @@
<select id="lineage-sex" name="sex">
<option value="0"></option>
<option value="1"></option>
<option value="2" selected>未知</option>
</select>
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-generation-no">世代序号</label>
<input id="lineage-generation-no" name="generation" type="number" min="1" placeholder="例如 3" />
<label for="lineage-generation-no">世代</label>
<select id="lineage-generation-no" name="generation" data-lineage-generation>
<option value="">请选择世代</option>
</select>
</div>
<div class="editor-field">
<label for="lineage-generation-name">字辈</label>
<input id="lineage-generation-name" name="generationName" type="text" placeholder="例如 忠" />
<input id="lineage-generation-name" name="generationName" type="text" readonly />
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-person-no">人物编号</label>
<input id="lineage-person-no" name="personNo" type="text" placeholder="可选" />
<input id="lineage-person-no" name="personNo" type="text" placeholder="可选;留空由系统生成" />
</div>
<div class="editor-field">
<label for="lineage-alias-name">别名</label>
<input id="lineage-alias-name" name="aliasName" type="text" placeholder="可选" />
</div>
</div>
<div class="editor-field">
<label for="lineage-sort-order">排序值</label>
<input id="lineage-sort-order" name="sortOrder" type="number" step="1" placeholder="可选,数值越小越靠前" />
</div>
<div class="editor-field">
<label for="lineage-relation-name">关系称谓</label>
<input id="lineage-relation-name" name="relationName" type="text" placeholder="添加配偶时可选,例如 妻、夫" />
<div class="editor-two">
<div class="editor-field">
<label for="lineage-father-id">父亲</label>
<select id="lineage-father-id" name="fatherId" data-lineage-parent-option data-empty-label="请选择父亲">
<option value="">请选择父亲</option>
</select>
</div>
<div class="editor-field">
<label for="lineage-mother-id">母亲</label>
<select id="lineage-mother-id" name="motherId" data-lineage-parent-option data-empty-label="请选择母亲">
<option value="">请选择母亲</option>
</select>
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-birth-date">出生日期</label>
<input id="lineage-birth-date" name="birthDate" type="date" />
<label for="lineage-birth-date">出生时间</label>
<input id="lineage-birth-date" name="birthDate" type="datetime-local" />
</div>
<div class="editor-field">
<label for="lineage-death-date">逝世日期</label>
<input id="lineage-death-date" name="deathDate" type="date" />
<label for="lineage-birth-lunar">出生历法</label>
<select id="lineage-birth-lunar" name="birthLunar">
<option value="0" selected>公历</option>
<option value="1">农历</option>
</select>
</div>
</div>
<div class="editor-field">
<label for="lineage-introduction">简介</label>
<label for="lineage-birth-place">出生地</label>
<input id="lineage-birth-place" name="birthPlace" type="text" placeholder="可选" />
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-death-date">逝世时间</label>
<input id="lineage-death-date" name="deathDate" type="datetime-local" />
</div>
<div class="editor-field">
<label for="lineage-death-lunar">逝世历法</label>
<select id="lineage-death-lunar" name="deathLunar">
<option value="0" selected>公历</option>
<option value="1">农历</option>
</select>
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-death-place">逝世地</label>
<input id="lineage-death-place" name="deathPlace" type="text" placeholder="可选" />
</div>
<div class="editor-field">
<label for="lineage-burial-place">安葬地</label>
<input id="lineage-burial-place" name="burialPlace" type="text" placeholder="可选" />
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-person-status">人物状态</label>
<select id="lineage-person-status" name="personStatus">
<option value="0">健在</option>
<option value="1">已故</option>
<option value="2" selected>未知</option>
</select>
</div>
<div class="editor-field">
<label for="lineage-sort-order">排序值</label>
<input id="lineage-sort-order" name="sortOrder" type="number" step="1" placeholder="可选,数值越小越靠前" />
</div>
</div>
<div class="editor-field" data-lineage-spouse-field hidden>
<label for="lineage-relation-name">配偶关系称谓</label>
<input id="lineage-relation-name" name="relationName" type="text" placeholder="可选,例如 妻、夫" />
</div>
<div class="editor-field">
<label for="lineage-introduction">人物简介</label>
<textarea id="lineage-introduction" name="biography" placeholder="记录成员生平简介"></textarea>
</div>
<div class="editor-field">
<label for="lineage-remark">备注</label>
<textarea id="lineage-remark" name="remark" placeholder="可选"></textarea>
</div>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存成员</button>
<button class="btn ghost" type="reset">清空</button>
@@ -191,6 +292,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/lineage-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>发布视频 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-video-edit-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-video.html" data-genealogy-context-link>视频</a>
</nav>
<div class="nav-actions">
<a href="profile-video.html" data-genealogy-context-link>返回视频列表</a>
<button class="btn primary magnetic" type="submit" form="video-edit-form" data-video-management hidden>保存视频</button>
</div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Video Editor</div>
<div class="module-title-row">
<div>
<h1 data-video-editor-title>发布视频</h1>
<p>文件通过统一分片上传取得内部 OSS ID,页面不允许手工填写文件编号。</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a>
<a class="active" href="profile-video.html" data-genealogy-context-link>视频</a>
<a href="profile-family-home.html" data-genealogy-context-link>家谱主页</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>视频内容</h2>
<form id="video-edit-form" class="editor-form" data-video-form data-video-management hidden>
<div class="editor-field">
<label for="video-title">视频标题</label>
<input id="video-title" name="videoTitle" type="text" placeholder="请输入视频标题" required />
</div>
<div class="editor-field">
<label for="video-description">视频说明</label>
<textarea id="video-description" name="videoDesc" placeholder="可选,记录视频内容"></textarea>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="video-file">视频文件</label>
<input id="video-oss-id" name="videoOssId" type="hidden" />
<label class="upload-control" for="video-file">选择视频文件</label>
<input
id="video-file"
class="upload-input"
type="file"
accept="video/*"
data-upload-target="#video-oss-id"
data-upload-status="#video-file-status"
data-video-duration-target="#video-duration"
/>
<p id="video-file-status" class="upload-status" data-video-file-status>请选择视频文件</p>
</div>
<div class="editor-field">
<label for="video-cover-file">封面图片</label>
<input id="video-cover-oss-id" name="coverOssId" type="hidden" />
<label class="upload-control" for="video-cover-file">选择封面图片</label>
<input
id="video-cover-file"
class="upload-input"
type="file"
accept="image/*"
data-upload-target="#video-cover-oss-id"
data-upload-status="#video-cover-status"
/>
<p id="video-cover-status" class="upload-status" data-video-cover-status>未选择封面</p>
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="video-duration">视频时长(秒)</label>
<input id="video-duration" name="durationSeconds" type="number" step="1" min="0" readonly />
<p class="upload-status">选择视频后自动读取,不会要求手工填写。</p>
</div>
<div class="editor-field">
<label for="video-sort-order">排序值</label>
<input id="video-sort-order" name="sortOrder" type="number" step="1" placeholder="可选,数值越小越靠前" />
</div>
</div>
<input name="status" type="hidden" value="0" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存视频</button>
<a class="btn ghost magnetic" href="profile-video.html" data-genealogy-context-link>取消</a>
<span class="form-status" data-video-form-status></span>
</div>
</form>
<div class="api-empty" data-video-permission-placeholder>正在核对视频维护权限…</div>
</section>
</div>
</div>
</section>
</main>
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/video-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+27 -46
View File
@@ -8,22 +8,21 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-feature-status="pending" data-feature-message="家族视频服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-video-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a
>
<a class="brand magnetic" href="profile.html">
<img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" />
<span class="brand-text">个人中心</span>
</a>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a
><a href="profile-album.html">相册</a
><a class="active" href="profile-video.html">视频</a>
<a href="profile-content.html">内容发布</a>
<a href="profile-album.html">相册</a>
<a class="active" href="profile-video.html" data-genealogy-context-link>视频</a>
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a
><a class="btn primary magnetic" href="profile-video.html"
>发布视频</a
>
<a href="profile-content.html">返回发布中心</a>
<a class="btn primary magnetic" href="profile-video-edit.html" data-genealogy-context-link data-video-management hidden>发布视频</a>
</div>
</div>
</header>
@@ -34,7 +33,7 @@
<div class="module-title-row">
<div>
<h1>家族视频</h1>
<p>发布和管理家族视频,支持标题、描述、封面和上传状态维护</p>
<p>查看家族视频资料;有内容维护权限的成员可以发布、编辑和删除视频</p>
</div>
</div>
</div>
@@ -42,45 +41,22 @@
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a href="profile-album.html">相册</a
><a class="active" href="profile-video.html">视频</a
><a href="profile-family-home.html">家谱主页</a>
<a href="profile-content.html">内容发布</a>
<a href="profile-album.html">相册</a>
<a class="active" href="profile-video.html" data-genealogy-context-link>视频</a>
<a href="profile-family-home.html" data-genealogy-context-link>家谱主页</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>视频列表</h2>
<div class="module-list">
<a class="module-row" href="profile-video.html"
><div>
<h3>四川武胜汤氏族视频</h3>
<p>已发布 · 2026.05 · 可编辑</p>
</div>
<span class="pill">管理</span></a
><a class="module-row" href="profile-video.html"
><div>
<h3>祭祖活动记录</h3>
<p>草稿 · 等待上传视频</p>
</div>
<span class="pill">继续</span></a
>
<div class="module-list" data-video-list aria-live="polite">
<div class="api-empty">正在加载视频…</div>
</div>
</section>
<section class="module-panel">
<h2>发布视频</h2>
<div class="form-like">
<p>
<span>视频标题</span><b>必填</b
><a class="link-btn" href="profile-video.html">填写</a>
</p>
<p>
<span>视频描述</span><b>0/200</b
><a class="link-btn" href="profile-video.html">编辑</a>
</p>
<p>
<span>上传视频</span><b>未上传</b
><a class="link-btn" href="profile-video.html">选择</a>
</p>
<h2>视频详情</h2>
<div data-video-detail aria-live="polite">
<div class="api-empty">请选择一个视频查看详情</div>
</div>
</section>
</div>
@@ -90,8 +66,13 @@
<script src="public/js/jquery360.js"></script>
<script src="public/layui/layui.js"></script>
<script src="public/js/lay-config.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/video-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+5 -5
View File
@@ -202,27 +202,27 @@
<div class="content-grid">
<a
class="content-tile large tilt-card"
href="profile-article.html"
href="profile-article.html" data-genealogy-context-link
><span>谱文</span>
<h3>撰写谱文与族史</h3>
<p>沉淀族训、谱序、谱论、恩荣录等家族文献。</p></a
>
<a class="content-tile tilt-card" href="profile-album.html"
<a class="content-tile tilt-card" href="profile-album.html" data-genealogy-context-link
><span>相册</span>
<h3>家族相册</h3>
<p>新建相册、上传照片、编辑封面。</p></a
>
<a class="content-tile tilt-card" href="profile-video.html"
<a class="content-tile tilt-card" href="profile-video.html" data-genealogy-context-link
><span>视频</span>
<h3>家族视频</h3>
<p>发布视频、管理家族影像。</p></a
>
<a class="content-tile tilt-card" href="profile-merit.html"
<a class="content-tile tilt-card" href="profile-merit.html" data-genealogy-context-link
><span>功德</span>
<h3>功德录</h3>
<p>记录功德人、事迹与纪念内容。</p></a
>
<a class="content-tile tilt-card" href="profile-gift.html"
<a class="content-tile tilt-card" href="profile-gift.html" data-genealogy-context-link
><span>贺礼</span>
<h3>贺礼邀请</h3>
<p>管理婚礼、生日、升学等贺礼。</p></a
+80
View File
@@ -197,6 +197,26 @@
align-items: center;
}
[data-generation-management][hidden] {
display: none !important;
}
[data-lineage-management][hidden] {
display: none !important;
}
[data-lineage-spouse-field][hidden] {
display: none !important;
}
[data-lineage-pagination][hidden] {
display: none !important;
}
[data-video-management][hidden] {
display: none !important;
}
.generation-row .pill {
border: 0;
cursor: pointer;
@@ -224,6 +244,10 @@
margin-bottom: 16px;
}
.lineage-filter-toolbar {
grid-template-columns: minmax(220px, 1fr) minmax(140px, .5fr) minmax(140px, .5fr) auto;
}
.lineage-toolbar input,
.lineage-toolbar select {
min-height: 46px;
@@ -302,6 +326,34 @@
background: #fde8e2;
}
.ceremony-invitation-row .pill {
border: 0;
cursor: pointer;
font: inherit;
}
.ceremony-invitation-row .pill:disabled {
cursor: wait;
opacity: .58;
}
.invitation-detail b {
overflow-wrap: anywhere;
}
.invitation-detail .pill {
display: inline-block;
margin-left: 8px;
font-size: 13px;
}
.video-row .pill,
.video-detail + .bottom-actions button {
border: 0;
cursor: pointer;
font: inherit;
}
.article-row {
/* 谱文列表行复用模块列表视觉,单独保留类名方便后续文章样式调整 */
align-items: center;
@@ -679,6 +731,26 @@
font-weight: 800;
}
[data-growth-editor][hidden],
[data-growth-editor-action][hidden] {
display: none !important;
}
[data-relative-editor][hidden],
[data-relative-editor-action][hidden] {
display: none !important;
}
[data-memo-editor][hidden],
[data-memo-editor-action][hidden] {
display: none !important;
}
[data-merit-editor][hidden],
[data-merit-editor-action][hidden] {
display: none !important;
}
.generation-batch-form {
/* 批量字辈表单复用编辑表单,只补充本页预览前的间距。 */
margin-top: 14px;
@@ -746,6 +818,10 @@
position: static;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lineage-filter-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
@@ -761,6 +837,10 @@
grid-template-columns: 1fr;
}
.lineage-filter-toolbar {
grid-template-columns: 1fr;
}
.form-like p,
.module-row,
.editor-two,
+736
View File
@@ -0,0 +1,736 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.AlbumPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.AlbumPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var albumsById = Object.create(null);
var photosById = Object.create(null);
var currentAlbum = null;
var canEditContent = false;
var writePending = false;
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function trimOrUndefined(value) {
var text = stringValue(value).trim();
return text || undefined;
}
function queryParam(search, name) {
return new URLSearchParams(stringValue(search).replace(/^\?/, '')).get(name) || '';
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function normalizeNullableId(value) {
if (value === undefined || value === null || value === '') return '';
return normalizeId(value);
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var contextId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
contextId = root.ProfileUI.getGenealogyId();
if (contextId) return normalizeId(contextId);
}
return normalizeId(queryParam(source, 'genealogyId'));
}
function getCurrentAlbumId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(queryParam(source, 'albumId'));
}
function toBackendDateTime(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function toDateTimeInputValue(value) {
var text = stringValue(value);
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) return '';
return text.slice(0, 16).replace(' ', 'T');
}
function isValidBackendDateTime(value) {
var match = stringValue(value).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
var year;
var month;
var day;
var days;
if (!match) return false;
year = Number(match[1]);
month = Number(match[2]);
day = Number(match[3]);
if (month < 1 || month > 12 || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false;
days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day >= 1 && day <= days[month - 1];
}
function buildAlbumBody(values) {
var source = values || {};
var body = {
albumName: stringValue(source.albumName).trim(),
status: '0'
};
var albumDesc = trimOrUndefined(source.albumDesc);
var coverOssId = normalizeNullableId(source.coverOssId);
var sortOrder = optionalSafeInteger(source.sortOrder);
if (albumDesc !== undefined) body.albumDesc = albumDesc;
if (source.coverOssId !== undefined && source.coverOssId !== null && source.coverOssId !== '') {
if (coverOssId) body.coverOssId = coverOssId;
else body.invalidCoverOssId = true;
}
if (source.sortOrder !== undefined && source.sortOrder !== null && source.sortOrder !== '') {
if (sortOrder !== undefined) body.sortOrder = sortOrder;
else body.invalidSortOrder = true;
}
return body;
}
function validateAlbumBody(body) {
if (!body || !body.albumName) return '请填写相册名称';
if (body.invalidCoverOssId) return '封面文件编号无效,请重新选择文件';
if (body.invalidSortOrder) return '排序值必须是安全整数';
if (body.status === '1') return '当前 PC 无法重新读取停用相册,暂不开放停用';
if (body.status !== '0') return '相册状态只能是 0';
return '';
}
function buildAlbumPhotoBody(values) {
var source = values || {};
var body = { status: '0' };
var ossId = normalizeNullableId(source.ossId);
var photoTitle = trimOrUndefined(source.photoTitle);
var photoDesc = trimOrUndefined(source.photoDesc);
var photographer = trimOrUndefined(source.photographer);
var shootTime = toBackendDateTime(source.shootTime);
var sortOrder = optionalSafeInteger(source.sortOrder);
if (ossId) body.ossId = ossId;
else if (source.ossId !== undefined && source.ossId !== null && source.ossId !== '') body.invalidOssId = true;
if (photoTitle !== undefined) body.photoTitle = photoTitle;
if (photoDesc !== undefined) body.photoDesc = photoDesc;
if (photographer !== undefined) body.photographer = photographer;
if (shootTime !== undefined) body.shootTime = shootTime;
if (source.sortOrder !== undefined && source.sortOrder !== null && source.sortOrder !== '') {
if (sortOrder !== undefined) body.sortOrder = sortOrder;
else body.invalidSortOrder = true;
}
return body;
}
function validateAlbumPhotoBody(body) {
if (!body || !body.ossId || body.invalidOssId) return '请选择并上传照片';
if (body.shootTime !== undefined && !isValidBackendDateTime(body.shootTime)) return '拍摄时间格式无效';
if (body.invalidSortOrder) return '排序值必须是安全整数';
if (body.status === '1') return '当前 PC 无法重新读取停用照片,暂不开放停用';
if (body.status !== '0') return '照片状态只能是 0';
return '';
}
function normalizeAlbum(item) {
var albumId = normalizeId(item && item.albumId);
var genealogyId = normalizeId(item && item.genealogyId);
var coverOssId = normalizeNullableId(item && item.coverOssId);
var albumName = stringValue(item && item.albumName).trim();
var photoCount = optionalSafeInteger(item && item.photoCount);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var status = stringValue(item && item.status);
var album;
if (!albumId || !genealogyId || !albumName) return null;
if (item.coverOssId !== undefined && item.coverOssId !== null && item.coverOssId !== '' && !coverOssId) return null;
if (item.photoCount !== undefined && item.photoCount !== null && item.photoCount !== '' && photoCount === undefined) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
if (status !== '0' && status !== '1') return null;
album = {
albumId: albumId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
albumName: albumName,
albumDesc: stringValue(item.albumDesc),
status: status,
remark: stringValue(item.remark)
};
if (coverOssId) album.coverOssId = coverOssId;
if (photoCount !== undefined) album.photoCount = photoCount;
if (sortOrder !== undefined) album.sortOrder = sortOrder;
return album;
}
function normalizeAlbumPhoto(item) {
var photoId = normalizeId(item && item.photoId);
var genealogyId = normalizeId(item && item.genealogyId);
var albumId = normalizeId(item && item.albumId);
var ossId = normalizeId(item && item.ossId);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var status = stringValue(item && item.status);
var photo;
if (!photoId || !genealogyId || !albumId || !ossId) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
if (status !== '0' && status !== '1') return null;
photo = {
photoId: photoId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
albumId: albumId,
albumName: stringValue(item.albumName),
ossId: ossId,
photoTitle: stringValue(item.photoTitle),
photoDesc: stringValue(item.photoDesc),
photographer: stringValue(item.photographer),
shootTime: stringValue(item.shootTime),
status: status,
remark: stringValue(item.remark)
};
if (sortOrder !== undefined) photo.sortOrder = sortOrder;
return photo;
}
function normalizeAlbums(data) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizeAlbum);
return normalized.some(function (album) { return !album; }) ? [] : normalized;
}
function normalizeAlbumPhotos(data) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizeAlbumPhoto);
return normalized.some(function (photo) { return !photo; }) ? [] : normalized;
}
function findAlbumById(data, albumId) {
var albums = normalizeAlbums(data);
var expectedId = normalizeId(albumId);
return albums.find(function (album) { return album.albumId === expectedId; }) || null;
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildListUrl(genealogyId) {
return 'profile-album.html?genealogyId=' + encodeURIComponent(genealogyId);
}
function buildEditUrl(genealogyId, albumId) {
var url = 'profile-album-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return albumId ? url + '&albumId=' + encodeURIComponent(albumId) : url;
}
function buildDetailUrl(genealogyId, albumId) {
return 'profile-album-detail.html?genealogyId=' + encodeURIComponent(genealogyId) +
'&albumId=' + encodeURIComponent(albumId);
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderAlbumDetail(album) {
var actions = '';
if (!album) return '<div class="api-empty">未找到该相册</div>';
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(album.genealogyId, album.albumId)) + '">编辑相册</a>' +
'<button class="btn ghost" type="button" data-album-delete-id="' +
escapeHtml(album.albumId) + '">删除相册</button></div>';
}
return '<div class="form-like album-detail">' +
detailValue('相册名称', album.albumName) +
detailValue('相册说明', album.albumDesc) +
detailValue('照片数量', album.photoCount === undefined ? '' : album.photoCount) +
detailValue('备注', album.remark) +
'</div>' + actions;
}
function renderAlbumRow(album) {
var actions = '<a class="pill" href="' + escapeHtml(buildDetailUrl(album.genealogyId, album.albumId)) +
'">查看照片</a>';
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(album.genealogyId, album.albumId)) +
'">编辑</a><button class="pill is-danger" type="button" data-album-delete-id="' +
escapeHtml(album.albumId) + '">删除</button>';
}
return '<article class="module-row album-row"><div><h3>' + escapeHtml(album.albumName) +
'</h3><p>' + escapeHtml(album.albumDesc || ('照片数量:' + (album.photoCount || 0))) +
'</p></div><div class="row-actions">' + actions + '</div></article>';
}
function renderPhotoList(data) {
var photos = Array.isArray(data) ? data.map(normalizeAlbumPhoto) : [];
if (photos.some(function (photo) { return !photo; })) return '<div class="api-empty">照片响应字段不完整</div>';
if (!photos.length) return '<div class="api-empty">暂无照片</div>';
return photos.map(function (photo) {
var action = canEditContent
? '<button class="pill is-danger" type="button" data-album-photo-delete-id="' +
escapeHtml(photo.photoId) + '">删除</button>'
: '';
return '<article class="module-row album-photo-row"><div><h3>' +
escapeHtml(photo.photoTitle || '未命名照片') + '</h3><p>' +
escapeHtml([photo.photoDesc, photo.photographer, photo.shootTime].filter(Boolean).join(' · ') || '文件已上传') +
'</p></div><div class="row-actions">' + action + '</div></article>';
}).join('');
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function showMessage(message) {
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setStatus(selector, message) {
var target = query(selector);
if (target) target.textContent = message || '';
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-album-editor-placeholder]');
queryAll('[data-album-editor], [data-album-editor-action], [data-album-photo-editor]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-album-form] button, [data-album-form] input, [data-album-form] textarea, [data-album-photo-form] button, [data-album-photo-form] input, [data-album-photo-form] textarea, [data-album-delete-id], [data-album-photo-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderNoContext() {
var message = '请先选择家谱,再查看或维护相册。';
if (query('[data-album-list]')) query('[data-album-list]').innerHTML = '<div class="api-empty">' + message + '</div>';
if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
if (query('[data-album-photo-list]')) query('[data-album-photo-list]').innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setStatus('[data-album-form-status]', message);
setStatus('[data-album-photo-status]', message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
async function loadCapability(api, genealogyId) {
var detail = await api.genealogyDetail(genealogyId);
canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage));
queryAll('[data-album-create-link], [data-album-editor-action], [data-album-photo-editor]').forEach(function (element) {
element.hidden = !canEditContent;
});
return detail;
}
function renderAlbums(data) {
var container = query('[data-album-list]');
var albums = normalizeAlbums(data);
albumsById = Object.create(null);
albums.forEach(function (album) { albumsById[album.albumId] = album; });
if (!container) return albums;
if (!Array.isArray(data) || (data.length && !albums.length)) {
container.innerHTML = '<div class="api-empty">相册响应缺少稳定 AlbumVo 字段,请联系后端核对。</div>';
return [];
}
if (!albums.length) {
container.innerHTML = '<div class="api-empty">暂无相册</div>';
return [];
}
container.innerHTML = albums.map(renderAlbumRow).join('');
return albums;
}
async function loadAlbums() {
var api = getApi();
var genealogyId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
try {
await loadCapability(api, genealogyId);
renderAlbums(await api.albums(genealogyId));
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-album-list]')) {
query('[data-album-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的相册。' : '相册加载失败,请稍后重试。') +
'</div>';
}
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的相册。' : (error.message || '相册加载失败'));
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) { values[field.name] = field.value; });
return values;
}
function setFieldValue(formSelector, name, value) {
var field = query(formSelector + ' [name="' + name + '"]');
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function fillAlbumForm(album) {
if (!album || album.status !== '0') throw new Error('停用相册无法通过当前 PC 接口重新读取或编辑');
setFieldValue('[data-album-form]', 'albumName', album.albumName);
setFieldValue('[data-album-form]', 'albumDesc', album.albumDesc);
setFieldValue('[data-album-form]', 'coverOssId', album.coverOssId);
setFieldValue('[data-album-form]', 'sortOrder', album.sortOrder);
setFieldValue('[data-album-form]', 'status', '0');
}
async function loadAlbumEditor() {
var api = getApi();
var genealogyId;
var albumId;
var albums;
var album;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
albumId = getCurrentAlbumId();
syncLinks();
setEditorEnabled(false, '正在加载相册编辑信息…');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的相册。' };
if (albumId) {
albums = await api.albums(genealogyId);
album = findAlbumById(albums, albumId);
if (!album) throw new Error('相册列表未返回当前相册');
fillAlbumForm(album);
if (query('[data-album-editor-title]')) query('[data-album-editor-title]').textContent = '编辑相册';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败'));
setStatus('[data-album-form-status]', isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败'));
}
}
async function loadPhotos(api, genealogyId, albumId) {
var data = await api.albumPhotos(genealogyId, albumId);
var photos = normalizeAlbumPhotos(data);
var container = query('[data-album-photo-list]');
photosById = Object.create(null);
photos.forEach(function (photo) { photosById[photo.photoId] = photo; });
if (container) {
container.innerHTML = !Array.isArray(data) || (data.length && !photos.length)
? '<div class="api-empty">照片响应缺少稳定 AlbumPhotoVo 字段,请联系后端核对。</div>'
: renderPhotoList(photos);
}
return photos;
}
async function loadAlbumDetailPage() {
var api = getApi();
var genealogyId;
var albumId;
var albums;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
albumId = getCurrentAlbumId();
if (!genealogyId || !albumId) {
if (genealogyId && query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '<div class="api-empty">缺少有效相册编号。</div>';
return;
}
syncLinks();
try {
await loadCapability(api, genealogyId);
albums = await api.albums(genealogyId);
currentAlbum = findAlbumById(albums, albumId);
if (!currentAlbum) throw new Error('相册列表未返回当前相册');
if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = renderAlbumDetail(currentAlbum);
if (query('[data-album-detail-title]')) query('[data-album-detail-title]').textContent = currentAlbum.albumName;
await loadPhotos(api, genealogyId, albumId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该相册。' : escapeHtml(error.message || '相册详情加载失败')) + '</div>';
showMessage(isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败'));
}
}
async function submitAlbum(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var albumId = getCurrentAlbumId();
var body;
var validation;
var result;
var saved;
var savedId;
var verified;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
body = buildAlbumBody(getFormValues(form));
validation = validateAlbumBody(body);
if (validation) {
setStatus('[data-album-form-status]', validation);
return;
}
delete body.invalidCoverOssId;
delete body.invalidSortOrder;
setWritePending(true);
setStatus('[data-album-form-status]', '正在保存相册…');
try {
result = albumId
? await api.updateAlbum(genealogyId, albumId, body)
: await api.createAlbum(genealogyId, body);
saved = normalizeAlbum(result);
savedId = albumId || (saved && saved.albumId);
if (!savedId) throw new Error('保存响应缺少 albumId');
verified = findAlbumById(await api.albums(genealogyId), savedId);
if (!verified) throw new Error('保存后相册列表未返回同一相册');
if (root.location) root.location.href = buildDetailUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-album-form-status]', isForbidden(error) ? '当前账号无权保存该相册。' : (error.message || '相册保存失败'));
} finally {
setWritePending(false);
}
}
async function submitPhoto(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var albumId = getCurrentAlbumId();
var body;
var validation;
var saved;
if (writePending || !genealogyId || !albumId || redirectUnauthorized(api)) return;
body = buildAlbumPhotoBody(getFormValues(form));
validation = validateAlbumPhotoBody(body);
if (validation) {
setStatus('[data-album-photo-status]', validation);
return;
}
delete body.invalidOssId;
delete body.invalidSortOrder;
setWritePending(true);
setStatus('[data-album-photo-status]', '正在添加照片…');
try {
saved = normalizeAlbumPhoto(await api.createAlbumPhoto(genealogyId, albumId, body));
if (!saved || saved.albumId !== albumId) throw new Error('添加照片响应缺少稳定 photoId');
if (!(await loadPhotos(api, genealogyId, albumId)).some(function (photo) { return photo.photoId === saved.photoId; })) {
throw new Error('添加后照片列表未返回同一照片');
}
form.reset();
setStatus('[data-album-photo-status]', '照片已添加');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-album-photo-status]', isForbidden(error) ? '当前账号无权添加照片。' : (error.message || '照片添加失败'));
} finally {
setWritePending(false);
}
}
async function deleteAlbum(albumId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var album = albumsById[albumId] || currentAlbum;
if (writePending || !genealogyId || !normalizeId(albumId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (album ? album.albumName : '该相册') + '”及其中全部照片吗?删除后不可恢复。')) return;
setWritePending(true);
try {
await api.deleteAlbum(genealogyId, albumId);
if (query('[data-album-page]')) await loadAlbums();
else if (root.location) root.location.href = buildListUrl(genealogyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该相册。' : (error.message || '相册删除失败'));
} finally {
setWritePending(false);
}
}
async function deletePhoto(photoId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var albumId = getCurrentAlbumId();
var photo = photosById[photoId];
if (writePending || !genealogyId || !albumId || !normalizeId(photoId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (photo && photo.photoTitle || '该照片') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
await api.deleteAlbumPhoto(genealogyId, albumId, photoId);
await loadPhotos(api, genealogyId, albumId);
showMessage('照片已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该照片。' : (error.message || '照片删除失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var albumDelete = event.target.closest('[data-album-delete-id]');
var photoDelete = event.target.closest('[data-album-photo-delete-id]');
if (albumDelete) {
event.preventDefault();
deleteAlbum(albumDelete.getAttribute('data-album-delete-id'));
return;
}
if (photoDelete) {
event.preventDefault();
deletePhoto(photoDelete.getAttribute('data-album-photo-delete-id'));
}
});
documentRef.addEventListener('submit', function (event) {
var albumForm = event.target.closest('[data-album-form]');
var photoForm = event.target.closest('[data-album-photo-form]');
if (albumForm) {
event.preventDefault();
submitAlbum(albumForm);
} else if (photoForm) {
event.preventDefault();
submitPhoto(photoForm);
}
});
}
function init() {
if (!documentRef) return;
bindActions();
if (query('[data-album-page]')) loadAlbums();
if (query('[data-album-edit-page]')) loadAlbumEditor();
if (query('[data-album-detail-page]')) loadAlbumDetailPage();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentAlbumId: getCurrentAlbumId,
buildAlbumBody: buildAlbumBody,
validateAlbumBody: validateAlbumBody,
buildAlbumPhotoBody: buildAlbumPhotoBody,
validateAlbumPhotoBody: validateAlbumPhotoBody,
normalizeAlbum: normalizeAlbum,
normalizeAlbumPhoto: normalizeAlbumPhoto,
normalizeAlbums: normalizeAlbums,
normalizeAlbumPhotos: normalizeAlbumPhotos,
findAlbumById: findAlbumById,
renderAlbumDetail: renderAlbumDetail,
renderPhotoList: renderPhotoList,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+567
View File
@@ -0,0 +1,567 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.ArticlePages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.ArticlePages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var articlesById = Object.create(null);
var writePending = false;
var canEditContent = false;
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function trimOrUndefined(value) {
var text = stringValue(value).trim();
return text || undefined;
}
function queryParam(search, name) {
return new URLSearchParams(stringValue(search).replace(/^\?/, '')).get(name) || '';
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function normalizeNullableId(value) {
if (value === undefined || value === null || value === '') return '';
return normalizeId(value);
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var contextId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
contextId = root.ProfileUI.getGenealogyId();
if (contextId) return normalizeId(contextId);
}
return normalizeId(queryParam(source, 'genealogyId'));
}
function getCurrentArticleId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(queryParam(source, 'articleId'));
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function buildArticleBody(values) {
var source = values || {};
var body = {
articleTitle: stringValue(source.articleTitle).trim(),
articleContent: stringValue(source.articleContent).trim(),
status: '0'
};
var articleSummary = trimOrUndefined(source.articleSummary);
var coverOssId = normalizeNullableId(source.coverOssId);
var authorName = trimOrUndefined(source.authorName);
var sortOrder = optionalSafeInteger(source.sortOrder);
if (articleSummary !== undefined) body.articleSummary = articleSummary;
if (source.coverOssId !== undefined && source.coverOssId !== null && source.coverOssId !== '') {
if (coverOssId) body.coverOssId = coverOssId;
else body.invalidCoverOssId = true;
}
if (authorName !== undefined) body.authorName = authorName;
if (source.sortOrder !== undefined && source.sortOrder !== null && source.sortOrder !== '') {
if (sortOrder !== undefined) body.sortOrder = sortOrder;
else body.invalidSortOrder = true;
}
return body;
}
function validateArticleBody(body) {
if (!body || !body.articleTitle) return '请填写谱文标题';
if (!body.articleContent) return '请填写谱文正文';
if (body.invalidCoverOssId) return '封面文件编号无效,请重新选择文件';
if (body.invalidSortOrder) return '排序值必须是安全整数';
if (body.status === '1') return '当前 PC 无法重新读取停用谱文,暂不开放停用';
if (body.status !== '0') return '谱文状态只能是 0';
return '';
}
function normalizeArticle(item) {
var articleId = normalizeId(item && item.articleId);
var genealogyId = normalizeId(item && item.genealogyId);
var categoryId = normalizeNullableId(item && item.categoryId);
var coverOssId = normalizeNullableId(item && item.coverOssId);
var articleTitle = stringValue(item && item.articleTitle).trim();
var articleContent = stringValue(item && item.articleContent).trim();
var viewCount = optionalSafeInteger(item && item.viewCount);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var status = stringValue(item && item.status);
var article;
if (!articleId || !genealogyId || !articleTitle || !articleContent) return null;
if (item.categoryId !== undefined && item.categoryId !== null && item.categoryId !== '' && !categoryId) return null;
if (item.coverOssId !== undefined && item.coverOssId !== null && item.coverOssId !== '' && !coverOssId) return null;
if (item.viewCount !== undefined && item.viewCount !== null && item.viewCount !== '' && viewCount === undefined) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
if (status !== '0' && status !== '1') return null;
article = {
articleId: articleId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
categoryName: stringValue(item.categoryName),
categoryCode: stringValue(item.categoryCode),
articleTitle: articleTitle,
articleSummary: stringValue(item.articleSummary),
articleContent: articleContent,
authorName: stringValue(item.authorName),
publishTime: stringValue(item.publishTime),
status: status,
remark: stringValue(item.remark)
};
if (categoryId) article.categoryId = categoryId;
if (coverOssId) article.coverOssId = coverOssId;
if (viewCount !== undefined) article.viewCount = viewCount;
if (sortOrder !== undefined) article.sortOrder = sortOrder;
return article;
}
function normalizeArticles(data) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizeArticle);
return normalized.some(function (article) { return !article; }) ? [] : normalized;
}
function matchesSavedArticle(item, expectedArticleId) {
var article = normalizeArticle(item);
return Boolean(article && article.articleId === normalizeId(expectedArticleId));
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildListUrl(genealogyId, articleId) {
var url = 'profile-article.html?genealogyId=' + encodeURIComponent(genealogyId);
return articleId ? url + '&articleId=' + encodeURIComponent(articleId) : url;
}
function buildEditUrl(genealogyId, articleId) {
var url = 'profile-article-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return articleId ? url + '&articleId=' + encodeURIComponent(articleId) : url;
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderArticleDetail(article) {
var actions = '';
if (!article) return '<div class="api-empty">请选择一篇谱文查看详情</div>';
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(article.genealogyId, article.articleId)) + '">编辑谱文</a>' +
'<button class="btn ghost" type="button" data-article-delete-id="' +
escapeHtml(article.articleId) + '">删除谱文</button></div>';
}
return '<div class="form-like article-detail">' +
detailValue('标题', article.articleTitle) +
detailValue('摘要', article.articleSummary) +
detailValue('正文', article.articleContent) +
detailValue('作者', article.authorName) +
detailValue('分类', article.categoryName) +
detailValue('发布时间', article.publishTime) +
detailValue('浏览量', article.viewCount === undefined ? '' : article.viewCount) +
detailValue('备注', article.remark) +
'</div>' + actions;
}
function renderArticleRow(article) {
var actions = '<button class="pill" type="button" data-article-detail-id="' +
escapeHtml(article.articleId) + '">查看详情</button>';
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(article.genealogyId, article.articleId)) +
'">编辑</a><button class="pill is-danger" type="button" data-article-delete-id="' +
escapeHtml(article.articleId) + '">删除</button>';
}
return '<article class="module-row article-row"><div><h3>' + escapeHtml(article.articleTitle) +
'</h3><p>' + escapeHtml(article.articleSummary || article.authorName || article.publishTime || '暂无摘要') +
'</p></div><div class="row-actions">' + actions + '</div></article>';
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function showMessage(message) {
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setFormStatus(message) {
var target = query('[data-article-form-status]');
if (target) target.textContent = message || '';
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-article-editor-placeholder]');
queryAll('[data-article-editor], [data-article-editor-action]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-article-form] button, [data-article-form] input, [data-article-form] textarea, [data-article-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderNoContext() {
var message = '请先选择家谱,再查看或维护谱文。';
var list = query('[data-article-list]');
var detail = query('[data-article-detail]');
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
async function loadCapability(api, genealogyId) {
var detail = await api.genealogyDetail(genealogyId);
canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage));
queryAll('[data-article-create-link], [data-article-editor-action]').forEach(function (element) {
element.hidden = !canEditContent;
});
return detail;
}
function renderArticles(data) {
var container = query('[data-article-list]');
var articles = normalizeArticles(data);
articlesById = Object.create(null);
articles.forEach(function (article) {
articlesById[article.articleId] = article;
});
if (!container) return articles;
if (!Array.isArray(data) || (data.length && !articles.length)) {
container.innerHTML = '<div class="api-empty">谱文响应缺少稳定 ArticleVo 字段,请联系后端核对。</div>';
return [];
}
if (!articles.length) {
container.innerHTML = '<div class="api-empty">暂无谱文</div>';
return [];
}
container.innerHTML = articles.map(renderArticleRow).join('');
return articles;
}
function renderDetail(article) {
var container = query('[data-article-detail]');
if (container) container.innerHTML = renderArticleDetail(article);
}
async function loadArticleDetail(articleId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var article;
if (!genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return null;
try {
article = normalizeArticle(await api.articleDetail(genealogyId, articleId));
if (!article || article.articleId !== normalizeId(articleId)) {
throw new Error('谱文详情响应缺少稳定 ArticleVo 字段');
}
articlesById[article.articleId] = article;
renderDetail(article);
return article;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
showMessage(isForbidden(error) ? '当前账号无权查看该谱文。' : (error.message || '谱文详情加载失败'));
return null;
}
}
async function loadArticles() {
var api = getApi();
var genealogyId;
var articles;
var requestedId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
try {
await loadCapability(api, genealogyId);
articles = renderArticles(await api.articles(genealogyId));
requestedId = getCurrentArticleId();
if (requestedId && articlesById[requestedId]) await loadArticleDetail(requestedId);
else if (articles.length) await loadArticleDetail(articles[0].articleId);
else renderDetail(null);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-article-list]')) {
query('[data-article-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : '谱文加载失败,请稍后重试。') +
'</div>';
}
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : (error.message || '谱文加载失败'));
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
function setFieldValue(name, value) {
var field = query('[data-article-form] [name="' + name + '"]');
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function fillArticleForm(article) {
var contentField;
var contentEditor;
if (!article || article.status !== '0') throw new Error('停用谱文无法通过当前 PC 接口重新读取或编辑');
setFieldValue('articleTitle', article.articleTitle);
setFieldValue('articleSummary', article.articleSummary);
setFieldValue('coverOssId', article.coverOssId);
setFieldValue('articleContent', article.articleContent);
setFieldValue('authorName', article.authorName);
setFieldValue('sortOrder', article.sortOrder);
setFieldValue('status', '0');
contentField = query('[name="articleContent"]');
if (root.AppRichEditor && root.AppRichEditor.init && contentField) {
contentEditor = root.AppRichEditor.init(contentField);
if (contentEditor && contentEditor.editor && contentEditor.editor.setHtml) {
contentEditor.editor.setHtml(article.articleContent);
contentEditor.sync();
}
}
}
async function loadArticleEditor() {
var api = getApi();
var genealogyId;
var articleId;
var article;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
articleId = getCurrentArticleId();
syncLinks();
setEditorEnabled(false, '正在加载谱文编辑信息…');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的谱文。' };
if (articleId) {
article = normalizeArticle(await api.articleDetail(genealogyId, articleId));
if (!article || article.articleId !== articleId) throw new Error('谱文详情响应缺少稳定 ArticleVo 字段');
fillArticleForm(article);
if (query('[data-article-editor-title]')) query('[data-article-editor-title]').textContent = '编辑谱文';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的谱文。'
: (error.message || '谱文编辑信息加载失败'));
setFormStatus(isForbidden(error) ? '当前账号无权维护该家谱的谱文。' : (error.message || '谱文编辑信息加载失败'));
}
}
async function submitArticle(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var articleId = getCurrentArticleId();
var body;
var validation;
var result;
var saved;
var savedId;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll();
body = buildArticleBody(getFormValues(form));
validation = validateArticleBody(body);
if (validation) {
setFormStatus(validation);
return;
}
delete body.invalidCoverOssId;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存谱文…');
try {
result = articleId
? await api.updateArticle(genealogyId, articleId, body)
: await api.createArticle(genealogyId, body);
saved = normalizeArticle(result);
savedId = articleId || (saved && saved.articleId);
if (!savedId) throw new Error('保存响应缺少 articleId');
if (!matchesSavedArticle(await api.articleDetail(genealogyId, savedId), savedId)) {
throw new Error('保存后重读未返回同一篇谱文');
}
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该谱文。' : (error.message || '谱文保存失败'));
} finally {
setWritePending(false);
}
}
async function deleteArticle(articleId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var article = articlesById[articleId];
if (writePending || !genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (article ? article.articleTitle : '该谱文') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
await api.deleteArticle(genealogyId, articleId);
await loadArticles();
showMessage('谱文已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该谱文。' : (error.message || '谱文删除失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-article-detail-id]');
var deleteButton = event.target.closest('[data-article-delete-id]');
if (detailButton) {
event.preventDefault();
loadArticleDetail(detailButton.getAttribute('data-article-detail-id'));
return;
}
if (deleteButton) {
event.preventDefault();
deleteArticle(deleteButton.getAttribute('data-article-delete-id'));
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-article-form]');
if (!form) return;
event.preventDefault();
submitArticle(form);
});
}
function init() {
if (!documentRef) return;
bindActions();
if (query('[data-article-page]')) loadArticles();
if (query('[data-article-edit-page]')) loadArticleEditor();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentArticleId: getCurrentArticleId,
buildArticleBody: buildArticleBody,
validateArticleBody: validateArticleBody,
normalizeArticle: normalizeArticle,
normalizeArticles: normalizeArticles,
matchesSavedArticle: matchesSavedArticle,
renderArticleDetail: renderArticleDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+920
View File
@@ -0,0 +1,920 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.CeremonyAdminPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.CeremonyAdminPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var ceremoniesById = Object.create(null);
var giftsById = Object.create(null);
var inviteeOptionsByUserId = Object.create(null);
var currentCeremony = null;
var canEditContent = false;
var writePending = false;
var invitationStatuses = ['PENDING', 'ACCEPTED', 'DECLINED', 'CANCELED'];
var invitationStatusLabels = {
PENDING: '待响应',
ACCEPTED: '已接受',
DECLINED: '已拒绝',
CANCELED: '已取消'
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function trimOrUndefined(value) {
var text = stringValue(value).trim();
return text || undefined;
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function normalizeNullableId(value) {
if (value === undefined || value === null || value === '') return '';
return normalizeId(value);
}
function queryParam(search, name) {
return new URLSearchParams(stringValue(search).replace(/^\?/, '')).get(name) || '';
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var contextId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
contextId = root.ProfileUI.getGenealogyId();
if (contextId) return normalizeId(contextId);
}
return normalizeId(queryParam(source, 'genealogyId'));
}
function getCurrentCeremonyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(queryParam(source, 'ceremonyId'));
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function optionalFiniteNumber(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function toBackendDateTime(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function toDateTimeInputValue(value) {
var text = stringValue(value);
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) return '';
return text.slice(0, 16).replace(' ', 'T');
}
function isValidBackendDateTime(value) {
var match = stringValue(value).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
var year;
var month;
var day;
var days;
if (!match) return false;
year = Number(match[1]);
month = Number(match[2]);
day = Number(match[3]);
if (month < 1 || month > 12 || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false;
days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day >= 1 && day <= days[month - 1];
}
function buildCeremonyBody(values) {
var source = values || {};
var body = {
ceremonyType: stringValue(source.ceremonyType).trim(),
ceremonyTitle: stringValue(source.ceremonyTitle).trim(),
status: '0'
};
var optionalNames = ['ceremonyDesc', 'location', 'locationAddress'];
var ceremonyTime = toBackendDateTime(source.ceremonyTime);
var longitude = optionalFiniteNumber(source.longitude);
var latitude = optionalFiniteNumber(source.latitude);
var coverOssId = normalizeNullableId(source.coverOssId);
var sortOrder = optionalSafeInteger(source.sortOrder);
optionalNames.forEach(function (name) {
var value = trimOrUndefined(source[name]);
if (value !== undefined) body[name] = value;
});
if (ceremonyTime !== undefined) body.ceremonyTime = ceremonyTime;
if (source.longitude !== undefined && source.longitude !== null && source.longitude !== '') {
if (longitude !== undefined) body.longitude = longitude;
else body.invalidLongitude = true;
}
if (source.latitude !== undefined && source.latitude !== null && source.latitude !== '') {
if (latitude !== undefined) body.latitude = latitude;
else body.invalidLatitude = true;
}
if (source.coverOssId !== undefined && source.coverOssId !== null && source.coverOssId !== '') {
if (coverOssId) body.coverOssId = coverOssId;
else body.invalidCoverOssId = true;
}
if (source.sortOrder !== undefined && source.sortOrder !== null && source.sortOrder !== '') {
if (sortOrder !== undefined) body.sortOrder = sortOrder;
else body.invalidSortOrder = true;
}
return body;
}
function validateCeremonyBody(body) {
if (!body || !body.ceremonyType) return '请填写活动类型';
if (!body.ceremonyTitle) return '请填写活动标题';
if (body.ceremonyTime !== undefined && !isValidBackendDateTime(body.ceremonyTime)) return '活动时间格式无效';
if ((body.longitude === undefined) !== (body.latitude === undefined)) return '经度和纬度必须同时提供';
if (body.invalidLongitude) return '经度必须是有效数字';
if (body.invalidLatitude) return '纬度必须是有效数字';
if (body.longitude !== undefined && (body.longitude < -180 || body.longitude > 180)) return '经度范围必须是 -180 到 180';
if (body.latitude !== undefined && (body.latitude < -90 || body.latitude > 90)) return '纬度范围必须是 -90 到 90';
if (stringValue(body.locationAddress).length > 300) return '详细地址不能超过 300 个字符';
if (body.invalidCoverOssId) return '封面文件编号无效,请重新选择文件';
if (body.invalidSortOrder) return '排序值必须是安全整数';
if (body.status === '1') return '当前 PC 无法重新读取停用活动,暂不开放停用';
if (body.status !== '0') return '活动状态只能是 0';
return '';
}
function buildCeremonyGiftBody(values) {
var source = values || {};
var body = {};
var giverName = trimOrUndefined(source.giverName);
var giftMessage = trimOrUndefined(source.giftMessage);
var giftAmount = optionalFiniteNumber(source.giftAmount);
if (giverName !== undefined) body.giverName = giverName;
if (source.giftAmount !== undefined && source.giftAmount !== null && source.giftAmount !== '') {
if (giftAmount !== undefined) body.giftAmount = giftAmount;
else body.invalidGiftAmount = true;
}
if (giftMessage !== undefined) body.giftMessage = giftMessage;
return body;
}
function validateCeremonyGiftBody(body) {
if (!body || (body.giftAmount === undefined && !body.invalidGiftAmount)) return '请填写礼金金额';
if (body.invalidGiftAmount) return '礼金金额必须是有效数字';
if (body.giftAmount < 0) return '礼金金额不能小于 0';
return '';
}
function normalizeCeremony(item) {
var ceremonyId = normalizeId(item && item.ceremonyId);
var genealogyId = normalizeId(item && item.genealogyId);
var sponsorUserId = normalizeNullableId(item && item.sponsorUserId);
var coverOssId = normalizeNullableId(item && item.coverOssId);
var ceremonyType = stringValue(item && item.ceremonyType).trim();
var ceremonyTitle = stringValue(item && item.ceremonyTitle).trim();
var longitude = optionalFiniteNumber(item && item.longitude);
var latitude = optionalFiniteNumber(item && item.latitude);
var giftCount = optionalSafeInteger(item && item.giftCount);
var giftAmount = optionalFiniteNumber(item && item.giftAmount);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var status = stringValue(item && item.status);
var ceremony;
if (!ceremonyId || !genealogyId || !ceremonyType || !ceremonyTitle) return null;
if (item.sponsorUserId !== undefined && item.sponsorUserId !== null && item.sponsorUserId !== '' && !sponsorUserId) return null;
if (item.coverOssId !== undefined && item.coverOssId !== null && item.coverOssId !== '' && !coverOssId) return null;
if ((item.longitude !== undefined && item.longitude !== null && item.longitude !== '' && longitude === undefined) ||
(item.latitude !== undefined && item.latitude !== null && item.latitude !== '' && latitude === undefined)) return null;
if ((longitude === undefined) !== (latitude === undefined)) return null;
if (item.giftCount !== undefined && item.giftCount !== null && item.giftCount !== '' && giftCount === undefined) return null;
if (item.giftAmount !== undefined && item.giftAmount !== null && item.giftAmount !== '' && giftAmount === undefined) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
if (status !== '0' && status !== '1') return null;
ceremony = {
ceremonyId: ceremonyId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
sponsorNickName: stringValue(item.sponsorNickName),
sponsorPhone: stringValue(item.sponsorPhone),
ceremonyType: ceremonyType,
ceremonyTitle: ceremonyTitle,
ceremonyDesc: stringValue(item.ceremonyDesc),
ceremonyTime: stringValue(item.ceremonyTime),
location: stringValue(item.location),
locationAddress: stringValue(item.locationAddress),
status: status,
remark: stringValue(item.remark)
};
if (sponsorUserId) ceremony.sponsorUserId = sponsorUserId;
if (coverOssId) ceremony.coverOssId = coverOssId;
if (longitude !== undefined) ceremony.longitude = longitude;
if (latitude !== undefined) ceremony.latitude = latitude;
if (giftCount !== undefined) ceremony.giftCount = giftCount;
if (giftAmount !== undefined) ceremony.giftAmount = giftAmount;
if (sortOrder !== undefined) ceremony.sortOrder = sortOrder;
return ceremony;
}
function normalizeCeremonyGift(item) {
var giftId = normalizeId(item && item.giftId);
var genealogyId = normalizeId(item && item.genealogyId);
var ceremonyId = normalizeId(item && item.ceremonyId);
var giverUserId = normalizeNullableId(item && item.giverUserId);
var giftAmount = optionalFiniteNumber(item && item.giftAmount);
var status = stringValue(item && item.status);
var gift;
if (!giftId || !genealogyId || !ceremonyId || giftAmount === undefined || giftAmount < 0) return null;
if (item.giverUserId !== undefined && item.giverUserId !== null && item.giverUserId !== '' && !giverUserId) return null;
if (status !== '0' && status !== '1') return null;
gift = {
giftId: giftId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
ceremonyId: ceremonyId,
ceremonyTitle: stringValue(item.ceremonyTitle),
giverNickName: stringValue(item.giverNickName),
giverPhone: stringValue(item.giverPhone),
giverName: stringValue(item.giverName),
giftAmount: giftAmount,
giftMessage: stringValue(item.giftMessage),
giftTime: stringValue(item.giftTime),
status: status,
remark: stringValue(item.remark)
};
if (giverUserId) gift.giverUserId = giverUserId;
return gift;
}
function normalizeInvitation(item) {
var invitationId = normalizeId(item && item.invitationId);
var genealogyId = normalizeId(item && item.genealogyId);
var ceremonyId = normalizeId(item && item.ceremonyId);
var inviteeUserId = normalizeId(item && item.inviteeUserId);
var inviteStatus = stringValue(item && item.inviteStatus);
var inviteVersion = optionalSafeInteger(item && item.inviteVersion);
var invitation;
if (!invitationId || !genealogyId || !ceremonyId || !inviteeUserId || invitationStatuses.indexOf(inviteStatus) < 0) return null;
if (item.inviteVersion !== undefined && item.inviteVersion !== null && item.inviteVersion !== '' && inviteVersion === undefined) return null;
invitation = {
invitationId: invitationId,
genealogyId: genealogyId,
ceremonyId: ceremonyId,
inviteeUserId: inviteeUserId,
inviteStatus: inviteStatus,
deliveredTime: stringValue(item.deliveredTime),
readTime: stringValue(item.readTime),
responseTime: stringValue(item.responseTime)
};
if (inviteVersion !== undefined) invitation.inviteVersion = inviteVersion;
return invitation;
}
function normalizeInviteeOption(item) {
var memberId = normalizeId(item && item.memberId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeNullableId(item && item.appUserId);
var status = stringValue(item && item.status);
if (!memberId || !genealogyId || !appUserId || status !== '0') return null;
return {
memberId: memberId,
genealogyId: genealogyId,
appUserId: appUserId,
appUserNickName: stringValue(item.appUserNickName),
memberName: stringValue(item.memberName),
roleType: stringValue(item.roleType),
status: status
};
}
function normalizeList(data, normalizer) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizer);
return normalized.some(function (item) { return !item; }) ? [] : normalized;
}
function buildInviteesBody(values) {
var seen = Object.create(null);
var ids = [];
(Array.isArray(values) ? values : []).forEach(function (value) {
var id = normalizeId(value);
if (id && !seen[id]) {
seen[id] = true;
ids.push(id);
}
});
return { inviteeUserIds: ids };
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value === '' || value === undefined ? '未提供' : value) + '</b></p>';
}
function buildListUrl(genealogyId) {
return 'profile-ceremony.html?genealogyId=' + encodeURIComponent(genealogyId);
}
function buildEditUrl(genealogyId, ceremonyId) {
var url = 'profile-gift-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return ceremonyId ? url + '&ceremonyId=' + encodeURIComponent(ceremonyId) : url;
}
function buildDetailUrl(genealogyId, ceremonyId) {
return 'profile-ceremony-detail.html?genealogyId=' + encodeURIComponent(genealogyId) +
'&ceremonyId=' + encodeURIComponent(ceremonyId);
}
function renderCeremonyDetail(ceremony) {
var mapLink = '';
var actions = '';
if (!ceremony) return '<div class="api-empty">未找到该活动</div>';
if (ceremony.longitude !== undefined && ceremony.latitude !== undefined) {
mapLink = ' <a class="pill" target="_blank" rel="noopener noreferrer" href="https://uri.amap.com/marker?position=' +
encodeURIComponent(ceremony.longitude + ',' + ceremony.latitude) + '&amp;name=' +
encodeURIComponent(ceremony.location || ceremony.locationAddress || ceremony.ceremonyTitle) + '">打开地图</a>';
}
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(ceremony.genealogyId, ceremony.ceremonyId)) + '">编辑活动</a>' +
'<button class="btn ghost" type="button" data-ceremony-delete-id="' +
escapeHtml(ceremony.ceremonyId) + '">删除活动</button></div>';
}
return '<div class="form-like ceremony-detail">' +
detailValue('活动标题', ceremony.ceremonyTitle) +
detailValue('活动类型', ceremony.ceremonyType) +
detailValue('活动说明', ceremony.ceremonyDesc) +
detailValue('活动时间', ceremony.ceremonyTime) +
detailValue('地点', ceremony.location) +
'<p><span>详细地址</span><b>' + escapeHtml(ceremony.locationAddress || '未提供') + mapLink + '</b></p>' +
detailValue('发起人', ceremony.sponsorNickName) +
detailValue('祭品数量', ceremony.giftCount === undefined ? '' : ceremony.giftCount) +
detailValue('礼金合计', ceremony.giftAmount === undefined ? '' : ceremony.giftAmount) +
detailValue('备注', ceremony.remark) +
'</div>' + actions;
}
function renderCeremonyRow(ceremony) {
var actions = '<a class="pill" href="' + escapeHtml(buildDetailUrl(ceremony.genealogyId, ceremony.ceremonyId)) + '">查看详情</a>';
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(ceremony.genealogyId, ceremony.ceremonyId)) +
'">编辑</a><button class="pill is-danger" type="button" data-ceremony-delete-id="' +
escapeHtml(ceremony.ceremonyId) + '">删除</button>';
}
return '<article class="module-row ceremony-row"><div><h3>' + escapeHtml(ceremony.ceremonyTitle) +
'</h3><p>' + escapeHtml([ceremony.ceremonyType, ceremony.ceremonyTime, ceremony.location].filter(Boolean).join(' · ')) +
'</p></div><div class="row-actions">' + actions + '</div></article>';
}
function renderGiftList(data) {
var gifts = Array.isArray(data) ? data.map(normalizeCeremonyGift) : [];
if (gifts.some(function (gift) { return !gift; })) return '<div class="api-empty">祭品响应字段不完整</div>';
if (!gifts.length) return '<div class="api-empty">暂无祭品记录</div>';
return gifts.map(function (gift) {
var action = canEditContent
? '<button class="pill is-danger" type="button" data-ceremony-gift-delete-id="' +
escapeHtml(gift.giftId) + '">删除</button>'
: '';
return '<article class="module-row ceremony-gift-row"><div><h3>' +
escapeHtml(gift.giverName || gift.giverNickName || '匿名宗亲') + ' · ' +
escapeHtml(gift.giftAmount) + '</h3><p>' +
escapeHtml([gift.giftMessage, gift.giftTime].filter(Boolean).join(' · ') || '未提供留言') +
'</p></div><div class="row-actions">' + action + '</div></article>';
}).join('');
}
function renderInvitationList(data) {
var invitations = normalizeList(data, normalizeInvitation);
if (!Array.isArray(data) || (data.length && !invitations.length)) return '<div class="api-empty">邀请响应字段不完整</div>';
if (!invitations.length) return '<div class="api-empty">暂无邀请记录</div>';
return invitations.map(function (invitation) {
var option = inviteeOptionsByUserId[invitation.inviteeUserId];
var label = option && (option.memberName || option.appUserNickName) || '已邀请成员';
return '<article class="module-row ceremony-invitee-row"><div><h3>' + escapeHtml(label) +
'</h3><p>' + escapeHtml(invitationStatusLabels[invitation.inviteStatus]) +
(invitation.deliveredTime ? ' · ' + escapeHtml(invitation.deliveredTime) : '') +
'</p></div></article>';
}).join('');
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function showMessage(message) {
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function setStatus(selector, message) {
var target = query(selector);
if (target) target.textContent = message || '';
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-ceremony-form] input, [data-ceremony-form] textarea, [data-ceremony-form] button, [data-ceremony-gift-form] input, [data-ceremony-gift-form] textarea, [data-ceremony-gift-form] button, [data-ceremony-invitee-form] input, [data-ceremony-invitee-form] button, [data-ceremony-delete-id], [data-ceremony-gift-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-ceremony-editor-placeholder]');
queryAll('[data-ceremony-editor], [data-ceremony-editor-action], [data-ceremony-admin-editor]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function renderNoContext() {
var message = '请先选择家谱,再查看或维护祭祀活动。';
if (query('[data-ceremony-list]')) query('[data-ceremony-list]').innerHTML = '<div class="api-empty">' + message + '</div>';
if (query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setStatus('[data-ceremony-form-status]', message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
async function loadCapability(api, genealogyId) {
var detail = await api.genealogyDetail(genealogyId);
canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage));
queryAll('[data-ceremony-create-link], [data-ceremony-editor-action], [data-ceremony-admin-editor]').forEach(function (element) {
element.hidden = !canEditContent;
});
return detail;
}
function renderCeremonies(data) {
var container = query('[data-ceremony-list]');
var ceremonies = normalizeList(data, normalizeCeremony);
ceremoniesById = Object.create(null);
ceremonies.forEach(function (ceremony) { ceremoniesById[ceremony.ceremonyId] = ceremony; });
if (!container) return ceremonies;
if (!Array.isArray(data) || (data.length && !ceremonies.length)) {
container.innerHTML = '<div class="api-empty">活动响应缺少稳定 CeremonyVo 字段,请联系后端核对。</div>';
return [];
}
container.innerHTML = ceremonies.length
? ceremonies.map(renderCeremonyRow).join('')
: '<div class="api-empty">暂无祭祀活动</div>';
return ceremonies;
}
async function loadCeremonies() {
var api = getApi();
var genealogyId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
try {
await loadCapability(api, genealogyId);
renderCeremonies(await api.ceremonies(genealogyId));
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-ceremony-list]')) query('[data-ceremony-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的祭祀活动。' : '活动加载失败,请稍后重试。') + '</div>';
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的祭祀活动。' : (error.message || '活动加载失败'));
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) { values[field.name] = field.value; });
return values;
}
function setFieldValue(formSelector, name, value) {
var field = query(formSelector + ' [name="' + name + '"]');
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function fillCeremonyForm(ceremony) {
var contentField;
var contentEditor;
if (!ceremony || ceremony.status !== '0') throw new Error('停用活动无法通过当前 PC 接口重新读取或编辑');
['ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'location', 'locationAddress', 'longitude', 'latitude', 'coverOssId', 'sortOrder'].forEach(function (name) {
setFieldValue('[data-ceremony-form]', name, ceremony[name]);
});
setFieldValue('[data-ceremony-form]', 'ceremonyTime', toDateTimeInputValue(ceremony.ceremonyTime));
setFieldValue('[data-ceremony-form]', 'status', '0');
contentField = query('[data-ceremony-form] [name="ceremonyDesc"]');
if (contentField && root.AppRichEditor && root.AppRichEditor.init) {
contentEditor = root.AppRichEditor.init(contentField);
if (contentEditor && contentEditor.editor && contentEditor.editor.setHtml) {
contentEditor.editor.setHtml(ceremony.ceremonyDesc || '');
contentEditor.sync();
}
}
}
async function loadCeremonyEditor() {
var api = getApi();
var genealogyId;
var ceremonyId;
var ceremony;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
ceremonyId = getCurrentCeremonyId();
syncLinks();
setEditorEnabled(false, '正在加载活动编辑信息…');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的祭祀活动。' };
if (ceremonyId) {
ceremony = normalizeCeremony(await api.ceremonyDetail(genealogyId, ceremonyId));
if (!ceremony || ceremony.ceremonyId !== ceremonyId) throw new Error('活动详情响应缺少稳定 ceremonyId');
fillCeremonyForm(ceremony);
if (query('[data-ceremony-editor-title]')) query('[data-ceremony-editor-title]').textContent = '编辑祭祀活动';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的祭祀活动。' : (error.message || '活动编辑信息加载失败'));
setStatus('[data-ceremony-form-status]', isForbidden(error) ? '当前账号无权维护该家谱的祭祀活动。' : (error.message || '活动编辑信息加载失败'));
}
}
async function loadGifts(api, genealogyId, ceremonyId) {
var data = await api.ceremonyGifts(genealogyId, ceremonyId);
var gifts = normalizeList(data, normalizeCeremonyGift);
var container = query('[data-ceremony-gift-list]');
giftsById = Object.create(null);
gifts.forEach(function (gift) { giftsById[gift.giftId] = gift; });
if (container) {
container.innerHTML = !Array.isArray(data) || (data.length && !gifts.length)
? '<div class="api-empty">祭品响应缺少稳定 CeremonyGiftVo 字段,请联系后端核对。</div>'
: renderGiftList(gifts);
}
return gifts;
}
function renderInviteeOptions(options, invitations) {
var container = query('[data-ceremony-invitee-options]');
var selected = Object.create(null);
invitations.forEach(function (invitation) {
if (invitation.inviteStatus !== 'CANCELED') selected[invitation.inviteeUserId] = true;
});
if (!container) return;
container.innerHTML = options.length ? options.map(function (option) {
var label = option.memberName || option.appUserNickName || '家谱成员';
return '<label class="editor-choice"><input type="checkbox" data-invitee-user-id="' +
escapeHtml(option.appUserId) + '"' + (selected[option.appUserId] ? ' checked' : '') +
' /> <span>' + escapeHtml(label) + '</span></label>';
}).join('') : '<div class="api-empty">暂无已绑定账号的正常成员</div>';
}
async function loadInvitees(api, genealogyId, ceremonyId) {
var results = await Promise.all([
api.genealogyMemberOptions(genealogyId),
api.ceremonyInvitations(genealogyId, ceremonyId)
]);
var options = normalizeList(results[0], normalizeInviteeOption);
var invitations = normalizeList(results[1], normalizeInvitation);
var list = query('[data-ceremony-invitation-list]');
inviteeOptionsByUserId = Object.create(null);
options.forEach(function (option) { inviteeOptionsByUserId[option.appUserId] = option; });
if (!Array.isArray(results[0]) || (results[0].length && !options.length)) {
if (query('[data-ceremony-invitee-options]')) query('[data-ceremony-invitee-options]').innerHTML = '<div class="api-empty">成员选项响应字段不完整</div>';
} else {
renderInviteeOptions(options, invitations);
}
if (list) list.innerHTML = renderInvitationList(results[1]);
return { options: options, invitations: invitations };
}
async function loadCeremonyDetailPage() {
var api = getApi();
var genealogyId;
var ceremonyId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
ceremonyId = getCurrentCeremonyId();
if (!genealogyId || !ceremonyId) {
if (genealogyId && query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = '<div class="api-empty">缺少有效活动编号。</div>';
return;
}
syncLinks();
try {
await loadCapability(api, genealogyId);
currentCeremony = normalizeCeremony(await api.ceremonyDetail(genealogyId, ceremonyId));
if (!currentCeremony || currentCeremony.ceremonyId !== ceremonyId) throw new Error('活动详情响应缺少稳定 ceremonyId');
if (query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = renderCeremonyDetail(currentCeremony);
if (query('[data-ceremony-detail-title]')) query('[data-ceremony-detail-title]').textContent = currentCeremony.ceremonyTitle;
if (query('[data-ceremony-editor-action]')) query('[data-ceremony-editor-action]').href = buildEditUrl(genealogyId, ceremonyId);
await loadGifts(api, genealogyId, ceremonyId);
if (canEditContent) await loadInvitees(api, genealogyId, ceremonyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该活动。' : escapeHtml(error.message || '活动详情加载失败')) + '</div>';
showMessage(isForbidden(error) ? '当前账号无权查看该活动。' : (error.message || '活动详情加载失败'));
}
}
async function submitCeremony(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var ceremonyId = getCurrentCeremonyId();
var body;
var validation;
var saved;
var savedId;
var verified;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
body = buildCeremonyBody(getFormValues(form));
validation = validateCeremonyBody(body);
if (validation) {
setStatus('[data-ceremony-form-status]', validation);
return;
}
delete body.invalidLongitude;
delete body.invalidLatitude;
delete body.invalidCoverOssId;
delete body.invalidSortOrder;
setWritePending(true);
setStatus('[data-ceremony-form-status]', '正在保存活动…');
try {
saved = normalizeCeremony(ceremonyId
? await api.updateCeremony(genealogyId, ceremonyId, body)
: await api.createCeremony(genealogyId, body));
savedId = ceremonyId || (saved && saved.ceremonyId);
if (!savedId) throw new Error('保存响应缺少 ceremonyId');
verified = normalizeCeremony(await api.ceremonyDetail(genealogyId, savedId));
if (!verified || verified.ceremonyId !== savedId) throw new Error('保存后未能重读同一活动');
if (root.location) root.location.href = buildDetailUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-ceremony-form-status]', isForbidden(error) ? '当前账号无权保存该活动。' : (error.message || '活动保存失败'));
} finally {
setWritePending(false);
}
}
async function submitGift(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var ceremonyId = getCurrentCeremonyId();
var body;
var validation;
var saved;
if (writePending || !genealogyId || !ceremonyId || redirectUnauthorized(api)) return;
body = buildCeremonyGiftBody(getFormValues(form));
validation = validateCeremonyGiftBody(body);
if (validation) {
setStatus('[data-ceremony-gift-status]', validation);
return;
}
delete body.invalidGiftAmount;
setWritePending(true);
setStatus('[data-ceremony-gift-status]', '正在添加祭品…');
try {
saved = normalizeCeremonyGift(await api.createCeremonyGift(genealogyId, ceremonyId, body));
if (!saved || saved.ceremonyId !== ceremonyId) throw new Error('添加祭品响应缺少稳定 giftId');
if (!(await loadGifts(api, genealogyId, ceremonyId)).some(function (gift) { return gift.giftId === saved.giftId; })) {
throw new Error('添加后祭品列表未返回同一记录');
}
form.reset();
setStatus('[data-ceremony-gift-status]', '祭品已添加');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-ceremony-gift-status]', isForbidden(error) ? '当前账号无权添加祭品。' : (error.message || '祭品添加失败'));
} finally {
setWritePending(false);
}
}
async function submitInvitees() {
var api = getApi();
var genealogyId = requireGenealogyId();
var ceremonyId = getCurrentCeremonyId();
var body;
if (writePending || !genealogyId || !ceremonyId || redirectUnauthorized(api)) return;
body = buildInviteesBody(queryAll('[data-invitee-user-id]:checked').map(function (input) {
return input.getAttribute('data-invitee-user-id');
}));
setWritePending(true);
setStatus('[data-ceremony-invitee-status]', '正在更新受邀人…');
try {
await api.replaceCeremonyInvitees(genealogyId, ceremonyId, body.inviteeUserIds);
await loadInvitees(api, genealogyId, ceremonyId);
setStatus('[data-ceremony-invitee-status]', '受邀名单已更新');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-ceremony-invitee-status]', isForbidden(error) ? '当前账号无权维护受邀名单。' : (error.message || '受邀名单更新失败'));
} finally {
setWritePending(false);
}
}
async function deleteCeremony(ceremonyId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var ceremony = ceremoniesById[ceremonyId] || currentCeremony;
if (writePending || !genealogyId || !normalizeId(ceremonyId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (ceremony ? ceremony.ceremonyTitle : '该活动') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
await api.deleteCeremony(genealogyId, ceremonyId);
if (query('[data-ceremony-list-page]')) await loadCeremonies();
else if (root.location) root.location.href = buildListUrl(genealogyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该活动。' : (error.message || '活动删除失败'));
} finally {
setWritePending(false);
}
}
async function deleteGift(giftId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var ceremonyId = getCurrentCeremonyId();
var gift = giftsById[giftId];
if (writePending || !genealogyId || !ceremonyId || !normalizeId(giftId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (gift && (gift.giverName || gift.giverNickName) || '该祭品') + '”的记录吗?')) return;
setWritePending(true);
try {
await api.deleteCeremonyGift(genealogyId, ceremonyId, giftId);
await loadGifts(api, genealogyId, ceremonyId);
showMessage('祭品记录已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该祭品。' : (error.message || '祭品删除失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var ceremonyDelete = event.target.closest('[data-ceremony-delete-id]');
var giftDelete = event.target.closest('[data-ceremony-gift-delete-id]');
if (ceremonyDelete) {
event.preventDefault();
deleteCeremony(ceremonyDelete.getAttribute('data-ceremony-delete-id'));
} else if (giftDelete) {
event.preventDefault();
deleteGift(giftDelete.getAttribute('data-ceremony-gift-delete-id'));
}
});
documentRef.addEventListener('submit', function (event) {
var ceremonyForm = event.target.closest('[data-ceremony-form]');
var giftForm = event.target.closest('[data-ceremony-gift-form]');
var inviteeForm = event.target.closest('[data-ceremony-invitee-form]');
if (ceremonyForm) {
event.preventDefault();
submitCeremony(ceremonyForm);
} else if (giftForm) {
event.preventDefault();
submitGift(giftForm);
} else if (inviteeForm) {
event.preventDefault();
submitInvitees();
}
});
}
function init() {
if (!documentRef) return;
bindActions();
if (query('[data-ceremony-list-page]')) loadCeremonies();
if (query('[data-ceremony-edit-page]')) loadCeremonyEditor();
if (query('[data-ceremony-detail-page]')) loadCeremonyDetailPage();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentCeremonyId: getCurrentCeremonyId,
buildCeremonyBody: buildCeremonyBody,
validateCeremonyBody: validateCeremonyBody,
buildCeremonyGiftBody: buildCeremonyGiftBody,
validateCeremonyGiftBody: validateCeremonyGiftBody,
normalizeCeremony: normalizeCeremony,
normalizeCeremonyGift: normalizeCeremonyGift,
normalizeInvitation: normalizeInvitation,
normalizeInviteeOption: normalizeInviteeOption,
buildInviteesBody: buildInviteesBody,
renderCeremonyDetail: renderCeremonyDetail,
renderGiftList: renderGiftList,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+316
View File
@@ -0,0 +1,316 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.CeremonyPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.CeremonyPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var invitationsById = Object.create(null);
var selectedInvitationId = '';
var writePending = false;
var invitationStatuses = ['PENDING', 'ACCEPTED', 'DECLINED', 'CANCELED'];
var responseStatuses = ['ACCEPTED', 'DECLINED'];
var statusLabels = {
PENDING: '待响应',
ACCEPTED: '已接受',
DECLINED: '已拒绝',
CANCELED: '已取消'
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') {
root.location.replace('login.html');
return true;
}
root.location.href = 'login.html';
return true;
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function optionalInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function optionalNumber(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function normalizeInvitation(item) {
var invitationId = normalizeId(item && item.invitationId);
var genealogyId = normalizeId(item && item.genealogyId);
var ceremonyId = normalizeId(item && item.ceremonyId);
var inviteeUserId = normalizeId(item && item.inviteeUserId);
var inviteStatus = stringValue(item && item.inviteStatus);
var invitation;
var inviteVersion;
var longitude;
var latitude;
if (!invitationId || !genealogyId || !ceremonyId || invitationStatuses.indexOf(inviteStatus) < 0) {
return null;
}
invitation = {
invitationId: invitationId,
genealogyId: genealogyId,
ceremonyId: ceremonyId,
inviteStatus: inviteStatus,
deliveredTime: stringValue(item.deliveredTime),
readTime: stringValue(item.readTime),
responseTime: stringValue(item.responseTime),
ceremonyTitle: stringValue(item.ceremonyTitle),
ceremonyTime: stringValue(item.ceremonyTime),
location: stringValue(item.location),
locationAddress: stringValue(item.locationAddress)
};
if (inviteeUserId) invitation.inviteeUserId = inviteeUserId;
inviteVersion = optionalInteger(item.inviteVersion);
longitude = optionalNumber(item.longitude);
latitude = optionalNumber(item.latitude);
if (inviteVersion !== undefined) invitation.inviteVersion = inviteVersion;
if (longitude !== undefined) invitation.longitude = longitude;
if (latitude !== undefined) invitation.latitude = latitude;
return invitation;
}
function normalizeList(data) {
return Array.isArray(data) ? data : [];
}
function buildInvitationResponseBody(values) {
return {
inviteStatus: stringValue(values && values.inviteStatus)
};
}
function validateInvitationResponseBody(body) {
return !body || responseStatuses.indexOf(body.inviteStatus) < 0 ? '邀请只能选择接受或拒绝' : '';
}
function canRespondToInvitation(invitation) {
return Boolean(invitation && invitation.inviteStatus === 'PENDING');
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function invitationTitle(invitation) {
return invitation.ceremonyTitle || '未命名贺礼活动';
}
function renderInvitationRow(invitation) {
var actions = '<button class="pill" type="button" data-invitation-detail="' +
escapeHtml(invitation.invitationId) + '">查看详情</button>';
if (canRespondToInvitation(invitation)) {
actions += '<button class="pill" type="button" data-invitation-response="ACCEPTED" data-invitation-id="' +
escapeHtml(invitation.invitationId) + '">接受邀请</button>' +
'<button class="pill is-danger" type="button" data-invitation-response="DECLINED" data-invitation-id="' +
escapeHtml(invitation.invitationId) + '">拒绝邀请</button>';
}
return '<article class="module-row ceremony-invitation-row" data-invitation-row="' +
escapeHtml(invitation.invitationId) + '"><div><h3>' + escapeHtml(invitationTitle(invitation)) +
'</h3><p>' + escapeHtml(statusLabels[invitation.inviteStatus]) +
(invitation.ceremonyTime ? ' · ' + escapeHtml(invitation.ceremonyTime) : '') +
(invitation.location ? ' · ' + escapeHtml(invitation.location) : '') +
'</p></div><div class="row-actions">' + actions + '</div></article>';
}
function renderDetailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderInvitationDetail(invitation) {
var mapLink = '';
if (!invitation) return '<div class="api-empty">请选择一条邀请查看详情</div>';
if (invitation.longitude !== undefined && invitation.latitude !== undefined) {
mapLink = ' <a class="pill" target="_blank" rel="noopener noreferrer" href="https://uri.amap.com/marker?position=' +
encodeURIComponent(invitation.longitude + ',' + invitation.latitude) + '&amp;name=' +
encodeURIComponent(invitation.location || invitation.locationAddress || invitationTitle(invitation)) +
'">打开地图</a>';
}
return '<div class="form-like invitation-detail">' +
renderDetailValue('活动标题', invitationTitle(invitation)) +
renderDetailValue('邀请状态', statusLabels[invitation.inviteStatus]) +
renderDetailValue('活动时间', invitation.ceremonyTime) +
renderDetailValue('地点', invitation.location) +
'<p><span>详细地址</span><b>' + escapeHtml(invitation.locationAddress || '未提供') + mapLink + '</b></p>' +
renderDetailValue('投递时间', invitation.deliveredTime) +
renderDetailValue('阅读时间', invitation.readTime) +
renderDetailValue('响应时间', invitation.responseTime) +
'</div>';
}
function renderDetail(invitation) {
var container = query('[data-my-invitation-detail]');
selectedInvitationId = invitation ? invitation.invitationId : '';
if (container) container.innerHTML = renderInvitationDetail(invitation);
}
function renderInvitations(data) {
var container = query('[data-my-invitation-list]');
var normalized = normalizeList(data).map(normalizeInvitation);
var invalidCount = normalized.filter(function (item) { return !item; }).length;
var invitations = normalized.filter(Boolean);
invitationsById = Object.create(null);
invitations.forEach(function (invitation) {
invitationsById[invitation.invitationId] = invitation;
});
if (!container) return invitations;
if (invalidCount) {
container.innerHTML = '<div class="api-empty">邀请响应缺少稳定 ID 或合法状态,请联系后端核对 DTO。</div>';
renderDetail(null);
return [];
}
if (!invitations.length) {
container.innerHTML = '<div class="api-empty">暂无贺礼邀请</div>';
renderDetail(null);
return [];
}
container.innerHTML = invitations.map(renderInvitationRow).join('');
renderDetail(invitationsById[selectedInvitationId] || invitations[0]);
return invitations;
}
function setWritePending(pending) {
var buttons;
writePending = pending;
if (!documentRef) return;
buttons = documentRef.querySelectorAll('[data-invitation-response]');
Array.prototype.forEach.call(buttons, function (button) {
button.disabled = pending;
});
}
function setListState(message) {
var container = query('[data-my-invitation-list]');
if (container) container.innerHTML = '<div class="api-empty">' + escapeHtml(message) + '</div>';
}
async function loadMyInvitations() {
var api = getApi();
if (redirectUnauthorized(api)) return;
setListState('正在加载我的邀请…');
try {
renderInvitations(await api.myCeremonyInvitations());
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权查看邀请' : (error.message || '邀请加载失败'));
renderDetail(null);
}
}
async function respondToInvitation(invitationId, inviteStatus) {
var api = getApi();
var invitation = invitationsById[normalizeId(invitationId)];
var body = buildInvitationResponseBody({ inviteStatus: inviteStatus });
var validation = validateInvitationResponseBody(body);
if (writePending || redirectUnauthorized(api) || !invitation || !canRespondToInvitation(invitation)) return;
if (validation) return;
setWritePending(true);
try {
await api.respondCeremonyInvitation(invitation.genealogyId, invitation.ceremonyId, body);
selectedInvitationId = invitation.invitationId;
await loadMyInvitations();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权响应该邀请' : (error.message || '邀请响应失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-invitation-detail]');
var responseButton = event.target.closest('[data-invitation-response]');
if (detailButton) {
event.preventDefault();
renderDetail(invitationsById[detailButton.getAttribute('data-invitation-detail')] || null);
return;
}
if (!responseButton) return;
event.preventDefault();
respondToInvitation(
responseButton.getAttribute('data-invitation-id'),
responseButton.getAttribute('data-invitation-response')
);
});
}
function init() {
if (!documentRef || !query('[data-ceremony-page]')) return;
bindActions();
loadMyInvitations();
}
return {
normalizeInvitation: normalizeInvitation,
normalizeList: normalizeList,
buildInvitationResponseBody: buildInvitationResponseBody,
validateInvitationResponseBody: validateInvitationResponseBody,
canRespondToInvitation: canRespondToInvitation,
renderInvitationRow: renderInvitationRow,
renderInvitationDetail: renderInvitationDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
loadMyInvitations: loadMyInvitations,
init: init
};
});
+462 -97
View File
@@ -15,6 +15,16 @@
'use strict';
var documentRef = root.document;
var PAGE_SIZE = 20;
var currentFeedPage = 1;
var currentCommentPages = {};
var currentReplyPages = {};
var pendingActions = {};
var viewerContext = {
userId: '',
canEditContent: false,
canManage: false
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
@@ -62,9 +72,14 @@
}
function getCurrentGenealogyId(search) {
var page = query('[data-feed-page], [data-feed-edit-page]');
var page = query('[data-feed-page], [data-feed-edit-page], [data-feed-detail-page]');
var source = search === undefined && root.location ? root.location.search : search;
var profileGenealogyId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
profileGenealogyId = root.ProfileUI.getGenealogyId();
if (profileGenealogyId) return profileGenealogyId;
}
return getQueryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
}
@@ -86,20 +101,86 @@
if (!text) return undefined;
number = Number(text);
return Number.isFinite(number) ? number : undefined;
return Number.isSafeInteger(number) && number >= 0 ? number : undefined;
}
function toId(value) {
var text;
if (value === undefined || value === null || value === '') return '';
text = String(value);
return /^\d+$/.test(text) ? text : '';
}
function setDefined(target, source, fields) {
fields.forEach(function (field) {
if (source[field] !== undefined && source[field] !== null) target[field] = source[field];
});
return target;
}
function buildPageQuery(pageNum) {
var number = Number(pageNum);
if (!Number.isSafeInteger(number) || number < 1) number = 1;
return {
pageNum: number,
pageSize: PAGE_SIZE
};
}
function buildFeedUrl(kind, genealogyId, feedId) {
var params = new URLSearchParams();
var page = 'profile-feed.html';
params.set('genealogyId', toId(genealogyId));
if (kind === 'detail' || kind === 'edit') {
params.set('feedId', toId(feedId));
page = kind === 'detail' ? 'profile-feed-detail.html' : 'profile-feed-edit.html';
}
return page + '?' + params.toString();
}
function buildPostSaveUrl(genealogyId, feedId, status) {
return String(status || '0') === '1'
? buildFeedUrl('list', genealogyId)
: buildFeedUrl('detail', genealogyId, feedId);
}
function withActionLock(key, task) {
var promise;
if (pendingActions[key]) return pendingActions[key];
try {
promise = Promise.resolve(task());
} catch (error) {
promise = Promise.reject(error);
}
pendingActions[key] = promise.finally(function () {
delete pendingActions[key];
});
return pendingActions[key];
}
function buildFeedBody(values) {
// FamilyFeedBody 只提交最新接口文档定义的字段。
var source = values || {};
var mediaOssIds = trimOrUndefined(source.mediaOssIds);
var status = trimOrUndefined(source.status);
var body = {
feedType: trimOrUndefined(source.feedType) || 'text',
feedContent: String(source.feedContent || '').trim(),
mediaOssIds: trimOrUndefined(source.mediaOssIds),
status: trimOrUndefined(source.status)
feedContent: String(source.feedContent || '').trim()
};
var sortOrder = toIntegerOrUndefined(source.sortOrder);
if (status !== undefined && status !== '0' && status !== '1') {
throw new Error('动态状态只能是 0 或 1');
}
if (trimOrUndefined(source.sortOrder) !== undefined && sortOrder === undefined) {
throw new Error('排序值必须是非负整数');
}
if (mediaOssIds !== undefined) body.mediaOssIds = mediaOssIds;
if (status !== undefined) body.status = status;
if (sortOrder !== undefined) body.sortOrder = sortOrder;
return body;
}
@@ -117,21 +198,11 @@
}
function getFeedId(item) {
var value = item && item.feedId;
var text;
if (value === undefined || value === null || value === '') return '';
text = String(value);
return /^\d+$/.test(text) ? text : '';
return toId(item && item.feedId);
}
function getCommentId(item) {
var value = item && item.commentId;
var text;
if (value === undefined || value === null || value === '') return '';
text = String(value);
return /^\d+$/.test(text) ? text : '';
return toId(item && item.commentId);
}
function normalizeFeed(item) {
@@ -140,13 +211,29 @@
var feed;
if (!feedId || feedContent === undefined || feedContent === null) return null;
feed = {
feed = setDefined({
feedId: feedId,
feedContent: String(feedContent)
};
if (trimOrUndefined(item.mediaOssIds)) feed.mediaOssIds = trimOrUndefined(item.mediaOssIds);
if (trimOrUndefined(item.feedType)) feed.feedType = trimOrUndefined(item.feedType);
if (trimOrUndefined(item.status)) feed.status = trimOrUndefined(item.status);
}, item, [
'genealogyNo',
'genealogyName',
'publisherNickName',
'publisherStatus',
'feedType',
'mediaOssIds',
'likedByMe',
'likeCount',
'commentCount',
'pinned',
'pinnedTime',
'sortOrder',
'status',
'remark',
'createTime',
'updateTime'
]);
if (toId(item.genealogyId)) feed.genealogyId = toId(item.genealogyId);
if (toId(item.publisherUserId)) feed.publisherUserId = toId(item.publisherUserId);
return feed;
}
@@ -157,13 +244,24 @@
if (!commentId || commentContent === undefined) return null;
userDeleted = String(item && item.userDeleted || '') === '1';
return {
var comment = {
commentId: commentId,
commentContent: commentContent === null ? '该评论已删除' : String(commentContent),
appUserNickName: trimOrUndefined(item && item.appUserNickName),
replyCount: item && item.replyCount,
userDeleted: userDeleted || commentContent === null
};
setDefined(comment, item, [
'appUserAvatar',
'parentAppUserNickName',
'commentLevel',
'status',
'createTime'
]);
['genealogyId', 'feedId', 'parentCommentId', 'appUserId', 'parentAppUserId'].forEach(function (field) {
if (toId(item && item[field])) comment[field] = toId(item[field]);
});
return comment;
}
function normalizeList(data) {
@@ -183,24 +281,38 @@
}
function getListUrl(genealogyId, feedId) {
var params = new URLSearchParams();
params.set('genealogyId', genealogyId);
if (feedId) params.set('feedId', feedId);
return 'profile-feed' + (feedId ? '-edit' : '') + '.html?' + params.toString();
return buildFeedUrl(feedId ? 'edit' : 'list', genealogyId, feedId);
}
function renderFeedRow(feed, genealogyId) {
var canDelete = viewerContext.userId && viewerContext.userId === feed.publisherUserId;
var mediaCount = feed.mediaOssIds ? String(feed.mediaOssIds).split(',').filter(Boolean).length : 0;
var title = feed.publisherNickName || feed.genealogyName || '家族动态';
var meta = [];
var actions = '';
if (feed.pinned === true || String(feed.pinned) === '1') meta.push('置顶');
if (feed.createTime) meta.push(feed.createTime);
if (mediaCount) meta.push('附件 ' + mediaCount);
meta.push('点赞 ' + Number(feed.likeCount || 0));
meta.push('评论 ' + Number(feed.commentCount || 0));
actions += feed.likedByMe
? '<button class="pill" type="button" data-feed-unlike="' + escapeHtml(feed.feedId) + '">取消点赞</button>'
: '<button class="pill" type="button" data-feed-like="' + escapeHtml(feed.feedId) + '">点赞</button>';
actions += '<a class="pill" href="' + escapeHtml(buildFeedUrl('detail', genealogyId, feed.feedId)) + '">查看详情</a>';
actions += '<button class="pill" type="button" data-feed-comments="' + escapeHtml(feed.feedId) + '">查看评论</button>';
actions += '<button class="pill" type="button" data-feed-comment="' + escapeHtml(feed.feedId) + '">发表评论</button>';
if (viewerContext.canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildFeedUrl('edit', genealogyId, feed.feedId)) + '">编辑</a>';
}
if (canDelete) {
actions += '<button class="pill" type="button" data-feed-delete="' + escapeHtml(feed.feedId) + '">删除</button>';
}
return '<article class="module-row feed-row" data-feed-id="' + escapeHtml(feed.feedId) + '">' +
'<div><h3>家族动态</h3><p>' + escapeHtml(feed.feedContent) + '</p></div>' +
'<div class="row-actions">' +
'<button class="pill" type="button" data-feed-like="' + escapeHtml(feed.feedId) + '">点赞</button>' +
'<button class="pill" type="button" data-feed-unlike="' + escapeHtml(feed.feedId) + '">取消点赞</button>' +
'<button class="pill" type="button" data-feed-comments="' + escapeHtml(feed.feedId) + '">查看评论</button>' +
'<button class="pill" type="button" data-feed-comment="' + escapeHtml(feed.feedId) + '">发表评论</button>' +
'<a class="pill" href="' + escapeHtml(getListUrl(genealogyId, feed.feedId)) + '">编辑</a>' +
'<button class="pill" type="button" data-feed-delete="' + escapeHtml(feed.feedId) + '">删除</button>' +
'</div></article>';
'<div><h3>' + escapeHtml(title) + '</h3><p>' + escapeHtml(feed.feedContent) + '</p>' +
'<p class="module-meta">' + escapeHtml(meta.join(' · ')) + '</p></div>' +
'<div class="row-actions">' + actions + '</div></article>';
}
function renderFeeds(data, genealogyId) {
@@ -224,6 +336,24 @@
}).join('');
}
function getPageTotal(data) {
var total = Number(data && data.total);
return Number.isFinite(total) && total >= 0 ? total : 0;
}
function updateListPager(data) {
var total = getPageTotal(data);
var totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
var previous = query('[data-feed-page-prev]');
var next = query('[data-feed-page-next]');
var label = query('[data-feed-page-label]');
if (previous) previous.disabled = currentFeedPage <= 1;
if (next) next.disabled = currentFeedPage >= totalPages;
if (label) label.textContent = '第 ' + currentFeedPage + ' / ' + totalPages + ' 页,共 ' + total + ' 条';
}
function hasReplies(comment) {
var count = comment && comment.replyCount;
@@ -233,16 +363,20 @@
function renderCommentRow(feedId, comment) {
var author = comment.appUserNickName ? '<strong>' + escapeHtml(comment.appUserNickName) + '</strong> ' : '';
var actions = '';
var canDelete = viewerContext.userId && viewerContext.userId === comment.appUserId;
if (!comment.userDeleted) {
actions += '<button class="pill" type="button" data-feed-comment-reply="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">回复</button>';
actions += '<button class="pill" type="button" data-feed-comment-delete="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">删除评论</button>';
if (canDelete) {
actions += '<button class="pill" type="button" data-feed-comment-delete="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">删除评论</button>';
}
}
if (hasReplies(comment)) {
actions += '<button class="pill" type="button" data-feed-comment-replies="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">查看回复</button>';
}
return '<div class="module-row" data-feed-comment-node="' + escapeHtml(comment.commentId) + '"><p>' + author + escapeHtml(comment.commentContent) + '</p>' + actions + '</div>';
return '<div class="module-row" data-feed-comment-node="' + escapeHtml(comment.commentId) + '"><p>' + author + escapeHtml(comment.commentContent) + '</p>' +
(comment.createTime ? '<p class="module-meta">' + escapeHtml(comment.createTime) + '</p>' : '') + actions + '</div>';
}
function normalizeComments(data) {
@@ -255,25 +389,33 @@
}
function renderComments(feedId, data) {
var detailContainer = query('[data-feed-detail-comments]');
var row = query('[data-feed-id="' + feedId + '"]');
var result = normalizeComments(data);
var list = result.comments;
var previous = row && row.nextElementSibling;
var content;
if (!row) return;
if (!row && !detailContainer) return;
if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove();
if (result.invalidCount) {
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-comment-list="' + escapeHtml(feedId) + '">评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。</div>');
content = '<div class="api-empty">评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。</div>';
if (detailContainer) detailContainer.innerHTML = content;
else row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
return;
}
if (!list.length) {
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-comment-list="' + escapeHtml(feedId) + '">暂无评论</div>');
content = '<div class="api-empty">暂无评论</div>';
if (detailContainer) detailContainer.innerHTML = content;
else row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
return;
}
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-comment-list="' + escapeHtml(feedId) + '">' + list.map(function (comment) {
content = '<div class="module-list">' + list.map(function (comment) {
return renderCommentRow(feedId, comment);
}).join('') + '</div>');
}).join('') + '</div>' + renderPageControls('comments', feedId, '', currentCommentPages[feedId] || 1, getPageTotal(data));
if (detailContainer) detailContainer.innerHTML = content;
else row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
}
function renderCommentReplies(feedId, parentCommentId, data) {
@@ -294,13 +436,27 @@
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">' + result.comments.map(function (comment) {
return renderCommentRow(feedId, comment);
}).join('') + '</div>');
}).join('') + renderPageControls('replies', feedId, parentCommentId, currentReplyPages[parentCommentId] || 1, getPageTotal(data)) + '</div>');
}
function renderPageControls(kind, feedId, commentId, pageNum, total) {
var totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
var prefix = kind === 'comments' ? 'data-feed-comments-' : 'data-feed-replies-';
var ids = ' data-feed-id="' + escapeHtml(feedId) + '"' +
(commentId ? ' data-comment-id="' + escapeHtml(commentId) + '"' : '');
return '<div class="row-actions">' +
'<button class="pill" type="button" ' + prefix + 'prev' + ids + (pageNum <= 1 ? ' disabled' : '') + '>上一页</button>' +
'<span>第 ' + pageNum + ' / ' + totalPages + ' 页</span>' +
'<button class="pill" type="button" ' + prefix + 'next' + ids + (pageNum >= totalPages ? ' disabled' : '') + '>下一页</button>' +
'</div>';
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
if (field.disabled) return;
values[field.name] = field.value;
});
return values;
@@ -315,9 +471,70 @@
});
}
function normalizeViewerContext(genealogy, profile) {
return {
userId: toId(profile && profile.userId),
canEditContent: genealogy && genealogy.canEditContent === true,
canManage: genealogy && genealogy.canManage === true
};
}
async function loadViewerContext(api, genealogyId) {
var genealogy;
var profile;
try {
genealogy = await api.genealogyDetail(genealogyId);
} catch (error) {
genealogy = null;
}
try {
profile = await api.currentProfile();
} catch (error) {
profile = null;
}
viewerContext = normalizeViewerContext(genealogy, profile);
return viewerContext;
}
function renderFeedDetail(feed, genealogyId) {
var container = query('[data-feed-detail]');
var canDelete = viewerContext.userId && viewerContext.userId === feed.publisherUserId;
var actions = '';
if (!container) return;
actions += feed.likedByMe
? '<button class="pill" type="button" data-feed-unlike="' + escapeHtml(feed.feedId) + '">取消点赞</button>'
: '<button class="pill" type="button" data-feed-like="' + escapeHtml(feed.feedId) + '">点赞</button>';
actions += '<button class="pill" type="button" data-feed-comment="' + escapeHtml(feed.feedId) + '">发表评论</button>';
actions += '<button class="pill" type="button" data-feed-share>复制分享链接</button>';
if (viewerContext.canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildFeedUrl('edit', genealogyId, feed.feedId)) + '">编辑</a>';
}
if (canDelete) {
actions += '<button class="pill" type="button" data-feed-delete="' + escapeHtml(feed.feedId) + '">删除</button>';
}
container.setAttribute('data-feed-id', feed.feedId);
container.innerHTML = '<h2>' + escapeHtml(feed.publisherNickName || feed.genealogyName || '家族动态') + '</h2>' +
'<p>' + escapeHtml(feed.feedContent) + '</p>' +
'<p class="module-meta">' +
escapeHtml([
feed.createTime || '',
'点赞 ' + Number(feed.likeCount || 0),
'评论 ' + Number(feed.commentCount || 0),
String(feed.status) === '1' ? '已停用' : '正常'
].filter(Boolean).join(' · ')) +
'</p><div class="row-actions">' + actions + '</div>';
}
function syncGenealogyLinks(genealogyId) {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
root.ProfileUI.syncGenealogyContextLinks();
}
queryAll('[data-feed-create-link], [data-feed-list-link]').forEach(function (link) {
link.href = getListUrl(genealogyId);
link.href = link.hasAttribute('data-feed-create-link')
? buildFeedUrl('edit', genealogyId)
: buildFeedUrl('list', genealogyId);
});
}
@@ -333,18 +550,24 @@
return genealogyId;
}
async function loadFeeds() {
async function loadFeeds(pageNum) {
var api = getApi();
var genealogyId;
var data;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
currentFeedPage = buildPageQuery(pageNum || getQueryParam(root.location && root.location.search, 'pageNum')).pageNum;
try {
renderFeeds(await api.feedsPage(genealogyId, { pageNum: 1, pageSize: 20 }), genealogyId);
await loadViewerContext(api, genealogyId);
data = await api.feedsPage(genealogyId, buildPageQuery(currentFeedPage));
renderFeeds(data, genealogyId);
updateListPager(data);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-list]')) query('[data-feed-list]').innerHTML = '<div class="api-empty">家族动态加载失败,请稍后重试。</div>';
showMessage(error.message || '家族动态加载失败');
}
}
@@ -358,8 +581,15 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
if (!feedId) return;
try {
await loadViewerContext(api, genealogyId);
if (query('[data-feed-advanced]')) {
query('[data-feed-advanced]').hidden = !viewerContext.canManage;
queryAll('[name]', query('[data-feed-advanced]')).forEach(function (field) {
field.disabled = !viewerContext.canManage;
});
}
if (!feedId) return;
fillFeedForm(await api.feedDetail(genealogyId, feedId));
} catch (error) {
if (redirectUnauthorized(api, error)) return;
@@ -367,6 +597,32 @@
}
}
async function loadFeedDetail() {
var api = getApi();
var genealogyId;
var feedId = getCurrentFeedId();
var feed;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) {
if (query('[data-feed-detail]')) query('[data-feed-detail]').innerHTML = '<div class="api-empty">动态链接缺少有效编号</div>';
return;
}
syncGenealogyLinks(genealogyId);
try {
await loadViewerContext(api, genealogyId);
feed = normalizeFeed(await api.feedDetail(genealogyId, feedId));
if (!feed) throw new Error('动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO。');
renderFeedDetail(feed, genealogyId);
await showComments(feedId, currentCommentPages[feedId] || 1);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-detail]')) query('[data-feed-detail]').innerHTML = '<div class="api-empty">动态详情加载失败,请返回列表重试。</div>';
showMessage(error.message || '动态详情加载失败');
}
}
async function submitFeed(form) {
var api = getApi();
var genealogyId;
@@ -378,23 +634,42 @@
if (!genealogyId) return;
// 富文本编辑器在提交前同步回 textarea,保证发送的是用户当前输入。
if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll();
body = buildFeedBody(getFormValues(form));
try {
body = buildFeedBody(getFormValues(form));
} catch (error) {
showMessage(error.message || '动态表单校验失败');
return;
}
if (!body.feedContent) {
showMessage('请填写动态内容');
return;
}
try {
if (feedId) {
await api.updateFeed(genealogyId, feedId, body);
} else {
await api.createFeed(genealogyId, body);
return withActionLock('save:' + (feedId || 'new'), async function () {
var saved;
var savedFeedId;
var buttons = queryAll('[type="submit"]', form);
buttons.forEach(function (button) { button.disabled = true; });
try {
if (feedId) {
saved = await api.updateFeed(genealogyId, feedId, body);
} else {
saved = await api.createFeed(genealogyId, body);
}
savedFeedId = getFeedId(saved) || feedId;
if (!savedFeedId) throw new Error('动态保存响应缺少 feedId');
if (String(body.status || '0') === '0') {
await api.feedDetail(genealogyId, savedFeedId);
}
showMessage('动态已保存');
root.location.href = buildPostSaveUrl(genealogyId, savedFeedId, body.status);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '动态保存失败');
} finally {
buttons.forEach(function (button) { button.disabled = false; });
}
showMessage('动态已保存');
root.location.href = getListUrl(genealogyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '动态保存失败');
}
});
}
async function likeFeed(feedId, shouldLike) {
@@ -404,17 +679,20 @@
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) return;
try {
if (shouldLike) {
await api.likeFeed(genealogyId, feedId);
} else {
await api.unlikeFeed(genealogyId, feedId);
return withActionLock((shouldLike ? 'like:' : 'unlike:') + feedId, async function () {
try {
if (shouldLike) {
await api.likeFeed(genealogyId, feedId);
} else {
await api.unlikeFeed(genealogyId, feedId);
}
if (query('[data-feed-detail-page]')) await loadFeedDetail();
else await loadFeeds(currentFeedPage);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || (shouldLike ? '点赞失败' : '取消点赞失败'));
}
await loadFeeds();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || (shouldLike ? '点赞失败' : '取消点赞失败'));
}
});
}
async function deleteFeed(feedId) {
@@ -425,41 +703,67 @@
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) return;
if (root.confirm && !root.confirm('确认删除这条动态吗?')) return;
try {
await api.deleteFeed(genealogyId, feedId);
await loadFeeds();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '删除动态失败');
}
return withActionLock('delete:' + feedId, async function () {
try {
await api.deleteFeed(genealogyId, feedId);
if (query('[data-feed-detail-page]')) root.location.href = buildFeedUrl('list', genealogyId);
else await loadFeeds(currentFeedPage);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '删除动态失败');
}
});
}
async function showComments(feedId) {
async function showComments(feedId, pageNum) {
var api = getApi();
var genealogyId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) return;
currentCommentPages[feedId] = buildPageQuery(pageNum).pageNum;
try {
renderComments(feedId, await api.feedComments(genealogyId, feedId));
renderComments(
feedId,
await api.feedCommentsPage(genealogyId, feedId, buildPageQuery(currentCommentPages[feedId]))
);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-detail-comments]')) {
query('[data-feed-detail-comments]').innerHTML = '<div class="api-empty">评论加载失败,请稍后重试。</div>';
}
showMessage(error.message || '评论加载失败');
}
}
async function showCommentReplies(feedId, commentId) {
async function showCommentReplies(feedId, commentId, pageNum) {
var api = getApi();
var genealogyId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId || !commentId) return;
currentReplyPages[commentId] = buildPageQuery(pageNum).pageNum;
try {
renderCommentReplies(feedId, commentId, await api.feedCommentReplies(genealogyId, feedId, commentId));
renderCommentReplies(
feedId,
commentId,
await api.feedCommentRepliesPage(
genealogyId,
feedId,
commentId,
buildPageQuery(currentReplyPages[commentId])
)
);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-comment-node="' + commentId + '"]')) {
query('[data-feed-comment-node="' + commentId + '"]').insertAdjacentHTML(
'afterend',
'<div class="api-empty">回复加载失败,请稍后重试。</div>'
);
}
showMessage(error.message || '回复加载失败');
}
}
@@ -476,13 +780,26 @@
content = root.prompt ? root.prompt(parentCommentId ? '请输入回复内容' : '请输入评论内容', '') : '';
body = buildCommentBody({ commentContent: content, parentCommentId: parentCommentId });
if (!body.commentContent) return;
try {
await api.createFeedComment(genealogyId, feedId, body);
await showComments(feedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '评论发布失败');
if (body.commentContent.length > 1000) {
showMessage('评论不能超过 1000 字');
return;
}
return withActionLock('comment:' + feedId + ':' + (parentCommentId || 'root'), async function () {
try {
await api.createFeedComment(genealogyId, feedId, body);
await showComments(feedId, currentCommentPages[feedId] || 1);
if (parentCommentId) await showCommentReplies(feedId, parentCommentId, currentReplyPages[parentCommentId] || 1);
if (query('[data-feed-detail-page]')) {
var feed = normalizeFeed(await api.feedDetail(genealogyId, feedId));
if (feed) renderFeedDetail(feed, genealogyId);
} else {
await loadFeeds(currentFeedPage);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '评论发布失败');
}
});
}
async function deleteComment(feedId, commentId) {
@@ -493,13 +810,21 @@
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId || !commentId) return;
if (root.confirm && !root.confirm('确认删除这条评论吗?')) return;
try {
await api.deleteFeedComment(genealogyId, feedId, commentId);
await showComments(feedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '删除评论失败');
}
return withActionLock('delete-comment:' + commentId, async function () {
try {
await api.deleteFeedComment(genealogyId, feedId, commentId);
await showComments(feedId, currentCommentPages[feedId] || 1);
if (query('[data-feed-detail-page]')) {
var feed = normalizeFeed(await api.feedDetail(genealogyId, feedId));
if (feed) renderFeedDetail(feed, genealogyId);
} else {
await loadFeeds(currentFeedPage);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '删除评论失败');
}
});
}
function bindActions() {
@@ -515,6 +840,7 @@
var target = event.target;
var feedId;
var commentId;
var pageNum;
if (target.closest('[data-feed-like]')) {
event.preventDefault();
@@ -527,7 +853,7 @@
if (target.closest('[data-feed-comments]')) {
event.preventDefault();
feedId = target.closest('[data-feed-comments]').getAttribute('data-feed-comments');
showComments(feedId);
showComments(feedId, 1);
}
if (target.closest('[data-feed-comment]')) {
event.preventDefault();
@@ -544,7 +870,7 @@
event.preventDefault();
commentId = target.closest('[data-feed-comment-replies]').getAttribute('data-feed-comment-replies');
feedId = target.closest('[data-feed-comment-replies]').getAttribute('data-feed-id');
showCommentReplies(feedId, commentId);
showCommentReplies(feedId, commentId, 1);
}
if (target.closest('[data-feed-delete]')) {
event.preventDefault();
@@ -556,6 +882,39 @@
feedId = target.closest('[data-feed-comment-delete]').getAttribute('data-feed-id');
deleteComment(feedId, commentId);
}
if (target.closest('[data-feed-page-prev]')) {
event.preventDefault();
loadFeeds(Math.max(1, currentFeedPage - 1));
}
if (target.closest('[data-feed-page-next]')) {
event.preventDefault();
loadFeeds(currentFeedPage + 1);
}
if (target.closest('[data-feed-comments-prev], [data-feed-comments-next]')) {
event.preventDefault();
feedId = target.closest('[data-feed-id]').getAttribute('data-feed-id');
pageNum = currentCommentPages[feedId] || 1;
showComments(feedId, target.closest('[data-feed-comments-prev]') ? Math.max(1, pageNum - 1) : pageNum + 1);
}
if (target.closest('[data-feed-replies-prev], [data-feed-replies-next]')) {
event.preventDefault();
feedId = target.closest('[data-feed-id]').getAttribute('data-feed-id');
commentId = target.closest('[data-comment-id]').getAttribute('data-comment-id');
pageNum = currentReplyPages[commentId] || 1;
showCommentReplies(feedId, commentId, target.closest('[data-feed-replies-prev]') ? Math.max(1, pageNum - 1) : pageNum + 1);
}
if (target.closest('[data-feed-share]')) {
event.preventDefault();
if (root.navigator && root.navigator.clipboard && root.location) {
root.navigator.clipboard.writeText(root.location.href).then(function () {
showMessage('分享链接已复制');
}).catch(function () {
showMessage('复制失败,请从浏览器地址栏复制');
});
} else {
showMessage('请从浏览器地址栏复制分享链接');
}
}
});
}
@@ -564,6 +923,7 @@
bindActions();
if (query('[data-feed-page]')) loadFeeds();
if (query('[data-feed-edit-page]')) loadFeedForEdit();
if (query('[data-feed-detail-page]')) loadFeedDetail();
}
return {
@@ -571,8 +931,13 @@
getCurrentFeedId: getCurrentFeedId,
buildFeedBody: buildFeedBody,
buildCommentBody: buildCommentBody,
buildPageQuery: buildPageQuery,
buildFeedUrl: buildFeedUrl,
buildPostSaveUrl: buildPostSaveUrl,
withActionLock: withActionLock,
normalizeFeed: normalizeFeed,
normalizeComment: normalizeComment,
normalizeViewerContext: normalizeViewerContext,
normalizeComments: normalizeComments,
normalizeList: normalizeList,
shouldRedirectToLogin: shouldRedirectToLogin,
+268 -19
View File
@@ -13,16 +13,6 @@
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var TARGET_LABELS = {
'profile-family-home.html': '家谱主页',
'profile-feed.html': '家族圈动态',
'profile-growth.html': '成长记录',
'profile-relative.html': '亲友往来',
'profile-memo.html': '备忘录',
'profile-tree.html': '世系图',
'profile-generation.html': '字辈谱'
};
function getTargetPage(search) {
var params = new URLSearchParams(String(search === undefined && root.location ? root.location.search : search || '').replace(/^\?/, ''));
var target = params.get('next') || '';
@@ -30,38 +20,297 @@
return /^profile-[a-z-]+\.html$/.test(target) ? target : 'profile-family-home.html';
}
function buildStatus(quota, target) {
function buildStatus(quota) {
var source = quota || {};
var details = [];
var label = TARGET_LABELS[target] || '该家谱业务';
if (Number(source.createRemaining) >= 0) details.push('还可创建 ' + source.createRemaining + ' 部');
if (Number(source.joinRemaining) >= 0) details.push('还可加入 ' + source.joinRemaining + ' 部');
return '当前 PC 接口尚未提供“我的家谱”列表或家谱详情,不能伪造家谱编号进入“' + label + '”。' +
(details.length ? ' 已读取额度:' + details.join('') + '。' : '');
if (Number(source.createRemaining) === -1) details.push('创建不限');
else if (Number(source.createRemaining) >= 0) details.push('还可创建 ' + source.createRemaining + ' 部');
if (Number(source.joinRemaining) === -1) details.push('加入不限');
else if (Number(source.joinRemaining) >= 0) details.push('还可加入 ' + source.joinRemaining + '');
return details.length ? '家谱额度:' + details.join('') + '。' : '';
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function trimOrUndefined(value) {
var text;
if (value === undefined || value === null) return undefined;
text = String(value).trim();
return text || undefined;
}
function normalizeId(value) {
var text;
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
if (value === undefined || value === null) return '';
text = String(value).trim();
return /^[1-9][0-9]*$/.test(text) ? text : '';
}
function buildGenealogyCreateBody(values) {
var source = values || {};
var body = {
genealogyName: String(source.genealogyName || '').trim(),
surname: String(source.surname || '').trim(),
regionCode: String(source.regionCode || '').trim()
};
var optionalNames = ['ancestralHall', 'originPlace', 'addressDetail', 'intro'];
var coverOssId = normalizeId(source.coverOssId);
var visibility = trimOrUndefined(source.visibility);
var joinMode = trimOrUndefined(source.joinMode);
optionalNames.forEach(function (name) {
var value = trimOrUndefined(source[name]);
if (value !== undefined) body[name] = value;
});
if (source.coverOssId !== undefined && source.coverOssId !== null && source.coverOssId !== '') {
if (coverOssId) body.coverOssId = coverOssId;
else body.invalidCoverOssId = true;
}
if (visibility !== undefined) body.visibility = visibility;
if (joinMode !== undefined) body.joinMode = joinMode;
return body;
}
function validateGenealogyCreateBody(body) {
if (!body || !body.genealogyName) return '请填写谱名';
if (!body.surname) return '请填写姓氏';
if (!body.regionCode) return '请选择家谱所在地区';
if (body.visibility !== undefined && ['0', '1', '2'].indexOf(body.visibility) < 0) return '可见范围选项无效';
if (body.joinMode !== undefined && ['0', '1', '2'].indexOf(body.joinMode) < 0) return '加入方式选项无效';
if (body.invalidCoverOssId) return '封面文件无效,请重新选择';
return '';
}
function canCreateGenealogy(quota) {
var remaining = Number(quota && quota.createRemaining);
if (quota && typeof quota.canCreate === 'boolean') return quota.canCreate;
return Number.isInteger(remaining) && (remaining === -1 || remaining > 0);
}
function normalizeCreatedGenealogy(item) {
var genealogyId = normalizeId(item && item.genealogyId);
var genealogyName = String(item && item.genealogyName || '').trim();
var surname = String(item && item.surname || '').trim();
if (!genealogyId || !genealogyName || !surname) return null;
return {
genealogyId: genealogyId,
genealogyName: genealogyName,
surname: surname,
regionFullName: String(item && item.regionFullName || '')
};
}
function buildCreatedGenealogyUrl(item) {
var genealogy = normalizeCreatedGenealogy(item);
return genealogy ? buildEntryUrl(genealogy.genealogyId, 'profile-family-home.html') : '';
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function redirectToLogin(api) {
if (api && api.clearToken) api.clearToken();
if (!root.location) return;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
}
function getFormValues(form) {
var values = {};
Array.prototype.forEach.call(form.querySelectorAll('[name]'), function (field) {
values[field.name] = field.value;
});
return values;
}
function setCreateStatus(message) {
var status = root.document && root.document.querySelector('[data-genealogy-create-status]');
if (status) status.textContent = message || '';
}
function setCreatePending(pending) {
var button = root.document && root.document.querySelector('[data-genealogy-create-submit]');
if (button) button.disabled = Boolean(pending);
}
async function initGenealogyCreatePage() {
var form = root.document && root.document.querySelector('[data-genealogy-create-form]');
var quotaElement = root.document && root.document.querySelector('[data-genealogy-create-quota]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var quota;
var writePending = false;
if (!form) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
try {
quota = await api.genealogyQuota();
if (quotaElement) quotaElement.textContent = buildStatus(quota) || '当前额度信息不可用';
if (!canCreateGenealogy(quota)) {
setCreateStatus('当前没有可用的家谱创建额度');
setCreatePending(true);
return;
}
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
if (quotaElement) quotaElement.textContent = '家谱额度读取失败';
setCreateStatus(error.message || '无法读取家谱创建额度');
setCreatePending(true);
return;
}
if (form.__genealogyCreateBound) return;
form.__genealogyCreateBound = true;
form.addEventListener('submit', async function (event) {
var body;
var validation;
var created;
var verified;
event.preventDefault();
if (writePending) return;
body = buildGenealogyCreateBody(getFormValues(form));
validation = validateGenealogyCreateBody(body);
if (validation) {
setCreateStatus(validation);
return;
}
delete body.invalidCoverOssId;
writePending = true;
setCreatePending(true);
setCreateStatus('正在创建家谱…');
try {
created = normalizeCreatedGenealogy(await api.createGenealogy(body));
if (!created) throw new Error('创建响应缺少有效家谱信息');
verified = normalizeCreatedGenealogy(await api.genealogyDetail(created.genealogyId));
if (!verified || verified.genealogyId !== created.genealogyId) throw new Error('创建后无法读取同一家谱');
if (root.location) root.location.href = buildCreatedGenealogyUrl(verified);
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setCreateStatus(error.message || '创建家谱失败,请稍后重试');
} finally {
writePending = false;
setCreatePending(false);
}
});
}
function normalizeGenealogy(item) {
var source = item || {};
var id = source.genealogyId === undefined || source.genealogyId === null
? ''
: String(source.genealogyId);
if (!/^[1-9][0-9]*$/.test(id) || !source.genealogyName) return null;
return {
genealogyId: id,
genealogyName: String(source.genealogyName),
surname: source.surname === undefined || source.surname === null ? '' : String(source.surname),
regionFullName: source.regionFullName === undefined || source.regionFullName === null ? '' : String(source.regionFullName),
memberCount: source.memberCount,
personCount: source.personCount,
roleType: source.roleType === undefined || source.roleType === null ? '' : String(source.roleType),
canManage: source.canManage === true
};
}
function buildEntryUrl(genealogyId, target) {
var params = new URLSearchParams();
params.set('genealogyId', String(genealogyId));
return (target || 'profile-family-home.html') + '?' + params.toString();
}
function renderGenealogies(container, data, target) {
var items = Array.isArray(data) ? data.map(normalizeGenealogy).filter(Boolean) : [];
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="api-empty">当前账号还没有可进入的家谱</div>';
return;
}
container.innerHTML = items.map(function (item) {
var meta = [
item.surname ? item.surname + '氏' : '',
item.regionFullName,
item.memberCount === undefined || item.memberCount === null ? '' : item.memberCount + ' 位成员',
item.personCount === undefined || item.personCount === null ? '' : item.personCount + ' 位世系人物'
].filter(Boolean).join(' · ');
return '<a class="module-row" data-genealogy-id="' + escapeHtml(item.genealogyId) +
'" href="' + escapeHtml(buildEntryUrl(item.genealogyId, target)) + '">' +
'<div><h3>' + escapeHtml(item.genealogyName) + '</h3><p>' + escapeHtml(meta) +
'</p></div><span class="pill">' + (item.canManage ? '管理并进入' : '进入家谱') + '</span></a>';
}).join('');
}
async function init() {
if (root.document && root.document.querySelector('[data-genealogy-create-page]')) {
await initGenealogyCreatePage();
return;
}
var status = root.document && root.document.querySelector('[data-genealogy-entry-status]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var target = getTargetPage();
var container = root.document && root.document.querySelector('[data-genealogy-list="mine"]');
var result;
if (!status) return;
if (!api || !api.genealogyQuota) {
if (!api || !api.genealogyQuota || !api.genealogiesMine) {
status.textContent = '家谱入口接口初始化失败,请刷新后重试。';
return;
}
try {
status.textContent = buildStatus(await api.genealogyQuota(), target);
result = await Promise.all([api.genealogiesMine(), api.genealogyQuota()]);
renderGenealogies(container, result[0], target);
status.textContent = buildStatus(result[1]);
} catch (error) {
status.textContent = '无法读取家谱额度,请稍后重试。当前 PC 接口仍未提供家谱列表或详情,不能伪造家谱编号。';
status.textContent = '无法读取我的家谱,请稍后重试。';
}
}
return {
getTargetPage: getTargetPage,
buildStatus: buildStatus,
buildGenealogyCreateBody: buildGenealogyCreateBody,
validateGenealogyCreateBody: validateGenealogyCreateBody,
canCreateGenealogy: canCreateGenealogy,
normalizeCreatedGenealogy: normalizeCreatedGenealogy,
buildCreatedGenealogyUrl: buildCreatedGenealogyUrl,
shouldRedirectToLogin: shouldRedirectToLogin,
initGenealogyCreatePage: initGenealogyCreatePage,
normalizeGenealogy: normalizeGenealogy,
buildEntryUrl: buildEntryUrl,
renderGenealogies: renderGenealogies,
init: init
};
});
+111 -19
View File
@@ -17,7 +17,7 @@
var documentRef = root.document;
var poemsById = Object.create(null);
var lastPreviewSignature = '';
var managementAccess = true;
var managementAccess = false;
var writePending = false;
function getApi() {
@@ -150,11 +150,16 @@
}
function splitPoemText(value) {
return String(value || '').trim().split(/[\s,;;、/|]+/).filter(Boolean);
var text = String(value || '').trim();
if (!text) return [];
if (/[\s,;;、/|]/.test(text)) {
return text.split(/[\s,;;、/|]+/).filter(Boolean);
}
return Array.from(text);
}
function getPoemId(item) {
var value = item && item.poemId;
function normalizeId(value) {
var text;
if (value === undefined || value === null || value === '') return '';
@@ -163,8 +168,13 @@
return /^\d+$/.test(text) ? text : '';
}
function getPoemId(item) {
return normalizeId(item && item.poemId);
}
function normalizeGenerationPoem(item) {
var poemId = getPoemId(item);
var genealogyId = normalizeId(item && item.genealogyId);
var generationNo = toIntegerOrUndefined(item && item.generationNo);
var generationText = trimOrUndefined(item && item.generationText);
var status = trimOrUndefined(item && item.status);
@@ -177,11 +187,24 @@
generationText: generationText,
status: status
};
if (genealogyId) poem.genealogyId = genealogyId;
if (item.genealogyNo !== undefined && item.genealogyNo !== null) poem.genealogyNo = String(item.genealogyNo);
if (item.genealogyName !== undefined && item.genealogyName !== null) poem.genealogyName = String(item.genealogyName);
if (item.description !== undefined && item.description !== null) poem.description = String(item.description);
if (toIntegerOrUndefined(item.sortOrder) !== undefined) poem.sortOrder = toIntegerOrUndefined(item.sortOrder);
if (item.remark !== undefined && item.remark !== null) poem.remark = String(item.remark);
return poem;
}
function normalizeGenerationAccess(genealogy) {
var canEditContent = Boolean(genealogy && genealogy.canEditContent === true);
return {
canEditContent: canEditContent,
listMethod: canEditContent ? 'generationPoemsManagement' : 'generationPoems'
};
}
function normalizeList(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.rows)) return data.rows;
@@ -200,16 +223,20 @@
function renderGenerationPoem(poem) {
var details = [];
var stateText = poem.status === '1' ? '已停用' : '正常';
var actions = '';
if (poem.description) details.push('说明:' + poem.description);
if (poem.sortOrder !== undefined) details.push('排序:' + poem.sortOrder);
details.push('状态:' + stateText);
if (managementAccess) {
actions = '<div class="row-actions">' +
'<button class="pill" type="button" data-generation-edit="' + escapeHtml(poem.poemId) + '">编辑</button>' +
'<button class="pill" type="button" data-generation-status="' + escapeHtml(poem.poemId) + '">' + (poem.status === '1' ? '恢复' : '停用') + '</button>' +
'</div>';
}
return '<div class="module-row" data-generation-poem-id="' + escapeHtml(poem.poemId) + '">' +
'<div><h3>第 ' + escapeHtml(poem.generationNo) + ' 代:' + escapeHtml(poem.generationText) + '</h3><p>' + escapeHtml(details.join(' · ')) + '</p></div>' +
'<div class="row-actions">' +
'<button class="pill" type="button" data-generation-edit="' + escapeHtml(poem.poemId) + '">编辑</button>' +
'<button class="pill" type="button" data-generation-status="' + escapeHtml(poem.poemId) + '">' + (poem.status === '1' ? '恢复' : '停用') + '</button>' +
'</div></div>';
actions + '</div>';
}
function renderGenerationPoems(data) {
@@ -234,16 +261,71 @@
container.innerHTML = poems.map(renderGenerationPoem).join('');
}
function normalizePreview(data) {
if (!data || !Array.isArray(data.items)) return null;
function normalizeCount(value) {
var count = toIntegerOrUndefined(value);
return {
createCount: toIntegerOrUndefined(data.createCount),
updateCount: toIntegerOrUndefined(data.updateCount),
keepCount: toIntegerOrUndefined(data.keepCount),
disableCount: toIntegerOrUndefined(data.disableCount),
items: data.items
return count !== undefined && count >= 0 ? count : undefined;
}
function normalizePreviewItem(item) {
var action = item && item.action;
var generationNo = toIntegerOrUndefined(item && item.generationNo);
var poemId = getPoemId(item);
var oldText = trimOrUndefined(item && item.oldGenerationText);
var newText = trimOrUndefined(item && item.newGenerationText);
var oldStatus = trimOrUndefined(item && item.oldStatus);
var newStatus = trimOrUndefined(item && item.newStatus);
var normalized;
if (!generationNo || ['create', 'update', 'keep', 'disable'].indexOf(action) === -1) return null;
if (newStatus !== '0' && newStatus !== '1') return null;
if (action === 'create' && !newText) return null;
if (action !== 'create' && (!poemId || !oldText || (oldStatus !== '0' && oldStatus !== '1'))) return null;
if ((action === 'update' || action === 'keep') && !newText) return null;
normalized = {
generationNo: generationNo
};
if (poemId) normalized.poemId = poemId;
if (oldText) normalized.oldGenerationText = oldText;
if (newText) normalized.newGenerationText = newText;
if (oldStatus !== undefined) normalized.oldStatus = oldStatus;
normalized.newStatus = newStatus;
normalized.action = action;
if (item.warning !== undefined && item.warning !== null) normalized.warning = String(item.warning);
return normalized;
}
function normalizePreview(data) {
var counts;
var items;
var actualCounts = { create: 0, update: 0, keep: 0, disable: 0 };
if (!data || !Array.isArray(data.items) || !data.items.length) return null;
counts = {
createCount: normalizeCount(data.createCount),
updateCount: normalizeCount(data.updateCount),
keepCount: normalizeCount(data.keepCount),
disableCount: normalizeCount(data.disableCount)
};
if (Object.keys(counts).some(function (key) { return counts[key] === undefined; })) return null;
items = data.items.map(normalizePreviewItem);
if (items.some(function (item) { return !item; })) return null;
items.forEach(function (item) { actualCounts[item.action] += 1; });
if (counts.createCount !== actualCounts.create ||
counts.updateCount !== actualCounts.update ||
counts.keepCount !== actualCounts.keep ||
counts.disableCount !== actualCounts.disable) return null;
counts.items = items;
return counts;
}
function buildBatchConfirmMessage(disableMissing) {
if (disableMissing) {
return '确认按当前预览保存字辈,并停用未出现在文本中的后续世代吗?历史记录不会删除。';
}
return '确认按当前预览保存字辈吗?';
}
function renderBatchPreview(data) {
@@ -307,9 +389,15 @@
function refreshManagementControls() {
var disabled = !managementAccess || writePending;
queryAll('[data-generation-management]').forEach(function (element) {
element.hidden = !managementAccess;
});
queryAll('[data-generation-add], [data-generation-batch-action], [data-generation-edit], [data-generation-status]').forEach(function (control) {
control.disabled = disabled;
});
queryAll('[data-generation-management] textarea, [data-generation-management] input').forEach(function (control) {
control.disabled = disabled;
});
}
function setManagementEnabled(enabled) {
@@ -385,14 +473,16 @@
async function loadGenerationPoems() {
var api = getApi();
var genealogyId;
var access;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
try {
renderGenerationPoems(await api.generationPoemsManagement(genealogyId));
setManagementEnabled(true);
access = normalizeGenerationAccess(await api.genealogyDetail(genealogyId));
setManagementEnabled(access.canEditContent);
renderGenerationPoems(await api[access.listMethod](genealogyId));
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (isForbidden(error)) {
@@ -533,7 +623,7 @@
setBatchStatus('请先预览当前内容,再保存');
return;
}
if (root.confirm && !root.confirm('确认按当前预览保存字辈吗?')) return;
if (root.confirm && !root.confirm(buildBatchConfirmMessage(body.disableMissing))) return;
setBatchStatus('正在保存…');
setWritePending(true);
try {
@@ -592,7 +682,9 @@
validateBatchBody: validateBatchBody,
splitPoemText: splitPoemText,
normalizeGenerationPoem: normalizeGenerationPoem,
normalizeGenerationAccess: normalizeGenerationAccess,
normalizePreview: normalizePreview,
buildBatchConfirmMessage: buildBatchConfirmMessage,
normalizeList: normalizeList,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
+466 -111
View File
@@ -1,5 +1,4 @@
(function (root, factory) {
// 成长记录页面只消费 ApiClient 中已在 Apifox PC 目录核验的方法。
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
@@ -15,6 +14,7 @@
'use strict';
var documentRef = root.document;
var recordsById = Object.create(null);
var writePending = false;
function getApi() {
@@ -29,37 +29,70 @@
return documentRef ? Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)) : [];
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toSafeIntegerOrUndefined(value) {
var text = trimOrUndefined(value);
var number;
if (!text) return undefined;
number = Number(text);
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function getCurrentGenealogyId(search) {
var page = query('[data-growth-page], [data-growth-edit-page]');
var source = search === undefined && root.location ? root.location.search : search;
var profileGenealogyId;
var fromProfile;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
profileGenealogyId = root.ProfileUI.getGenealogyId();
if (profileGenealogyId) return profileGenealogyId;
fromProfile = root.ProfileUI.getGenealogyId();
if (fromProfile) return normalizeId(fromProfile);
}
return getQueryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
return normalizeId(getQueryParam(source, 'genealogyId'));
}
function getCurrentRecordId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(getQueryParam(source, 'recordId'));
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toSafeInteger(value) {
var text = trimOrUndefined(value);
var number;
if (text === undefined) return undefined;
number = Number(text);
return Number.isSafeInteger(number) ? number : undefined;
}
function toBackendDateTime(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function toDateInputValue(value) {
var text = String(value || '');
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : '';
}
function toDateTimeInputValue(value) {
var text = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) return '';
return text.slice(0, 16).replace(' ', 'T');
}
function buildGrowthRecordBody(values) {
@@ -71,10 +104,10 @@
var recordType = trimOrUndefined(source.recordType);
var recordContent = trimOrUndefined(source.recordContent);
var recordDate = trimOrUndefined(source.recordDate);
var remindTime = trimOrUndefined(source.remindTime);
var remindTime = toBackendDateTime(source.remindTime);
var mediaOssIds = trimOrUndefined(source.mediaOssIds);
var status = trimOrUndefined(source.status);
var sortOrderText = trimOrUndefined(source.sortOrder);
var status = trimOrUndefined(source.status);
if (lineagePersonId !== undefined) body.lineagePersonId = lineagePersonId;
if (recordType !== undefined) body.recordType = recordType;
@@ -82,27 +115,105 @@
if (recordDate !== undefined) body.recordDate = recordDate;
if (remindTime !== undefined) body.remindTime = remindTime;
if (mediaOssIds !== undefined) body.mediaOssIds = mediaOssIds;
if (sortOrderText !== undefined) body.sortOrder = toSafeIntegerOrUndefined(sortOrderText);
if (sortOrderText !== undefined) {
body.sortOrder = toSafeInteger(sortOrderText);
if (body.sortOrder === undefined) body.invalidSortOrder = true;
}
if (status !== undefined) body.status = status;
return body;
}
function validateGrowthRecordBody(body) {
if (!body || !body.recordTitle) return '请填写记录标题';
if (body.lineagePersonId !== undefined && !/^\d+$/.test(body.lineagePersonId)) return '世系人物 ID 必须是整数';
if (body.lineagePersonId !== undefined && !normalizeId(body.lineagePersonId)) return '请选择有效的世系人物';
if (body.mediaOssIds !== undefined && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(body.mediaOssIds)) {
return '附件 OSS ID 请使用英文逗号分隔的正整数';
return '附件上传结果无效';
}
if (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && body.sortOrder === undefined) return '排序值必须是安全整数';
if (body.invalidSortOrder || (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && !Number.isSafeInteger(body.sortOrder))) {
return '排序值必须是安全整数';
}
if (body.recordDate !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(body.recordDate)) return '记录日期格式无效';
if (body.remindTime !== undefined && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(body.remindTime)) {
return '提醒时间格式无效';
}
if (body.status === '1') return '当前 PC 无法重新读取停用记录,暂不开放停用';
if (body.status !== undefined && body.status !== '0') return '记录状态只能是 0';
return '';
}
function normalizeGrowthList(data) {
return Array.isArray(data) ? data : [];
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function normalizeGrowthRecord(item) {
var recordId = normalizeId(item && item.recordId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var lineagePersonId = normalizeId(item && item.lineagePersonId);
var title = String(item && item.recordTitle || '').trim();
var status = stringValue(item && item.status);
var mediaOssIds = stringValue(item && item.mediaOssIds);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var record;
if (!recordId || !genealogyId || !title || (status !== '0' && status !== '1')) return null;
if (item.appUserId !== undefined && item.appUserId !== null && item.appUserId !== '' && !appUserId) return null;
if (item.lineagePersonId !== undefined && item.lineagePersonId !== null && item.lineagePersonId !== '' && !lineagePersonId) return null;
if (mediaOssIds && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(mediaOssIds)) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
record = {
recordId: recordId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
appUserNickName: stringValue(item.appUserNickName),
appUserPhone: stringValue(item.appUserPhone),
lineagePersonNo: stringValue(item.lineagePersonNo),
lineagePersonName: stringValue(item.lineagePersonName),
recordType: stringValue(item.recordType),
recordTitle: title,
recordContent: stringValue(item.recordContent),
recordDate: stringValue(item.recordDate),
remindTime: stringValue(item.remindTime),
mediaOssIds: mediaOssIds,
status: status,
remark: stringValue(item.remark)
};
if (appUserId) record.appUserId = appUserId;
if (lineagePersonId) record.lineagePersonId = lineagePersonId;
if (sortOrder !== undefined) record.sortOrder = sortOrder;
return record;
}
function matchesSavedRecord(item, expectedRecordId) {
var record = normalizeGrowthRecord(item);
return Boolean(record && record.recordId === normalizeId(expectedRecordId));
}
function normalizeLineageOption(item) {
var personId = normalizeId(item && (item.personId || item.lineagePersonId));
var name = String(item && (item.name || item.lineagePersonName) || '').trim();
if (!personId || !name) return null;
return {
personId: personId,
name: name,
generationName: stringValue(item.generationName)
};
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -110,32 +221,76 @@
.replace(/'/g, '&#39;');
}
function stringifyRecord(record) {
try {
return typeof record === 'string' ? record : JSON.stringify(record);
} catch (error) {
return String(record);
function renderLineageOptions(data, selectedId, fallbackName) {
var selected = normalizeId(selectedId);
var options = Array.isArray(data) ? data.map(normalizeLineageOption).filter(Boolean) : [];
var selectedFound = options.some(function (option) { return option.personId === selected; });
if (selected && !selectedFound && fallbackName) {
options.push({ personId: selected, name: String(fallbackName), generationName: '' });
}
return '<option value="">不关联世系人物</option>' + options.map(function (option) {
return '<option value="' + escapeHtml(option.personId) + '"' +
(option.personId === selected ? ' selected' : '') + '>' +
escapeHtml(option.name + (option.generationName ? ' · ' + option.generationName : '')) +
'</option>';
}).join('');
}
function renderGrowthRecords(data) {
var container = query('[data-growth-list]');
var records = normalizeGrowthList(data);
function attachmentCount(mediaOssIds) {
return mediaOssIds ? mediaOssIds.split(',').filter(Boolean).length : 0;
}
if (!container) return;
if (!Array.isArray(data)) {
container.innerHTML = '<div class="api-empty">成长记录响应未按 Apifox ListResult 返回数组,无法安全展示或操作记录。</div>';
return;
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无成长记录</div>';
return;
}
// ListResult 的元素 DTO 尚未展开,不能假设 recordId、标题或权限字段。
container.innerHTML = records.map(function (record, index) {
return '<article class="module-row"><div><h3>成长记录 ' + (index + 1) + '</h3><p>' +
escapeHtml(stringifyRecord(record)) + '</p></div></article>';
}).join('');
function buildListUrl(genealogyId, recordId) {
var url = 'profile-growth.html?genealogyId=' + encodeURIComponent(genealogyId);
return recordId ? url + '&recordId=' + encodeURIComponent(recordId) : url;
}
function buildEditUrl(genealogyId, recordId) {
var url = 'profile-growth-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return recordId ? url + '&recordId=' + encodeURIComponent(recordId) : url;
}
function renderGrowthRow(record) {
var summary = [];
if (record.lineagePersonName) summary.push(record.lineagePersonName);
if (record.recordType) summary.push(record.recordType);
if (record.recordDate) summary.push(record.recordDate);
if (!summary.length) summary.push('未填写日期和关联人物');
return '<article class="module-row growth-row"><div><h3>' + escapeHtml(record.recordTitle) + '</h3><p>' +
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-growth-detail-id="' + escapeHtml(record.recordId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.recordId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-growth-delete-id="' + escapeHtml(record.recordId) + '">删除</button>' +
'</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderGrowthDetail(record) {
var count;
if (!record) return '<div class="api-empty">请选择一条成长记录查看详情</div>';
count = attachmentCount(record.mediaOssIds);
return '<div class="form-like growth-detail">' +
detailValue('标题', record.recordTitle) +
detailValue('关联人物', record.lineagePersonName || record.lineagePersonNo) +
detailValue('记录类型', record.recordType) +
detailValue('记录日期', record.recordDate) +
detailValue('提醒时间', record.remindTime) +
detailValue('记录内容', record.recordContent) +
detailValue('附件', count ? count + ' 个附件' : '未上传') +
detailValue('记录人', record.appUserNickName) +
detailValue('状态', record.status === '0' ? '正常' : '停用') +
detailValue('备注', record.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.recordId)) + '">编辑记录</a>' +
'<button class="btn ghost" type="button" data-growth-delete-id="' + escapeHtml(record.recordId) + '">删除记录</button></div>';
}
function showMessage(message) {
@@ -152,6 +307,10 @@
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
@@ -164,6 +323,133 @@
return true;
}
function syncGenealogyLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setFormStatus(message) {
var status = query('[data-growth-form-status]');
if (status) status.textContent = message || '';
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-growth-editor-placeholder]');
queryAll('[data-growth-editor], [data-growth-editor-action]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function renderNoContext() {
var list = query('[data-growth-list]');
var detail = query('[data-growth-detail]');
var message = '请先选择家谱,再查看或维护成长记录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
function setWritePending(pending) {
writePending = Boolean(pending);
queryAll('[data-growth-form] input, [data-growth-form] textarea, [data-growth-form] select, [data-growth-form] button, [data-growth-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderGrowthRecords(data) {
var container = query('[data-growth-list]');
var normalized = Array.isArray(data) ? data.map(normalizeGrowthRecord) : [];
var invalidCount = normalized.filter(function (record) { return !record; }).length;
var records = normalized.filter(Boolean);
recordsById = Object.create(null);
records.forEach(function (record) {
recordsById[record.recordId] = record;
});
if (!container) return records;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">成长记录响应缺少稳定 GrowthRecordVo 字段,请联系后端核对。</div>';
return [];
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无成长记录</div>';
return [];
}
container.innerHTML = records.map(renderGrowthRow).join('');
return records;
}
function renderDetail(record) {
var container = query('[data-growth-detail]');
if (container) container.innerHTML = renderGrowthDetail(record);
}
async function loadGrowthDetail(recordId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var record;
if (!genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return null;
try {
record = normalizeGrowthRecord(await api.growthRecordDetail(genealogyId, recordId));
if (!record) throw new Error('成长记录详情响应缺少稳定 GrowthRecordVo 字段');
recordsById[record.recordId] = record;
renderDetail(record);
return record;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
showMessage(isForbidden(error) ? '当前账号无权查看该成长记录。' : (error.message || '成长记录详情加载失败'));
return null;
}
}
async function loadGrowthRecords() {
var api = getApi();
var genealogyId;
var records;
var requestedRecordId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks();
try {
records = renderGrowthRecords(await api.growthRecords(genealogyId));
requestedRecordId = getCurrentRecordId();
if (requestedRecordId && recordsById[requestedRecordId]) {
await loadGrowthDetail(requestedRecordId);
} else if (records.length) {
await loadGrowthDetail(records[0].recordId);
} else {
renderDetail(null);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-growth-list]')) {
query('[data-growth-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : '成长记录加载失败,请稍后重试。') +
'</div>';
}
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : (error.message || '成长记录加载失败'));
}
}
function getFormValues(form) {
var values = {};
@@ -173,102 +459,171 @@
return values;
}
function setFormStatus(message) {
var status = query('[data-growth-form-status]');
function setFieldValue(name, value) {
var field = query('[data-growth-form] [name="' + name + '"]');
if (status) status.textContent = message || '';
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function setWritePending(pending) {
writePending = Boolean(pending);
queryAll('[data-growth-form] button, [data-growth-form] input, [data-growth-form] textarea').forEach(function (control) {
control.disabled = writePending;
});
}
function fillGrowthForm(record) {
var contentField;
var editorInstance;
function syncGenealogyLinks(genealogyId) {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
root.ProfileUI.syncGenealogyContextLinks();
return;
setFieldValue('lineagePersonId', record.lineagePersonId);
setFieldValue('recordType', record.recordType);
setFieldValue('recordTitle', record.recordTitle);
setFieldValue('recordContent', record.recordContent);
setFieldValue('recordDate', toDateInputValue(record.recordDate));
setFieldValue('remindTime', toDateTimeInputValue(record.remindTime));
setFieldValue('mediaOssIds', record.mediaOssIds);
setFieldValue('sortOrder', record.sortOrder);
setFieldValue('status', '0');
contentField = query('[data-growth-form] [name="recordContent"]');
if (contentField && root.AppRichEditor && root.AppRichEditor.init) {
editorInstance = root.AppRichEditor.init(contentField);
if (editorInstance && editorInstance.editor && editorInstance.editor.setHtml) {
editorInstance.editor.setHtml(record.recordContent || '');
}
}
queryAll('[data-genealogy-context-link]').forEach(function (link) {
var href = link.getAttribute('href');
var base;
if (!href || href === '#') return;
base = href.split('?')[0];
link.href = base + '?genealogyId=' + encodeURIComponent(genealogyId);
});
}
function renderNoContext() {
var list = query('[data-growth-list]');
if (list) list.innerHTML = '<div class="api-empty">等待家谱入口接口;请从具体家谱进入成长记录。</div>';
setFormStatus('等待家谱入口接口;请从具体家谱进入成长记录。');
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) {
renderNoContext();
showMessage('等待家谱入口接口;请从具体家谱进入成长记录。');
if (query('[data-growth-media-status]')) {
query('[data-growth-media-status]').textContent = record.mediaOssIds
? '已保留 ' + attachmentCount(record.mediaOssIds) + ' 个附件,可继续选择追加'
: '未选择文件';
}
return genealogyId;
}
function buildListUrl(genealogyId) {
return 'profile-growth.html?genealogyId=' + encodeURIComponent(genealogyId);
function applyLineageOptions(data, selectedId, fallbackName) {
var select = query('[data-growth-lineage-person]');
if (select) select.innerHTML = renderLineageOptions(data, selectedId, fallbackName);
}
async function loadGrowthRecords() {
async function loadGrowthEditor() {
var api = getApi();
var genealogyId;
var recordId;
var result;
var record;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
recordId = getCurrentRecordId();
syncGenealogyLinks();
setEditorEnabled(false, '正在加载成长记录编辑信息…');
try {
renderGrowthRecords(await api.growthRecords(genealogyId));
result = await Promise.all([
api.lineagePersonOptions(genealogyId),
recordId ? api.growthRecordDetail(genealogyId, recordId) : Promise.resolve(null)
]);
record = recordId ? normalizeGrowthRecord(result[1]) : null;
if (recordId && !record) throw new Error('成长记录详情响应缺少稳定 GrowthRecordVo 字段');
applyLineageOptions(result[0], record && record.lineagePersonId, record && record.lineagePersonName);
if (record) {
fillGrowthForm(record);
if (query('[data-growth-editor-title]')) query('[data-growth-editor-title]').textContent = '编辑成长记录';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
renderGrowthRecords(null);
showMessage(error.message || '成长记录加载失败');
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的成长记录。'
: (error.message || '成长记录编辑信息加载失败'));
setFormStatus(error.message || '成长记录编辑信息加载失败');
}
}
async function submitGrowthRecord(form) {
var api = getApi();
var genealogyId;
var genealogyId = requireGenealogyId();
var recordId = getCurrentRecordId();
var body;
var validation;
var result;
var saved;
var savedId;
var verifiedDetail;
if (writePending || redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
body = buildGrowthRecordBody(getFormValues(form));
validation = validateGrowthRecordBody(body);
if (validation) {
setFormStatus(validation);
return;
}
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存成长记录...');
setFormStatus('正在保存成长记录');
try {
await api.createGrowthRecord(genealogyId, body);
if (root.location) root.location.href = buildListUrl(genealogyId);
result = recordId
? await api.updateGrowthRecord(genealogyId, recordId, body)
: await api.createGrowthRecord(genealogyId, body);
saved = normalizeGrowthRecord(result);
savedId = recordId || (saved && saved.recordId);
if (!savedId) throw new Error('保存响应缺少 recordId');
verifiedDetail = await api.growthRecordDetail(genealogyId, savedId);
if (!matchesSavedRecord(verifiedDetail, savedId)) throw new Error('保存后重读未返回同一条成长记录');
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(error.message || '成长记录保存失败');
setFormStatus(isForbidden(error) ? '当前账号无权保存该成长记录。' : (error.message || '成长记录保存失败'));
} finally {
setWritePending(false);
}
}
async function deleteGrowthRecord(recordId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var record = recordsById[recordId];
if (writePending || !genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (record ? record.recordTitle : '该成长记录') + '”吗?删除后不可恢复,并会释放附件引用。')) return;
setWritePending(true);
try {
await api.deleteGrowthRecord(genealogyId, recordId);
await loadGrowthRecords();
showMessage('成长记录已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该成长记录。' : (error.message || '成长记录删除失败'));
} finally {
setWritePending(false);
}
}
function clearMediaSelection() {
var target = query('[data-growth-form] [name="mediaOssIds"]');
var status = query('[data-growth-media-status]');
var fileInput = query('[data-growth-media-input]');
if (target) target.value = '';
if (fileInput) fileInput.value = '';
if (status) status.textContent = '未选择文件';
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-growth-detail-id]');
var deleteButton = event.target.closest('[data-growth-delete-id]');
var clearMediaButton = event.target.closest('[data-growth-clear-media]');
if (detailButton) {
event.preventDefault();
loadGrowthDetail(detailButton.getAttribute('data-growth-detail-id'));
return;
}
if (deleteButton) {
event.preventDefault();
deleteGrowthRecord(deleteButton.getAttribute('data-growth-delete-id'));
return;
}
if (clearMediaButton) {
event.preventDefault();
clearMediaSelection();
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-growth-form]');
@@ -279,23 +634,23 @@
}
function init() {
var genealogyId;
if (!documentRef) return;
bindActions();
if (query('[data-growth-page]')) loadGrowthRecords();
if (query('[data-growth-edit-page]')) {
genealogyId = requireGenealogyId();
if (genealogyId) syncGenealogyLinks(genealogyId);
}
if (query('[data-growth-edit-page]')) loadGrowthEditor();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentRecordId: getCurrentRecordId,
buildGrowthRecordBody: buildGrowthRecordBody,
validateGrowthRecordBody: validateGrowthRecordBody,
normalizeGrowthList: normalizeGrowthList,
normalizeGrowthRecord: normalizeGrowthRecord,
matchesSavedRecord: matchesSavedRecord,
renderLineageOptions: renderLineageOptions,
renderGrowthDetail: renderGrowthDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+308 -35
View File
@@ -22,8 +22,9 @@
var currentPageNum = 1;
var currentPageSize = 20;
var currentPageTotal = null;
var managementAccess = true;
var managementAccess = false;
var writePending = false;
var currentUserId = '';
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
@@ -101,8 +102,7 @@
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
}
function getPersonId(item) {
var value = item && item.personId;
function normalizeId(value) {
var text;
if (value === undefined || value === null || value === '') return '';
@@ -111,22 +111,52 @@
return /^\d+$/.test(text) ? text : '';
}
function getPersonId(item) {
return normalizeId(item && item.personId);
}
function formatLineageDate(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function buildLineagePersonBody(values) {
var source = values || {};
var body = { name: trimOrUndefined(source.name) };
var bindingMode = trimOrUndefined(source.bindingMode);
var body = {
bindingMode: bindingMode,
name: trimOrUndefined(source.name)
};
var optionalFields = [
'personNo', 'aliasName', 'sex', 'generationName', 'birthDate', 'birthLunar', 'birthPlace',
'deathDate', 'deathLunar', 'deathPlace', 'burialPlace', 'personStatus', 'biography',
'personNo', 'aliasName', 'sex', 'generationName', 'birthLunar', 'birthPlace',
'deathLunar', 'deathPlace', 'burialPlace', 'personStatus', 'biography',
'remark', 'relationName'
];
var idFields = ['avatarOssId', 'fatherId', 'motherId'];
var generation = trimOrUndefined(source.generation);
var sortOrder = trimOrUndefined(source.sortOrder);
var birthDate = formatLineageDate(source.birthDate);
var deathDate = formatLineageDate(source.deathDate);
optionalFields.forEach(function (field) {
var value = trimOrUndefined(source[field]);
if (value !== undefined) body[field] = value;
});
idFields.forEach(function (field) {
var value = normalizeId(source[field]);
if (value) body[field] = value;
});
if (bindingMode === 'SPECIFIED') {
body.appUserId = normalizeId(source.appUserId) || trimOrUndefined(source.appUserId);
}
if (birthDate !== undefined) body.birthDate = birthDate;
if (deathDate !== undefined) body.deathDate = deathDate;
if (generation !== undefined) body.generation = Number(generation);
if (sortOrder !== undefined) body.sortOrder = Number(sortOrder);
return body;
@@ -134,6 +164,17 @@
function validateLineagePersonBody(body) {
if (!body || !body.name) return '请填写成员姓名';
if (!body.bindingMode) return '请选择账号绑定方式';
if (['NONE', 'SELF', 'SPECIFIED'].indexOf(body.bindingMode) === -1) return '账号绑定方式无效';
if (body.bindingMode === 'SPECIFIED' && !normalizeId(body.appUserId)) return '指定账号绑定缺少用户选项';
if (body.bindingMode !== 'SPECIFIED' && body.appUserId !== undefined) return '当前绑定方式不能提交指定用户';
if (body.sex !== undefined && ['0', '1', '2'].indexOf(body.sex) === -1) return '性别选项无效';
if (body.personStatus !== undefined && ['0', '1', '2'].indexOf(body.personStatus) === -1) return '人物状态选项无效';
if (body.birthLunar !== undefined && ['0', '1'].indexOf(body.birthLunar) === -1) return '出生历法选项无效';
if (body.deathLunar !== undefined && ['0', '1'].indexOf(body.deathLunar) === -1) return '逝世历法选项无效';
if (['avatarOssId', 'fatherId', 'motherId'].some(function (field) {
return body[field] !== undefined && !normalizeId(body[field]);
})) return '人物关联字段无效';
if (body.generation !== undefined && (!Number.isInteger(body.generation) || !Number.isSafeInteger(body.generation))) {
return '世代序号必须是整数';
}
@@ -147,6 +188,7 @@
var personId = getPersonId(item);
var name = trimOrUndefined(item && item.name);
var person;
var invalidId = false;
if (!personId || !name) return null;
person = { personId: personId, name: name };
@@ -160,15 +202,70 @@
});
['appUserId', 'genealogyId', 'fatherId', 'motherId', 'avatarOssId'].forEach(function (field) {
if (item && item[field] !== undefined && item[field] !== null) {
if (typeof item[field] === 'number' && !Number.isSafeInteger(item[field])) return;
person[field] = String(item[field]);
var value = normalizeId(item[field]);
if (!value) {
invalidId = true;
return;
}
person[field] = value;
}
});
if (invalidId) return null;
if (toIntegerOrUndefined(item && item.generation) !== undefined) person.generation = toIntegerOrUndefined(item.generation);
if (toIntegerOrUndefined(item && item.sortOrder) !== undefined) person.sortOrder = toIntegerOrUndefined(item.sortOrder);
return person;
}
function normalizeLineageAccess(genealogy) {
return {
canEditContent: Boolean(genealogy && genealogy.canEditContent === true)
};
}
function normalizeGenerationOption(item) {
var generation = toIntegerOrUndefined(item && item.generationNo);
var generationName = trimOrUndefined(item && item.generationText);
if (!generation || !generationName) return null;
return {
generation: generation,
generationName: generationName
};
}
function normalizeGenealogyMemberOption(item) {
var memberId = normalizeId(item && item.memberId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var status = trimOrUndefined(item && item.status);
if (!memberId || !genealogyId || !appUserId || status !== '0') return null;
return {
memberId: memberId,
genealogyId: genealogyId,
appUserId: appUserId,
appUserNickName: String(item.appUserNickName === undefined || item.appUserNickName === null ? '' : item.appUserNickName),
memberName: String(item.memberName === undefined || item.memberName === null ? '' : item.memberName),
roleType: String(item.roleType === undefined || item.roleType === null ? '' : item.roleType),
status: status
};
}
function normalizeGenealogyMemberOptions(data) {
if (!Array.isArray(data)) return [];
return data.map(normalizeGenealogyMemberOption).filter(Boolean);
}
function normalizeLineageBinding(person, userId) {
var appUserId = normalizeId(person && person.appUserId);
var normalizedUserId = normalizeId(userId);
if (!appUserId) return { bindingMode: 'NONE', appUserId: '' };
if (normalizedUserId && appUserId === normalizedUserId) return { bindingMode: 'SELF', appUserId: '' };
return { bindingMode: 'SPECIFIED', appUserId: appUserId };
}
function normalizeList(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.rows)) return data.rows;
@@ -259,12 +356,68 @@
function renderOptions(data) {
var select = query('[data-lineage-person-options]');
var people = normalizeList(data).map(normalizeLineagePerson).filter(Boolean);
if (!select) return;
select.innerHTML = '<option value="">请选择当前成员</option>' + people.map(function (person) {
var options = people.map(function (person) {
return '<option value="' + escapeHtml(person.personId) + '">' + escapeHtml(person.name + (person.generationName ? ' · ' + person.generationName : '')) + '</option>';
}).join('');
if (!select) return;
select.innerHTML = '<option value="">请选择当前成员</option>' + options;
select.value = selectedPersonId;
queryAll('[data-lineage-parent-option]').forEach(function (parentSelect) {
var emptyLabel = parentSelect.getAttribute('data-empty-label') || '请选择人物';
parentSelect.innerHTML = '<option value="">' + escapeHtml(emptyLabel) + '</option>' + options;
});
}
function renderGenerationOptions(data) {
var select = query('[data-lineage-generation]');
var filter = query('[data-lineage-generation-filter]');
var selectedGeneration = select ? select.value : '';
var selectedFilter = filter ? filter.value : '';
var options = normalizeList(data).map(normalizeGenerationOption).filter(Boolean);
var optionHtml = options.map(function (option) {
return '<option value="' + escapeHtml(option.generation) + '" data-generation-name="' + escapeHtml(option.generationName) + '">' +
escapeHtml('第 ' + option.generation + ' 世 · ' + option.generationName) + '</option>';
}).join('');
if (select) {
select.innerHTML = '<option value="">请选择世代</option>' + optionHtml;
select.value = selectedGeneration;
}
if (filter) {
filter.innerHTML = '<option value="">全部世代</option>' + optionHtml;
filter.value = selectedFilter;
}
}
function updateBindingFields() {
var bindingMode = query('[name="bindingMode"]');
var memberSelect = query('[data-lineage-member-options]');
var specified = Boolean(bindingMode && bindingMode.value === 'SPECIFIED');
if (!memberSelect) return;
memberSelect.hidden = !specified;
memberSelect.required = specified;
if (!specified) memberSelect.value = '';
}
function renderGenealogyMemberOptions(data) {
var select = query('[data-lineage-member-options]');
var selectedValue = select ? select.value : '';
var options = normalizeGenealogyMemberOptions(data).filter(function (option) {
return option.appUserId !== currentUserId;
});
if (!select) return options;
select.innerHTML = '<option value="">请选择已绑定账号的家谱成员</option>' + options.map(function (option) {
var label = option.memberName || option.appUserNickName || '家谱成员';
return '<option value="' + escapeHtml(option.appUserId) + '">' + escapeHtml(label) + '</option>';
}).join('');
select.value = selectedValue;
updateBindingFields();
return options;
}
function renderListSummary(data) {
@@ -281,7 +434,7 @@
var previous = query('[data-lineage-page-action="previous"]');
var next = query('[data-lineage-page-action="next"]');
var status = query('[data-lineage-page-status]');
var disabled = !managementAccess || writePending;
var disabled = writePending;
if (!container) return;
if (currentPageTotal === null) {
@@ -294,6 +447,10 @@
if (status) status.textContent = '第 ' + currentPageNum + ' 页,共 ' + currentPageTotal + ' 人';
}
function canUseLineagePagination(pending) {
return !Boolean(pending);
}
function renderPagination(data) {
var total = data && Number(data.total);
@@ -305,6 +462,7 @@
var container = query('[data-lineage-detail]');
var person = normalizeLineagePerson(data);
var values;
var actions = '';
if (!container) return;
if (!person) {
@@ -314,17 +472,30 @@
selectedPersonId = person.personId;
peopleById[person.personId] = person;
values = [
person.sex ? '性别:' + person.sex : '',
person.sex !== undefined ? '性别:' + ({ '0': '男', '1': '女', '2': '未知' }[person.sex] || person.sex) : '',
person.generation !== undefined ? '世代:' + person.generation : '',
person.generationName ? '字辈:' + person.generationName : '',
person.personNo ? '人物编号:' + person.personNo : '',
person.aliasName ? '别名:' + person.aliasName : '',
person.fatherName ? '父亲:' + person.fatherName : '',
person.motherName ? '母亲:' + person.motherName : '',
person.spouseNames ? '配偶:' + person.spouseNames : '',
person.appUserNickName ? '绑定账号:' + person.appUserNickName : '',
person.birthDate ? '出生:' + person.birthDate : '',
person.birthPlace ? '出生地:' + person.birthPlace : '',
person.deathDate ? '逝世:' + person.deathDate : '',
person.biography ? '简介' + person.biography : ''
person.deathPlace ? '逝世地' + person.deathPlace : '',
person.burialPlace ? '安葬地:' + person.burialPlace : '',
person.personStatus !== undefined ? '人物状态:' + ({ '0': '健在', '1': '已故', '2': '未知' }[person.personStatus] || person.personStatus) : '',
person.biography ? '简介:' + person.biography : '',
person.remark ? '备注:' + person.remark : ''
].filter(Boolean);
if (managementAccess) {
actions = '<div class="row-actions"><button class="pill" type="button" data-lineage-edit="' + escapeHtml(person.personId) + '">编辑</button>' +
'<button class="pill is-danger" type="button" data-lineage-disable="' + escapeHtml(person.personId) + '">停用</button></div>';
}
container.innerHTML = '<div class="module-row"><div><h3>' + escapeHtml(person.name) + '</h3><p>' + escapeHtml(values.join(' · ') || '暂无更多资料') + '</p></div>' +
'<div class="row-actions"><button class="pill" type="button" data-lineage-edit="' + escapeHtml(person.personId) + '">编辑</button>' +
'<button class="pill is-danger" type="button" data-lineage-disable="' + escapeHtml(person.personId) + '">停用</button></div></div>';
actions + '</div>';
renderLineageList(Object.keys(peopleById).map(function (id) { return peopleById[id]; }));
if (query('[data-lineage-person-options]')) query('[data-lineage-person-options]').value = person.personId;
refreshControls();
@@ -377,6 +548,7 @@
});
if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = '';
currentPageTotal = null;
setLineageEnabled(false);
updatePaginationControls();
}
@@ -391,10 +563,16 @@
}
function refreshControls() {
var disabled = !managementAccess || writePending;
var writeDisabled = !managementAccess || writePending;
queryAll('[data-lineage-relation], [data-lineage-search], [data-lineage-form] button, [data-lineage-person-options], [data-lineage-edit], [data-lineage-disable]').forEach(function (control) {
control.disabled = disabled;
queryAll('[data-lineage-management]').forEach(function (element) {
element.hidden = !managementAccess;
});
queryAll('[data-lineage-relation], [data-lineage-form] button, [data-lineage-form] input, [data-lineage-form] select, [data-lineage-form] textarea, [data-lineage-edit], [data-lineage-disable]').forEach(function (control) {
control.disabled = writeDisabled;
});
queryAll('[data-lineage-search], [data-lineage-person-options]').forEach(function (control) {
control.disabled = writePending;
});
updatePaginationControls();
}
@@ -427,10 +605,38 @@
return input ? input.value : '';
}
function buildLineagePageQuery(values, pageNum, pageSize) {
var source = values || {};
var queryValues = {
pageNum: Number(pageNum),
pageSize: Number(pageSize)
};
var keyword = trimOrUndefined(source.keyword);
var generation = toIntegerOrUndefined(source.generation);
var personStatus = trimOrUndefined(source.personStatus);
if (keyword !== undefined) queryValues.keyword = keyword;
if (generation !== undefined && generation > 0) queryValues.generation = generation;
if (['0', '1', '2'].indexOf(personStatus) !== -1) queryValues.personStatus = personStatus;
return queryValues;
}
function getCurrentFilters(keyword) {
var generation = query('[data-lineage-generation-filter]');
var status = query('[data-lineage-status-filter]');
return {
keyword: keyword,
generation: generation ? generation.value : '',
personStatus: status ? status.value : ''
};
}
async function loadLineage(keyword, pageNum) {
var api = getApi();
var genealogyId;
var results;
var access;
var searchKeyword = trimOrUndefined(keyword);
var requestedPage = Number(pageNum);
@@ -441,27 +647,41 @@
syncGenealogyLinks(genealogyId);
try {
results = await Promise.all([
api.genealogyDetail(genealogyId),
api.currentProfile(),
api.lineageTree(genealogyId),
api.lineagePersons(genealogyId),
api.lineagePersonsPage(genealogyId, {
pageNum: currentPageNum,
pageSize: currentPageSize,
keyword: searchKeyword
}),
api.lineagePersonOptions(genealogyId)
api.lineagePersonsPage(genealogyId, buildLineagePageQuery(
getCurrentFilters(searchKeyword),
currentPageNum,
currentPageSize
)),
api.lineagePersonOptions(genealogyId),
api.generationPoems(genealogyId)
]);
renderTree(results[0]);
renderListSummary(results[1]);
renderLineageList(results[2]);
renderPagination(results[2]);
renderOptions(results[3]);
setLineageEnabled(true);
access = normalizeLineageAccess(results[0]);
currentUserId = normalizeId(results[1] && results[1].userId);
setLineageEnabled(access.canEditContent);
renderTree(results[2]);
renderListSummary(results[3]);
renderLineageList(results[4]);
renderPagination(results[4]);
renderOptions(results[5]);
renderGenerationOptions(results[6]);
if (access.canEditContent) {
renderGenealogyMemberOptions(await api.genealogyMemberOptions(genealogyId));
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (isForbidden(error)) {
renderForbiddenState();
return;
}
['[data-lineage-tree]', '[data-lineage-list]'].forEach(function (selector) {
var container = query(selector);
if (container) container.innerHTML = '<div class="api-empty">世系人物加载失败,请稍后重试</div>';
});
showMessage(error.message || '世系人物加载失败');
}
}
@@ -496,34 +716,63 @@
}
relationMode = mode;
editingPersonId = '';
setRelationFieldVisibility(mode === 'spouses');
if (submitButton) submitButton.textContent = '新增' + relationLabel(mode);
setFormStatus('当前将为“' + person.name + '”新增' + relationLabel(mode));
}
function toLocalDateTime(value) {
var text = trimOrUndefined(value);
return text ? text.slice(0, 16).replace(' ', 'T') : '';
}
function setRelationFieldVisibility(visible) {
var field = query('[data-lineage-spouse-field]');
if (field) field.hidden = !visible;
}
function fillForm(person) {
var form = query('[data-lineage-form]');
var values;
var submitButton;
var binding;
if (!form || !person) return;
binding = normalizeLineageBinding(person, currentUserId);
values = {
bindingMode: binding.bindingMode,
appUserId: binding.appUserId,
avatarOssId: person.avatarOssId,
name: person.name,
sex: person.sex,
generation: person.generation,
generationName: person.generationName,
fatherId: person.fatherId,
motherId: person.motherId,
personNo: person.personNo,
aliasName: person.aliasName,
birthDate: person.birthDate ? person.birthDate.slice(0, 10) : '',
deathDate: person.deathDate ? person.deathDate.slice(0, 10) : '',
birthDate: toLocalDateTime(person.birthDate),
birthLunar: person.birthLunar,
birthPlace: person.birthPlace,
deathDate: toLocalDateTime(person.deathDate),
deathLunar: person.deathLunar,
deathPlace: person.deathPlace,
burialPlace: person.burialPlace,
personStatus: person.personStatus,
sortOrder: person.sortOrder,
relationName: person.relationName,
biography: person.biography
biography: person.biography,
remark: person.remark
};
queryAll('[name]', form).forEach(function (field) {
field.value = values[field.name] === undefined || values[field.name] === null ? '' : values[field.name];
});
updateBindingFields();
relationMode = '';
editingPersonId = person.personId;
setRelationFieldVisibility(false);
submitButton = query('[data-lineage-form] button[type="submit"]');
if (submitButton) submitButton.textContent = '保存修改';
setFormStatus('正在编辑“' + person.name + '”');
@@ -534,8 +783,10 @@
relationMode = '';
editingPersonId = '';
setRelationFieldVisibility(false);
if (submitButton) submitButton.textContent = '保存成员';
setFormStatus('');
if (root.setTimeout) root.setTimeout(updateBindingFields, 0);
}
async function submitPerson(form) {
@@ -627,8 +878,23 @@
});
documentRef.addEventListener('change', function (event) {
var select = event.target.closest('[data-lineage-person-options]');
var generationSelect = event.target.closest('[data-lineage-generation]');
var bindingSelect = event.target.closest('[name="bindingMode"]');
var selectedOption;
var generationName;
var appUserId;
if (select && select.value) loadPersonDetail(select.value);
if (generationSelect) {
selectedOption = generationSelect.options[generationSelect.selectedIndex];
generationName = query('[name="generationName"]', generationSelect.form);
if (generationName) generationName.value = selectedOption ? selectedOption.getAttribute('data-generation-name') || '' : '';
}
if (bindingSelect) {
appUserId = query('[name="appUserId"]', bindingSelect.form);
if (bindingSelect.value !== 'SPECIFIED' && appUserId) appUserId.value = '';
updateBindingFields();
}
});
documentRef.addEventListener('click', function (event) {
var target = event.target;
@@ -645,7 +911,7 @@
}
if (pageButton) {
event.preventDefault();
if (!managementAccess || writePending) return;
if (!canUseLineagePagination(writePending)) return;
if (pageButton.getAttribute('data-lineage-page-action') === 'previous' && currentPageNum > 1) {
loadLineage(getCurrentKeyword(), currentPageNum - 1);
}
@@ -688,6 +954,13 @@
buildLineagePersonBody: buildLineagePersonBody,
validateLineagePersonBody: validateLineagePersonBody,
normalizeLineagePerson: normalizeLineagePerson,
normalizeLineageAccess: normalizeLineageAccess,
canUseLineagePagination: canUseLineagePagination,
normalizeGenerationOption: normalizeGenerationOption,
normalizeGenealogyMemberOption: normalizeGenealogyMemberOption,
normalizeGenealogyMemberOptions: normalizeGenealogyMemberOptions,
normalizeLineageBinding: normalizeLineageBinding,
buildLineagePageQuery: buildLineagePageQuery,
normalizeList: normalizeList,
relationLabel: relationLabel,
shouldRedirectToLogin: shouldRedirectToLogin,
+581
View File
@@ -0,0 +1,581 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.MemberAdminPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.MemberAdminPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var membersById = Object.create(null);
var selectedMemberId = '';
var currentMemberId = '';
var actorRole = '';
var managementAccess = false;
var writePending = false;
var responseRoles = ['owner', 'admin', 'editor', 'member', 'visitor'];
var assignableRoles = ['admin', 'editor', 'member'];
var roleLabels = {
owner: '谱主',
admin: '管理员',
editor: '编辑',
member: '成员',
visitor: '访客'
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function trimOrUndefined(value) {
var text = stringValue(value).trim();
return text || undefined;
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var contextId;
var params;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
contextId = root.ProfileUI.getGenealogyId();
if (contextId) return normalizeId(contextId);
}
params = new URLSearchParams(stringValue(source).replace(/^\?/, ''));
return normalizeId(params.get('genealogyId'));
}
function normalizeMember(item) {
var memberId = normalizeId(item && item.memberId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var lineagePersonId = normalizeId(item && item.lineagePersonId);
var inviterUserId = normalizeId(item && item.inviterUserId);
var roleType = stringValue(item && item.roleType);
var status = stringValue(item && item.status);
var member;
if (!memberId || !genealogyId || !appUserId || responseRoles.indexOf(roleType) < 0 || status !== '0') return null;
if (item.lineagePersonId !== undefined && item.lineagePersonId !== null && item.lineagePersonId !== '' && !lineagePersonId) return null;
if (item.inviterUserId !== undefined && item.inviterUserId !== null && item.inviterUserId !== '' && !inviterUserId) return null;
member = {
memberId: memberId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
appUserId: appUserId,
appUserNickName: stringValue(item.appUserNickName),
appUserPhone: stringValue(item.appUserPhone),
lineagePersonNo: stringValue(item.lineagePersonNo),
lineagePersonName: stringValue(item.lineagePersonName),
memberName: stringValue(item.memberName),
roleType: roleType,
relationName: stringValue(item.relationName),
joinSource: stringValue(item.joinSource),
inviterNickName: stringValue(item.inviterNickName),
inviterPhone: stringValue(item.inviterPhone),
joinTime: stringValue(item.joinTime),
status: status
};
if (lineagePersonId) member.lineagePersonId = lineagePersonId;
if (inviterUserId) member.inviterUserId = inviterUserId;
return member;
}
function normalizeMembers(data) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizeMember);
return normalized.some(function (member) { return !member; }) ? [] : normalized;
}
function normalizeLineageOption(item) {
var personId = normalizeId(item && item.personId);
var name = trimOrUndefined(item && item.name);
if (!personId || !name) return null;
return {
personId: personId,
name: name,
generationName: stringValue(item.generationName)
};
}
function buildMemberUpdateBody(values) {
var source = values || {};
var body = {};
var memberName = trimOrUndefined(source.memberName);
var relationName = source.relationName === undefined || source.relationName === null
? undefined
: stringValue(source.relationName).trim();
var roleType = trimOrUndefined(source.roleType);
var lineagePersonId = normalizeId(source.lineagePersonId);
if (roleType !== undefined && assignableRoles.indexOf(roleType) < 0) {
throw new Error('成员角色只能是管理员、编辑或成员');
}
if (memberName !== undefined) body.memberName = memberName;
if (relationName !== undefined) body.relationName = relationName;
if (roleType !== undefined) body.roleType = roleType;
if (source.lineagePersonId !== undefined && source.lineagePersonId !== null && source.lineagePersonId !== '') {
if (lineagePersonId) body.lineagePersonId = lineagePersonId;
else body.invalidLineagePersonId = true;
}
return body;
}
function validateMemberUpdateBody(body) {
if (!body) return '成员修改内容无效';
if (body.memberName !== undefined && body.memberName.length > 50) return '成员名称不能超过 50 个字符';
if (body.relationName !== undefined && body.relationName.length > 100) return '关系名称不能超过 100 个字符';
if (body.roleType !== undefined && assignableRoles.indexOf(body.roleType) < 0) return '成员角色只能是管理员、编辑或成员';
if (body.invalidLineagePersonId) return '世系人物选项无效';
return '';
}
function canEditMember(role, targetRole) {
if (targetRole === 'owner') return false;
if (targetRole === 'admin') return role === 'owner';
return role === 'owner' || role === 'admin';
}
function canRemoveMember(role, targetRole) {
return canEditMember(role, targetRole);
}
function canLeaveGenealogy(role) {
return Boolean(role && role !== 'owner');
}
function canTransferOwner(role) {
return role === 'owner';
}
function buildOwnerTransferBody(targetMemberId) {
return { targetMemberId: normalizeId(targetMemberId) };
}
function validateOwnerTransferBody(body, ownerMemberId) {
if (!body || !body.targetMemberId) return '请选择新谱主';
if (normalizeId(ownerMemberId) && body.targetMemberId === normalizeId(ownerMemberId)) return '不能转让给自己';
return '';
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function memberDisplayName(member) {
return member.memberName || member.lineagePersonName || member.appUserNickName || '未命名成员';
}
function renderMemberRow(member, context) {
var settings = context || {};
var meta = [
roleLabels[member.roleType] || member.roleType,
member.relationName,
member.lineagePersonName ? '世系人物:' + member.lineagePersonName : '',
member.joinTime ? '加入:' + member.joinTime : ''
].filter(Boolean).join(' · ');
var actions = '';
if (settings.canManage && canEditMember(settings.actorRole, member.roleType)) {
actions = '<button class="pill" type="button" data-member-edit-id="' + escapeHtml(member.memberId) +
'">编辑</button><button class="pill is-danger" type="button" data-member-remove-id="' +
escapeHtml(member.memberId) + '">移出家谱</button>';
}
return '<article class="module-row member-admin-row"><div><h3>' + escapeHtml(memberDisplayName(member)) +
(member.memberId === settings.currentMemberId ? ' <span class="pill">当前账号</span>' : '') +
'</h3><p>' + escapeHtml(meta) + '</p></div><div class="row-actions">' + actions + '</div></article>';
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function showMessage(message) {
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function setStatus(selector, message) {
var target = query(selector);
if (target) target.textContent = message || '';
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-member-form] input, [data-member-form] select, [data-member-form] button, [data-owner-transfer-form] select, [data-owner-transfer-form] button, [data-member-edit-id], [data-member-remove-id], [data-member-leave]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderLineageOptions(data) {
var select = query('[data-member-lineage-options]');
var options = Array.isArray(data) ? data.map(normalizeLineageOption).filter(Boolean) : [];
if (!select) return options;
select.innerHTML = '<option value="">不修改世系人物绑定</option>' + options.map(function (option) {
var label = option.name + (option.generationName ? ' · ' + option.generationName : '');
return '<option value="' + escapeHtml(option.personId) + '">' + escapeHtml(label) + '</option>';
}).join('');
return options;
}
function renderTransferOptions(members) {
var select = query('[data-owner-transfer-target]');
var targets = members.filter(function (member) {
return member.memberId !== currentMemberId && member.roleType !== 'owner';
});
if (!select) return targets;
select.innerHTML = '<option value="">请选择新谱主</option>' + targets.map(function (member) {
return '<option value="' + escapeHtml(member.memberId) + '">' +
escapeHtml(memberDisplayName(member) + ' · ' + (roleLabels[member.roleType] || member.roleType)) +
'</option>';
}).join('');
return targets;
}
function renderMembers(data) {
var container = query('[data-member-list]');
var members = normalizeMembers(data);
membersById = Object.create(null);
members.forEach(function (member) { membersById[member.memberId] = member; });
if (!container) return members;
if (!Array.isArray(data) || (data.length && !members.length)) {
container.innerHTML = '<div class="api-empty">成员响应缺少稳定 GenealogyMemberVo 字段,请联系后端核对。</div>';
return [];
}
container.innerHTML = members.length ? members.map(function (member) {
return renderMemberRow(member, {
actorRole: actorRole,
currentMemberId: currentMemberId,
canManage: managementAccess
});
}).join('') : '<div class="api-empty">暂无正常成员</div>';
renderTransferOptions(members);
return members;
}
function renderRoleOptions(targetRole) {
var select = query('[data-member-role-options]');
var roles = actorRole === 'owner' ? assignableRoles : ['editor', 'member'];
if (!select) return;
select.innerHTML = roles.map(function (role) {
return '<option value="' + role + '">' + roleLabels[role] + '</option>';
}).join('');
select.value = roles.indexOf(targetRole) >= 0 ? targetRole : roles[roles.length - 1];
}
function fillMemberForm(member) {
var form = query('[data-member-form]');
if (!form || !member || !managementAccess || !canEditMember(actorRole, member.roleType)) return;
selectedMemberId = member.memberId;
form.hidden = false;
query('[name="memberName"]', form).value = member.memberName;
query('[name="relationName"]', form).value = member.relationName;
renderRoleOptions(member.roleType);
query('[name="lineagePersonId"]', form).value = member.lineagePersonId || '';
if (query('[data-member-form-title]')) query('[data-member-form-title]').textContent = '编辑“' + memberDisplayName(member) + '”';
setStatus('[data-member-form-status]', '');
}
function resetMemberForm() {
var form = query('[data-member-form]');
selectedMemberId = '';
if (form) {
form.reset();
form.hidden = true;
}
setStatus('[data-member-form-status]', '');
}
async function fetchAndRenderMembers(api, genealogyId) {
var data = await api.genealogyMembers(genealogyId);
var members = renderMembers(data);
if (Array.isArray(data) && data.length && !members.length) throw new Error('成员响应字段不完整');
return members;
}
function renderNoContext() {
if (query('[data-member-list]')) query('[data-member-list]').innerHTML = '<div class="api-empty">请先选择家谱,再管理成员。</div>';
setStatus('[data-member-overview]', '当前没有家谱上下文。');
queryAll('[data-member-management], [data-member-leave-section], [data-owner-transfer-section]').forEach(function (element) {
element.hidden = true;
});
}
async function loadPage() {
var api = getApi();
var genealogyId;
var results;
var detail;
var profileUserId;
var members;
var currentMember;
if (redirectUnauthorized(api)) return;
genealogyId = getCurrentGenealogyId();
if (!genealogyId) {
renderNoContext();
return;
}
syncLinks();
try {
results = await Promise.all([
api.genealogyDetail(genealogyId),
api.currentProfile(),
api.genealogyMembers(genealogyId),
api.lineagePersonOptions(genealogyId)
]);
detail = results[0] || {};
profileUserId = normalizeId(results[1] && results[1].userId);
actorRole = responseRoles.indexOf(stringValue(detail.roleType)) >= 0 ? stringValue(detail.roleType) : '';
managementAccess = detail.canManage === true;
members = normalizeMembers(results[2]);
currentMember = members.find(function (member) { return member.appUserId === profileUserId; }) || null;
currentMemberId = currentMember ? currentMember.memberId : '';
if (!actorRole && currentMember) actorRole = currentMember.roleType;
setStatus('[data-member-overview]', (detail.genealogyName || '当前家谱') + ' · ' + members.length + ' 位正常成员 · 当前角色:' + (roleLabels[actorRole] || '成员'));
queryAll('[data-member-management]').forEach(function (element) { element.hidden = !managementAccess; });
queryAll('[data-member-leave-section]').forEach(function (element) { element.hidden = !currentMember || !canLeaveGenealogy(actorRole); });
queryAll('[data-owner-transfer-section]').forEach(function (element) { element.hidden = !currentMember || !canTransferOwner(actorRole); });
renderLineageOptions(results[3]);
renderMembers(results[2]);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-member-list]')) query('[data-member-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。') + '</div>';
showMessage(isForbidden(error) ? '当前账号无权查看该家谱成员。' : (error.message || '成员加载失败'));
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) { values[field.name] = field.value; });
return values;
}
async function submitMember(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var target = membersById[selectedMemberId];
var body;
var validation;
var saved;
if (writePending || !genealogyId || !target || !managementAccess || !canEditMember(actorRole, target.roleType) || redirectUnauthorized(api)) return;
body = buildMemberUpdateBody(getFormValues(form));
validation = validateMemberUpdateBody(body);
if (validation) {
setStatus('[data-member-form-status]', validation);
return;
}
delete body.invalidLineagePersonId;
setWritePending(true);
setStatus('[data-member-form-status]', '正在保存成员…');
try {
saved = normalizeMember(await api.updateGenealogyMember(genealogyId, selectedMemberId, body));
if (!saved || saved.memberId !== selectedMemberId) throw new Error('成员修改响应缺少稳定 memberId');
if (!(await fetchAndRenderMembers(api, genealogyId)).some(function (member) { return member.memberId === saved.memberId; })) {
throw new Error('修改后成员列表未返回同一成员');
}
resetMemberForm();
showMessage('成员信息已更新');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-member-form-status]', isForbidden(error) ? '当前账号无权修改该成员。' : (error.message || '成员修改失败'));
} finally {
setWritePending(false);
}
}
async function removeMember(memberId) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var target = membersById[memberId];
if (writePending || !genealogyId || !target || !managementAccess || !canRemoveMember(actorRole, target.roleType) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认将“' + memberDisplayName(target) + '”移出当前家谱吗?该操作不会删除其世系人物资料。')) return;
setWritePending(true);
try {
await api.removeGenealogyMember(genealogyId, memberId);
if ((await fetchAndRenderMembers(api, genealogyId)).some(function (member) { return member.memberId === memberId; })) {
throw new Error('移出后成员仍存在于正常成员列表');
}
if (selectedMemberId === memberId) resetMemberForm();
showMessage('成员已移出家谱');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权移出该成员。' : (error.message || '移出成员失败'));
} finally {
setWritePending(false);
}
}
async function leaveGenealogy() {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
if (writePending || !genealogyId || !currentMemberId || !canLeaveGenealogy(actorRole) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认退出当前家谱吗?退出后需重新受邀才能进入。世系人物资料不会被删除。')) return;
setWritePending(true);
try {
await api.leaveGenealogy(genealogyId);
if (root.location) root.location.href = 'profile-families.html';
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权退出该家谱。' : (error.message || '退出家谱失败'));
} finally {
setWritePending(false);
}
}
async function transferOwner(form) {
var api = getApi();
var genealogyId = getCurrentGenealogyId();
var body = buildOwnerTransferBody(query('[name="targetMemberId"]', form).value);
var validation = validateOwnerTransferBody(body, currentMemberId);
var target = membersById[body.targetMemberId];
if (writePending || !genealogyId || !canTransferOwner(actorRole) || redirectUnauthorized(api)) return;
if (validation || !target) {
setStatus('[data-owner-transfer-status]', validation || '请选择成员列表中的新谱主');
return;
}
if (root.confirm && !root.confirm('确认将谱主转让给“' + memberDisplayName(target) + '”吗?转让后当前账号将变为管理员。')) return;
setWritePending(true);
setStatus('[data-owner-transfer-status]', '正在转让谱主…');
try {
await api.transferGenealogyOwner(genealogyId, body);
await loadPage();
setStatus('[data-owner-transfer-status]', '谱主已转让');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-owner-transfer-status]', isForbidden(error) ? '只有当前谱主可以执行转让。' : (error.message || '谱主转让失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var editButton = event.target.closest('[data-member-edit-id]');
var removeButton = event.target.closest('[data-member-remove-id]');
var leaveButton = event.target.closest('[data-member-leave]');
if (editButton) {
event.preventDefault();
fillMemberForm(membersById[editButton.getAttribute('data-member-edit-id')]);
} else if (removeButton) {
event.preventDefault();
removeMember(removeButton.getAttribute('data-member-remove-id'));
} else if (leaveButton) {
event.preventDefault();
leaveGenealogy();
}
});
documentRef.addEventListener('submit', function (event) {
var memberForm = event.target.closest('[data-member-form]');
var transferForm = event.target.closest('[data-owner-transfer-form]');
if (memberForm) {
event.preventDefault();
submitMember(memberForm);
} else if (transferForm) {
event.preventDefault();
transferOwner(transferForm);
}
});
documentRef.addEventListener('reset', function (event) {
if (event.target.closest('[data-member-form]')) resetMemberForm();
});
}
function init() {
if (!documentRef || !query('[data-member-admin-page]')) return;
bindActions();
loadPage();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
normalizeMember: normalizeMember,
normalizeMembers: normalizeMembers,
normalizeLineageOption: normalizeLineageOption,
buildMemberUpdateBody: buildMemberUpdateBody,
validateMemberUpdateBody: validateMemberUpdateBody,
canEditMember: canEditMember,
canRemoveMember: canRemoveMember,
canLeaveGenealogy: canLeaveGenealogy,
canTransferOwner: canTransferOwner,
buildOwnerTransferBody: buildOwnerTransferBody,
validateOwnerTransferBody: validateOwnerTransferBody,
renderMemberRow: renderMemberRow,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+437 -128
View File
@@ -14,18 +14,19 @@
'use strict';
var documentRef = root.document;
var memosById = Object.create(null);
var writePending = false;
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, rootNode) {
return documentRef ? Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)) : [];
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function trimOrUndefined(value) {
@@ -34,63 +35,176 @@
return text || undefined;
}
function toSafeIntegerOrUndefined(value) {
var text = trimOrUndefined(value);
var number;
if (!text) return undefined;
number = Number(text);
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
function queryParam(search, name) {
return new URLSearchParams(String(search || '').replace(/^\?/, '')).get(name) || '';
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function getCurrentGenealogyId(search) {
var page = query('[data-memo-page], [data-memo-edit-page]');
var source = search === undefined && root.location ? root.location.search : search;
var profileGenealogyId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
profileGenealogyId = root.ProfileUI.getGenealogyId();
if (profileGenealogyId) return profileGenealogyId;
if (profileGenealogyId) return normalizeId(profileGenealogyId);
}
return getQueryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
return normalizeId(queryParam(source, 'genealogyId'));
}
function getCurrentMemoId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(queryParam(source, 'memoId'));
}
function toBackendDateTime(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function toDateTimeInputValue(value) {
var text = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) return '';
return text.slice(0, 16).replace(' ', 'T');
}
function toSafeInteger(value) {
var text = trimOrUndefined(value);
var number;
if (!text) return undefined;
number = Number(text);
return Number.isSafeInteger(number) ? number : undefined;
}
function buildMemoBody(values) {
var source = values || {};
var body = { memoTitle: String(source.memoTitle || '').trim() };
var fields = ['memoContent', 'remindTime', 'completed', 'mediaOssIds', 'status'];
var memoContent = trimOrUndefined(source.memoContent);
var remindTime = toBackendDateTime(source.remindTime);
var completed = trimOrUndefined(source.completed);
var mediaOssIds = trimOrUndefined(source.mediaOssIds);
var sortOrderText = trimOrUndefined(source.sortOrder);
var status = trimOrUndefined(source.status);
fields.forEach(function (field) {
var value = trimOrUndefined(source[field]);
if (value !== undefined) body[field] = value;
});
if (sortOrderText !== undefined) body.sortOrder = toSafeIntegerOrUndefined(sortOrderText);
if (memoContent !== undefined) body.memoContent = memoContent;
if (remindTime !== undefined) body.remindTime = remindTime;
if (completed !== undefined) body.completed = completed;
if (mediaOssIds !== undefined) body.mediaOssIds = mediaOssIds;
if (sortOrderText !== undefined) {
body.sortOrder = toSafeInteger(sortOrderText);
if (body.sortOrder === undefined) body.invalidSortOrder = true;
}
if (status !== undefined) body.status = status;
return body;
}
function isValidBackendDateTime(value) {
var match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
var year;
var month;
var day;
var days;
if (!match) return false;
year = Number(match[1]);
month = Number(match[2]);
day = Number(match[3]);
if (month < 1 || month > 12 || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false;
days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day >= 1 && day <= days[month - 1];
}
function validateMemoBody(body) {
if (!body || !body.memoTitle) return '请填写备忘标题';
if (body.mediaOssIds !== undefined && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(body.mediaOssIds)) {
return '附件 OSS ID 请使用英文逗号分隔的正整数';
if (!body || !body.memoTitle) return '请填写备忘标题';
if (body.remindTime !== undefined && !isValidBackendDateTime(body.remindTime)) return '提醒时间格式无效';
if (body.completed !== undefined && body.completed !== '0' && body.completed !== '1') {
return '完成状态只能是未完成或已完成';
}
if (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && body.sortOrder === undefined) return '排序值必须是安全整数';
if (body.mediaOssIds !== undefined && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(body.mediaOssIds)) {
return '附件上传结果无效';
}
if (body.invalidSortOrder || (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && !Number.isSafeInteger(body.sortOrder))) {
return '排序值必须是安全整数';
}
if (body.status === '1') return '当前 PC 无法重新读取停用备忘录,暂不开放停用';
if (body.status !== undefined && body.status !== '0') return '备忘录状态只能是 0';
return '';
}
function normalizeMemoList(data) {
return Array.isArray(data) ? data : [];
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function normalizeMemo(item) {
var memoId = normalizeId(item && item.memoId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var memoTitle = String(item && item.memoTitle || '').trim();
var completed = stringValue(item && item.completed);
var mediaOssIds = stringValue(item && item.mediaOssIds);
var status = stringValue(item && item.status);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var memo;
if (!memoId || !genealogyId || !memoTitle || (completed !== '0' && completed !== '1') || (status !== '0' && status !== '1')) {
return null;
}
if (item.appUserId !== undefined && item.appUserId !== null && item.appUserId !== '' && !appUserId) return null;
if (mediaOssIds && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(mediaOssIds)) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
if (item.remindTime && !isValidBackendDateTime(String(item.remindTime))) return null;
memo = {
memoId: memoId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
appUserNickName: stringValue(item.appUserNickName),
appUserPhone: stringValue(item.appUserPhone),
memoTitle: memoTitle,
memoContent: stringValue(item.memoContent),
remindTime: stringValue(item.remindTime),
completed: completed,
mediaOssIds: mediaOssIds,
status: status,
remark: stringValue(item.remark)
};
if (appUserId) memo.appUserId = appUserId;
if (sortOrder !== undefined) memo.sortOrder = sortOrder;
return memo;
}
function matchesSavedMemo(item, expectedMemoId) {
var memo = normalizeMemo(item);
return Boolean(memo && memo.memoId === normalizeId(expectedMemoId));
}
function isEditableMemo(memo) {
return Boolean(memo && memo.status === '0');
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -98,40 +212,61 @@
.replace(/'/g, '&#39;');
}
function stringifyMemo(memo) {
try {
return typeof memo === 'string' ? memo : JSON.stringify(memo);
} catch (error) {
return String(memo);
}
function attachmentCount(mediaOssIds) {
return mediaOssIds ? mediaOssIds.split(',').filter(Boolean).length : 0;
}
function renderMemos(data) {
var container = query('[data-memo-list]');
var memos = normalizeMemoList(data);
function buildListUrl(genealogyId, memoId) {
var url = 'profile-memo.html?genealogyId=' + encodeURIComponent(genealogyId);
if (!container) return;
if (!Array.isArray(data)) {
container.innerHTML = '<div class="api-empty">备忘录响应未按 Apifox ListResult 返回数组,无法安全展示或操作记录。</div>';
return;
}
if (!memos.length) {
container.innerHTML = '<div class="api-empty">暂无备忘录</div>';
return;
}
// ListResult 的元素 DTO 尚未展开,不能假设 memoId、标题或权限字段。
container.innerHTML = memos.map(function (memo, index) {
return '<article class="module-row"><div><h3>备忘录 ' + (index + 1) + '</h3><p>' +
escapeHtml(stringifyMemo(memo)) + '</p></div></article>';
}).join('');
return memoId ? url + '&memoId=' + encodeURIComponent(memoId) : url;
}
function buildEditUrl(genealogyId, memoId) {
var url = 'profile-memo-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return memoId ? url + '&memoId=' + encodeURIComponent(memoId) : url;
}
function renderMemoRow(memo) {
var summary = [];
summary.push(memo.completed === '1' ? '已完成' : '未完成');
if (memo.remindTime) summary.push('提醒:' + memo.remindTime);
if (memo.memoContent) summary.push(memo.memoContent);
return '<article class="module-row memo-row"><div><h3>' + escapeHtml(memo.memoTitle) + '</h3><p>' +
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-memo-detail-id="' + escapeHtml(memo.memoId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(memo.genealogyId, memo.memoId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-memo-delete-id="' + escapeHtml(memo.memoId) + '">删除</button>' +
'</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderMemoDetail(memo) {
var count;
if (!memo) return '<div class="api-empty">请选择一条备忘录查看详情</div>';
count = attachmentCount(memo.mediaOssIds);
return '<div class="form-like memo-detail">' +
detailValue('备忘标题', memo.memoTitle) +
detailValue('备忘内容', memo.memoContent) +
detailValue('提醒时间', memo.remindTime) +
detailValue('完成状态', memo.completed === '1' ? '已完成' : '未完成') +
detailValue('附件', count ? count + ' 个附件' : '未上传') +
detailValue('记录人', memo.appUserNickName) +
detailValue('备注', memo.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(memo.genealogyId, memo.memoId)) + '">编辑备忘录</a>' +
'<button class="btn ghost" type="button" data-memo-delete-id="' + escapeHtml(memo.memoId) + '">删除备忘录</button></div>';
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
if (root.alert) root.alert(message);
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function shouldRedirectToLogin(api, error) {
@@ -140,18 +275,146 @@
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') {
root.location.replace('login.html');
return true;
}
root.location.href = 'login.html';
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function setFormStatus(message) {
var target = query('[data-memo-form-status]');
if (target) target.textContent = message || '';
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-memo-editor-placeholder]');
queryAll('[data-memo-editor], [data-memo-editor-action]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-memo-form] button, [data-memo-form] input, [data-memo-form] textarea, [data-memo-form] select, [data-memo-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function renderNoContext() {
var list = query('[data-memo-list]');
var detail = query('[data-memo-detail]');
var message = '请先选择家谱,再查看或维护备忘录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
function renderMemos(data) {
var container = query('[data-memo-list]');
var normalized = Array.isArray(data) ? data.map(normalizeMemo) : [];
var invalidCount = normalized.filter(function (memo) { return !memo; }).length;
var memos = normalized.filter(Boolean);
memosById = Object.create(null);
memos.forEach(function (memo) {
memosById[memo.memoId] = memo;
});
if (!container) return memos;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">备忘录响应缺少稳定 MemoVo 字段,请联系后端核对。</div>';
return [];
}
if (!memos.length) {
container.innerHTML = '<div class="api-empty">暂无备忘录</div>';
return [];
}
container.innerHTML = memos.map(renderMemoRow).join('');
return memos;
}
function renderDetail(memo) {
var container = query('[data-memo-detail]');
if (container) container.innerHTML = renderMemoDetail(memo);
}
async function loadMemoDetail(memoId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var memo;
if (!genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return null;
try {
memo = normalizeMemo(await api.memoDetail(genealogyId, memoId));
if (!memo) throw new Error('备忘录详情响应缺少稳定 MemoVo 字段');
memosById[memo.memoId] = memo;
renderDetail(memo);
return memo;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
showMessage(isForbidden(error) ? '当前账号无权查看该备忘录。' : (error.message || '备忘录详情加载失败'));
return null;
}
}
async function loadMemos() {
var api = getApi();
var genealogyId;
var memos;
var requestedMemoId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
try {
memos = renderMemos(await api.memos(genealogyId));
requestedMemoId = getCurrentMemoId();
if (requestedMemoId && memosById[requestedMemoId]) {
await loadMemoDetail(requestedMemoId);
} else if (memos.length) {
await loadMemoDetail(memos[0].memoId);
} else {
renderDetail(null);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-memo-list]')) {
query('[data-memo-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : '备忘录加载失败,请稍后重试。') +
'</div>';
}
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : (error.message || '备忘录加载失败'));
}
}
function getFormValues(form) {
var values = {};
@@ -161,102 +424,148 @@
return values;
}
function setFormStatus(message) {
var status = query('[data-memo-form-status]');
function setFieldValue(name, value) {
var field = query('[data-memo-form] [name="' + name + '"]');
if (status) status.textContent = message || '';
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function setWritePending(pending) {
writePending = Boolean(pending);
queryAll('[data-memo-form] button, [data-memo-form] input, [data-memo-form] textarea, [data-memo-form] select').forEach(function (control) {
control.disabled = writePending;
});
}
function syncGenealogyLinks(genealogyId) {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
root.ProfileUI.syncGenealogyContextLinks();
return;
function fillMemoForm(memo) {
if (!isEditableMemo(memo)) throw new Error('停用备忘录无法通过当前 PC 接口重新读取或编辑');
setFieldValue('memoTitle', memo.memoTitle);
setFieldValue('memoContent', memo.memoContent);
setFieldValue('remindTime', toDateTimeInputValue(memo.remindTime));
setFieldValue('completed', memo.completed);
setFieldValue('mediaOssIds', memo.mediaOssIds);
setFieldValue('sortOrder', memo.sortOrder);
setFieldValue('status', '0');
if (query('[data-memo-media-status]')) {
query('[data-memo-media-status]').textContent = memo.mediaOssIds
? '已保留 ' + attachmentCount(memo.mediaOssIds) + ' 个附件,可继续选择追加'
: '未选择文件';
}
queryAll('[data-genealogy-context-link]').forEach(function (link) {
var href = link.getAttribute('href');
var base;
if (!href || href === '#') return;
base = href.split('?')[0];
link.href = base + '?genealogyId=' + encodeURIComponent(genealogyId);
});
}
function renderNoContext() {
var list = query('[data-memo-list]');
if (list) list.innerHTML = '<div class="api-empty">等待家谱入口接口;请从具体家谱进入备忘录。</div>';
setFormStatus('等待家谱入口接口;请从具体家谱进入备忘录。');
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) {
renderNoContext();
showMessage('等待家谱入口接口;请从具体家谱进入备忘录。');
}
return genealogyId;
}
function buildListUrl(genealogyId) {
return 'profile-memo.html?genealogyId=' + encodeURIComponent(genealogyId);
}
async function loadMemos() {
async function loadMemoEditor() {
var api = getApi();
var genealogyId;
var memoId;
var memo;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
memoId = getCurrentMemoId();
syncLinks();
setEditorEnabled(false, '正在加载备忘录编辑信息…');
try {
renderMemos(await api.memos(genealogyId));
if (memoId) {
memo = normalizeMemo(await api.memoDetail(genealogyId, memoId));
if (!memo) throw new Error('备忘录详情响应缺少稳定 MemoVo 字段');
fillMemoForm(memo);
if (query('[data-memo-editor-title]')) query('[data-memo-editor-title]').textContent = '编辑备忘录';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
renderMemos(null);
showMessage(error.message || '备忘录加载失败');
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的备忘录。'
: (error.message || '备忘录编辑信息加载失败'));
setFormStatus(error.message || '备忘录编辑信息加载失败');
}
}
async function submitMemo(form) {
var api = getApi();
var genealogyId;
var genealogyId = requireGenealogyId();
var memoId = getCurrentMemoId();
var body;
var validation;
var result;
var saved;
var savedId;
var verifiedDetail;
if (writePending || redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
body = buildMemoBody(getFormValues(form));
validation = validateMemoBody(body);
if (validation) {
setFormStatus(validation);
return;
}
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存备忘录...');
setFormStatus('正在保存备忘录');
try {
await api.createMemo(genealogyId, body);
if (root.location) root.location.href = buildListUrl(genealogyId);
result = memoId
? await api.updateMemo(genealogyId, memoId, body)
: await api.createMemo(genealogyId, body);
saved = normalizeMemo(result);
savedId = memoId || (saved && saved.memoId);
if (!savedId) throw new Error('保存响应缺少 memoId');
verifiedDetail = await api.memoDetail(genealogyId, savedId);
if (!matchesSavedMemo(verifiedDetail, savedId)) throw new Error('保存后重读未返回同一条备忘录');
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(error.message || '备忘录保存失败');
setFormStatus(isForbidden(error) ? '当前账号无权保存该备忘录。' : (error.message || '备忘录保存失败'));
} finally {
setWritePending(false);
}
}
async function deleteMemo(memoId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var memo = memosById[memoId];
if (writePending || !genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (memo ? memo.memoTitle : '该备忘录') + '”吗?删除后不可恢复,并会释放附件引用。')) return;
setWritePending(true);
try {
await api.deleteMemo(genealogyId, memoId);
await loadMemos();
showMessage('备忘录已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该备忘录。' : (error.message || '备忘录删除失败'));
} finally {
setWritePending(false);
}
}
function clearMediaSelection() {
var target = query('[data-memo-form] [name="mediaOssIds"]');
var status = query('[data-memo-media-status]');
var fileInput = query('[data-memo-media-input]');
if (target) target.value = '';
if (fileInput) fileInput.value = '';
if (status) status.textContent = '未选择文件';
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-memo-detail-id]');
var deleteButton = event.target.closest('[data-memo-delete-id]');
var clearMediaButton = event.target.closest('[data-memo-clear-media]');
if (detailButton) {
event.preventDefault();
loadMemoDetail(detailButton.getAttribute('data-memo-detail-id'));
return;
}
if (deleteButton) {
event.preventDefault();
deleteMemo(deleteButton.getAttribute('data-memo-delete-id'));
return;
}
if (clearMediaButton) {
event.preventDefault();
clearMediaSelection();
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-memo-form]');
@@ -267,23 +576,23 @@
}
function init() {
var genealogyId;
if (!documentRef) return;
bindActions();
if (query('[data-memo-page]')) loadMemos();
if (query('[data-memo-edit-page]')) {
genealogyId = requireGenealogyId();
if (genealogyId) syncGenealogyLinks(genealogyId);
}
if (query('[data-memo-edit-page]')) loadMemoEditor();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentMemoId: getCurrentMemoId,
buildMemoBody: buildMemoBody,
validateMemoBody: validateMemoBody,
normalizeMemoList: normalizeMemoList,
normalizeMemo: normalizeMemo,
matchesSavedMemo: matchesSavedMemo,
isEditableMemo: isEditableMemo,
renderMemoDetail: renderMemoDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+644
View File
@@ -0,0 +1,644 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.MeritPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.MeritPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var recordsById = Object.create(null);
var writePending = false;
var meritTypes = {
donation: '捐赠',
repair: '修缮',
public: '公益',
other: '其他'
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function queryParam(search, name) {
return new URLSearchParams(String(search || '').replace(/^\?/, '')).get(name) || '';
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var profileGenealogyId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
profileGenealogyId = root.ProfileUI.getGenealogyId();
if (profileGenealogyId) return normalizeId(profileGenealogyId);
}
return normalizeId(queryParam(source, 'genealogyId'));
}
function getCurrentMeritId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(queryParam(source, 'meritId'));
}
function toBackendDateTime(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function toDateTimeInputValue(value) {
var text = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) return '';
return text.slice(0, 16).replace(' ', 'T');
}
function toFiniteNumber(value) {
var text = trimOrUndefined(value);
var number;
if (!text) return undefined;
number = Number(text);
return Number.isFinite(number) ? number : undefined;
}
function toSafeInteger(value) {
var number = toFiniteNumber(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function decimalKey(value) {
var text = String(value).trim().replace(/^([+-]?)\./, '$10.');
var match = text.match(/^([+-]?)(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/);
var coefficient;
var scale;
if (!match) return '';
coefficient = BigInt((match[2] + (match[3] || '')).replace(/^0+(?=\d)/, ''));
scale = (match[3] || '').length - Number(match[4] || 0);
if (coefficient === 0n) return '0:0';
if (scale < 0) {
coefficient *= 10n ** BigInt(-scale);
scale = 0;
}
while (scale > 0 && coefficient % 10n === 0n) {
coefficient /= 10n;
scale -= 1;
}
if (match[1] === '-') coefficient = -coefficient;
return String(coefficient) + ':' + String(scale);
}
function isExactJsonNumber(sourceText, number) {
return Number.isFinite(number) && decimalKey(sourceText) === decimalKey(JSON.stringify(number));
}
function buildMeritRecordBody(values) {
var source = values || {};
var body = {
donorName: String(source.donorName || '').trim(),
meritTitle: String(source.meritTitle || '').trim()
};
var meritType = trimOrUndefined(source.meritType);
var meritContent = trimOrUndefined(source.meritContent);
var amountText = trimOrUndefined(source.amount);
var meritTime = toBackendDateTime(source.meritTime);
var sortOrderText = trimOrUndefined(source.sortOrder);
var status = trimOrUndefined(source.status);
if (meritType !== undefined) body.meritType = meritType;
if (meritContent !== undefined) body.meritContent = meritContent;
if (amountText !== undefined) {
body.amount = toFiniteNumber(amountText);
if (body.amount === undefined) body.invalidAmount = true;
else if (!isExactJsonNumber(amountText, body.amount)) body.invalidAmountPrecision = true;
}
if (meritTime !== undefined) body.meritTime = meritTime;
if (sortOrderText !== undefined) {
body.sortOrder = toSafeInteger(sortOrderText);
if (body.sortOrder === undefined) body.invalidSortOrder = true;
}
if (status !== undefined) body.status = status;
return body;
}
function isValidBackendDateTime(value) {
var match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
var year;
var month;
var day;
var days;
if (!match) return false;
year = Number(match[1]);
month = Number(match[2]);
day = Number(match[3]);
if (month < 1 || month > 12 || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false;
days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day >= 1 && day <= days[month - 1];
}
function validateMeritRecordBody(body) {
if (!body || !body.donorName) return '请填写功德人姓名';
if (!body.meritTitle) return '请填写功德标题';
if (body.meritType !== undefined && !Object.prototype.hasOwnProperty.call(meritTypes, body.meritType)) {
return '功德类型无效';
}
if (body.invalidAmount || (Object.prototype.hasOwnProperty.call(body, 'amount') && !Number.isFinite(body.amount))) {
return '功德金额必须是有限数字';
}
if (body.invalidAmountPrecision) return '功德金额超出浏览器可安全提交的精度';
if (body.meritTime !== undefined && !isValidBackendDateTime(body.meritTime)) return '功德时间格式无效';
if (body.invalidSortOrder || (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && !Number.isSafeInteger(body.sortOrder))) {
return '排序值必须是安全整数';
}
if (body.status === '1') return '当前 PC 无法重新读取停用功德记录,暂不开放停用';
if (body.status !== undefined && body.status !== '0') return '功德记录状态只能是 0';
return '';
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function normalizeAmount(value) {
var text;
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '';
text = String(value).trim();
return /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text) ? text : '';
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function normalizeMeritRecord(item) {
var meritId = normalizeId(item && item.meritId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var donorName = String(item && item.donorName || '').trim();
var meritType = stringValue(item && item.meritType);
var meritTitle = String(item && item.meritTitle || '').trim();
var amount = normalizeAmount(item && item.amount);
var meritTime = stringValue(item && item.meritTime);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var status = stringValue(item && item.status);
var record;
if (!meritId || !genealogyId || !donorName || !meritTitle || !Object.prototype.hasOwnProperty.call(meritTypes, meritType)) {
return null;
}
if (item.appUserId !== undefined && item.appUserId !== null && item.appUserId !== '' && !appUserId) return null;
if (item.amount !== undefined && item.amount !== null && item.amount !== '' && amount === '') return null;
if (meritTime && !isValidBackendDateTime(meritTime)) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
if (status !== '0' && status !== '1') return null;
record = {
meritId: meritId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
appUserNickName: stringValue(item.appUserNickName),
appUserPhone: stringValue(item.appUserPhone),
donorName: donorName,
meritType: meritType,
meritTitle: meritTitle,
meritContent: stringValue(item.meritContent),
amount: amount,
meritTime: meritTime,
status: status,
remark: stringValue(item.remark)
};
if (appUserId) record.appUserId = appUserId;
if (sortOrder !== undefined) record.sortOrder = sortOrder;
return record;
}
function matchesSavedMerit(item, expectedMeritId) {
var record = normalizeMeritRecord(item);
return Boolean(record && record.meritId === normalizeId(expectedMeritId));
}
function isEditableMerit(record) {
return Boolean(record && record.status === '0');
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildListUrl(genealogyId, meritId) {
var url = 'profile-merit.html?genealogyId=' + encodeURIComponent(genealogyId);
return meritId ? url + '&meritId=' + encodeURIComponent(meritId) : url;
}
function buildEditUrl(genealogyId, meritId) {
var url = 'profile-merit-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return meritId ? url + '&meritId=' + encodeURIComponent(meritId) : url;
}
function renderMeritRow(record) {
var summary = [meritTypes[record.meritType]];
if (record.meritTime) summary.push(record.meritTime);
if (record.amount) summary.push('金额:' + record.amount);
return '<article class="module-row merit-row"><div><h3>' + escapeHtml(record.meritTitle) + '</h3><p>' +
escapeHtml(record.donorName + ' · ' + summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-merit-detail-id="' + escapeHtml(record.meritId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.meritId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-merit-delete-id="' + escapeHtml(record.meritId) + '">删除</button>' +
'</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderMeritDetail(record) {
if (!record) return '<div class="api-empty">请选择一条功德记录查看详情</div>';
return '<div class="form-like merit-detail">' +
detailValue('功德人', record.donorName) +
detailValue('功德类型', meritTypes[record.meritType]) +
detailValue('功德标题', record.meritTitle) +
detailValue('事迹内容', record.meritContent) +
detailValue('金额', record.amount) +
detailValue('功德时间', record.meritTime) +
detailValue('记录人', record.appUserNickName) +
detailValue('备注', record.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.meritId)) + '">编辑功德记录</a>' +
'<button class="btn ghost" type="button" data-merit-delete-id="' + escapeHtml(record.meritId) + '">删除功德记录</button></div>';
}
function showMessage(message) {
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function setFormStatus(message) {
var target = query('[data-merit-form-status]');
if (target) target.textContent = message || '';
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-merit-editor-placeholder]');
queryAll('[data-merit-editor], [data-merit-editor-action]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-merit-form] button, [data-merit-form] input, [data-merit-form] textarea, [data-merit-form] select, [data-merit-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function renderNoContext() {
var list = query('[data-merit-list]');
var detail = query('[data-merit-detail]');
var message = '请先选择家谱,再查看或维护功德记录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
function renderMeritRecords(data) {
var container = query('[data-merit-list]');
var normalized = Array.isArray(data) ? data.map(normalizeMeritRecord) : [];
var invalidCount = normalized.filter(function (record) { return !record; }).length;
var records = normalized.filter(Boolean);
recordsById = Object.create(null);
records.forEach(function (record) {
recordsById[record.meritId] = record;
});
if (!container) return records;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">功德记录响应缺少稳定 MeritRecordVo 字段,请联系后端核对。</div>';
return [];
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无功德记录</div>';
return [];
}
container.innerHTML = records.map(renderMeritRow).join('');
return records;
}
function renderDetail(record) {
var container = query('[data-merit-detail]');
if (container) container.innerHTML = renderMeritDetail(record);
}
async function loadMeritDetail(meritId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var record;
if (!genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return null;
try {
record = normalizeMeritRecord(await api.meritRecordDetail(genealogyId, meritId));
if (!record) throw new Error('功德记录详情响应缺少稳定 MeritRecordVo 字段');
recordsById[record.meritId] = record;
renderDetail(record);
return record;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
showMessage(isForbidden(error) ? '当前账号无权查看该功德记录。' : (error.message || '功德记录详情加载失败'));
return null;
}
}
async function loadMeritRecords() {
var api = getApi();
var genealogyId;
var records;
var requestedMeritId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
try {
records = renderMeritRecords(await api.meritRecords(genealogyId));
requestedMeritId = getCurrentMeritId();
if (requestedMeritId && recordsById[requestedMeritId]) {
await loadMeritDetail(requestedMeritId);
} else if (records.length) {
await loadMeritDetail(records[0].meritId);
} else {
renderDetail(null);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-merit-list]')) {
query('[data-merit-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : '功德记录加载失败,请稍后重试。') +
'</div>';
}
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : (error.message || '功德记录加载失败'));
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
function setFieldValue(name, value) {
var field = query('[data-merit-form] [name="' + name + '"]');
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function fillMeritForm(record) {
var contentField;
var contentEditor;
if (!isEditableMerit(record)) throw new Error('停用功德记录无法通过当前 PC 接口重新读取或编辑');
setFieldValue('donorName', record.donorName);
setFieldValue('meritType', record.meritType);
setFieldValue('meritTitle', record.meritTitle);
setFieldValue('meritContent', record.meritContent);
setFieldValue('amount', record.amount);
setFieldValue('meritTime', toDateTimeInputValue(record.meritTime));
setFieldValue('sortOrder', record.sortOrder);
setFieldValue('status', '0');
contentField = query('[name="meritContent"]');
if (root.AppRichEditor && root.AppRichEditor.init && contentField) {
contentEditor = root.AppRichEditor.init(contentField);
if (contentEditor && contentEditor.editor && contentEditor.editor.setHtml) {
contentEditor.editor.setHtml(record.meritContent);
contentEditor.sync();
}
}
}
async function loadMeritEditor() {
var api = getApi();
var genealogyId;
var meritId;
var record;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
meritId = getCurrentMeritId();
syncLinks();
setEditorEnabled(false, '正在加载功德记录编辑信息…');
try {
if (meritId) {
record = normalizeMeritRecord(await api.meritRecordDetail(genealogyId, meritId));
if (!record) throw new Error('功德记录详情响应缺少稳定 MeritRecordVo 字段');
fillMeritForm(record);
if (query('[data-merit-editor-title]')) query('[data-merit-editor-title]').textContent = '编辑功德记录';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的功德记录。'
: (error.message || '功德记录编辑信息加载失败'));
setFormStatus(error.message || '功德记录编辑信息加载失败');
}
}
async function submitMeritRecord(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var meritId = getCurrentMeritId();
var body;
var validation;
var result;
var saved;
var savedId;
var verifiedDetail;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
body = buildMeritRecordBody(getFormValues(form));
validation = validateMeritRecordBody(body);
if (validation) {
setFormStatus(validation);
return;
}
delete body.invalidAmount;
delete body.invalidAmountPrecision;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存功德记录…');
try {
result = meritId
? await api.updateMeritRecord(genealogyId, meritId, body)
: await api.createMeritRecord(genealogyId, body);
saved = normalizeMeritRecord(result);
savedId = meritId || (saved && saved.meritId);
if (!savedId) throw new Error('保存响应缺少 meritId');
verifiedDetail = await api.meritRecordDetail(genealogyId, savedId);
if (!matchesSavedMerit(verifiedDetail, savedId)) throw new Error('保存后重读未返回同一条功德记录');
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该功德记录。' : (error.message || '功德记录保存失败'));
} finally {
setWritePending(false);
}
}
async function deleteMeritRecord(meritId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var record = recordsById[meritId];
if (writePending || !genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (record ? record.meritTitle : '该功德记录') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
await api.deleteMeritRecord(genealogyId, meritId);
await loadMeritRecords();
showMessage('功德记录已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该功德记录。' : (error.message || '功德记录删除失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-merit-detail-id]');
var deleteButton = event.target.closest('[data-merit-delete-id]');
if (detailButton) {
event.preventDefault();
loadMeritDetail(detailButton.getAttribute('data-merit-detail-id'));
return;
}
if (deleteButton) {
event.preventDefault();
deleteMeritRecord(deleteButton.getAttribute('data-merit-delete-id'));
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-merit-form]');
if (!form) return;
event.preventDefault();
submitMeritRecord(form);
});
}
function init() {
if (!documentRef) return;
bindActions();
if (query('[data-merit-page]')) loadMeritRecords();
if (query('[data-merit-edit-page]')) loadMeritEditor();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentMeritId: getCurrentMeritId,
buildMeritRecordBody: buildMeritRecordBody,
validateMeritRecordBody: validateMeritRecordBody,
normalizeMeritRecord: normalizeMeritRecord,
matchesSavedMerit: matchesSavedMerit,
isEditableMerit: isEditableMerit,
renderMeritDetail: renderMeritDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+287 -45
View File
@@ -14,118 +14,360 @@
'use strict';
var documentRef = root.document;
var notificationsById = Object.create(null);
var selectedNotificationId = '';
var writePending = false;
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function normalizeNotifications(data) {
return Array.isArray(data) ? data : [];
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function formatNotification(data) {
return JSON.stringify(data || {}, null, 2);
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function normalizeNullableId(value) {
var id;
if (value === undefined || value === null || value === '') return null;
id = normalizeId(value);
return id || undefined;
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function buildNotificationQuery(readStatus) {
return readStatus === '0' || readStatus === '1' ? { readStatus: readStatus } : {};
}
function normalizeNotification(item) {
var notificationId = normalizeId(item && item.notificationId);
var genealogyId = normalizeNullableId(item && item.genealogyId);
var senderUserId = normalizeNullableId(item && item.senderUserId);
var bizId = normalizeNullableId(item && item.bizId);
var readStatus = stringValue(item && item.readStatus);
var status = stringValue(item && item.status);
if (!notificationId || genealogyId === undefined || senderUserId === undefined || bizId === undefined) return null;
if ((readStatus !== '0' && readStatus !== '1') || (status !== '0' && status !== '1')) return null;
return {
notificationId: notificationId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
senderUserId: senderUserId,
senderNickName: stringValue(item.senderNickName),
senderPhone: stringValue(item.senderPhone),
noticeType: stringValue(item.noticeType),
noticeTitle: stringValue(item.noticeTitle),
noticeContent: stringValue(item.noticeContent),
bizType: stringValue(item.bizType),
bizId: bizId,
bizSummary: stringValue(item.bizSummary),
publishTime: stringValue(item.publishTime),
readStatus: readStatus,
readTime: item.readTime === undefined || item.readTime === null ? null : String(item.readTime),
status: status,
remark: stringValue(item.remark)
};
}
function normalizeNotifications(data) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizeNotification);
return normalized.some(function (item) { return !item; }) ? [] : normalized;
}
function getUnreadCount(data) {
var count = Number(data);
return Number.isFinite(count) && count >= 0 ? count : 0;
return Number.isSafeInteger(count) && count >= 0 ? count : 0;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
function matchesNotification(item, expectedNotificationId) {
var notification = normalizeNotification(item);
return Boolean(notification && notification.notificationId === normalizeId(expectedNotificationId));
}
function canMarkRead(notification) {
return Boolean(notification && notification.status === '0' && notification.readStatus === '0');
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderNotificationRow(notification) {
var statusText = notification.readStatus === '0' ? '未读' : '已读';
var action = canMarkRead(notification)
? '<button class="pill" type="button" data-notification-read-id="' + escapeHtml(notification.notificationId) + '">标记已读</button>'
: '';
return '<article class="module-row notification-row' + (notification.readStatus === '0' ? ' is-unread' : '') + '">' +
'<div><h3>' + escapeHtml(notification.noticeTitle || '通知') + '</h3><p>' +
escapeHtml(notification.noticeContent || '暂无通知内容') + '</p><p>' +
escapeHtml([notification.publishTime, statusText].filter(Boolean).join(' · ')) +
'</p></div><div class="row-actions"><button class="pill" type="button" data-notification-detail-id="' +
escapeHtml(notification.notificationId) + '">查看详情</button>' + action + '</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderNotificationDetail(notification) {
var action;
if (!notification) return '<div class="api-empty">请选择一条通知查看详情</div>';
action = canMarkRead(notification)
? '<div class="bottom-actions"><button class="btn ghost" type="button" data-notification-read-id="' +
escapeHtml(notification.notificationId) + '">标记为已读</button></div>'
: '';
return '<div class="form-like notification-detail">' +
detailValue('通知标题', notification.noticeTitle) +
detailValue('通知内容', notification.noticeContent) +
detailValue('通知类型', notification.noticeType) +
detailValue('家谱', notification.genealogyName) +
detailValue('发送人', notification.senderNickName) +
detailValue('业务类型', notification.bizType) +
detailValue('业务摘要', notification.bizSummary) +
detailValue('发布时间', notification.publishTime) +
detailValue('阅读状态', notification.readStatus === '0' ? '未读' : '已读') +
detailValue('阅读时间', notification.readTime) +
detailValue('备注', notification.remark) +
'</div>' + action;
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
if (root.alert) root.alert(message);
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function renderNotifications(container, notifications) {
if (!container) return;
container.innerHTML = '';
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
if (!notifications.length) {
container.textContent = '暂无通知';
return;
}
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
notifications.forEach(function (notification) {
var row = documentRef.createElement('pre');
row.className = 'notification-raw';
row.textContent = formatNotification(notification);
container.appendChild(row);
});
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function updateStatus(message) {
var status = query('[data-notification-status]');
if (status) status.textContent = message;
if (status) status.textContent = message || '';
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-notification-read-id], [data-notification-read-all], [data-notification-refresh], [data-notification-filter]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderNotifications(data) {
var container = query('[data-notification-list]');
var normalized = Array.isArray(data) ? data.map(normalizeNotification) : [];
var invalidCount = normalized.filter(function (item) { return !item; }).length;
var notifications = normalized.filter(Boolean);
notificationsById = Object.create(null);
notifications.forEach(function (notification) {
notificationsById[notification.notificationId] = notification;
});
if (!container) return notifications;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">通知列表响应缺少稳定 NotificationView 字段,请联系后端核对。</div>';
return [];
}
if (!notifications.length) {
container.innerHTML = '<div class="api-empty">暂无通知</div>';
return [];
}
container.innerHTML = notifications.map(renderNotificationRow).join('');
return notifications;
}
function renderDetail(notification) {
var container = query('[data-notification-detail]');
if (container) container.innerHTML = renderNotificationDetail(notification);
}
async function loadNotificationDetail(notificationId) {
var api = getApi();
var notification;
if (!normalizeId(notificationId) || redirectUnauthorized(api)) return null;
try {
notification = normalizeNotification(await api.notificationDetail(notificationId));
if (!notification || !matchesNotification(notification, notificationId)) {
throw new Error('通知详情未返回同一条稳定 NotificationView');
}
selectedNotificationId = notification.notificationId;
notificationsById[notification.notificationId] = notification;
renderDetail(notification);
return notification;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
updateStatus(error.message || '通知详情加载失败');
return null;
}
}
function currentFilter() {
var filter = query('[data-notification-filter]');
return filter ? filter.value : '';
}
async function loadNotifications() {
var api = getApi();
var container = query('[data-notification-list]');
var countTarget = query('[data-notification-unread-count]');
var result;
var notifications;
var nextSelectedId;
if (!api || !api.notifications || !api.unreadNotificationCount) {
if (redirectUnauthorized(api)) return;
if (!api.notifications || !api.notificationDetail || !api.unreadNotificationCount) {
updateStatus('消息接口初始化失败,请刷新后重试');
return;
}
updateStatus('正在同步消息…');
try {
result = await Promise.all([api.notifications(), api.unreadNotificationCount()]);
renderNotifications(container, normalizeNotifications(result[0]));
result = await Promise.all([
api.notifications(buildNotificationQuery(currentFilter())),
api.unreadNotificationCount()
]);
notifications = renderNotifications(result[0]);
if (countTarget) countTarget.textContent = String(getUnreadCount(result[1]));
updateStatus('已同步消息。列表元素 DTO 尚未在 PC 契约展开,以下保留服务端原始记录。');
nextSelectedId = selectedNotificationId && notificationsById[selectedNotificationId]
? selectedNotificationId
: (notifications[0] && notifications[0].notificationId);
if (nextSelectedId) await loadNotificationDetail(nextSelectedId);
else renderDetail(null);
updateStatus('消息已同步');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
renderNotifications(null);
renderDetail(null);
updateStatus(error.message || '消息同步失败');
}
}
async function markAllRead(button) {
async function markNotificationRead(notificationId) {
var api = getApi();
var notification = notificationsById[notificationId];
if (writePending || !normalizeId(notificationId) || !canMarkRead(notification) || redirectUnauthorized(api)) return;
setWritePending(true);
try {
await api.markNotificationRead(notificationId);
selectedNotificationId = notificationId;
showMessage('通知已标记为已读');
await loadNotifications();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
updateStatus(error.message || '标记通知已读失败');
} finally {
setWritePending(false);
}
}
async function markAllRead() {
var api = getApi();
if (!api || !api.markAllNotificationsRead) {
if (writePending || redirectUnauthorized(api)) return;
if (!api.markAllNotificationsRead) {
updateStatus('消息接口初始化失败,请刷新后重试');
return;
}
button.disabled = true;
setWritePending(true);
try {
await api.markAllNotificationsRead();
showMessage('已全部标记为已读');
await loadNotifications();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
updateStatus(error.message || '全部标记已读失败');
} finally {
button.disabled = false;
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-notification-detail-id]');
var readButton = event.target.closest('[data-notification-read-id]');
if (detailButton) {
event.preventDefault();
loadNotificationDetail(detailButton.getAttribute('data-notification-detail-id'));
return;
}
if (readButton) {
event.preventDefault();
markNotificationRead(readButton.getAttribute('data-notification-read-id'));
}
});
if (query('[data-notification-read-all]')) {
query('[data-notification-read-all]').addEventListener('click', markAllRead);
}
if (query('[data-notification-refresh]')) {
query('[data-notification-refresh]').addEventListener('click', loadNotifications);
}
if (query('[data-notification-filter]')) {
query('[data-notification-filter]').addEventListener('change', function () {
selectedNotificationId = '';
loadNotifications();
});
}
}
function init() {
var readAll = query('[data-notification-read-all]');
var refresh = query('[data-notification-refresh]');
if (!documentRef || !query('[data-notification-list]')) return;
if (readAll) readAll.addEventListener('click', function () { markAllRead(readAll); });
if (refresh) refresh.addEventListener('click', loadNotifications);
bindActions();
loadNotifications();
}
return {
buildNotificationQuery: buildNotificationQuery,
normalizeNotification: normalizeNotification,
normalizeNotifications: normalizeNotifications,
formatNotification: formatNotification,
getUnreadCount: getUnreadCount,
matchesNotification: matchesNotification,
canMarkRead: canMarkRead,
renderNotificationRow: renderNotificationRow,
renderNotificationDetail: renderNotificationDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
loadNotifications: loadNotifications,
init: init
};
+9 -1
View File
@@ -121,11 +121,17 @@
});
}
function normalizeGenealogyId(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return /^[1-9][0-9]*$/.test(text) ? text : '';
}
function getGenealogyId(search) {
var source = search === undefined ? (window.location && window.location.search) : search;
var params = new URLSearchParams(String(source || '').replace(/^\?/, ''));
return params.get('genealogyId') || '';
return normalizeGenealogyId(params.get('genealogyId'));
}
function withGenealogyId(href, genealogyId) {
@@ -135,6 +141,7 @@
var parts;
var params;
genealogyId = normalizeGenealogyId(genealogyId);
if (!genealogyId || !value || value.charAt(0) === '#' || /^(?:https?:|mailto:|tel:)/i.test(value)) return value;
hashIndex = value.indexOf('#');
if (hashIndex >= 0) {
@@ -278,6 +285,7 @@
closeLogout: closeLegacyLogout,
initLayui: initLayui,
getGenealogyId: getGenealogyId,
normalizeGenealogyId: normalizeGenealogyId,
withGenealogyId: withGenealogyId,
getGenealogyEntryUrl: getGenealogyEntryUrl,
syncGenealogyContextLinks: syncGenealogyContextLinks
+572 -82
View File
@@ -3,34 +3,67 @@
module.exports = factory(root);
return;
}
root.RelativePages = factory(root);
if (root.document) root.document.addEventListener('DOMContentLoaded', function () { root.RelativePages.init(); });
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.RelativePages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var recordsById = Object.create(null);
var writePending = false;
function getApi() { return root.GenealogyApi && root.GenealogyApi.defaultClient; }
function query(selector, node) { return documentRef ? (node || documentRef).querySelector(selector) : null; }
function queryAll(selector, node) { return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : []; }
function trim(value) { var text = String(value === undefined || value === null ? '' : value).trim(); return text || undefined; }
function queryParam(search, name) { return new URLSearchParams(String(search || '').replace(/^\?/, '')).get(name) || ''; }
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function queryParam(search, name) {
return new URLSearchParams(String(search || '').replace(/^\?/, '')).get(name) || '';
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function getCurrentGenealogyId(search) {
var page = query('[data-relative-page], [data-relative-edit-page]');
var source = search === undefined && root.location ? root.location.search : search;
var profileGenealogyId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
profileGenealogyId = root.ProfileUI.getGenealogyId();
if (profileGenealogyId) return profileGenealogyId;
if (profileGenealogyId) return normalizeId(profileGenealogyId);
}
return queryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
return normalizeId(queryParam(source, 'genealogyId'));
}
function toSafeNumber(value) {
var text = trim(value);
function getCurrentRelativeId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(queryParam(source, 'relativeId'));
}
function toFiniteNumber(value) {
var text = trimOrUndefined(value);
var number;
if (!text) return undefined;
@@ -39,130 +72,587 @@
}
function toSafeInteger(value) {
var number = toSafeNumber(value);
var number = toFiniteNumber(value);
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
return Number.isSafeInteger(number) ? number : undefined;
}
function decimalKey(value) {
var text = String(value).trim().replace(/^([+-]?)\./, '$10.');
var match = text.match(/^([+-]?)(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/);
var coefficient;
var scale;
if (!match) return '';
coefficient = BigInt((match[2] + (match[3] || '')).replace(/^0+(?=\d)/, ''));
scale = (match[3] || '').length - Number(match[4] || 0);
if (coefficient === 0n) return '0:0';
if (scale < 0) {
coefficient *= 10n ** BigInt(-scale);
scale = 0;
}
while (scale > 0 && coefficient % 10n === 0n) {
coefficient /= 10n;
scale -= 1;
}
if (match[1] === '-') coefficient = -coefficient;
return String(coefficient) + ':' + String(scale);
}
function isExactJsonNumber(sourceText, number) {
return Number.isFinite(number) && decimalKey(sourceText) === decimalKey(JSON.stringify(number));
}
function toBackendDateTime(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function toDateTimeInputValue(value) {
var text = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) return '';
return text.slice(0, 16).replace(' ', 'T');
}
function buildRelativeRecordBody(values) {
var source = values || {};
var body = { relativeName: String(source.relativeName || '').trim() };
var fields = ['relationName', 'eventName', 'eventTime', 'recordContent', 'mediaOssIds', 'status'];
var amountText = trim(source.giftAmount);
var sortOrderText = trim(source.sortOrder);
var relationName = trimOrUndefined(source.relationName);
var eventName = trimOrUndefined(source.eventName);
var eventTime = toBackendDateTime(source.eventTime);
var amountText = trimOrUndefined(source.giftAmount);
var recordContent = trimOrUndefined(source.recordContent);
var mediaOssIds = trimOrUndefined(source.mediaOssIds);
var sortOrderText = trimOrUndefined(source.sortOrder);
var status = trimOrUndefined(source.status);
fields.forEach(function (field) {
var value = trim(source[field]);
if (value !== undefined) body[field] = value;
});
if (amountText !== undefined) body.giftAmount = toSafeNumber(amountText);
if (sortOrderText !== undefined) body.sortOrder = toSafeInteger(sortOrderText);
if (relationName !== undefined) body.relationName = relationName;
if (eventName !== undefined) body.eventName = eventName;
if (eventTime !== undefined) body.eventTime = eventTime;
if (amountText !== undefined) {
body.giftAmount = toFiniteNumber(amountText);
if (body.giftAmount === undefined) body.invalidGiftAmount = true;
else if (!isExactJsonNumber(amountText, body.giftAmount)) body.invalidGiftPrecision = true;
}
if (recordContent !== undefined) body.recordContent = recordContent;
if (mediaOssIds !== undefined) body.mediaOssIds = mediaOssIds;
if (sortOrderText !== undefined) {
body.sortOrder = toSafeInteger(sortOrderText);
if (body.sortOrder === undefined) body.invalidSortOrder = true;
}
if (status !== undefined) body.status = status;
return body;
}
function validateRelativeRecordBody(body) {
if (!body || !body.relativeName) return '请填写亲友姓名';
if (body.mediaOssIds !== undefined && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(body.mediaOssIds)) {
return '附件 OSS ID 请使用英文逗号分隔的正整数';
if (body.invalidGiftAmount || (Object.prototype.hasOwnProperty.call(body, 'giftAmount') && !Number.isFinite(body.giftAmount))) {
return '礼金金额必须是有限数字';
}
if (Object.prototype.hasOwnProperty.call(body, 'giftAmount') && body.giftAmount === undefined) return '礼金金额必须是数字';
if (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && body.sortOrder === undefined) return '排序值必须是安全整数';
if (body.invalidGiftPrecision) return '礼金金额超出浏览器可安全提交的精度';
if (body.eventTime !== undefined && !isValidBackendDateTime(body.eventTime)) {
return '事件时间格式无效';
}
if (body.mediaOssIds !== undefined && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(body.mediaOssIds)) {
return '附件上传结果无效';
}
if (body.invalidSortOrder || (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && !Number.isSafeInteger(body.sortOrder))) {
return '排序值必须是安全整数';
}
if (body.status === '1') return '当前 PC 无法重新读取停用记录,暂不开放停用';
if (body.status !== undefined && body.status !== '0') return '记录状态只能是 0';
return '';
}
function normalizeRelativeList(data) { return Array.isArray(data) ? data : []; }
function escapeHtml(value) { return String(value === undefined || value === null ? '' : value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function stringify(value) { try { return typeof value === 'string' ? value : JSON.stringify(value); } catch (error) { return String(value); } }
function isValidBackendDateTime(value) {
var match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
var year;
var month;
var day;
var days;
function renderRelativeRecords(data) {
var container = query('[data-relative-list]');
var records = normalizeRelativeList(data);
if (!container) return;
if (!Array.isArray(data)) {
container.innerHTML = '<div class="api-empty">亲友往来响应未按 Apifox ListResult 返回数组,无法安全展示或操作记录。</div>';
return;
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无亲友往来记录</div>';
return;
}
container.innerHTML = records.map(function (record, index) {
return '<article class="module-row"><div><h3>亲友往来 ' + (index + 1) + '</h3><p>' + escapeHtml(stringify(record)) + '</p></div></article>';
}).join('');
if (!match) return false;
year = Number(match[1]);
month = Number(match[2]);
day = Number(match[3]);
if (month < 1 || month > 12 || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false;
days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day >= 1 && day <= days[month - 1];
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function normalizeAmount(value) {
var text;
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '';
text = String(value).trim();
return /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text) ? text : '';
}
function optionalSafeInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function normalizeRelativeRecord(item) {
var relativeId = normalizeId(item && item.relativeId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var relativeName = String(item && item.relativeName || '').trim();
var status = stringValue(item && item.status);
var mediaOssIds = stringValue(item && item.mediaOssIds);
var giftAmount = normalizeAmount(item && item.giftAmount);
var sortOrder = optionalSafeInteger(item && item.sortOrder);
var record;
if (!relativeId || !genealogyId || !relativeName || (status !== '0' && status !== '1')) return null;
if (item.appUserId !== undefined && item.appUserId !== null && item.appUserId !== '' && !appUserId) return null;
if (item.giftAmount !== undefined && item.giftAmount !== null && item.giftAmount !== '' && giftAmount === '') return null;
if (mediaOssIds && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(mediaOssIds)) return null;
if (item.sortOrder !== undefined && item.sortOrder !== null && item.sortOrder !== '' && sortOrder === undefined) return null;
record = {
relativeId: relativeId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
appUserNickName: stringValue(item.appUserNickName),
appUserPhone: stringValue(item.appUserPhone),
relativeName: relativeName,
relationName: stringValue(item.relationName),
eventName: stringValue(item.eventName),
eventTime: stringValue(item.eventTime),
giftAmount: giftAmount,
recordContent: stringValue(item.recordContent),
mediaOssIds: mediaOssIds,
status: status,
remark: stringValue(item.remark)
};
if (appUserId) record.appUserId = appUserId;
if (sortOrder !== undefined) record.sortOrder = sortOrder;
return record;
}
function matchesSavedRecord(item, expectedRelativeId) {
var record = normalizeRelativeRecord(item);
return Boolean(record && record.relativeId === normalizeId(expectedRelativeId));
}
function isEditableRecord(record) {
return Boolean(record && record.status === '0');
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function attachmentCount(mediaOssIds) {
return mediaOssIds ? mediaOssIds.split(',').filter(Boolean).length : 0;
}
function buildListUrl(genealogyId, relativeId) {
var url = 'profile-relative.html?genealogyId=' + encodeURIComponent(genealogyId);
return relativeId ? url + '&relativeId=' + encodeURIComponent(relativeId) : url;
}
function buildEditUrl(genealogyId, relativeId) {
var url = 'profile-relative-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return relativeId ? url + '&relativeId=' + encodeURIComponent(relativeId) : url;
}
function renderRelativeRow(record) {
var summary = [];
if (record.relationName) summary.push(record.relationName);
if (record.eventName) summary.push(record.eventName);
if (record.eventTime) summary.push(record.eventTime);
if (!summary.length) summary.push('未填写关系和事件');
return '<article class="module-row relative-row"><div><h3>' + escapeHtml(record.relativeName) + '</h3><p>' +
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-relative-detail-id="' + escapeHtml(record.relativeId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.relativeId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-relative-delete-id="' + escapeHtml(record.relativeId) + '">删除</button>' +
'</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderRelativeDetail(record) {
var count;
if (!record) return '<div class="api-empty">请选择一条亲友记录查看详情</div>';
count = attachmentCount(record.mediaOssIds);
return '<div class="form-like relative-detail">' +
detailValue('亲友姓名', record.relativeName) +
detailValue('关系名称', record.relationName) +
detailValue('事件名称', record.eventName) +
detailValue('事件时间', record.eventTime) +
detailValue('礼金金额', record.giftAmount) +
detailValue('记录内容', record.recordContent) +
detailValue('附件', count ? count + ' 个附件' : '未上传') +
detailValue('记录人', record.appUserNickName) +
detailValue('状态', record.status === '0' ? '正常' : '停用') +
detailValue('备注', record.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.relativeId)) + '">编辑记录</a>' +
'<button class="btn ghost" type="button" data-relative-delete-id="' + escapeHtml(record.relativeId) + '">删除记录</button></div>';
}
function showMessage(message) {
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function showMessage(message) { if (root.layui && root.layui.layer) root.layui.layer.msg(message); else if (root.alert) root.alert(message); }
function shouldRedirectToLogin(api, error) { var status = error && (error.status || error.code); return !api || !api.getToken || !api.getToken() || Number(status) === 401; }
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (root.location && typeof root.location.replace === 'function') root.location.replace('login.html');
else if (root.location) root.location.href = 'login.html';
if (!root.location) return true;
if (typeof root.location.replace === 'function') root.location.replace('login.html');
else root.location.href = 'login.html';
return true;
}
function setFormStatus(message) { var target = query('[data-relative-form-status]'); if (target) target.textContent = message || ''; }
function setWritePending(value) { writePending = Boolean(value); queryAll('[data-relative-form] button, [data-relative-form] input, [data-relative-form] textarea').forEach(function (control) { control.disabled = writePending; }); }
function syncLinks(genealogyId) {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) { root.ProfileUI.syncGenealogyContextLinks(); return; }
queryAll('[data-genealogy-context-link]').forEach(function (link) { var href = link.getAttribute('href'); if (href) link.href = href.split('?')[0] + '?genealogyId=' + encodeURIComponent(genealogyId); });
function setFormStatus(message) {
var target = query('[data-relative-form-status]');
if (target) target.textContent = message || '';
}
function setEditorEnabled(enabled, message) {
var placeholder = query('[data-relative-editor-placeholder]');
queryAll('[data-relative-editor], [data-relative-editor-action]').forEach(function (element) {
element.hidden = !enabled;
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
}
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-relative-form] button, [data-relative-form] input, [data-relative-form] textarea, [data-relative-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function renderNoContext() {
var list = query('[data-relative-list]');
var detail = query('[data-relative-detail]');
var message = '请先选择家谱,再查看或维护亲友记录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
var list = query('[data-relative-list]');
if (genealogyId) return genealogyId;
if (list) list.innerHTML = '<div class="api-empty">等待家谱入口接口;请从具体家谱进入亲友往来。</div>';
setFormStatus('等待家谱入口接口;请从具体家谱进入亲友往来。');
showMessage('等待家谱入口接口;请从具体家谱进入亲友往来。');
return '';
if (!genealogyId) renderNoContext();
return genealogyId;
}
function renderRelativeRecords(data) {
var container = query('[data-relative-list]');
var normalized = Array.isArray(data) ? data.map(normalizeRelativeRecord) : [];
var invalidCount = normalized.filter(function (record) { return !record; }).length;
var records = normalized.filter(Boolean);
recordsById = Object.create(null);
records.forEach(function (record) {
recordsById[record.relativeId] = record;
});
if (!container) return records;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">亲友记录响应缺少稳定 RelativeRecordVo 字段,请联系后端核对。</div>';
return [];
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无亲友往来记录</div>';
return [];
}
container.innerHTML = records.map(renderRelativeRow).join('');
return records;
}
function renderDetail(record) {
var container = query('[data-relative-detail]');
if (container) container.innerHTML = renderRelativeDetail(record);
}
async function loadRelativeDetail(relativeId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var record;
if (!genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return null;
try {
record = normalizeRelativeRecord(await api.relativeRecordDetail(genealogyId, relativeId));
if (!record) throw new Error('亲友记录详情响应缺少稳定 RelativeRecordVo 字段');
recordsById[record.relativeId] = record;
renderDetail(record);
return record;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
showMessage(isForbidden(error) ? '当前账号无权查看该亲友记录。' : (error.message || '亲友记录详情加载失败'));
return null;
}
}
async function loadRelativeRecords() {
var api = getApi();
var genealogyId;
var records;
var requestedRelativeId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks(genealogyId);
try { renderRelativeRecords(await api.relativeRecords(genealogyId)); }
catch (error) { if (!redirectUnauthorized(api, error)) { renderRelativeRecords(null); showMessage(error.message || '亲友往来加载失败'); } }
syncLinks();
try {
records = renderRelativeRecords(await api.relativeRecords(genealogyId));
requestedRelativeId = getCurrentRelativeId();
if (requestedRelativeId && recordsById[requestedRelativeId]) {
await loadRelativeDetail(requestedRelativeId);
} else if (records.length) {
await loadRelativeDetail(records[0].relativeId);
} else {
renderDetail(null);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-relative-list]')) {
query('[data-relative-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : '亲友记录加载失败,请稍后重试。') +
'</div>';
}
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : (error.message || '亲友记录加载失败'));
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
function setFieldValue(name, value) {
var field = query('[data-relative-form] [name="' + name + '"]');
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function fillRelativeForm(record) {
if (!isEditableRecord(record)) throw new Error('停用亲友记录无法通过当前 PC 接口重新读取或编辑');
setFieldValue('relativeName', record.relativeName);
setFieldValue('relationName', record.relationName);
setFieldValue('eventName', record.eventName);
setFieldValue('eventTime', toDateTimeInputValue(record.eventTime));
setFieldValue('giftAmount', record.giftAmount);
setFieldValue('recordContent', record.recordContent);
setFieldValue('mediaOssIds', record.mediaOssIds);
setFieldValue('sortOrder', record.sortOrder);
setFieldValue('status', '0');
if (query('[data-relative-media-status]')) {
query('[data-relative-media-status]').textContent = record.mediaOssIds
? '已保留 ' + attachmentCount(record.mediaOssIds) + ' 个附件,可继续选择追加'
: '未选择文件';
}
}
async function loadRelativeEditor() {
var api = getApi();
var genealogyId;
var relativeId;
var record;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
relativeId = getCurrentRelativeId();
syncLinks();
setEditorEnabled(false, '正在加载亲友记录编辑信息…');
try {
if (relativeId) {
record = normalizeRelativeRecord(await api.relativeRecordDetail(genealogyId, relativeId));
if (!record) throw new Error('亲友记录详情响应缺少稳定 RelativeRecordVo 字段');
fillRelativeForm(record);
if (query('[data-relative-editor-title]')) query('[data-relative-editor-title]').textContent = '编辑亲友记录';
}
setEditorEnabled(true);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的亲友记录。'
: (error.message || '亲友记录编辑信息加载失败'));
setFormStatus(error.message || '亲友记录编辑信息加载失败');
}
}
async function submitRelativeRecord(form) {
var api = getApi();
var genealogyId;
var values = {};
var genealogyId = requireGenealogyId();
var relativeId = getCurrentRelativeId();
var body;
var validation;
var result;
var saved;
var savedId;
var verifiedDetail;
if (writePending || redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
queryAll('[name]', form).forEach(function (field) { values[field.name] = field.value; });
body = buildRelativeRecordBody(values);
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
body = buildRelativeRecordBody(getFormValues(form));
validation = validateRelativeRecordBody(body);
if (validation) { setFormStatus(validation); return; }
if (validation) {
setFormStatus(validation);
return;
}
delete body.invalidGiftAmount;
delete body.invalidGiftPrecision;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存亲友往来...');
setFormStatus('正在保存亲友记录…');
try {
await api.createRelativeRecord(genealogyId, body);
if (root.location) root.location.href = 'profile-relative.html?genealogyId=' + encodeURIComponent(genealogyId);
result = relativeId
? await api.updateRelativeRecord(genealogyId, relativeId, body)
: await api.createRelativeRecord(genealogyId, body);
saved = normalizeRelativeRecord(result);
savedId = relativeId || (saved && saved.relativeId);
if (!savedId) throw new Error('保存响应缺少 relativeId');
verifiedDetail = await api.relativeRecordDetail(genealogyId, savedId);
if (!matchesSavedRecord(verifiedDetail, savedId)) throw new Error('保存后重读未返回同一条亲友记录');
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (!redirectUnauthorized(api, error)) setFormStatus(error.message || '亲友往来保存失败');
} finally { setWritePending(false); }
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该亲友记录。' : (error.message || '亲友记录保存失败'));
} finally {
setWritePending(false);
}
}
async function deleteRelativeRecord(relativeId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var record = recordsById[relativeId];
if (writePending || !genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (record ? record.relativeName : '该亲友记录') + '”吗?删除后不可恢复,并会释放附件引用。')) return;
setWritePending(true);
try {
await api.deleteRelativeRecord(genealogyId, relativeId);
await loadRelativeRecords();
showMessage('亲友记录已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号无权删除该亲友记录。' : (error.message || '亲友记录删除失败'));
} finally {
setWritePending(false);
}
}
function clearMediaSelection() {
var target = query('[data-relative-form] [name="mediaOssIds"]');
var status = query('[data-relative-media-status]');
var fileInput = query('[data-relative-media-input]');
if (target) target.value = '';
if (fileInput) fileInput.value = '';
if (status) status.textContent = '未选择文件';
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-relative-detail-id]');
var deleteButton = event.target.closest('[data-relative-delete-id]');
var clearMediaButton = event.target.closest('[data-relative-clear-media]');
if (detailButton) {
event.preventDefault();
loadRelativeDetail(detailButton.getAttribute('data-relative-detail-id'));
return;
}
if (deleteButton) {
event.preventDefault();
deleteRelativeRecord(deleteButton.getAttribute('data-relative-delete-id'));
return;
}
if (clearMediaButton) {
event.preventDefault();
clearMediaSelection();
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-relative-form]');
if (!form) return;
event.preventDefault();
submitRelativeRecord(form);
});
}
function init() {
var genealogyId;
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) { var form = event.target.closest('[data-relative-form]'); if (form) { event.preventDefault(); submitRelativeRecord(form); } });
bindActions();
if (query('[data-relative-page]')) loadRelativeRecords();
if (query('[data-relative-edit-page]')) { genealogyId = requireGenealogyId(); if (genealogyId) syncLinks(genealogyId); }
if (query('[data-relative-edit-page]')) loadRelativeEditor();
}
return { getCurrentGenealogyId: getCurrentGenealogyId, buildRelativeRecordBody: buildRelativeRecordBody, validateRelativeRecordBody: validateRelativeRecordBody, normalizeRelativeList: normalizeRelativeList, shouldRedirectToLogin: shouldRedirectToLogin, init: init };
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentRelativeId: getCurrentRelativeId,
buildRelativeRecordBody: buildRelativeRecordBody,
validateRelativeRecordBody: validateRelativeRecordBody,
normalizeRelativeRecord: normalizeRelativeRecord,
matchesSavedRecord: matchesSavedRecord,
isEditableRecord: isEditableRecord,
renderRelativeDetail: renderRelativeDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+616
View File
@@ -0,0 +1,616 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.VideoPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.VideoPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var videosById = Object.create(null);
var canEditContent = false;
var writePending = false;
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
}
function queryAll(selector, rootNode) {
return documentRef ? Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)) : [];
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return params.get(name) || '';
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var fromProfile;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
fromProfile = root.ProfileUI.getGenealogyId();
if (fromProfile) return normalizeId(fromProfile);
}
return normalizeId(getQueryParam(source, 'genealogyId'));
}
function getCurrentVideoId(search) {
var source = search === undefined && root.location ? root.location.search : search;
return normalizeId(getQueryParam(source, 'videoId'));
}
function trimOrUndefined(value) {
var text = String(value === undefined || value === null ? '' : value).trim();
return text || undefined;
}
function toSafeInteger(value) {
var text = trimOrUndefined(value);
var number;
if (text === undefined) return undefined;
number = Number(text);
return Number.isSafeInteger(number) ? number : undefined;
}
function buildVideoBody(values) {
var source = values || {};
var body = {
videoTitle: String(source.videoTitle || '').trim()
};
var videoDesc = trimOrUndefined(source.videoDesc);
var coverOssId = trimOrUndefined(source.coverOssId);
var videoOssId = trimOrUndefined(source.videoOssId);
var durationText = trimOrUndefined(source.durationSeconds);
var sortOrderText = trimOrUndefined(source.sortOrder);
var status = trimOrUndefined(source.status);
if (videoDesc !== undefined) body.videoDesc = videoDesc;
if (coverOssId !== undefined) body.coverOssId = coverOssId;
if (videoOssId !== undefined) body.videoOssId = videoOssId;
if (durationText !== undefined) {
body.durationSeconds = toSafeInteger(durationText);
if (body.durationSeconds === undefined) body.invalidDuration = true;
}
if (sortOrderText !== undefined) {
body.sortOrder = toSafeInteger(sortOrderText);
if (body.sortOrder === undefined) body.invalidSortOrder = true;
}
if (status !== undefined) body.status = status;
return body;
}
function validateVideoBody(body) {
if (!body || !body.videoTitle) return '请填写视频标题';
if (!body.videoOssId) return '请先选择并上传视频文件';
if (!normalizeId(body.videoOssId)) return '视频文件上传结果无效';
if (body.coverOssId !== undefined && !normalizeId(body.coverOssId)) return '封面上传结果无效';
if (body.invalidDuration || (body.durationSeconds !== undefined && !Number.isSafeInteger(body.durationSeconds))) {
return '视频时长必须是安全整数';
}
if (body.durationSeconds !== undefined && body.durationSeconds < 0) return '视频时长不能小于 0';
if (body.invalidSortOrder || (Object.prototype.hasOwnProperty.call(body, 'sortOrder') && !Number.isSafeInteger(body.sortOrder))) {
return '排序值必须是安全整数';
}
if (body.status === '1') return '当前 PC 无法重新读取停用视频,暂不开放停用';
if (body.status !== undefined && body.status !== '0') return '视频状态只能是 0';
return '';
}
function optionalInteger(value, minimum) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
if (!Number.isSafeInteger(number) || (minimum !== undefined && number < minimum)) return undefined;
return number;
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function normalizeVideo(item) {
var videoId = normalizeId(item && item.videoId);
var genealogyId = normalizeId(item && item.genealogyId);
var videoOssId = normalizeId(item && item.videoOssId);
var coverOssId = normalizeId(item && item.coverOssId);
var publisherUserId = normalizeId(item && item.publisherUserId);
var title = String(item && item.videoTitle || '').trim();
var status = stringValue(item && item.status);
var video;
var duration;
var viewCount;
var sortOrder;
if (!videoId || !genealogyId || !videoOssId || !title || (status !== '0' && status !== '1')) return null;
if (item.coverOssId !== undefined && item.coverOssId !== null && item.coverOssId !== '' && !coverOssId) return null;
if (item.publisherUserId !== undefined && item.publisherUserId !== null && item.publisherUserId !== '' && !publisherUserId) return null;
video = {
videoId: videoId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
surname: stringValue(item.surname),
videoTitle: title,
videoDesc: stringValue(item.videoDesc),
videoOssId: videoOssId,
publisherNickName: stringValue(item.publisherNickName),
publisherPhone: stringValue(item.publisherPhone),
publishTime: stringValue(item.publishTime),
status: status,
remark: stringValue(item.remark)
};
if (coverOssId) video.coverOssId = coverOssId;
if (publisherUserId) video.publisherUserId = publisherUserId;
duration = optionalInteger(item.durationSeconds, 0);
viewCount = optionalInteger(item.viewCount, 0);
sortOrder = optionalInteger(item.sortOrder);
if (duration !== undefined) video.durationSeconds = duration;
if (viewCount !== undefined) video.viewCount = viewCount;
if (sortOrder !== undefined) video.sortOrder = sortOrder;
return video;
}
function normalizeDurationSeconds(value) {
var number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.ceil(number) : undefined;
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDuration(seconds) {
var value = optionalInteger(seconds, 0);
var minutes;
var remaining;
if (value === undefined) return '未提供';
minutes = Math.floor(value / 60);
remaining = String(value % 60).padStart(2, '0');
return minutes + ':' + remaining;
}
function buildListUrl(genealogyId, videoId) {
var url = 'profile-video.html?genealogyId=' + encodeURIComponent(genealogyId);
return videoId ? url + '&videoId=' + encodeURIComponent(videoId) : url;
}
function buildEditUrl(genealogyId, videoId) {
var url = 'profile-video-edit.html?genealogyId=' + encodeURIComponent(genealogyId);
return videoId ? url + '&videoId=' + encodeURIComponent(videoId) : url;
}
function renderVideoRow(video, managementAccess) {
var actions = '<button class="pill" type="button" data-video-detail-id="' + escapeHtml(video.videoId) + '">查看详情</button>';
if (managementAccess) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(video.genealogyId, video.videoId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-video-delete-id="' + escapeHtml(video.videoId) + '">删除</button>';
}
return '<article class="module-row video-row"><div><h3>' + escapeHtml(video.videoTitle) + '</h3><p>' +
escapeHtml(video.publisherNickName || '未知发布人') +
(video.publishTime ? ' · ' + escapeHtml(video.publishTime) : '') +
' · 时长 ' + escapeHtml(formatDuration(video.durationSeconds)) +
'</p></div><div class="row-actions">' + actions + '</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderVideoDetail(video, managementAccess) {
var actions = '';
if (!video) return '<div class="api-empty">请选择一个视频查看详情</div>';
if (managementAccess) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(video.genealogyId, video.videoId)) + '">编辑视频</a>' +
'<button class="btn ghost" type="button" data-video-delete-id="' + escapeHtml(video.videoId) + '">删除视频</button></div>';
}
return '<div class="form-like video-detail">' +
detailValue('视频标题', video.videoTitle) +
detailValue('视频说明', video.videoDesc) +
detailValue('所属家谱', video.genealogyName || video.genealogyNo) +
detailValue('发布人', video.publisherNickName) +
detailValue('发布时间', video.publishTime) +
detailValue('视频文件', '已上传') +
detailValue('封面文件', video.coverOssId ? '已上传' : '未上传') +
detailValue('视频时长', formatDuration(video.durationSeconds)) +
detailValue('浏览量', video.viewCount === undefined ? '' : String(video.viewCount)) +
detailValue('状态', video.status === '0' ? '正常' : '停用') +
detailValue('备注', video.remark) +
'</div>' + actions;
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
if (root.alert) root.alert(message);
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') {
root.location.replace('login.html');
return true;
}
root.location.href = 'login.html';
return true;
}
function syncGenealogyLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function renderNoContext() {
var list = query('[data-video-list]');
var detail = query('[data-video-detail]');
var placeholder = query('[data-video-permission-placeholder]');
if (list) list.innerHTML = '<div class="api-empty">请先选择家谱,再查看家族视频。</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
if (placeholder) placeholder.textContent = '请先选择家谱,再发布视频。';
setFormStatus('请先选择家谱,再发布视频。');
setManagementAccess(false);
}
function requireGenealogyId() {
var genealogyId = getCurrentGenealogyId();
if (!genealogyId) renderNoContext();
return genealogyId;
}
function setManagementAccess(enabled) {
var placeholder = query('[data-video-permission-placeholder]');
canEditContent = Boolean(enabled);
queryAll('[data-video-management]').forEach(function (element) {
element.hidden = !canEditContent;
});
if (placeholder) placeholder.hidden = canEditContent;
}
function setFormStatus(message) {
var status = query('[data-video-form-status]');
if (status) status.textContent = message || '';
}
function setWritePending(pending) {
writePending = Boolean(pending);
queryAll('[data-video-form] input, [data-video-form] textarea, [data-video-form] button, [data-video-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderVideos(data) {
var container = query('[data-video-list]');
var normalized = Array.isArray(data) ? data.map(normalizeVideo) : [];
var invalidCount = normalized.filter(function (item) { return !item; }).length;
var videos = normalized.filter(Boolean);
videosById = Object.create(null);
videos.forEach(function (video) {
videosById[video.videoId] = video;
});
if (!container) return videos;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">视频响应缺少稳定 VideoVo 字段,请联系后端核对。</div>';
return [];
}
if (!videos.length) {
container.innerHTML = '<div class="api-empty">暂无家族视频</div>';
return [];
}
container.innerHTML = videos.map(function (video) {
return renderVideoRow(video, canEditContent);
}).join('');
return videos;
}
function renderDetail(video) {
var container = query('[data-video-detail]');
if (container) container.innerHTML = renderVideoDetail(video, canEditContent);
}
async function loadVideoDetail(videoId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var video;
if (!genealogyId || !normalizeId(videoId) || redirectUnauthorized(api)) return null;
try {
video = normalizeVideo(await api.videoDetail(genealogyId, videoId));
if (!video) throw new Error('视频详情响应缺少稳定 VideoVo 字段');
videosById[video.videoId] = video;
renderDetail(video);
return video;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
showMessage(error.message || '视频详情加载失败');
return null;
}
}
async function loadVideos() {
var api = getApi();
var genealogyId;
var result;
var videos;
var requestedVideoId;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks();
try {
result = await Promise.all([api.genealogyDetail(genealogyId), api.videos(genealogyId)]);
setManagementAccess(Boolean(result[0] && result[0].canEditContent === true));
videos = renderVideos(result[1]);
requestedVideoId = getCurrentVideoId();
if (requestedVideoId && videosById[requestedVideoId]) {
await loadVideoDetail(requestedVideoId);
} else if (videos.length) {
await loadVideoDetail(videos[0].videoId);
} else {
renderDetail(null);
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (isForbidden(error)) {
setManagementAccess(false);
if (query('[data-video-list]')) query('[data-video-list]').innerHTML = '<div class="api-empty">当前账号无权查看该家谱视频。</div>';
return;
}
if (query('[data-video-list]')) query('[data-video-list]').innerHTML = '<div class="api-empty">视频加载失败,请稍后重试。</div>';
showMessage(error.message || '视频加载失败');
}
}
function getFormValues(form) {
var values = {};
queryAll('[name]', form).forEach(function (field) {
values[field.name] = field.value;
});
return values;
}
function setFieldValue(name, value) {
var field = query('[data-video-form] [name="' + name + '"]');
if (field) field.value = value === undefined || value === null ? '' : String(value);
}
function fillVideoForm(video) {
setFieldValue('videoTitle', video.videoTitle);
setFieldValue('videoDesc', video.videoDesc);
setFieldValue('coverOssId', video.coverOssId);
setFieldValue('videoOssId', video.videoOssId);
setFieldValue('durationSeconds', video.durationSeconds);
setFieldValue('sortOrder', video.sortOrder);
setFieldValue('status', '0');
if (query('[data-video-file-status]')) query('[data-video-file-status]').textContent = '已保留原视频文件,可重新选择替换';
if (query('[data-video-cover-status]')) query('[data-video-cover-status]').textContent = video.coverOssId ? '已保留原封面,可重新选择替换' : '未选择封面';
}
async function loadVideoEditor() {
var api = getApi();
var genealogyId;
var videoId;
var result;
var video;
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
videoId = getCurrentVideoId();
syncGenealogyLinks();
try {
result = await Promise.all([
api.genealogyDetail(genealogyId),
videoId ? api.videoDetail(genealogyId, videoId) : Promise.resolve(null)
]);
setManagementAccess(Boolean(result[0] && result[0].canEditContent === true));
if (!canEditContent) {
if (query('[data-video-permission-placeholder]')) {
query('[data-video-permission-placeholder]').textContent = '当前账号没有视频维护权限。';
}
setFormStatus('当前账号没有视频维护权限。');
return;
}
if (videoId) {
video = normalizeVideo(result[1]);
if (!video) throw new Error('视频详情响应缺少稳定 VideoVo 字段');
fillVideoForm(video);
if (query('[data-video-editor-title]')) query('[data-video-editor-title]').textContent = '编辑视频';
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setManagementAccess(false);
if (query('[data-video-permission-placeholder]')) {
query('[data-video-permission-placeholder]').textContent = isForbidden(error)
? '当前账号没有视频维护权限。'
: (error.message || '视频编辑信息加载失败');
}
setFormStatus(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败'));
}
}
async function submitVideo(form) {
var api = getApi();
var genealogyId = requireGenealogyId();
var videoId = getCurrentVideoId();
var body = buildVideoBody(getFormValues(form));
var validation = validateVideoBody(body);
var result;
var saved;
var savedId;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
if (validation) {
setFormStatus(validation);
return;
}
delete body.invalidDuration;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存视频…');
try {
result = videoId ? await api.updateVideo(genealogyId, videoId, body) : await api.createVideo(genealogyId, body);
saved = normalizeVideo(result);
savedId = videoId || (saved && saved.videoId);
if (!savedId) throw new Error('保存响应缺少 videoId');
await api.videoDetail(genealogyId, savedId);
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频保存失败'));
} finally {
setWritePending(false);
}
}
async function deleteVideo(videoId) {
var api = getApi();
var genealogyId = requireGenealogyId();
var video = videosById[videoId];
if (writePending || !canEditContent || !genealogyId || !normalizeId(videoId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (video ? video.videoTitle : '该视频') + '”吗?删除会释放视频和封面文件引用。')) return;
setWritePending(true);
try {
await api.deleteVideo(genealogyId, videoId);
await loadVideos();
showMessage('视频已删除');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频删除失败'));
} finally {
setWritePending(false);
}
}
function readVideoDuration(input) {
var file = input && input.files && input.files[0];
var targetSelector = input && input.getAttribute('data-video-duration-target');
var target = targetSelector ? query(targetSelector) : null;
var media;
var objectUrl;
if (!file || !target || !documentRef || !root.URL || !root.URL.createObjectURL) return;
media = documentRef.createElement('video');
objectUrl = root.URL.createObjectURL(file);
media.preload = 'metadata';
media.onloadedmetadata = function () {
var seconds = normalizeDurationSeconds(media.duration);
target.value = seconds === undefined ? '' : String(seconds);
root.URL.revokeObjectURL(objectUrl);
};
media.onerror = function () {
target.value = '';
root.URL.revokeObjectURL(objectUrl);
};
media.src = objectUrl;
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-video-detail-id]');
var deleteButton = event.target.closest('[data-video-delete-id]');
if (detailButton) {
event.preventDefault();
loadVideoDetail(detailButton.getAttribute('data-video-detail-id'));
return;
}
if (deleteButton) {
event.preventDefault();
deleteVideo(deleteButton.getAttribute('data-video-delete-id'));
}
});
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-video-form]');
if (!form) return;
event.preventDefault();
submitVideo(form);
});
documentRef.addEventListener('change', function (event) {
var input = event.target.closest('[data-video-duration-target]');
if (input && input.type === 'file') readVideoDuration(input);
});
}
function init() {
if (!documentRef) return;
bindActions();
if (query('[data-video-page]')) loadVideos();
if (query('[data-video-edit-page]')) loadVideoEditor();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
getCurrentVideoId: getCurrentVideoId,
buildVideoBody: buildVideoBody,
validateVideoBody: validateVideoBody,
normalizeVideo: normalizeVideo,
normalizeDurationSeconds: normalizeDurationSeconds,
renderVideoRow: renderVideoRow,
renderVideoDetail: renderVideoDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+195
View File
@@ -0,0 +1,195 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const modulePath = path.join(__dirname, '..', 'public', 'js', 'album-pages.js');
const AlbumPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof AlbumPages[name], 'function', `缺少 AlbumPages.${name}`);
return AlbumPages[name];
}
test('相册页面只从 URL 读取安全的家谱、相册和照片编号', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentAlbumId = requireFunction('getCurrentAlbumId');
assert.equal(getCurrentGenealogyId('?genealogyId=2060000000000000001'), '2060000000000000001');
assert.equal(getCurrentAlbumId('?albumId=2060000000000000002'), '2060000000000000002');
assert.equal(getCurrentAlbumId('?albumId=unsafe'), '');
});
test('相册写入只构造 AlbumBody 并固定正常状态', () => {
const buildAlbumBody = requireFunction('buildAlbumBody');
assert.deepEqual(buildAlbumBody({
albumName: ' 祠堂旧影 ',
albumDesc: ' 历史照片 ',
coverOssId: '2060000000000000003',
sortOrder: '2',
status: '1',
albumId: 'must-drop',
photoCount: 99
}), {
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
sortOrder: 2,
status: '0'
});
});
test('照片写入只构造 AlbumPhotoBody 并转换拍摄时间', () => {
const buildAlbumPhotoBody = requireFunction('buildAlbumPhotoBody');
assert.deepEqual(buildAlbumPhotoBody({
ossId: '2060000000000000004',
photoTitle: ' 合影 ',
photoDesc: ' 清明祭祖 ',
photographer: ' 宗亲 ',
shootTime: '2026-07-29T10:30',
sortOrder: '3',
status: '1',
albumId: 'must-drop'
}), {
ossId: '2060000000000000004',
photoTitle: '合影',
photoDesc: '清明祭祖',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 3,
status: '0'
});
});
test('相册和照片校验上传派生 ID、日期、排序和停用状态', () => {
const buildAlbumBody = requireFunction('buildAlbumBody');
const buildAlbumPhotoBody = requireFunction('buildAlbumPhotoBody');
const validateAlbumBody = requireFunction('validateAlbumBody');
const validateAlbumPhotoBody = requireFunction('validateAlbumPhotoBody');
assert.equal(validateAlbumBody({ albumName: '', status: '0' }), '请填写相册名称');
assert.equal(
validateAlbumBody(buildAlbumBody({ albumName: '相册', coverOssId: Number.MAX_SAFE_INTEGER + 1 })),
'封面文件编号无效,请重新选择文件'
);
assert.equal(validateAlbumBody({ albumName: '相册', status: '1' }), '当前 PC 无法重新读取停用相册,暂不开放停用');
assert.equal(validateAlbumPhotoBody({ ossId: '', status: '0' }), '请选择并上传照片');
assert.equal(
validateAlbumPhotoBody(buildAlbumPhotoBody({ ossId: '1', shootTime: '2026-02-30T10:00' })),
'拍摄时间格式无效'
);
assert.equal(
validateAlbumPhotoBody(buildAlbumPhotoBody({ ossId: '1', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
});
test('相册和照片响应使用完整 PC Vo 并拒绝不安全 ID', () => {
const normalizeAlbum = requireFunction('normalizeAlbum');
const normalizeAlbumPhoto = requireFunction('normalizeAlbumPhoto');
const album = normalizeAlbum({
albumId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyName: '叶氏家谱',
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
photoCount: 2,
sortOrder: 1,
status: '0',
remark: '旧影'
});
const photo = normalizeAlbumPhoto({
photoId: '2060000000000000004',
genealogyId: '2060000000000000002',
albumId: '2060000000000000001',
albumName: '祠堂旧影',
ossId: '2060000000000000005',
photoTitle: '合影',
photoDesc: '清明祭祖',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 1,
status: '0',
remark: ''
});
assert.equal(album.albumId, '2060000000000000001');
assert.equal(album.photoCount, 2);
assert.equal(photo.photoId, '2060000000000000004');
assert.equal(photo.ossId, '2060000000000000005');
assert.equal(normalizeAlbum({
albumId: Number('2060000000000000001'),
genealogyId: '2',
albumName: '相册',
status: '0'
}), null);
});
test('相册详情通过列表中的稳定 albumId 重读并匹配', () => {
const findAlbumById = requireFunction('findAlbumById');
const albums = [{
albumId: '1',
genealogyId: '2',
albumName: '旧影',
photoCount: 0,
status: '0'
}];
assert.equal(findAlbumById(albums, '1').albumName, '旧影');
assert.equal(findAlbumById(albums, '9'), null);
assert.equal(findAlbumById({ rows: albums }, '1'), null);
});
test('相册和照片展示转义内容并隐藏 OSS ID 与内部 ID', () => {
const normalizeAlbum = requireFunction('normalizeAlbum');
const normalizeAlbumPhoto = requireFunction('normalizeAlbumPhoto');
const renderAlbumDetail = requireFunction('renderAlbumDetail');
const renderPhotoList = requireFunction('renderPhotoList');
const albumHtml = renderAlbumDetail(normalizeAlbum({
albumId: '1',
genealogyId: '2',
albumName: '<img src=x>旧影',
albumDesc: '<script>alert(1)</script>历史',
coverOssId: '3',
photoCount: 1,
status: '0'
}));
const photoHtml = renderPhotoList([normalizeAlbumPhoto({
photoId: '4',
genealogyId: '2',
albumId: '1',
ossId: '5',
photoTitle: '<img src=x>合影',
photoDesc: '祭祖',
status: '0'
})]);
assert.doesNotMatch(albumHtml + photoHtml, /<script|<img/);
assert.doesNotMatch(albumHtml + photoHtml, />2<|>3<|>4<|>5</);
assert.match(albumHtml + photoHtml, /旧影|历史|合影|祭祖/);
});
test('相册页面拆分列表、编辑和照片详情且不允许手填 OSS ID 或状态', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-album.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-album-edit.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-album-detail.html'), 'utf8');
[listPage, editPage, detailPage].forEach((source) => {
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /public\/js\/album-pages\.js/);
});
assert.match(listPage, /data-album-list/);
assert.match(editPage, /name="albumName"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-album-detail/);
assert.match(detailPage, /data-album-photo-list/);
assert.match(detailPage, /name="ossId" type="hidden"/);
assert.match(detailPage, /name="shootTime" type="datetime-local"/);
assert.match(detailPage, /name="status" type="hidden" value="0"/);
assert.doesNotMatch(editPage + detailPage, /type="text"[^>]+(?:coverOssId|ossId)|name="albumId"|name="photoId"/);
});
+426 -20
View File
@@ -29,8 +29,10 @@ test('API client exposes latest document-defined PC operations', () => {
'completeResumableUpload',
'captchaRequirement', 'captchaChallenge', 'captchaVerify', 'captchaChallengeUrl', 'captchaVerifyUrl',
'regionChildren', 'regionPath', 'regionSearch', 'regionDetail',
'genealogyQuota',
'notifications', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
'genealogiesMine', 'genealogyOptions', 'genealogyDetail', 'genealogyOverview', 'genealogyQuota',
'createGenealogy', 'applyToGenealogy', 'myGenealogyJoinApplies',
'pendingGenealogyJoinApplies', 'auditGenealogyJoinApply', 'cancelGenealogyJoinApply',
'notifications', 'notificationDetail', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
'feedCommentReplies', 'feedCommentRepliesPage', 'deleteFeedComment',
@@ -41,9 +43,16 @@ test('API client exposes latest document-defined PC operations', () => {
'createLineageChild', 'createLineageParent', 'createLineageSibling', 'createLineageSpouse',
'growthRecords', 'createGrowthRecord', 'growthRecordDetail', 'updateGrowthRecord', 'deleteGrowthRecord',
'relativeRecords', 'createRelativeRecord', 'relativeRecordDetail', 'updateRelativeRecord', 'deleteRelativeRecord',
'memos', 'createMemo', 'memoDetail', 'updateMemo', 'deleteMemo', 'deleteMeritRecord',
'deleteArticle', 'deleteAlbum', 'deleteAlbumPhoto', 'deleteVideo', 'deleteCeremony', 'deleteCeremonyGift',
'replaceCeremonyInvitees', 'ceremonyInvitations', 'respondCeremonyInvitation', 'myCeremonyInvitations'
'memos', 'createMemo', 'memoDetail', 'updateMemo', 'deleteMemo',
'meritRecords', 'createMeritRecord', 'meritRecordDetail', 'updateMeritRecord', 'deleteMeritRecord',
'articles', 'articleDetail', 'createArticle', 'updateArticle', 'deleteArticle',
'albums', 'createAlbum', 'updateAlbum', 'albumPhotos', 'createAlbumPhoto', 'deleteAlbum', 'deleteAlbumPhoto',
'videos', 'createVideo', 'videoDetail', 'updateVideo', 'deleteVideo',
'ceremonies', 'ceremonyDetail', 'createCeremony', 'updateCeremony',
'ceremonyGifts', 'createCeremonyGift', 'deleteCeremony', 'deleteCeremonyGift',
'replaceCeremonyInvitees', 'ceremonyInvitations', 'respondCeremonyInvitation', 'myCeremonyInvitations',
'genealogyMembers', 'genealogyMemberOptions', 'updateGenealogyMember',
'removeGenealogyMember', 'leaveGenealogy', 'transferGenealogyOwner'
].sort();
assert.deepEqual(Object.keys(client).sort(), allowed);
@@ -72,19 +81,19 @@ test('password login stores access_token from the real AppLoginVo response', asy
deviceType: 'pc',
userType: 'app_user',
profile: {
userId: '2062179707935264769',
userId: '9007199254740993001',
tenantId: '000000',
userNo: 'U2062179707910225920',
phone: '19181970173',
nickName: '叶子',
userNo: 'U9007199254740993001',
phone: '13800000000',
nickName: '测试用户',
realName: '',
avatar: null,
sex: '2',
birthday: null,
email: '',
registerSource: 'h5',
loginIp: '112.45.165.24',
loginDate: '2026-07-28 11:06:09',
loginIp: '127.0.0.1',
loginDate: '2026-01-01 00:00:00',
status: '0',
clientKey: 'web_pc',
deviceType: 'pc'
@@ -497,6 +506,113 @@ test('genealogy quota uses the current PC path without manufacturing a genealogy
await client.genealogyQuota();
});
test('genealogy context methods obtain IDs from the real PC list and detail paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.genealogiesMine();
await client.genealogyOptions({ keyword: '汤氏' });
await client.genealogyDetail('9007199254740993002');
await client.genealogyOverview('9007199254740993002');
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params]), [
['get', '/genealogy/pc/genealogies/mine', undefined],
['get', '/genealogy/pc/genealogies/options', { keyword: '汤氏' }],
['get', '/genealogy/pc/genealogies/9007199254740993002', undefined],
['get', '/genealogy/pc/genealogies/9007199254740993002/overview', undefined]
]);
});
test('genealogy lifecycle methods use PC paths and strict request bodies', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: {} } });
}
}
});
const genealogyId = '2062179707935264769';
const applyId = '2062179707935264770';
await client.createGenealogy({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
ancestralHall: '汤氏宗祠',
originPlace: '四川',
addressDetail: '沿口镇',
coverOssId: '2062179707935264771',
intro: '家谱简介',
visibility: '1',
joinMode: '1',
legacyField: 'must-drop'
});
await client.applyToGenealogy(genealogyId, {
applicantName: '申请人',
phone: '19100000000',
relationDesc: '族亲',
applyReason: '申请加入',
inviterUserId: 'must-drop'
});
await client.myGenealogyJoinApplies();
await client.pendingGenealogyJoinApplies(genealogyId);
await client.auditGenealogyJoinApply(genealogyId, applyId, {
status: '1',
auditRemark: '资料一致',
legacyField: 'must-drop'
});
await client.cancelGenealogyJoinApply(applyId);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['post', '/genealogy/pc/genealogies', {
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
ancestralHall: '汤氏宗祠',
originPlace: '四川',
addressDetail: '沿口镇',
coverOssId: '2062179707935264771',
intro: '家谱简介',
visibility: '1',
joinMode: '1'
}],
['post', '/genealogy/pc/genealogies/2062179707935264769/join-applies', {
applicantName: '申请人',
phone: '19100000000',
relationDesc: '族亲',
applyReason: '申请加入'
}],
['get', '/genealogy/pc/genealogies/join-applies/mine', undefined],
['get', '/genealogy/pc/genealogies/2062179707935264769/join-applies/pending', undefined],
['put', '/genealogy/pc/genealogies/2062179707935264769/join-applies/2062179707935264770/audit', {
status: '1',
auditRemark: '资料一致'
}],
['delete', '/genealogy/pc/genealogies/join-applies/2062179707935264770', undefined]
]);
assert.throws(
() => client.pendingGenealogyJoinApplies(Number.MAX_SAFE_INTEGER + 1),
/家谱编号/
);
assert.throws(
() => client.cancelGenealogyJoinApply(Number.MAX_SAFE_INTEGER + 1),
/申请编号/
);
});
test('notification methods use the current PC list, unread and read paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -510,13 +626,15 @@ test('notification methods use the current PC list, unread and read paths', asyn
}
});
await client.notifications({ readStatus: '0' });
await client.notifications({ readStatus: '0', pageNum: 99 });
await client.notificationDetail('2060000000000000001');
await client.unreadNotificationCount();
await client.markNotificationRead('2060000000000000001');
await client.markAllNotificationsRead();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
['get', '/genealogy/pc/notifications', { readStatus: '0' }, undefined],
['get', '/genealogy/pc/notifications/2060000000000000001', undefined, undefined],
['get', '/genealogy/pc/notifications/unread-count', undefined, undefined],
['post', '/genealogy/pc/notifications/2060000000000000001/read', undefined, undefined],
['post', '/genealogy/pc/notifications/read-all', undefined, undefined]
@@ -527,6 +645,24 @@ test('notification methods use the current PC list, unread and read paths', asyn
});
});
test('notification methods reject unsafe numeric path IDs before sending a request', async () => {
let requestCount = 0;
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request() {
requestCount += 1;
return Promise.resolve({ data: { code: 200, data: null } });
}
}
});
assert.throws(() => client.notificationDetail(Number.MAX_SAFE_INTEGER + 1));
assert.throws(() => client.markNotificationRead(Number.MAX_SAFE_INTEGER + 1));
assert.equal(requestCount, 0);
});
test('family feed methods use the documented paths and request bodies', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -785,22 +921,54 @@ test('memo methods use the Apifox PC paths and documented request fields', async
]);
});
test('merit record deletion uses the only documented PC merit operation', async () => {
test('merit record methods use the complete PC CRUD paths and MeritRecordBody', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
assert.equal(config.method, 'delete');
assert.equal(config.url, '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002');
assert.equal(config.headers.Authorization, 'Bearer access-token');
assert.equal(config.headers.clientid, 'ced7e5f0498645c6ec642dcf450b036f');
return Promise.resolve({ data: { code: 200, data: null } });
calls.push(config);
return Promise.resolve({ data: { code: 200, data: {} } });
}
}
});
const genealogyId = '2060000000000000001';
const meritId = '2060000000000000002';
const record = {
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与宗祠修缮',
amount: 500.5,
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0'
};
const recordWithExtras = Object.assign({}, record, {
appUserId: 'must-not-send',
mediaOssIds: 'must-not-send'
});
await client.deleteMeritRecord('2060000000000000001', '2060000000000000002');
await client.meritRecords(genealogyId);
await client.createMeritRecord(genealogyId, recordWithExtras);
await client.meritRecordDetail(genealogyId, meritId);
await client.updateMeritRecord(genealogyId, meritId, recordWithExtras);
await client.deleteMeritRecord(genealogyId, meritId);
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.data,
config.headers.Authorization,
config.headers.clientid
]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/merit-records', undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['post', '/genealogy/pc/genealogies/2060000000000000001/merit-records', record, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['get', '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002', undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['put', '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002', record, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['delete', '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002', undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f']
]);
});
test('remaining document-defined deletion operations use PC paths only', async () => {
@@ -829,6 +997,244 @@ test('remaining document-defined deletion operations use PC paths only', async (
]);
});
test('article methods use the complete PC CRUD paths and ArticleBody whitelist', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const articleId = '2060000000000000002';
const input = {
categoryId: '2060000000000000003',
articleTitle: '家族源流',
articleSummary: '摘要',
coverOssId: '2060000000000000004',
articleContent: '<p>正文</p>',
authorName: '宗亲',
sortOrder: 1,
status: '0',
appUserId: 'must-drop'
};
const body = {
categoryId: '2060000000000000003',
articleTitle: '家族源流',
articleSummary: '摘要',
coverOssId: '2060000000000000004',
articleContent: '<p>正文</p>',
authorName: '宗亲',
sortOrder: 1,
status: '0'
};
await client.articles(genealogyId);
await client.createArticle(genealogyId, input);
await client.articleDetail(genealogyId, articleId);
await client.updateArticle(genealogyId, articleId, input);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/articles', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/articles', body],
['get', '/genealogy/pc/genealogies/2060000000000000001/articles/2060000000000000002', undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/articles/2060000000000000002', body]
]);
});
test('album methods use the complete PC album and photo contracts', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const albumId = '2060000000000000002';
const albumInput = {
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
sortOrder: 2,
status: '0',
photoCount: 99
};
const albumBody = {
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
sortOrder: 2,
status: '0'
};
const photoInput = {
ossId: '2060000000000000004',
photoTitle: '合影',
photoDesc: '祭祖合影',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 3,
status: '0',
albumId: 'must-drop'
};
const photoBody = {
ossId: '2060000000000000004',
photoTitle: '合影',
photoDesc: '祭祖合影',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 3,
status: '0'
};
await client.albums(genealogyId);
await client.createAlbum(genealogyId, albumInput);
await client.updateAlbum(genealogyId, albumId, albumInput);
await client.albumPhotos(genealogyId, albumId);
await client.createAlbumPhoto(genealogyId, albumId, photoInput);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/albums', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/albums', albumBody],
['put', '/genealogy/pc/genealogies/2060000000000000001/albums/2060000000000000002', albumBody],
['get', '/genealogy/pc/genealogies/2060000000000000001/albums/2060000000000000002/photos', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/albums/2060000000000000002/photos', photoBody]
]);
});
test('ceremony admin methods use PC activity and gift contracts', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const ceremonyId = '2060000000000000002';
const ceremonyInput = {
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '宗亲祭祖',
ceremonyTime: '2026-04-05 09:00:00',
location: '祠堂',
locationAddress: '宗祠路 1 号',
longitude: 104.1,
latitude: 30.6,
coverOssId: '2060000000000000003',
sortOrder: 1,
status: '0',
giftCount: 10
};
const ceremonyBody = {
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '宗亲祭祖',
ceremonyTime: '2026-04-05 09:00:00',
location: '祠堂',
locationAddress: '宗祠路 1 号',
longitude: 104.1,
latitude: 30.6,
coverOssId: '2060000000000000003',
sortOrder: 1,
status: '0'
};
const giftInput = {
giverName: '宗亲',
giftAmount: 88.5,
giftMessage: '敬献',
status: 'must-drop'
};
const giftBody = { giverName: '宗亲', giftAmount: 88.5, giftMessage: '敬献' };
await client.ceremonies(genealogyId);
await client.createCeremony(genealogyId, ceremonyInput);
await client.ceremonyDetail(genealogyId, ceremonyId);
await client.updateCeremony(genealogyId, ceremonyId, ceremonyInput);
await client.ceremonyGifts(genealogyId, ceremonyId);
await client.createCeremonyGift(genealogyId, ceremonyId, giftInput);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/ceremonies', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/ceremonies', ceremonyBody],
['get', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002', undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002', ceremonyBody],
['get', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002/gifts', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002/gifts', giftBody]
]);
});
test('genealogy member methods use PC member paths and body whitelists', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const memberId = '2060000000000000002';
const memberInput = {
memberName: '族员甲',
relationName: '侄',
roleType: 'editor',
lineagePersonId: '2060000000000000003',
appUserId: 'must-drop'
};
const memberBody = {
memberName: '族员甲',
relationName: '侄',
roleType: 'editor',
lineagePersonId: '2060000000000000003'
};
await client.genealogyMembers(genealogyId);
await client.genealogyMemberOptions(genealogyId);
await client.updateGenealogyMember(genealogyId, memberId, memberInput);
await client.removeGenealogyMember(genealogyId, memberId);
await client.leaveGenealogy(genealogyId);
await client.transferGenealogyOwner(genealogyId, { targetMemberId: memberId, roleType: 'must-drop' });
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/members', undefined, undefined],
['get', '/genealogy/pc/genealogies/2060000000000000001/members/options', undefined, undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/members/2060000000000000002', undefined, memberBody],
['delete', '/genealogy/pc/genealogies/2060000000000000001/members/2060000000000000002', undefined, undefined],
['delete', '/genealogy/pc/genealogies/2060000000000000001/members/me', undefined, undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/members/owner-transfer', undefined, {
targetMemberId: memberId
}]
]);
});
test('video methods use the complete PC CRUD paths and VideoBody', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const videoId = '2060000000000000002';
const body = {
videoTitle: '家族活动记录',
videoDesc: '清明祭祖活动视频',
coverOssId: '2060000000000000003',
videoOssId: '2060000000000000004',
durationSeconds: 180,
sortOrder: 1,
status: '0'
};
await client.videos(genealogyId);
await client.createVideo(genealogyId, body);
await client.videoDetail(genealogyId, videoId);
await client.updateVideo(genealogyId, videoId, body);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/videos', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/videos', body],
['get', '/genealogy/pc/genealogies/2060000000000000001/videos/2060000000000000002', undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/videos/2060000000000000002', body]
]);
});
test('ceremony invitation methods use the current PC invitation contracts', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -839,7 +1245,7 @@ test('ceremony invitation methods use the current PC invitation contracts', asyn
const genealogyId = '2060000000000000001';
const ceremonyId = '2060000000000000002';
await client.replaceCeremonyInvitees(genealogyId, ceremonyId, { inviteeUserIds: ['900000001', '900000002'] });
await client.replaceCeremonyInvitees(genealogyId, ceremonyId, ['900000001', '900000002']);
await client.ceremonyInvitations(genealogyId, ceremonyId);
await client.respondCeremonyInvitation(genealogyId, ceremonyId, { inviteStatus: 'ACCEPTED' });
await client.myCeremonyInvitations();
+188
View File
@@ -0,0 +1,188 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const modulePath = path.join(__dirname, '..', 'public', 'js', 'article-pages.js');
const ArticlePages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof ArticlePages[name], 'function', `缺少 ArticlePages.${name}`);
return ArticlePages[name];
}
test('谱文页只从 URL 读取安全的真实家谱和谱文编号', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentArticleId = requireFunction('getCurrentArticleId');
assert.equal(
getCurrentGenealogyId('?genealogyId=2060000000000000001&articleId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
getCurrentArticleId('?genealogyId=2060000000000000001&articleId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(getCurrentGenealogyId('?genealogyId=unsafe'), '');
assert.equal(getCurrentArticleId('?articleId=unsafe'), '');
});
test('谱文写入只构造 ArticleBody 且固定为可重读正常状态', () => {
const buildArticleBody = requireFunction('buildArticleBody');
assert.deepEqual(buildArticleBody({
articleTitle: ' 家族源流 ',
articleSummary: ' 先祖迁徙记录 ',
coverOssId: '2060000000000000003',
articleContent: ' <p>正文</p> ',
authorName: ' 宗亲 ',
sortOrder: '2',
status: '1',
categoryId: '2060000000000000004',
articleId: 'must-not-send'
}), {
articleTitle: '家族源流',
articleSummary: '先祖迁徙记录',
coverOssId: '2060000000000000003',
articleContent: '<p>正文</p>',
authorName: '宗亲',
sortOrder: 2,
status: '0'
});
});
test('谱文校验必填、上传派生 ID、排序和可重读状态', () => {
const buildArticleBody = requireFunction('buildArticleBody');
const validateArticleBody = requireFunction('validateArticleBody');
assert.equal(validateArticleBody({ articleTitle: '', articleContent: '正文', status: '0' }), '请填写谱文标题');
assert.equal(validateArticleBody({ articleTitle: '标题', articleContent: '', status: '0' }), '请填写谱文正文');
assert.equal(
validateArticleBody(buildArticleBody({
articleTitle: '标题',
articleContent: '正文',
coverOssId: Number.MAX_SAFE_INTEGER + 1
})),
'封面文件编号无效,请重新选择文件'
);
assert.equal(
validateArticleBody(buildArticleBody({
articleTitle: '标题',
articleContent: '正文',
sortOrder: '1.5'
})),
'排序值必须是安全整数'
);
assert.equal(validateArticleBody({ articleTitle: '标题', articleContent: '正文', status: '1' }), '当前 PC 无法重新读取停用谱文,暂不开放停用');
assert.equal(validateArticleBody({ articleTitle: '标题', articleContent: '正文', status: '0' }), '');
});
test('谱文响应使用完整 ArticleVo 并拒绝不安全长 ID', () => {
const normalizeArticle = requireFunction('normalizeArticle');
const article = normalizeArticle({
articleId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
categoryId: null,
categoryName: '',
categoryCode: '',
articleTitle: '家族源流',
articleSummary: '先祖迁徙记录',
coverOssId: '2060000000000000003',
articleContent: '<p>正文</p>',
authorName: '宗亲',
publishTime: '2026-07-29 10:00:00',
viewCount: 3,
sortOrder: 1,
status: '0',
remark: '年度谱文'
});
assert.equal(article.articleId, '2060000000000000001');
assert.equal(article.coverOssId, '2060000000000000003');
assert.equal(article.viewCount, 3);
assert.equal(article.articleTitle, '家族源流');
assert.equal(normalizeArticle({
articleId: Number('2060000000000000001'),
genealogyId: '2',
articleTitle: '标题',
articleContent: '正文',
status: '0'
}), null);
});
test('谱文列表只接受直接数组且任一非法元素使整批失败', () => {
const normalizeArticles = requireFunction('normalizeArticles');
const valid = {
articleId: '1',
genealogyId: '2',
articleTitle: '标题',
articleContent: '正文',
status: '0'
};
assert.equal(normalizeArticles([valid]).length, 1);
assert.deepEqual(normalizeArticles({ rows: [valid] }), []);
assert.deepEqual(normalizeArticles([valid, {}]), []);
});
test('谱文详情转义正文并隐藏内部 ID 和 OSS ID', () => {
const normalizeArticle = requireFunction('normalizeArticle');
const renderArticleDetail = requireFunction('renderArticleDetail');
const html = renderArticleDetail(normalizeArticle({
articleId: '1',
genealogyId: '2',
categoryId: '3',
coverOssId: '4',
articleTitle: '<img src=x onerror=alert(1)>家史',
articleSummary: '摘要',
articleContent: '<script>alert(1)</script>正文',
authorName: '宗亲',
publishTime: '2026-07-29 10:00:00',
viewCount: 5,
status: '0'
}));
assert.doesNotMatch(html, /<script|<img/);
assert.match(html, /&lt;img|&lt;script/);
assert.match(html, /家史|正文|宗亲|5/);
assert.doesNotMatch(html, />1<|>2<|>3<|>4</);
});
test('谱文写后重读必须返回同一条稳定记录', () => {
const matchesSavedArticle = requireFunction('matchesSavedArticle');
const detail = {
articleId: '2060000000000000001',
genealogyId: '2060000000000000002',
articleTitle: '标题',
articleContent: '正文',
status: '0'
};
assert.equal(matchesSavedArticle(detail, '2060000000000000001'), true);
assert.equal(matchesSavedArticle(detail, '2060000000000000009'), false);
assert.equal(matchesSavedArticle({}, '2060000000000000001'), false);
});
test('谱文页面开放 PC CRUD 且不提供手填分类、业务 ID 或停用状态', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-article.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-article-edit.html'), 'utf8');
assert.doesNotMatch(listPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(listPage, /data-article-list/);
assert.match(listPage, /data-article-create-link[^>]+hidden|hidden[^>]+data-article-create-link/);
assert.match(listPage, /data-article-detail/);
assert.match(listPage, /public\/js\/article-pages\.js/);
assert.doesNotMatch(editPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(editPage, /name="articleTitle"/);
assert.match(editPage, /name="articleSummary"/);
assert.match(editPage, /name="articleContent"/);
assert.match(editPage, /name="authorName"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /public\/js\/article-pages\.js/);
assert.doesNotMatch(editPage, /name="articleId"|name="categoryId"|type="text"[^>]+coverOssId/);
});
+251
View File
@@ -0,0 +1,251 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const modulePath = path.join(__dirname, '..', 'public', 'js', 'ceremony-admin-pages.js');
const CeremonyAdminPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof CeremonyAdminPages[name], 'function', `缺少 CeremonyAdminPages.${name}`);
return CeremonyAdminPages[name];
}
test('祭祀管理页只从上下文读取安全的家谱和活动 ID', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentCeremonyId = requireFunction('getCurrentCeremonyId');
assert.equal(getCurrentGenealogyId('?genealogyId=2060000000000000001'), '2060000000000000001');
assert.equal(getCurrentCeremonyId('?ceremonyId=2060000000000000002'), '2060000000000000002');
assert.equal(getCurrentCeremonyId('?ceremonyId=unsafe'), '');
});
test('活动写入只构造 CeremonyBody 并固定正常状态', () => {
const buildCeremonyBody = requireFunction('buildCeremonyBody');
assert.deepEqual(buildCeremonyBody({
ceremonyType: ' ancestor ',
ceremonyTitle: ' 清明祭祖 ',
ceremonyDesc: ' 缅怀先祖 ',
ceremonyTime: '2026-04-04T09:00',
location: ' 祠堂 ',
locationAddress: ' 四川省成都市示例路1号 ',
longitude: '104.066541',
latitude: '30.572269',
coverOssId: '2060000000000000003',
sortOrder: '1',
status: '1',
ceremonyId: 'must-drop',
giftAmount: 999
}), {
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '缅怀先祖',
ceremonyTime: '2026-04-04 09:00:00',
location: '祠堂',
locationAddress: '四川省成都市示例路1号',
longitude: 104.066541,
latitude: 30.572269,
coverOssId: '2060000000000000003',
sortOrder: 1,
status: '0'
});
});
test('活动校验必填字段、成对坐标、范围、长度和上传派生 ID', () => {
const buildCeremonyBody = requireFunction('buildCeremonyBody');
const validateCeremonyBody = requireFunction('validateCeremonyBody');
assert.equal(validateCeremonyBody({ ceremonyType: '', ceremonyTitle: '', status: '0' }), '请填写活动类型');
assert.equal(validateCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '', status: '0' }), '请填写活动标题');
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', longitude: '104' })),
'经度和纬度必须同时提供'
);
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', longitude: '181', latitude: '30' })),
'经度范围必须是 -180 到 180'
);
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', locationAddress: '地'.repeat(301) })),
'详细地址不能超过 300 个字符'
);
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', coverOssId: Number.MAX_SAFE_INTEGER + 1 })),
'封面文件编号无效,请重新选择文件'
);
});
test('祭品写入只提交 CeremonyGiftBody 并校验后端非负金额', () => {
const buildCeremonyGiftBody = requireFunction('buildCeremonyGiftBody');
const validateCeremonyGiftBody = requireFunction('validateCeremonyGiftBody');
assert.deepEqual(buildCeremonyGiftBody({
giverName: ' 叶子 ',
giftAmount: '66.66',
giftMessage: ' 缅怀先祖 ',
giverUserId: 'must-drop',
giftTime: 'must-drop'
}), {
giverName: '叶子',
giftAmount: 66.66,
giftMessage: '缅怀先祖'
});
assert.equal(validateCeremonyGiftBody(buildCeremonyGiftBody({ giftAmount: '' })), '请填写礼金金额');
assert.equal(validateCeremonyGiftBody(buildCeremonyGiftBody({ giftAmount: '-0.01' })), '礼金金额不能小于 0');
assert.equal(validateCeremonyGiftBody(buildCeremonyGiftBody({ giftAmount: 'not-number' })), '礼金金额必须是有效数字');
});
test('活动、祭品和邀请响应使用完整 PC VO 并拒绝不安全 ID', () => {
const normalizeCeremony = requireFunction('normalizeCeremony');
const normalizeCeremonyGift = requireFunction('normalizeCeremonyGift');
const normalizeInvitation = requireFunction('normalizeInvitation');
const ceremony = normalizeCeremony({
ceremonyId: '2060000000000000001',
genealogyId: '2060000000000000002',
sponsorUserId: '2060000000000000003',
sponsorNickName: '叶子',
sponsorPhone: '19181970173',
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '缅怀先祖',
ceremonyTime: '2026-04-04 09:00:00',
location: '祠堂',
locationAddress: '成都',
longitude: 104.066541,
latitude: 30.572269,
coverOssId: '2060000000000000004',
giftCount: 2,
giftAmount: 88.88,
sortOrder: 1,
status: '0',
remark: ''
});
const gift = normalizeCeremonyGift({
giftId: '2060000000000000005',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000001',
ceremonyTitle: '清明祭祖',
giverUserId: '2060000000000000006',
giverNickName: '宗亲',
giverPhone: '19100000000',
giverName: '叶先生',
giftAmount: 66.66,
giftMessage: '缅怀先祖',
giftTime: '2026-04-04 10:00:00',
status: '0',
remark: ''
});
const invitation = normalizeInvitation({
invitationId: '2060000000000000007',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000001',
inviteeUserId: '2060000000000000006',
inviteStatus: 'PENDING',
inviteVersion: 1
});
assert.equal(ceremony.ceremonyId, '2060000000000000001');
assert.equal(ceremony.giftCount, 2);
assert.equal(gift.giftId, '2060000000000000005');
assert.equal(invitation.inviteeUserId, '2060000000000000006');
assert.equal(normalizeCeremony({
ceremonyId: Number('2060000000000000001'),
genealogyId: '2',
ceremonyType: 'ancestor',
ceremonyTitle: '祭祖',
status: '0'
}), null);
});
test('管理员邀约名单只使用正常成员中的真实 appUserId 并去重', () => {
const normalizeInviteeOption = requireFunction('normalizeInviteeOption');
const buildInviteesBody = requireFunction('buildInviteesBody');
const normal = normalizeInviteeOption({
memberId: '1',
genealogyId: '2',
appUserId: '2060000000000000001',
appUserNickName: '宗亲',
memberName: '叶先生',
status: '0'
});
assert.equal(normal.appUserId, '2060000000000000001');
assert.equal(normalizeInviteeOption({
memberId: '1',
genealogyId: '2',
appUserId: null,
memberName: '未绑定成员',
status: '0'
}), null);
assert.equal(normalizeInviteeOption({
memberId: '1',
genealogyId: '2',
appUserId: '3',
memberName: '停用成员',
status: '1'
}), null);
assert.deepEqual(buildInviteesBody([
'2060000000000000001',
'2060000000000000001',
'2060000000000000002',
'unsafe'
]), {
inviteeUserIds: ['2060000000000000001', '2060000000000000002']
});
});
test('活动和祭品展示转义内容且不暴露手机号、OSS ID 或内部 ID', () => {
const normalizeCeremony = requireFunction('normalizeCeremony');
const normalizeCeremonyGift = requireFunction('normalizeCeremonyGift');
const renderCeremonyDetail = requireFunction('renderCeremonyDetail');
const renderGiftList = requireFunction('renderGiftList');
const html = renderCeremonyDetail(normalizeCeremony({
ceremonyId: '2060000000000000001',
genealogyId: '2060000000000000002',
sponsorUserId: '2060000000000000003',
sponsorNickName: '<img src=x>叶子',
sponsorPhone: '19181970173',
ceremonyType: 'ancestor',
ceremonyTitle: '<script>alert(1)</script>祭祖',
coverOssId: '2060000000000000004',
giftCount: 1,
giftAmount: 66.66,
status: '0'
})) + renderGiftList([normalizeCeremonyGift({
giftId: '2060000000000000005',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000001',
giverUserId: '2060000000000000006',
giverNickName: '<img src=x>宗亲',
giverPhone: '19100000000',
giverName: '叶先生',
giftAmount: 66.66,
giftMessage: '<script>alert(2)</script>缅怀',
status: '0'
})]);
assert.doesNotMatch(html, /<script|<img/);
assert.doesNotMatch(html, /19181970173|19100000000|206000000000000000[1-6]/);
assert.match(html, /祭祖|缅怀|66\.66/);
});
test('祭祀功能拆分列表、编辑和详情页面且不允许手填 ID', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-ceremony.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-gift-edit.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-ceremony-detail.html'), 'utf8');
[listPage, editPage, detailPage].forEach((source) => {
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /public\/js\/ceremony-admin-pages\.js/);
});
assert.match(listPage, /data-ceremony-list/);
assert.match(editPage, /name="ceremonyType"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-ceremony-gift-list/);
assert.match(detailPage, /data-ceremony-invitee-options/);
assert.match(detailPage, /name="giftAmount"/);
assert.doesNotMatch(editPage + detailPage, /name="(?:genealogyId|ceremonyId|giftId|inviteeUserIds)"/);
});
+138
View File
@@ -0,0 +1,138 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const CeremonyPages = require('../public/js/ceremony-pages.js');
test('我的邀请响应保留完整展示字段并保持业务 ID 为字符串', () => {
assert.deepEqual(CeremonyPages.normalizeInvitation({
invitationId: '2060000000000000001',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000003',
inviteeUserId: '2060000000000000004',
inviteStatus: 'PENDING',
inviteVersion: 2,
deliveredTime: '2026-07-29T09:00:00+08:00',
readTime: null,
responseTime: null,
ceremonyTitle: '家族答谢宴',
ceremonyTime: '2026-08-08T18:00:00+08:00',
location: '锦江厅',
locationAddress: '成都市锦江区示例路 8 号',
longitude: 104.0668,
latitude: 30.5728
}), {
invitationId: '2060000000000000001',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000003',
inviteeUserId: '2060000000000000004',
inviteStatus: 'PENDING',
inviteVersion: 2,
deliveredTime: '2026-07-29T09:00:00+08:00',
readTime: '',
responseTime: '',
ceremonyTitle: '家族答谢宴',
ceremonyTime: '2026-08-08T18:00:00+08:00',
location: '锦江厅',
locationAddress: '成都市锦江区示例路 8 号',
longitude: 104.0668,
latitude: 30.5728
});
});
test('邀请响应拒绝缺少稳定 ID、非法状态和不安全数字长 ID', () => {
assert.equal(CeremonyPages.normalizeInvitation({
invitationId: '1',
genealogyId: '2',
inviteeUserId: '4',
inviteStatus: 'PENDING'
}), null);
assert.equal(CeremonyPages.normalizeInvitation({
invitationId: '1',
genealogyId: '2',
ceremonyId: '3',
inviteeUserId: '4',
inviteStatus: 'UNKNOWN'
}), null);
assert.equal(CeremonyPages.normalizeInvitation({
invitationId: Number('2060000000000000001'),
genealogyId: '2',
ceremonyId: '3',
inviteeUserId: '4',
inviteStatus: 'PENDING'
}), null);
});
test('YAML 未声明必返的 inviteeUserId 缺失时仍可用活动上下文响应', () => {
const invitation = CeremonyPages.normalizeInvitation({
invitationId: '1',
genealogyId: '2',
ceremonyId: '3',
inviteStatus: 'PENDING'
});
assert.notEqual(invitation, null);
assert.equal(invitation.inviteeUserId, undefined);
assert.equal(CeremonyPages.canRespondToInvitation(invitation), true);
});
test('响应邀请只允许提交 YAML 定义的 inviteStatus 字段', () => {
assert.deepEqual(CeremonyPages.buildInvitationResponseBody({
inviteStatus: 'ACCEPTED',
invitationId: '2060000000000000001',
inviteVersion: 2
}), {
inviteStatus: 'ACCEPTED'
});
assert.deepEqual(CeremonyPages.buildInvitationResponseBody({ inviteStatus: 'DECLINED' }), {
inviteStatus: 'DECLINED'
});
assert.equal(CeremonyPages.validateInvitationResponseBody({ inviteStatus: 'PENDING' }), '邀请只能选择接受或拒绝');
});
test('只有待响应邀请显示接受和拒绝动作', () => {
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'PENDING' }), true);
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'ACCEPTED' }), false);
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'DECLINED' }), false);
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'CANCELED' }), false);
});
test('邀请列表渲染转义内容、展示详情但不直接暴露内部 ID 和坐标', () => {
const invitation = CeremonyPages.normalizeInvitation({
invitationId: '2060000000000000001',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000003',
inviteeUserId: '2060000000000000004',
inviteStatus: 'PENDING',
inviteVersion: 1,
ceremonyTitle: '<script>alert(1)</script>',
ceremonyTime: '2026-08-08T18:00:00+08:00',
location: '锦江厅',
locationAddress: '成都市锦江区示例路 8 号',
longitude: 104.0668,
latitude: 30.5728
});
const row = CeremonyPages.renderInvitationRow(invitation);
const detail = CeremonyPages.renderInvitationDetail(invitation);
assert.doesNotMatch(row, /<script>/);
assert.match(row, /接受邀请/);
assert.match(row, /拒绝邀请/);
assert.match(detail, /成都市锦江区示例路 8 号/);
assert.match(detail, /打开地图/);
assert.doesNotMatch(detail, /206000000000000000[1-4]/);
assert.doesNotMatch(detail, />104\.0668</);
assert.doesNotMatch(detail, />30\.5728</);
});
test('贺礼邀请页面只接入我的邀请闭环,不提供活动或献礼猜测表单', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-gift.html'), 'utf8');
assert.doesNotMatch(page, /data-feature-status="pending"/);
assert.match(page, /data-my-invitation-list/);
assert.match(page, /data-my-invitation-detail/);
assert.match(page, /src="public\/js\/ceremony-pages\.js"/);
assert.doesNotMatch(page, /data-ceremony-gift-form/);
assert.doesNotMatch(page, /href="profile-gift-edit\.html"/);
});
+196
View File
@@ -1,7 +1,14 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const FeedPages = require('../public/js/feed-pages.js');
const rootDir = path.resolve(__dirname, '..');
function read(file) {
return fs.readFileSync(path.join(rootDir, file), 'utf8');
}
test('动态表单只构造最新文档定义的 FamilyFeedBody 字段', () => {
assert.deepEqual(
@@ -71,3 +78,192 @@ test('评论响应使用 commentContent,并保留已删除评论的占位状
userDeleted: true
});
});
test('动态响应保留页面闭环所需的完整 FamilyFeedView 字段和字符串 ID', () => {
assert.deepEqual(FeedPages.normalizeFeed({
feedId: 2060000000000000000n,
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
publisherUserId: '2062000000000000000',
publisherNickName: '叶子',
publisherStatus: '0',
feedType: 'text',
feedContent: '今日修谱。',
mediaOssIds: '301,302',
likedByMe: true,
likeCount: 8,
commentCount: 3,
pinned: '1',
pinnedTime: '2026-07-28 10:00:00',
sortOrder: 2,
status: '0',
remark: '族长发布',
createTime: '2026-07-28 09:00:00',
updateTime: '2026-07-28 09:30:00'
}), {
feedId: '2060000000000000000',
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
publisherUserId: '2062000000000000000',
publisherNickName: '叶子',
publisherStatus: '0',
feedType: 'text',
feedContent: '今日修谱。',
mediaOssIds: '301,302',
likedByMe: true,
likeCount: 8,
commentCount: 3,
pinned: '1',
pinnedTime: '2026-07-28 10:00:00',
sortOrder: 2,
status: '0',
remark: '族长发布',
createTime: '2026-07-28 09:00:00',
updateTime: '2026-07-28 09:30:00'
});
});
test('评论响应保留回复、归属、层级和时间字段', () => {
assert.deepEqual(FeedPages.normalizeComment({
commentId: '2060000000000000001',
genealogyId: '2061000000000000000',
feedId: '2060000000000000000',
parentCommentId: '2060000000000000002',
appUserId: '2062000000000000000',
parentAppUserId: '2062000000000000003',
appUserNickName: '叶子',
appUserAvatar: '401',
parentAppUserNickName: '小叶',
commentContent: '收到',
replyCount: 2,
commentLevel: 'reply',
userDeleted: '0',
status: '0',
createTime: '2026-07-28 10:30:00'
}), {
commentId: '2060000000000000001',
genealogyId: '2061000000000000000',
feedId: '2060000000000000000',
parentCommentId: '2060000000000000002',
appUserId: '2062000000000000000',
parentAppUserId: '2062000000000000003',
appUserNickName: '叶子',
appUserAvatar: '401',
parentAppUserNickName: '小叶',
commentContent: '收到',
replyCount: 2,
commentLevel: 'reply',
userDeleted: false,
status: '0',
createTime: '2026-07-28 10:30:00'
});
});
test('动态、评论和回复分页只接受正整数页码并使用固定页面大小', () => {
assert.deepEqual(FeedPages.buildPageQuery('3'), { pageNum: 3, pageSize: 20 });
assert.deepEqual(FeedPages.buildPageQuery('-1'), { pageNum: 1, pageSize: 20 });
assert.deepEqual(FeedPages.buildPageQuery('1.5'), { pageNum: 1, pageSize: 20 });
});
test('动态列表、详情和编辑深链始终同时携带真实家谱和动态 ID', () => {
assert.equal(
FeedPages.buildFeedUrl('detail', '2061000000000000000', '2060000000000000000'),
'profile-feed-detail.html?genealogyId=2061000000000000000&feedId=2060000000000000000'
);
assert.equal(
FeedPages.buildFeedUrl('edit', '2061000000000000000', '2060000000000000000'),
'profile-feed-edit.html?genealogyId=2061000000000000000&feedId=2060000000000000000'
);
assert.equal(
FeedPages.buildFeedUrl('list', '2061000000000000000'),
'profile-feed.html?genealogyId=2061000000000000000'
);
});
test('正常动态保存后进入详情,停用动态保存后返回可读取的列表', () => {
assert.equal(
FeedPages.buildPostSaveUrl('2061000000000000000', '2060000000000000000', '0'),
'profile-feed-detail.html?genealogyId=2061000000000000000&feedId=2060000000000000000'
);
assert.equal(
FeedPages.buildPostSaveUrl('2061000000000000000', '2060000000000000000', '1'),
'profile-feed.html?genealogyId=2061000000000000000'
);
});
test('动态表单省略未填写的可选字段并拒绝契约外状态', () => {
assert.deepEqual(FeedPages.buildFeedBody({
feedContent: '仅发布文字'
}), {
feedType: 'text',
feedContent: '仅发布文字'
});
assert.throws(
() => FeedPages.buildFeedBody({ feedContent: '错误状态', status: 'draft' }),
/状态只能是 0 或 1/
);
});
test('同一动态动作在请求完成前只执行一次', async () => {
let resolveAction;
let calls = 0;
const action = new Promise((resolve) => {
resolveAction = resolve;
});
const first = FeedPages.withActionLock('like:2060', async () => {
calls += 1;
await action;
return 'ok';
});
const second = FeedPages.withActionLock('like:2060', async () => {
calls += 1;
});
assert.equal(first, second);
assert.equal(calls, 1);
resolveAction();
assert.equal(await first, 'ok');
});
test('家谱能力和当前用户决定编辑、管理与本人删除上下文', () => {
assert.deepEqual(
FeedPages.normalizeViewerContext(
{ canEditContent: true, canManage: false },
{ userId: 2062000000000000000n }
),
{
userId: '2062000000000000000',
canEditContent: true,
canManage: false
}
);
});
test('家族圈页面提供真实详情、分页和上传控件,不暴露 OSS ID 或草稿伪语义', () => {
const listPage = read('profile-feed.html');
const editPage = read('profile-feed-edit.html');
const detailPage = read('profile-feed-detail.html');
const script = read('public/js/feed-pages.js');
assert.match(listPage, /data-feed-page-prev/);
assert.match(listPage, /data-feed-page-next/);
assert.match(listPage, /data-feed-create-link[^>]+data-genealogy-context-link|data-genealogy-context-link[^>]+data-feed-create-link/);
assert.match(detailPage, /data-feed-detail-page/);
assert.match(detailPage, /data-feed-detail/);
assert.match(detailPage, /data-feed-detail-comments/);
assert.match(detailPage, /data-feed-list-link[^>]+data-genealogy-context-link|data-genealogy-context-link[^>]+data-feed-list-link/);
assert.doesNotMatch(editPage, /type="text"[^>]+name="mediaOssIds"|name="mediaOssIds"[^>]+type="text"/);
assert.match(editPage, /name="mediaOssIds"[^>]+type="hidden"|type="hidden"[^>]+name="mediaOssIds"/);
assert.match(editPage, /multiple/);
assert.match(editPage, /data-feed-advanced[^>]+hidden/);
assert.match(editPage, /name="status"[^>]+disabled|disabled[^>]+name="status"/);
assert.doesNotMatch(editPage, /草稿|图片 OSS ID/);
assert.match(script, /feedCommentsPage/);
assert.match(script, /feedCommentRepliesPage/);
assert.match(script, /currentProfile/);
assert.match(script, /genealogyDetail/);
assert.match(script, /field\.disabled/);
});
+193 -9
View File
@@ -14,19 +14,203 @@ test('家谱入口只接受本地家谱业务页作为返回目标', () => {
assert.equal(GenealogyEntryPages.getTargetPage('?next=https%3A%2F%2Fevil.example'), 'profile-family-home.html');
});
test('家谱入口只展示当前 PC 已定义的额度,不伪造家谱编号', () => {
const message = GenealogyEntryPages.buildStatus({ createRemaining: 1, joinRemaining: 2 }, 'profile-feed.html');
test('家谱入口只使用 AppGenealogyVo 的真实字段并保持大整数 ID 字符串', () => {
assert.deepEqual(GenealogyEntryPages.normalizeGenealogy({
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县',
memberCount: 12,
personCount: 34,
roleType: 'owner',
canManage: true,
legacyId: 'forbidden'
}), {
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县',
memberCount: 12,
personCount: 34,
roleType: 'owner',
canManage: true
});
assert.equal(
GenealogyEntryPages.buildEntryUrl('2062179707935264769', 'profile-feed.html'),
'profile-feed.html?genealogyId=2062179707935264769'
);
});
assert.match(message, /还可创建 1 部/);
assert.match(message, /还可加入 2 部/);
assert.match(message, /不能伪造家谱编号/);
test('家谱创建只构造 AppGenealogyCreateBody 并省略空可选字段', () => {
assert.deepEqual(GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: ' 汤氏家谱 ',
surname: ' 汤 ',
regionCode: '511622',
ancestralHall: '',
originPlace: ' 四川 ',
addressDetail: '',
coverOssId: '2062179707935264769',
intro: ' 家谱简介 ',
visibility: '1',
joinMode: '1',
genealogyId: 'must-drop',
ownerUserId: 'must-drop'
}), {
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
originPlace: '四川',
coverOssId: '2062179707935264769',
intro: '家谱简介',
visibility: '1',
joinMode: '1'
});
});
test('家谱创建校验必填、枚举、上传派生 ID 和真实额度', () => {
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({ surname: '汤', regionCode: '511622' })
),
'请填写谱名'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({ genealogyName: '汤氏家谱', regionCode: '511622' })
),
'请填写姓氏'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({ genealogyName: '汤氏家谱', surname: '汤' })
),
'请选择家谱所在地区'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
visibility: '9'
})
),
'可见范围选项无效'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
joinMode: '9'
})
),
'加入方式选项无效'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
coverOssId: Number.MAX_SAFE_INTEGER + 1
})
),
'封面文件无效,请重新选择'
);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: 1 }), true);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: 0 }), false);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: -1, canCreate: true }), true);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: 3, canCreate: false }), false);
});
test('创建响应必须提供稳定家谱 ID、谱名和姓氏', () => {
const created = GenealogyEntryPages.normalizeCreatedGenealogy({
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县',
status: '0'
});
assert.deepEqual(created, {
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县'
});
assert.equal(
GenealogyEntryPages.buildCreatedGenealogyUrl(created),
'profile-family-home.html?genealogyId=2062179707935264769'
);
assert.equal(GenealogyEntryPages.normalizeCreatedGenealogy({
genealogyId: Number.MAX_SAFE_INTEGER + 1,
genealogyName: '汤氏家谱',
surname: '汤'
}), null);
assert.equal(GenealogyEntryPages.normalizeCreatedGenealogy({
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱'
}), null);
});
test('负数额度不向用户展示为可用数量', () => {
assert.doesNotMatch(
GenealogyEntryPages.buildStatus({ createRemaining: -1, joinRemaining: -1 }, 'profile-feed.html'),
/-1/
);
const status = GenealogyEntryPages.buildStatus({ createRemaining: -1, joinRemaining: -1 });
assert.doesNotMatch(status, /-1/);
assert.match(status, /创建不限/);
assert.match(status, /加入不限/);
});
test('我的家谱页是可用的真实列表入口', () => {
const source = read('profile-families.html');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.doesNotMatch(source, /pending-pages\.js/);
assert.match(source, /data-genealogy-list="mine"/);
assert.match(source, /genealogy-entry-pages\.js/);
});
test('创建家谱页开放真实 PC 表单且不允许手填业务 ID', () => {
const source = read('profile-create-family.html');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.doesNotMatch(source, /pending-pages\.js/);
assert.match(source, /data-genealogy-create-page/);
assert.match(source, /data-genealogy-create-quota/);
assert.match(source, /data-genealogy-create-status/);
assert.match(source, /name="coverOssId"\s+type="hidden"/);
assert.match(source, /data-upload-target="#familyCoverOssId"/);
assert.match(source, /public\/js\/region-pages\.js/);
assert.match(source, /public\/js\/md5\.js/);
assert.match(source, /public\/js\/upload-pages\.js/);
assert.match(source, /public\/js\/genealogy-entry-pages\.js/);
assert.doesNotMatch(source, /name="(?:genealogyId|applyId|inviterUserId)"/);
assert.doesNotMatch(source, /name="coverOssId"[^>]*type="text"/);
});
test('加入家谱页面不允许用户手工填写 genealogyId', () => {
const source = read('join-genealogy.html');
assert.doesNotMatch(source, /name="genealogyId"/);
assert.doesNotMatch(source, /请输入家谱ID/);
});
test('家谱业务模块运行时统一委托 ProfileUI 读取和传播上下文', () => {
[
'feed-pages.js',
'growth-pages.js',
'lineage-pages.js',
'memo-pages.js',
'relative-pages.js',
'generation-pages.js'
].forEach((file) => {
const source = read('public/js/' + file);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.getGenealogyId/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.syncGenealogyContextLinks/);
});
});
test('个人中心和内容发布在缺少家谱上下文时统一进入家谱入口', () => {
+114 -1
View File
@@ -74,6 +74,32 @@ test('字辈列表要求管理接口返回稳定的直接字段', () => {
}), null);
});
test('字辈响应保留页面闭环所需字段并保持所有业务 ID 为字符串', () => {
assert.deepEqual(GenerationPages.normalizeGenerationPoem({
poemId: '2060000000000000000',
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
generationNo: 3,
generationText: '万',
description: '第三世',
sortOrder: 30,
status: '0',
remark: '祖训'
}), {
poemId: '2060000000000000000',
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
generationNo: 3,
generationText: '万',
status: '0',
description: '第三世',
sortOrder: 30,
remark: '祖训'
});
});
test('字辈批量输入按 Apifox 限制校验', () => {
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 0, generationText: '万' }), '世代序号必须是 1 到 2147483647 之间的整数');
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 3, generationText: '' }), '请填写字辈文字');
@@ -81,16 +107,103 @@ test('字辈批量输入按 Apifox 限制校验', () => {
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 3, generationText: '万', sortOrder: 2147483648 }), '排序值必须在 -2147483648 到 2147483647 之间');
assert.equal(GenerationPages.validateBatchBody({ poemText: '', disableMissing: false }), '请填写批量字辈内容');
assert.deepEqual(GenerationPages.splitPoemText('德,承;家、亦/传|芳\n远'), ['德', '承', '家', '亦', '传', '芳', '远']);
assert.equal(GenerationPages.validateBatchBody({ poemText: '甲'.repeat(51), disableMissing: false }), '单个字辈不能超过 50 个字符');
assert.deepEqual(GenerationPages.splitPoemText('德承家亦'), ['德', '承', '家', '亦']);
assert.equal(GenerationPages.validateBatchBody({ poemText: '甲'.repeat(51) + ' 德', disableMissing: false }), '单个字辈不能超过 50 个字符');
assert.equal(GenerationPages.validateBatchBody({ poemText: Array(502).fill('甲').join(' '), disableMissing: false }), '一次最多导入 500 个世代');
assert.equal(GenerationPages.validateBatchBody({ poemText: '德承家亦', disableMissing: false }), '');
});
test('家谱内容权限决定读取正常列表还是包含停用项的管理列表', () => {
assert.deepEqual(GenerationPages.normalizeGenerationAccess({ canEditContent: false }), {
canEditContent: false,
listMethod: 'generationPoems'
});
assert.deepEqual(GenerationPages.normalizeGenerationAccess({ canEditContent: true }), {
canEditContent: true,
listMethod: 'generationPoemsManagement'
});
});
test('批量预览逐项校验动作、状态、ID 与汇总计数', () => {
assert.deepEqual(GenerationPages.normalizePreview({
createCount: 1,
updateCount: 0,
keepCount: 0,
disableCount: 1,
items: [
{
generationNo: 1,
newGenerationText: '德',
newStatus: '0',
action: 'create'
},
{
poemId: '2060000000000000000',
generationNo: 2,
oldGenerationText: '承',
oldStatus: '0',
newStatus: '1',
action: 'disable',
warning: '保存后该世代会停用,不会删除历史记录'
}
]
}), {
createCount: 1,
updateCount: 0,
keepCount: 0,
disableCount: 1,
items: [
{
generationNo: 1,
newGenerationText: '德',
newStatus: '0',
action: 'create'
},
{
poemId: '2060000000000000000',
generationNo: 2,
oldGenerationText: '承',
oldStatus: '0',
newStatus: '1',
action: 'disable',
warning: '保存后该世代会停用,不会删除历史记录'
}
]
});
assert.equal(GenerationPages.normalizePreview({ createCount: 0, updateCount: 0, keepCount: 0, disableCount: 0, items: [] }), null);
assert.equal(GenerationPages.normalizePreview({
createCount: 1,
updateCount: 0,
keepCount: 0,
disableCount: 0,
items: [{ generationNo: 1, action: 'create' }]
}), null);
assert.equal(GenerationPages.normalizePreview({
createCount: 2,
updateCount: 0,
keepCount: 0,
disableCount: 0,
items: [{ generationNo: 1, newGenerationText: '德', newStatus: '0', action: 'create' }]
}), null);
});
test('停用缺失世代的批量保存确认文案明确不会删除历史', () => {
assert.equal(
GenerationPages.buildBatchConfirmMessage(true),
'确认按当前预览保存字辈,并停用未出现在文本中的后续世代吗?历史记录不会删除。'
);
assert.equal(GenerationPages.buildBatchConfirmMessage(false), '确认按当前预览保存字辈吗?');
});
test('字辈批量示例明确使用 Apifox 支持的分隔符', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-generation.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(page, /placeholder="例如:德 承 家 亦(用空格或标点分隔)"/);
assert.doesNotMatch(page, /placeholder="例如:德承家亦"/);
assert.match(page, /data-generation-management[^>]+hidden/);
assert.match(page, /data-generation-add[^>]+disabled|disabled[^>]+data-generation-add/);
assert.match(styles, /\[data-generation-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
});
test('字辈管理接口的 403 不是登录失效', () => {
+176 -27
View File
@@ -1,51 +1,200 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const GrowthPages = require('../public/js/growth-pages.js');
test('成长记录页只从 URL 读取真实家谱编号', () => {
assert.equal(GrowthPages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
test('成长记录页只从 URL 读取安全的真实家谱和记录编号', () => {
assert.equal(
GrowthPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&recordId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
GrowthPages.getCurrentRecordId('?genealogyId=2060000000000000001&recordId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(GrowthPages.getCurrentGenealogyId(''), '');
assert.equal(GrowthPages.getCurrentRecordId('?recordId=unsafe'), '');
});
test('成长记录写入只构造 Apifox GrowthRecordBody 字段', () => {
test('成长记录写入只构造 YAML GrowthRecordBody 字段并转换后端日期格式', () => {
assert.deepEqual(
GrowthPages.buildGrowthRecordBody({
lineagePersonId: '2060000000000000001',
recordType: 'birth',
recordTitle: ' 出生记录 ',
recordContent: ' 平安出生 ',
lineagePersonId: '2060000000000000003',
recordType: ' 入学 ',
recordTitle: ' 入学记录 ',
recordContent: ' 顺利入学 ',
recordDate: '2026-07-24',
remindTime: '2026-07-24T09:00:00+08:00',
mediaOssIds: '101,102',
remindTime: '2026-07-24T09:30',
mediaOssIds: '2060000000000000004,2060000000000000005',
sortOrder: '3',
status: 'enabled',
content: '旧字段',
personId: '旧字段'
status: '0',
recordId: 'should-not-send',
appUserId: 'should-not-send'
}),
{
lineagePersonId: '2060000000000000001',
recordType: 'birth',
recordTitle: '出生记录',
recordContent: '平安出生',
lineagePersonId: '2060000000000000003',
recordType: '入学',
recordTitle: '入学记录',
recordContent: '顺利入学',
recordDate: '2026-07-24',
remindTime: '2026-07-24T09:00:00+08:00',
mediaOssIds: '101,102',
remindTime: '2026-07-24 09:30:00',
mediaOssIds: '2060000000000000004,2060000000000000005',
sortOrder: 3,
status: 'enabled'
status: '0'
}
);
});
test('成长记录校验标题、整数 ID、附件 OSS ID 和排序值', () => {
test('成长记录校验标题、选择器 ID、附件、排序和可重读状态', () => {
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '' }), '请填写记录标题');
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', lineagePersonId: 'A-1' }), '世系人物 ID 必须是整数');
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101, 102' }), '附件 OSS ID 请使用英文逗号分隔的正整数');
assert.equal(GrowthPages.validateGrowthRecordBody(GrowthPages.buildGrowthRecordBody({ recordTitle: '记录', sortOrder: '1.5' })), '排序值必须是安全整数');
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101,102', sortOrder: 1 }), '');
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', lineagePersonId: 'A-1' }),
'请选择有效的世系人物'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101, 102' }),
'附件上传结果无效'
);
assert.equal(
GrowthPages.validateGrowthRecordBody(GrowthPages.buildGrowthRecordBody({ recordTitle: '记录', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', recordDate: '2026/07/24' }),
'记录日期格式无效'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', remindTime: '2026-07-24T09:30:00+08:00' }),
'提醒时间格式无效'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', status: '1' }),
'当前 PC 无法重新读取停用记录,暂不开放停用'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101,102', sortOrder: 1, status: '0' }),
''
);
});
test('成长记录列表不猜测未展开的响应 DTO', () => {
assert.deepEqual(GrowthPages.normalizeGrowthList([{ property1: 'value' }]), [{ property1: 'value' }]);
assert.deepEqual(GrowthPages.normalizeGrowthList({ rows: [] }), []);
test('成长记录写后重读必须返回同一条稳定记录', () => {
const detail = {
recordId: '2060000000000000001',
genealogyId: '2060000000000000002',
recordTitle: '入学记录',
status: '0'
};
assert.equal(GrowthPages.matchesSavedRecord(detail, '2060000000000000001'), true);
assert.equal(GrowthPages.matchesSavedRecord(detail, '2060000000000000009'), false);
assert.equal(GrowthPages.matchesSavedRecord({}, '2060000000000000001'), false);
});
test('成长记录响应使用 PC GrowthRecordVo 并拒绝不安全长 ID', () => {
assert.deepEqual(GrowthPages.normalizeGrowthRecord({
recordId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
lineagePersonId: '2060000000000000004',
lineagePersonNo: 'P001',
lineagePersonName: '叶小明',
recordType: '入学',
recordTitle: '小学入学',
recordContent: '<p>第一天上学</p>',
recordDate: '2026-09-01 00:00:00',
remindTime: '2027-09-01 08:30:00',
mediaOssIds: '2060000000000000005,2060000000000000006',
sortOrder: 1,
status: '0',
remark: '成长节点'
}), {
recordId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
lineagePersonId: '2060000000000000004',
lineagePersonNo: 'P001',
lineagePersonName: '叶小明',
recordType: '入学',
recordTitle: '小学入学',
recordContent: '<p>第一天上学</p>',
recordDate: '2026-09-01 00:00:00',
remindTime: '2027-09-01 08:30:00',
mediaOssIds: '2060000000000000005,2060000000000000006',
sortOrder: 1,
status: '0',
remark: '成长节点'
});
assert.equal(GrowthPages.normalizeGrowthRecord({
recordId: Number('2060000000000000001'),
genealogyId: '2',
recordTitle: '标题',
status: '0'
}), null);
});
test('成长记录渲染业务字段但不暴露手机号、OSS ID 或未转义正文', () => {
const record = GrowthPages.normalizeGrowthRecord({
recordId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
lineagePersonId: '4',
lineagePersonName: '叶小明',
recordType: '入学',
recordTitle: '<script>alert(1)</script>',
recordContent: '<img src=x onerror=alert(1)>第一天上学',
recordDate: '2026-09-01 00:00:00',
mediaOssIds: '5,6',
sortOrder: 1,
status: '0'
});
const html = GrowthPages.renderGrowthDetail(record);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /第一天上学/);
assert.match(html, /叶小明/);
assert.match(html, /2 个附件/);
assert.doesNotMatch(html, /19100000000|>5<|>6</);
});
test('世系人物选择器只使用稳定人物编号和名称', () => {
assert.equal(
GrowthPages.renderLineageOptions([
{ personId: '2060000000000000001', name: '叶小明', generationName: '承' },
{ personId: Number('2060000000000000002'), name: '不安全编号' }
], '2060000000000000001'),
'<option value="">不关联世系人物</option><option value="2060000000000000001" selected>叶小明 · 承</option>'
);
});
test('成长记录列表和编辑页开放 PC CRUD 且不允许手填人物或 OSS ID', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-growth.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-growth-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(listPage, /data-growth-list/);
assert.match(listPage, /data-growth-detail/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="lineagePersonId"[^>]*data-growth-lineage-person/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.doesNotMatch(editPage, /name="(?:lineagePersonId|mediaOssIds)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/growth-pages\.js/);
assert.match(styles, /\[data-growth-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+243 -6
View File
@@ -13,33 +13,67 @@ test('世系页只从 URL 读取真实家谱编号', () => {
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
assert.deepEqual(
LineagePages.buildLineagePersonBody({
bindingMode: 'SPECIFIED',
appUserId: '2061000000000000001',
avatarOssId: '2061000000000000002',
name: '李明',
sex: '0',
generation: '3',
generationName: '德',
fatherId: '2061000000000000003',
motherId: '2061000000000000004',
personNo: 'P202607090001',
aliasName: '明远',
birthDate: '1990-01-01',
birthDate: '1990-01-01T08:30',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03T10:45',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
sortOrder: '1',
relationName: '妻',
biography: '人物简介',
remark: '家族备注',
personName: '旧字段',
generationNo: 4,
introduction: '旧字段'
}),
{
bindingMode: 'SPECIFIED',
appUserId: '2061000000000000001',
avatarOssId: '2061000000000000002',
name: '李明',
sex: '0',
generation: 3,
generationName: '德',
fatherId: '2061000000000000003',
motherId: '2061000000000000004',
personNo: 'P202607090001',
aliasName: '明远',
birthDate: '1990-01-01',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
sortOrder: 1,
relationName: '妻',
biography: '人物简介'
biography: '人物简介',
remark: '家族备注'
}
);
assert.deepEqual(LineagePages.buildLineagePersonBody({
bindingMode: 'SELF',
appUserId: '2061000000000000001',
name: '李明'
}), {
bindingMode: 'SELF',
name: '李明'
});
});
test('世系人物要求安全的 ID、姓名和有效世代值', () => {
@@ -60,9 +94,168 @@ test('世系人物要求安全的 ID、姓名和有效世代值', () => {
}
);
assert.equal(LineagePages.normalizeLineagePerson({ personId: Number('2060000000000000000'), name: '李明' }), null);
assert.equal(LineagePages.validateLineagePersonBody({ name: '' }), '请填写成员姓名');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', generation: '1.5' })), '世代序号必须是整数');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
assert.equal(LineagePages.normalizeLineagePerson({
personId: '2060000000000000000',
fatherId: Number('2061000000000000000'),
name: '李明'
}), null);
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '' }), '请填写成员姓名');
assert.equal(LineagePages.validateLineagePersonBody({ name: '李明' }), '请选择账号绑定方式');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'OTHER', name: '李明' }), '账号绑定方式无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'SPECIFIED', name: '李明' }), '指定账号绑定缺少用户选项');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ bindingMode: 'NONE', name: '李明', generation: '1.5' })), '世代序号必须是整数');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ bindingMode: 'NONE', name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', sex: '3' }), '性别选项无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', personStatus: '3' }), '人物状态选项无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', birthLunar: '2' }), '出生历法选项无效');
});
test('世系人物响应保留完整详情字段并拒绝不安全的嵌套 ID', () => {
assert.deepEqual(LineagePages.normalizeLineagePerson({
personId: '2060000000000000000',
genealogyId: '2060000000000000001',
appUserId: '2060000000000000002',
avatarOssId: '2060000000000000003',
fatherId: '2060000000000000004',
motherId: '2060000000000000005',
genealogyName: '李氏家谱',
genealogyNo: 'G20260728001',
appUserNickName: '明远',
personNo: 'P202607090001',
name: '李明',
aliasName: '明远',
sex: '0',
generation: 3,
generationName: '德',
fatherName: '李父',
motherName: '王母',
spouseNames: '张氏',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
biography: '人物简介',
sortOrder: 1,
status: '0',
remark: '家族备注'
}), {
personId: '2060000000000000000',
name: '李明',
genealogyName: '李氏家谱',
genealogyNo: 'G20260728001',
appUserNickName: '明远',
personNo: 'P202607090001',
aliasName: '明远',
sex: '0',
generationName: '德',
fatherName: '李父',
motherName: '王母',
spouseNames: '张氏',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
biography: '人物简介',
status: '0',
remark: '家族备注',
appUserId: '2060000000000000002',
genealogyId: '2060000000000000001',
fatherId: '2060000000000000004',
motherId: '2060000000000000005',
avatarOssId: '2060000000000000003',
generation: 3,
sortOrder: 1
});
});
test('家谱内容权限只控制世系写操作,读取和分页始终可用', () => {
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: false }), {
canEditContent: false
});
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: true }), {
canEditContent: true
});
assert.equal(LineagePages.canUseLineagePagination(false), true);
assert.equal(LineagePages.canUseLineagePagination(true), false);
});
test('世代选项来自正常字辈列表并自动映射序号和字辈', () => {
assert.deepEqual(LineagePages.normalizeGenerationOption({
poemId: '2060000000000000000',
generationNo: 3,
generationText: '德',
status: '0'
}), {
generation: 3,
generationName: '德'
});
assert.equal(LineagePages.normalizeGenerationOption({ generationNo: 0, generationText: '德' }), null);
assert.equal(LineagePages.normalizeGenerationOption({ generationNo: 3, generationText: '' }), null);
});
test('已有账号绑定按当前登录用户区分 SELF 与只读保留的 SPECIFIED', () => {
assert.deepEqual(LineagePages.normalizeLineageBinding({}, '2060000000000000001'), {
bindingMode: 'NONE',
appUserId: ''
});
assert.deepEqual(LineagePages.normalizeLineageBinding({
appUserId: '2060000000000000001'
}, '2060000000000000001'), {
bindingMode: 'SELF',
appUserId: ''
});
assert.deepEqual(LineagePages.normalizeLineageBinding({
appUserId: '2060000000000000002'
}, '2060000000000000001'), {
bindingMode: 'SPECIFIED',
appUserId: '2060000000000000002'
});
});
test('指定账号绑定只使用当前家谱正常且已绑定账号的成员选项', () => {
assert.deepEqual(LineagePages.normalizeGenealogyMemberOption({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '明远',
memberName: '李明',
roleType: 'member',
status: '0'
}), {
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '明远',
memberName: '李明',
roleType: 'member',
status: '0'
});
assert.equal(LineagePages.normalizeGenealogyMemberOption({
memberId: '1',
genealogyId: '2',
appUserId: null,
memberName: '未绑定成员',
status: '0'
}), null);
assert.equal(LineagePages.normalizeGenealogyMemberOption({
memberId: '1',
genealogyId: '2',
appUserId: '3',
memberName: '停用成员',
status: '1'
}), null);
assert.deepEqual(LineagePages.normalizeGenealogyMemberOptions([
{ memberId: '1', genealogyId: '2', appUserId: '3', memberName: '正常成员', status: '0' },
{ memberId: '4', genealogyId: '2', appUserId: null, memberName: '未绑定成员', status: '0' }
]).map((option) => option.appUserId), ['3']);
});
test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
@@ -76,12 +269,56 @@ test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
test('世系页按 Apifox 关键词范围搜索并提供分页入口', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
assert.deepEqual(LineagePages.buildLineagePageQuery({
keyword: ' 李明 ',
generation: '3',
personStatus: '1'
}, 2, 20), {
pageNum: 2,
pageSize: 20,
keyword: '李明',
generation: 3,
personStatus: '1'
});
assert.deepEqual(LineagePages.buildLineagePageQuery({}, 1, 20), {
pageNum: 1,
pageSize: 20
});
assert.match(page, /placeholder="输入姓名、别名或人物编号搜索"/);
assert.match(page, /data-lineage-generation-filter/);
assert.match(page, /data-lineage-status-filter/);
assert.match(page, /data-lineage-pagination/);
assert.match(page, /data-lineage-page-action="previous"/);
assert.match(page, /data-lineage-page-action="next"/);
});
test('世系表单为全部 LineagePersonBody 字段提供真实来源且不允许手填业务 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
[
'bindingMode', 'appUserId', 'avatarOssId', 'personNo', 'name', 'aliasName', 'sex',
'generation', 'generationName', 'fatherId', 'motherId', 'birthDate', 'birthLunar',
'birthPlace', 'deathDate', 'deathLunar', 'deathPlace', 'burialPlace', 'personStatus',
'biography', 'sortOrder', 'remark', 'relationName'
].forEach((name) => assert.match(page, new RegExp('name="' + name + '"')));
assert.match(page, /value="SPECIFIED"(?![^>]+disabled)/);
assert.match(page, /select[^>]+name="appUserId"[^>]+data-lineage-member-options|select[^>]+data-lineage-member-options[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="(?:text|number)"|type="(?:text|number)"[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="hidden"|type="hidden"[^>]+name="appUserId"/);
assert.match(page, /name="avatarOssId"[^>]+type="hidden"|type="hidden"[^>]+name="avatarOssId"/);
assert.match(page, /data-upload-target="#lineage-avatar-oss-id"/);
assert.match(page, /name="generation"[^>]*data-lineage-generation/);
assert.match(page, /name="generationName"[^>]+readonly|readonly[^>]+name="generationName"/);
assert.match(page, /name="fatherId"[^>]*data-lineage-parent-option/);
assert.match(page, /name="motherId"[^>]*data-lineage-parent-option/);
assert.match(page, /data-lineage-management[^>]+hidden/);
assert.match(page, /public\/js\/upload-pages\.js/);
assert.match(styles, /\[data-lineage-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-spouse-field\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-pagination\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('世系管理的 403 不会被当作登录失效', () => {
assert.equal(LineagePages.isForbidden({ status: 403 }), true);
assert.equal(LineagePages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
+176
View File
@@ -0,0 +1,176 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const modulePath = path.join(__dirname, '..', 'public', 'js', 'member-admin-pages.js');
const MemberAdminPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof MemberAdminPages[name], 'function', `缺少 MemberAdminPages.${name}`);
return MemberAdminPages[name];
}
test('成员管理页只从家谱上下文读取安全 ID', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
assert.equal(getCurrentGenealogyId('?genealogyId=2060000000000000001'), '2060000000000000001');
assert.equal(getCurrentGenealogyId('?genealogyId=unsafe'), '');
assert.equal(getCurrentGenealogyId(''), '');
});
test('成员响应使用完整 GenealogyMemberVo 并保持长 ID 为字符串', () => {
const normalizeMember = requireFunction('normalizeMember');
const member = normalizeMember({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
lineagePersonId: '2060000000000000004',
appUserNickName: '明远',
appUserPhone: '19100000000',
lineagePersonName: '李明',
memberName: '李先生',
roleType: 'member',
relationName: '族亲',
joinSource: 'invite',
inviterUserId: '2060000000000000005',
inviterNickName: '谱主',
inviterPhone: '19111111111',
joinTime: '2026-07-29 10:00:00',
status: '0'
});
assert.equal(member.memberId, '2060000000000000001');
assert.equal(member.appUserId, '2060000000000000003');
assert.equal(member.lineagePersonId, '2060000000000000004');
assert.equal(normalizeMember({
memberId: Number('2060000000000000001'),
genealogyId: '2',
appUserId: '3',
roleType: 'member',
status: '0'
}), null);
});
test('成员修改只构造后端可更新字段并收紧角色枚举', () => {
const buildMemberUpdateBody = requireFunction('buildMemberUpdateBody');
const validateMemberUpdateBody = requireFunction('validateMemberUpdateBody');
assert.deepEqual(buildMemberUpdateBody({
memberName: ' 李先生 ',
relationName: ' 族亲 ',
roleType: 'editor',
lineagePersonId: '2060000000000000004',
memberId: 'must-drop',
appUserId: 'must-drop',
status: '3'
}), {
memberName: '李先生',
relationName: '族亲',
roleType: 'editor',
lineagePersonId: '2060000000000000004'
});
assert.equal(validateMemberUpdateBody(buildMemberUpdateBody({ memberName: '名'.repeat(51) })), '成员名称不能超过 50 个字符');
assert.equal(validateMemberUpdateBody(buildMemberUpdateBody({ relationName: '关系'.repeat(51) })), '关系名称不能超过 100 个字符');
assert.throws(
() => buildMemberUpdateBody({ roleType: 'owner' }),
/成员角色只能是管理员、编辑或成员/
);
assert.equal(
validateMemberUpdateBody(buildMemberUpdateBody({ lineagePersonId: Number.MAX_SAFE_INTEGER + 1 })),
'世系人物选项无效'
);
});
test('成员管理动作遵守谱主和管理员的后端权限边界', () => {
const canEditMember = requireFunction('canEditMember');
const canRemoveMember = requireFunction('canRemoveMember');
const canLeaveGenealogy = requireFunction('canLeaveGenealogy');
const canTransferOwner = requireFunction('canTransferOwner');
assert.equal(canEditMember('owner', 'admin'), true);
assert.equal(canEditMember('admin', 'admin'), false);
assert.equal(canEditMember('admin', 'member'), true);
assert.equal(canEditMember('owner', 'owner'), false);
assert.equal(canRemoveMember('owner', 'owner'), false);
assert.equal(canRemoveMember('admin', 'admin'), false);
assert.equal(canLeaveGenealogy('owner'), false);
assert.equal(canLeaveGenealogy('member'), true);
assert.equal(canTransferOwner('owner'), true);
assert.equal(canTransferOwner('admin'), false);
});
test('谱主转移只使用成员列表中的真实目标 memberId', () => {
const buildOwnerTransferBody = requireFunction('buildOwnerTransferBody');
const validateOwnerTransferBody = requireFunction('validateOwnerTransferBody');
assert.deepEqual(buildOwnerTransferBody('2060000000000000001'), {
targetMemberId: '2060000000000000001'
});
assert.equal(validateOwnerTransferBody(buildOwnerTransferBody('')), '请选择新谱主');
assert.equal(validateOwnerTransferBody(buildOwnerTransferBody('unsafe')), '请选择新谱主');
assert.equal(
validateOwnerTransferBody(buildOwnerTransferBody('2060000000000000001'), '2060000000000000001'),
'不能转让给自己'
);
});
test('世系人物绑定选项只接受当前家谱响应中的安全人物 ID', () => {
const normalizeLineageOption = requireFunction('normalizeLineageOption');
assert.deepEqual(normalizeLineageOption({
personId: '2060000000000000001',
name: '李明',
generationName: '德'
}), {
personId: '2060000000000000001',
name: '李明',
generationName: '德'
});
assert.equal(normalizeLineageOption({ personId: Number('2060000000000000001'), name: '李明' }), null);
assert.equal(normalizeLineageOption({ personId: '1', name: '' }), null);
});
test('成员列表展示转义内容并隐藏手机号与内部 ID', () => {
const normalizeMember = requireFunction('normalizeMember');
const renderMemberRow = requireFunction('renderMemberRow');
const html = renderMemberRow(normalizeMember({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '<img src=x>明远',
appUserPhone: '19100000000',
lineagePersonId: '2060000000000000004',
lineagePersonName: '<script>alert(1)</script>李明',
memberName: '李先生',
roleType: 'member',
relationName: '族亲',
inviterUserId: '2060000000000000005',
inviterPhone: '19111111111',
status: '0'
}), {
actorRole: 'member',
currentMemberId: '2060000000000000001'
});
assert.doesNotMatch(html, /<script|<img/);
assert.doesNotMatch(html, /19100000000|19111111111|206000000000000000[1-5]/);
assert.match(html, /李先生|族亲|成员/);
});
test('家族管理页开放成员维护、退出和谱主转移且不允许手填 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-family-admin.html'), 'utf8');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
assert.match(page, /public\/js\/member-admin-pages\.js/);
assert.match(page, /data-member-list/);
assert.match(page, /data-member-form/);
assert.match(page, /name="memberName"/);
assert.match(page, /name="relationName"/);
assert.match(page, /name="roleType"/);
assert.match(page, /select[^>]+name="lineagePersonId"|name="lineagePersonId"[^>]+select/);
assert.match(page, /data-member-leave/);
assert.match(page, /select[^>]+name="targetMemberId"|name="targetMemberId"[^>]+select/);
assert.doesNotMatch(page, /name="(?:memberId|appUserId)"|type="(?:text|number)"[^>]+name="(?:lineagePersonId|targetMemberId)"/);
assert.doesNotMatch(page, /href="profile-data\.html"[^>]*>\s*添加成员/);
});
+173 -34
View File
@@ -1,45 +1,184 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const MemoPages = require('../public/js/memo-pages.js');
test('备忘录页只从 URL 读取真实家谱编号', () => {
assert.equal(MemoPages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
assert.equal(MemoPages.getCurrentGenealogyId(''), '');
});
test('备忘录写入只构造 Apifox 已核验请求体字段', () => {
assert.deepEqual(
MemoPages.buildMemoBody({
memoTitle: ' 祭祖提醒 ',
memoContent: ' 准备供品 ',
remindTime: '2026-07-24T09:00:00+08:00',
completed: ' 0 ',
mediaOssIds: '101,102',
sortOrder: '3',
status: ' enabled ',
relativeName: '旧字段'
}),
{
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24T09:00:00+08:00',
completed: '0',
mediaOssIds: '101,102',
sortOrder: 3,
status: 'enabled'
}
test('备忘录页只从 URL 读取安全的真实家谱和备忘录编号', () => {
assert.equal(
MemoPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&memoId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
MemoPages.getCurrentMemoId('?genealogyId=2060000000000000001&memoId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(MemoPages.getCurrentGenealogyId(''), '');
assert.equal(MemoPages.getCurrentMemoId('?memoId=unsafe'), '');
});
test('备忘录校验标题、附件 OSS ID 和排序值', () => {
assert.equal(MemoPages.validateMemoBody({ memoTitle: '' }), '请填写备忘标题');
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', mediaOssIds: '101, 102' }), '附件 OSS ID 请使用英文逗号分隔的正整数');
assert.equal(MemoPages.validateMemoBody(MemoPages.buildMemoBody({ memoTitle: '提醒', sortOrder: '1.5' })), '排序值必须是安全整数');
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', mediaOssIds: '101,102', sortOrder: 1 }), '');
test('备忘录写入只构造 YAML MemoBody 并转换后端时间格式', () => {
assert.deepEqual(MemoPages.buildMemoBody({
memoTitle: ' 祭祖提醒 ',
memoContent: ' 准备供品 ',
remindTime: '2026-07-24T09:30',
completed: '1',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: '3',
status: '0',
memoId: 'should-not-send',
appUserId: 'should-not-send'
}), {
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: 3,
status: '0'
});
});
test('备忘录列表不猜测未展开的响应 DTO', () => {
assert.deepEqual(MemoPages.normalizeMemoList([{ property1: 'value' }]), [{ property1: 'value' }]);
assert.deepEqual(MemoPages.normalizeMemoList({ rows: [] }), []);
test('备忘录校验标题、时间、完成状态、附件、排序和可重读状态', () => {
assert.equal(MemoPages.validateMemoBody({ memoTitle: '' }), '请填写备忘录标题');
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', remindTime: '2026-07-24T09:30:00+08:00' }),
'提醒时间格式无效'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', remindTime: '2026-02-30 09:30:00' }),
'提醒时间格式无效'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', completed: '2' }),
'完成状态只能是未完成或已完成'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', mediaOssIds: '101, 102' }),
'附件上传结果无效'
);
assert.equal(
MemoPages.validateMemoBody(MemoPages.buildMemoBody({ memoTitle: '提醒', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', status: '1' }),
'当前 PC 无法重新读取停用备忘录,暂不开放停用'
);
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', completed: '0', status: '0' }), '');
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', completed: '1', status: '0' }), '');
});
test('备忘录响应使用 PC MemoVo 并拒绝不安全的数字长整型', () => {
assert.deepEqual(MemoPages.normalizeMemo({
memoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '年度事项'
}), {
memoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '年度事项'
});
assert.equal(MemoPages.normalizeMemo({
memoId: Number('2060000000000000001'),
genealogyId: '2',
memoTitle: '提醒',
completed: '0',
status: '0'
}), null);
});
test('备忘录渲染业务字段但不暴露手机号、OSS ID 或未转义内容', () => {
const memo = MemoPages.normalizeMemo({
memoId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
memoTitle: '<script>alert(1)</script>',
memoContent: '<img src=x onerror=alert(1)>准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '4,5',
sortOrder: 1,
status: '0',
remark: '年度事项'
});
const html = MemoPages.renderMemoDetail(memo);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /准备供品/);
assert.match(html, /已完成/);
assert.match(html, /2 个附件/);
assert.doesNotMatch(html, /19100000000|>4<|>5</);
});
test('备忘录写后重读必须返回同一条稳定记录', () => {
const detail = {
memoId: '2060000000000000001',
genealogyId: '2060000000000000002',
memoTitle: '祭祖提醒',
completed: '0',
status: '0'
};
assert.equal(MemoPages.matchesSavedMemo(detail, '2060000000000000001'), true);
assert.equal(MemoPages.matchesSavedMemo(detail, '2060000000000000009'), false);
assert.equal(MemoPages.matchesSavedMemo({}, '2060000000000000001'), false);
});
test('备忘录编辑器拒绝不可重读的停用记录', () => {
assert.equal(MemoPages.isEditableMemo({ status: '0' }), true);
assert.equal(MemoPages.isEditableMemo({ status: '1' }), false);
assert.equal(MemoPages.isEditableMemo(null), false);
});
test('备忘录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或业务状态', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-memo.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-memo-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(listPage, /data-memo-list/);
assert.match(listPage, /data-memo-detail/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="remindTime" type="datetime-local"/);
assert.match(editPage, /name="completed"/);
assert.match(editPage, /value="0"/);
assert.match(editPage, /value="1"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-memo-clear-media/);
assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/memo-pages\.js/);
assert.match(styles, /\[data-memo-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+216
View File
@@ -0,0 +1,216 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const modulePath = path.join(__dirname, '..', 'public', 'js', 'merit-pages.js');
const MeritPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof MeritPages[name], 'function', `缺少 MeritPages.${name}`);
return MeritPages[name];
}
test('功德录只从 URL 读取安全的真实家谱和功德记录编号', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentMeritId = requireFunction('getCurrentMeritId');
assert.equal(
getCurrentGenealogyId('?genealogyId=2060000000000000001&meritId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
getCurrentMeritId('?genealogyId=2060000000000000001&meritId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(getCurrentGenealogyId(''), '');
assert.equal(getCurrentMeritId('?meritId=unsafe'), '');
});
test('功德记录写入只构造 YAML MeritRecordBody 并转换后端时间格式', () => {
const buildMeritRecordBody = requireFunction('buildMeritRecordBody');
assert.deepEqual(buildMeritRecordBody({
donorName: ' 叶明 ',
meritType: 'repair',
meritTitle: ' 修缮宗祠 ',
meritContent: ' 参与修缮 ',
amount: '500.50',
meritTime: '2026-07-26T10:00',
sortOrder: '3',
status: '0',
meritId: 'should-not-send',
appUserId: 'should-not-send',
mediaOssIds: 'should-not-send'
}), {
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与修缮',
amount: 500.5,
meritTime: '2026-07-26 10:00:00',
sortOrder: 3,
status: '0'
});
});
test('功德记录校验必填、枚举、金额精度、时间、排序和可重读状态', () => {
const buildMeritRecordBody = requireFunction('buildMeritRecordBody');
const validateMeritRecordBody = requireFunction('validateMeritRecordBody');
assert.equal(validateMeritRecordBody({ donorName: '', meritTitle: '标题' }), '请填写功德人姓名');
assert.equal(validateMeritRecordBody({ donorName: '叶明', meritTitle: '' }), '请填写功德标题');
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', meritType: 'service' }),
'功德类型无效'
);
assert.equal(
validateMeritRecordBody(buildMeritRecordBody({ donorName: '叶明', meritTitle: '标题', amount: '9007199254740993.01' })),
'功德金额超出浏览器可安全提交的精度'
);
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', meritTime: '2026-02-30 10:00:00' }),
'功德时间格式无效'
);
assert.equal(
validateMeritRecordBody(buildMeritRecordBody({ donorName: '叶明', meritTitle: '标题', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', status: '1' }),
'当前 PC 无法重新读取停用功德记录,暂不开放停用'
);
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', meritType: 'public', amount: -500.25, status: '0' }),
''
);
});
test('功德记录响应使用 PC MeritRecordVo 并保留 BigDecimal 字符串', () => {
const normalizeMeritRecord = requireFunction('normalizeMeritRecord');
assert.deepEqual(normalizeMeritRecord({
meritId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与修缮',
amount: '500.50',
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0',
remark: '年度功德'
}), {
meritId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与修缮',
amount: '500.50',
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0',
remark: '年度功德'
});
assert.equal(normalizeMeritRecord({
meritId: Number('2060000000000000001'),
genealogyId: '2',
donorName: '叶明',
meritType: 'donation',
meritTitle: '捐赠',
amount: '1',
status: '0'
}), null);
});
test('功德记录详情转义内容并隐藏手机号和内部编号', () => {
const normalizeMeritRecord = requireFunction('normalizeMeritRecord');
const renderMeritDetail = requireFunction('renderMeritDetail');
const record = normalizeMeritRecord({
meritId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
donorName: '<script>alert(1)</script>',
meritType: 'public',
meritTitle: '公益助学',
meritContent: '<img src=x onerror=alert(1)>资助学生',
amount: '500.50',
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0'
});
const html = renderMeritDetail(record);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /资助学生/);
assert.match(html, /公益/);
assert.match(html, /500\.50/);
assert.doesNotMatch(html, /19100000000|>1<|>2<|>3</);
});
test('功德记录写后重读必须返回同一条稳定记录', () => {
const matchesSavedMerit = requireFunction('matchesSavedMerit');
const detail = {
meritId: '2060000000000000001',
genealogyId: '2060000000000000002',
donorName: '叶明',
meritType: 'donation',
meritTitle: '捐赠',
amount: '500',
meritTime: '2026-07-26 10:00:00',
status: '0'
};
assert.equal(matchesSavedMerit(detail, '2060000000000000001'), true);
assert.equal(matchesSavedMerit(detail, '2060000000000000009'), false);
assert.equal(matchesSavedMerit({}, '2060000000000000001'), false);
});
test('功德记录编辑器拒绝不可重读的停用记录', () => {
const isEditableMerit = requireFunction('isEditableMerit');
assert.equal(isEditableMerit({ status: '0' }), true);
assert.equal(isEditableMerit({ status: '1' }), false);
assert.equal(isEditableMerit(null), false);
});
test('功德录页面开放 PC CRUD 且不制造附件和契约外类型', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-merit.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-merit-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(projectRoot, 'public', 'css', 'profile-module.css'), 'utf8');
assert.doesNotMatch(listPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(listPage, /data-merit-list/);
assert.match(listPage, /data-merit-detail/);
assert.doesNotMatch(editPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(editPage, /name="meritTime" type="datetime-local"/);
assert.match(editPage, /value="donation"/);
assert.match(editPage, /value="repair"/);
assert.match(editPage, /value="public"/);
assert.match(editPage, /value="other"/);
assert.doesNotMatch(editPage, /value="service"/);
assert.match(editPage, /name="amount" type="number" step="any"/);
assert.match(editPage, /name="sortOrder"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.doesNotMatch(editPage, /mediaOssIds|type="file"|upload-pages\.js/);
assert.match(editPage, /public\/js\/rich-editor\.js/);
assert.match(editPage, /public\/js\/merit-pages\.js/);
assert.match(styles, /\[data-merit-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+98 -11
View File
@@ -5,22 +5,109 @@ const test = require('node:test');
const NotificationPages = require('../public/js/notification-pages.js');
test('notification page preserves only the unexpanded PC list records and unread count', () => {
const rows = [{ notificationId: '2060000000000000001', title: '系统通知' }];
function notificationFixture(overrides) {
return Object.assign({
notificationId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
senderUserId: '2060000000000000003',
senderNickName: '叶子',
senderPhone: '19100000000',
noticeType: 'family_feed_comment',
noticeTitle: '新的评论',
noticeContent: '有人评论了家族动态',
bizType: 'family_feed_comment',
bizId: '2060000000000000004',
bizSummary: '家族圈评论',
publishTime: '2026-07-29 10:30:00',
readStatus: '0',
readTime: null,
status: '0',
remark: ''
}, overrides || {});
}
assert.deepEqual(NotificationPages.normalizeNotifications(rows), rows);
assert.deepEqual(NotificationPages.normalizeNotifications({ rows }), []);
assert.equal(NotificationPages.getUnreadCount(3), 3);
assert.equal(NotificationPages.getUnreadCount(-1), 0);
assert.equal(NotificationPages.formatNotification(rows[0]), '{\n "notificationId": "2060000000000000001",\n "title": "系统通知"\n}');
test('通知筛选只允许全部、未读和已读三种查询', () => {
assert.deepEqual(NotificationPages.buildNotificationQuery(''), {});
assert.deepEqual(NotificationPages.buildNotificationQuery('0'), { readStatus: '0' });
assert.deepEqual(NotificationPages.buildNotificationQuery('1'), { readStatus: '1' });
assert.deepEqual(NotificationPages.buildNotificationQuery('unsafe'), {});
});
test('message page loads the PC notification module instead of pending-page controls', () => {
test('通知列表只接受 PC 的非分页数组并规范完整 NotificationView', () => {
const record = notificationFixture();
assert.deepEqual(NotificationPages.normalizeNotifications([record]), [
NotificationPages.normalizeNotification(record)
]);
assert.deepEqual(NotificationPages.normalizeNotifications({ rows: [record] }), []);
assert.equal(NotificationPages.normalizeNotification({
...record,
notificationId: Number('2060000000000000001')
}), null);
assert.equal(NotificationPages.normalizeNotification({
...record,
readStatus: '2'
}), null);
});
test('通知可选业务 ID 接受 null 但拒绝不安全数字', () => {
assert.ok(NotificationPages.normalizeNotification(notificationFixture({
genealogyId: null,
senderUserId: null,
bizId: null
})));
assert.equal(NotificationPages.normalizeNotification(notificationFixture({
bizId: Number('2060000000000000004')
})), null);
});
test('未读数只接受非负安全整数', () => {
assert.equal(NotificationPages.getUnreadCount(3), 3);
assert.equal(NotificationPages.getUnreadCount('3'), 3);
assert.equal(NotificationPages.getUnreadCount(-1), 0);
assert.equal(NotificationPages.getUnreadCount(1.5), 0);
assert.equal(NotificationPages.getUnreadCount(Number.MAX_SAFE_INTEGER + 1), 0);
});
test('通知列表和详情转义正文且不暴露手机号或内部 ID', () => {
const record = NotificationPages.normalizeNotification(notificationFixture({
noticeTitle: '<script>alert(1)</script>',
noticeContent: '<img src=x onerror=alert(1)>通知正文'
}));
const rowHtml = NotificationPages.renderNotificationRow(record);
const detailHtml = NotificationPages.renderNotificationDetail(record);
assert.doesNotMatch(rowHtml + detailHtml, /<script>|<img/);
assert.match(rowHtml + detailHtml, /通知正文/);
assert.match(rowHtml + detailHtml, /未读/);
assert.doesNotMatch(rowHtml + detailHtml, /19100000000|>206000000000000000[1-4]</);
});
test('通知详情必须与请求的稳定通知编号一致', () => {
const record = notificationFixture();
assert.equal(NotificationPages.matchesNotification(record, '2060000000000000001'), true);
assert.equal(NotificationPages.matchesNotification(record, '2060000000000000009'), false);
assert.equal(NotificationPages.matchesNotification({}, '2060000000000000001'), false);
});
test('只有未读且正常的通知提供单条已读动作', () => {
assert.equal(NotificationPages.canMarkRead({ readStatus: '0', status: '0' }), true);
assert.equal(NotificationPages.canMarkRead({ readStatus: '1', status: '0' }), false);
assert.equal(NotificationPages.canMarkRead({ readStatus: '0', status: '1' }), false);
});
test('消息页提供筛选、业务详情和单条/全部已读,不显示原始 JSON', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-messages.html'), 'utf8');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.match(source, /src="public\/js\/notification-pages\.js"/);
assert.doesNotMatch(source, /src="public\/js\/pending-pages\.js"/);
assert.doesNotMatch(source, /data-feature-status="pending"|notification-raw|原始记录/);
assert.match(source, /data-notification-filter/);
assert.match(source, /data-notification-list/);
assert.match(source, /data-notification-detail/);
assert.match(source, /data-notification-unread-count/);
assert.match(source, /data-notification-read-all/);
assert.match(source, /src="public\/js\/notification-pages\.js"/);
assert.doesNotMatch(source, /src="public\/js\/pending-pages\.js"/);
});
+5 -2
View File
@@ -36,9 +36,8 @@ test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
test('pages do not load scripts for unavailable business APIs', () => {
const removedScripts = [
'album-pages.js', 'article-pages.js', 'ceremony-pages.js',
'feedback-pages.js', 'genealogy-pages.js',
'help-pages.js', 'join-apply-pages.js', 'member-admin-pages.js',
'help-pages.js', 'join-apply-pages.js',
'promotion-pages.js', 'vip-pages.js'
];
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
@@ -55,6 +54,10 @@ test('pages do not load scripts for unavailable business APIs', () => {
});
assert.equal(read('profile-generation.html').includes('src="public/js/generation-pages.js"'), true, 'profile-generation.html must load generation-pages.js');
assert.equal(read('profile-tree.html').includes('src="public/js/lineage-pages.js"'), true, 'profile-tree.html must load lineage-pages.js');
assert.equal(read('profile-family-admin.html').includes('src="public/js/member-admin-pages.js"'), true, 'profile-family-admin.html must load member-admin-pages.js');
assert.equal(read('profile-video.html').includes('src="public/js/video-pages.js"'), true, 'profile-video.html must load video-pages.js');
assert.equal(read('profile-gift.html').includes('src="public/js/ceremony-pages.js"'), true, 'profile-gift.html must load ceremony-pages.js');
assert.equal(read('profile-ceremony.html').includes('src="public/js/ceremony-admin-pages.js"'), true, 'profile-ceremony.html must load ceremony-admin-pages.js');
assert.equal(read('profile-relative.html').includes('src="public/js/relative-pages.js"'), true, 'profile-relative.html must load relative-pages.js');
assert.equal(read('profile-memo.html').includes('src="public/js/memo-pages.js"'), true, 'profile-memo.html must load memo-pages.js');
});
+19 -14
View File
@@ -7,24 +7,12 @@ const PageAvailability = require('../public/js/pending-pages.js');
const root = path.resolve(__dirname, '..');
const familyPendingPages = [
'profile-families.html',
'profile-create-family.html',
'profile-join-family.html',
'profile-join-review.html',
'profile-family-admin.html',
'profile-invite.html',
];
const contentPendingPages = [
'profile-content.html',
'profile-article.html',
'profile-article-edit.html',
'profile-album.html',
'profile-video.html',
'profile-gift.html',
'profile-gift-edit.html',
'profile-merit.html',
'profile-merit-edit.html',
'profile-feedback.html',
'profile-admin-permissions.html',
'profile-data-reminders.html'
@@ -65,16 +53,32 @@ test('待开发页面显式加载状态脚本', () => {
});
test('已接入的家谱业务页面保持真实功能状态', () => {
['profile-feed.html', 'profile-feed-edit.html', 'profile-generation.html', 'profile-tree.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-messages.html'].forEach((page) => {
['profile-families.html', 'profile-create-family.html', 'profile-family-admin.html', 'profile-content.html', 'profile-feed.html', 'profile-feed-edit.html', 'profile-feed-detail.html', 'profile-generation.html', 'profile-tree.html', 'profile-article.html', 'profile-article-edit.html', 'profile-album.html', 'profile-album-edit.html', 'profile-album-detail.html', 'profile-video.html', 'profile-video-edit.html', 'profile-gift.html', 'profile-gift-edit.html', 'profile-ceremony.html', 'profile-ceremony-detail.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html', 'profile-merit-edit.html', 'profile-messages.html'].forEach((page) => {
assert.doesNotMatch(read(page), /data-feature-status="pending"/, `${page} 不应标记为待开发`);
});
assert.match(read('profile-generation.html'), /src="public\/js\/generation-pages\.js"/);
assert.match(read('profile-families.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-create-family.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-tree.html'), /src="public\/js\/lineage-pages\.js"/);
assert.match(read('profile-family-admin.html'), /src="public\/js\/member-admin-pages\.js"/);
assert.match(read('profile-article.html'), /src="public\/js\/article-pages\.js"/);
assert.match(read('profile-article-edit.html'), /src="public\/js\/article-pages\.js"/);
assert.match(read('profile-album.html'), /src="public\/js\/album-pages\.js"/);
assert.match(read('profile-album-edit.html'), /src="public\/js\/album-pages\.js"/);
assert.match(read('profile-album-detail.html'), /src="public\/js\/album-pages\.js"/);
assert.match(read('profile-video.html'), /src="public\/js\/video-pages\.js"/);
assert.match(read('profile-video-edit.html'), /src="public\/js\/video-pages\.js"/);
assert.match(read('profile-gift.html'), /src="public\/js\/ceremony-pages\.js"/);
assert.match(read('profile-gift-edit.html'), /src="public\/js\/ceremony-admin-pages\.js"/);
assert.match(read('profile-ceremony.html'), /src="public\/js\/ceremony-admin-pages\.js"/);
assert.match(read('profile-ceremony-detail.html'), /src="public\/js\/ceremony-admin-pages\.js"/);
assert.match(read('profile-growth.html'), /src="public\/js\/growth-pages\.js"/);
assert.match(read('profile-growth-edit.html'), /src="public\/js\/growth-pages\.js"/);
assert.match(read('profile-relative.html'), /src="public\/js\/relative-pages\.js"/);
assert.match(read('profile-relative-edit.html'), /src="public\/js\/relative-pages\.js"/);
assert.match(read('profile-memo.html'), /src="public\/js\/memo-pages\.js"/);
assert.match(read('profile-merit.html'), /src="public\/js\/merit-pages\.js"/);
assert.match(read('profile-merit-edit.html'), /src="public\/js\/merit-pages\.js"/);
assert.match(read('profile-messages.html'), /src="public\/js\/notification-pages\.js"/);
assert.match(read('profile-memo-edit.html'), /src="public\/js\/memo-pages\.js"/);
});
@@ -89,7 +93,7 @@ test('待开发的家谱管理页明确放行已接入的字辈入口并透传
});
test('待开发页面仅放行显式标记的可用功能入口', () => {
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-article.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feedback.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feed.html', 'data-feature-link': 'available' })), false);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
});
@@ -99,6 +103,7 @@ test('内容入口明确保留家族圈动态跳转', () => {
assert.match(read('profile-content.html'), /href="profile-growth\.html" data-feature-link="available" data-genealogy-context-link/);
assert.match(read('profile-content.html'), /href="profile-relative\.html" data-feature-link="available" data-genealogy-context-link/);
assert.match(read('profile-content.html'), /href="profile-memo\.html" data-feature-link="available" data-genealogy-context-link/);
assert.match(read('profile-content.html'), /href="profile-merit\.html" data-feature-link="available" data-genealogy-context-link/);
});
test('家谱主页明确保留家族圈动态跳转', () => {
+188 -10
View File
@@ -1,23 +1,201 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const RelativePages = require('../public/js/relative-pages.js');
test('亲友往来页只从 URL 读取真实家谱编号', () => {
assert.equal(RelativePages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
test('亲友记录页只从 URL 读取安全的真实家谱和记录编号', () => {
assert.equal(
RelativePages.getCurrentGenealogyId('?genealogyId=2060000000000000001&relativeId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
RelativePages.getCurrentRelativeId('?genealogyId=2060000000000000001&relativeId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(RelativePages.getCurrentGenealogyId(''), '');
assert.equal(RelativePages.getCurrentRelativeId('?relativeId=unsafe'), '');
});
test('亲友往来写入只构造 Apifox RelativeRecordBody 字段', () => {
test('亲友记录写入只构造 YAML RelativeRecordBody 并转换后端时间格式', () => {
assert.deepEqual(RelativePages.buildRelativeRecordBody({
relativeName: ' 王叔 ', relationName: '长辈', eventName: '寿宴', eventTime: '2026-07-24T09:00:00+08:00',
giftAmount: '500.5', recordContent: ' 出席 ', mediaOssIds: '101,102', sortOrder: '3', status: 'enabled', memoTitle: '旧字段'
}), { relativeName: '王叔', relationName: '长辈', eventName: '寿宴', eventTime: '2026-07-24T09:00:00+08:00', giftAmount: 500.5, recordContent: '出席', mediaOssIds: '101,102', status: 'enabled', sortOrder: 3 });
relativeName: ' 王叔 ',
relationName: ' 长辈 ',
eventName: ' 寿宴 ',
eventTime: '2026-07-24T09:30',
giftAmount: '500.5',
recordContent: ' 出席 ',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: '3',
status: '0',
relativeId: 'should-not-send',
appUserId: 'should-not-send'
}), {
relativeName: '王叔',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: 500.5,
recordContent: '出席',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: 3,
status: '0'
});
});
test('亲友往来校验必填姓名、金额、附件排序', () => {
test('亲友记录校验姓名、金额、时间、附件排序和可重读状态', () => {
assert.equal(RelativePages.validateRelativeRecordBody({ relativeName: '' }), '请填写亲友姓名');
assert.equal(RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', giftAmount: '一百' })), '礼金金额必须是数字');
assert.equal(RelativePages.validateRelativeRecordBody({ relativeName: '王叔', mediaOssIds: '101, 102' }), '附件 OSS ID 请使用英文逗号分隔的正整数');
assert.equal(RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', sortOrder: '1.5' })), '排序值必须是安全整数');
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', giftAmount: '一百' })),
'礼金金额必须是有限数字'
);
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({
relativeName: '王叔',
giftAmount: '9007199254740993.01'
})),
'礼金金额超出浏览器可安全提交的精度'
);
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', giftAmount: '500.50' })),
''
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', eventTime: '2026-07-24T09:30:00+08:00' }),
'事件时间格式无效'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', eventTime: '2026-99-24 09:30:00' }),
'事件时间格式无效'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', mediaOssIds: '101, 102' }),
'附件上传结果无效'
);
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', status: '1' }),
'当前 PC 无法重新读取停用记录,暂不开放停用'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', giftAmount: -500.25, status: '0' }),
''
);
});
test('亲友记录响应使用 PC RelativeRecordVo 并保留 BigDecimal 字符串', () => {
assert.deepEqual(RelativePages.normalizeRelativeRecord({
relativeId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
relativeName: '王叔',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50',
recordContent: '出席',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '往来记录'
}), {
relativeId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
relativeName: '王叔',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50',
recordContent: '出席',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '往来记录'
});
assert.equal(RelativePages.normalizeRelativeRecord({
relativeId: Number('2060000000000000001'),
genealogyId: '2',
relativeName: '王叔',
status: '0'
}), null);
});
test('亲友记录渲染业务字段但不暴露手机号、OSS ID 或未转义内容', () => {
const record = RelativePages.normalizeRelativeRecord({
relativeId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
relativeName: '<script>alert(1)</script>',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50',
recordContent: '<img src=x onerror=alert(1)>出席',
mediaOssIds: '4,5',
sortOrder: 1,
status: '0'
});
const html = RelativePages.renderRelativeDetail(record);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /出席/);
assert.match(html, /500\.50/);
assert.match(html, /2 个附件/);
assert.doesNotMatch(html, /19100000000|>4<|>5</);
});
test('亲友记录写后重读必须返回同一条稳定记录', () => {
const detail = {
relativeId: '2060000000000000001',
genealogyId: '2060000000000000002',
relativeName: '王叔',
status: '0'
};
assert.equal(RelativePages.matchesSavedRecord(detail, '2060000000000000001'), true);
assert.equal(RelativePages.matchesSavedRecord(detail, '2060000000000000009'), false);
assert.equal(RelativePages.matchesSavedRecord({}, '2060000000000000001'), false);
});
test('亲友记录编辑器拒绝不可重读的停用记录', () => {
assert.equal(RelativePages.isEditableRecord({ status: '0' }), true);
assert.equal(RelativePages.isEditableRecord({ status: '1' }), false);
assert.equal(RelativePages.isEditableRecord(null), false);
});
test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或状态', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-relative.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-relative-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(listPage, /data-relative-list/);
assert.match(listPage, /data-relative-detail/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="eventTime" type="datetime-local"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-relative-clear-media/);
assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/relative-pages\.js/);
assert.match(styles, /\[data-relative-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+4 -1
View File
@@ -161,8 +161,11 @@ test('duplicate file submissions share one active upload', async () => {
test('business upload pages never expose editable OSS ID fields', () => {
const pages = [
'profile-data.html',
'profile-create-family.html',
'profile-article-edit.html',
'profile-album.html',
'profile-album-edit.html',
'profile-album-detail.html',
'profile-video-edit.html',
'profile-gift-edit.html',
'profile-growth-edit.html',
'profile-memo-edit.html',
+35
View File
@@ -0,0 +1,35 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.join(__dirname, '..');
function read(file) {
return fs.readFileSync(path.join(root, file), 'utf8');
}
test('内容发布页为全部家谱功能入口透传真实家谱上下文', () => {
const page = read('profile-content.html');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js|最近内容加载中/);
['profile-article.html', 'profile-album.html', 'profile-video.html', 'profile-merit.html', 'profile-gift.html', 'profile-feed.html', 'profile-growth.html', 'profile-relative.html', 'profile-memo.html'].forEach((href) => {
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少家谱上下文透传`);
});
});
test('家谱主页公开谱文、相册、视频、功德和祭祀活动入口并透传上下文', () => {
const page = read('profile-family-home.html');
['profile-article.html', 'profile-album.html', 'profile-video.html', 'profile-merit.html', 'profile-ceremony.html'].forEach((href) => {
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少家谱主页入口`);
});
});
test('个人中心功能入口没有把家谱业务页当作无上下文普通链接', () => {
const page = read('profile.html');
['profile-article.html', 'profile-album.html', 'profile-video.html', 'profile-merit.html', 'profile-gift.html'].forEach((href) => {
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少个人中心上下文入口`);
});
});
+154
View File
@@ -0,0 +1,154 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const VideoPages = require('../public/js/video-pages.js');
test('视频页只从 URL 读取真实家谱和视频编号', () => {
assert.equal(VideoPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&videoId=2060000000000000002'), '2060000000000000001');
assert.equal(VideoPages.getCurrentVideoId('?genealogyId=2060000000000000001&videoId=2060000000000000002'), '2060000000000000002');
assert.equal(VideoPages.getCurrentGenealogyId(''), '');
assert.equal(VideoPages.getCurrentVideoId('?videoId=unsafe'), '');
});
test('视频写入只构造 YAML VideoBody 字段并保持 OSS ID 为字符串', () => {
assert.deepEqual(VideoPages.buildVideoBody({
videoTitle: ' 家族活动记录 ',
videoDesc: ' 清明祭祖活动视频 ',
coverOssId: '2060000000000000001',
videoOssId: '2060000000000000002',
durationSeconds: '181',
sortOrder: '3',
status: '0',
publisherUserId: 'should-not-send',
viewCount: 99
}), {
videoTitle: '家族活动记录',
videoDesc: '清明祭祖活动视频',
coverOssId: '2060000000000000001',
videoOssId: '2060000000000000002',
durationSeconds: 181,
sortOrder: 3,
status: '0'
});
});
test('视频校验标题、上传文件、时长、排序和状态', () => {
assert.equal(VideoPages.validateVideoBody({ videoTitle: '', videoOssId: '1' }), '请填写视频标题');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题' }), '请先选择并上传视频文件');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '0' }), '视频文件上传结果无效');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', coverOssId: 'abc' }), '封面上传结果无效');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', durationSeconds: -1 }), '视频时长不能小于 0');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', sortOrder: undefined, invalidSortOrder: true }), '排序值必须是安全整数');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', status: '1' }), '当前 PC 无法重新读取停用视频,暂不开放停用');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', status: '0' }), '');
});
test('视频响应使用 PC VideoVo 字段并拒绝不安全长 ID', () => {
assert.deepEqual(VideoPages.normalizeVideo({
videoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
videoTitle: '祭祖活动记录',
videoDesc: '活动影像',
coverOssId: '2060000000000000003',
videoOssId: '2060000000000000004',
durationSeconds: 180,
publisherUserId: '2060000000000000005',
publisherNickName: '叶子',
publisherPhone: '19100000000',
publishTime: '2026-07-29 10:00:00',
viewCount: 12,
sortOrder: 1,
status: '0',
remark: '归档'
}), {
videoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
videoTitle: '祭祖活动记录',
videoDesc: '活动影像',
coverOssId: '2060000000000000003',
videoOssId: '2060000000000000004',
durationSeconds: 180,
publisherUserId: '2060000000000000005',
publisherNickName: '叶子',
publisherPhone: '19100000000',
publishTime: '2026-07-29 10:00:00',
viewCount: 12,
sortOrder: 1,
status: '0',
remark: '归档'
});
assert.equal(VideoPages.normalizeVideo({
videoId: Number('2060000000000000001'),
genealogyId: '2',
videoTitle: '标题',
videoOssId: '3',
status: '0'
}), null);
});
test('视频渲染展示业务信息但不直接暴露 OSS ID 和发布人手机号', () => {
const video = VideoPages.normalizeVideo({
videoId: '1',
genealogyId: '2',
videoTitle: '<script>alert(1)</script>',
videoDesc: '活动影像',
coverOssId: '3',
videoOssId: '4',
durationSeconds: 180,
publisherUserId: '5',
publisherNickName: '叶子',
publisherPhone: '19100000000',
publishTime: '2026-07-29 10:00:00',
viewCount: 12,
sortOrder: 1,
status: '0'
});
const html = VideoPages.renderVideoDetail(video, true);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /活动影像/);
assert.match(html, /叶子/);
assert.match(html, /已上传/);
assert.doesNotMatch(html, />3</);
assert.doesNotMatch(html, />4</);
assert.doesNotMatch(html, /19100000000/);
});
test('视频文件元数据时长按秒取整且拒绝无效值', () => {
assert.equal(VideoPages.normalizeDurationSeconds(180.2), 181);
assert.equal(VideoPages.normalizeDurationSeconds(0), 0);
assert.equal(VideoPages.normalizeDurationSeconds(-1), undefined);
assert.equal(VideoPages.normalizeDurationSeconds(Infinity), undefined);
});
test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-video.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-video-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.doesNotMatch(listPage, /data-feature-status="pending"/);
assert.match(listPage, /data-video-list/);
assert.match(listPage, /data-video-detail/);
assert.match(listPage, /data-video-management hidden/);
assert.match(listPage, /src="public\/js\/video-pages\.js"/);
assert.match(editPage, /data-video-form data-video-management hidden/);
assert.match(editPage, /name="videoTitle"/);
assert.match(editPage, /name="videoDesc"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="videoOssId" type="hidden"/);
assert.match(editPage, /name="durationSeconds"[^>]+readonly/);
assert.doesNotMatch(editPage, /name="(?:coverOssId|videoOssId)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/video-pages\.js/);
assert.match(styles, /\[data-video-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
});
+223 -14
View File
@@ -163,6 +163,9 @@
}
function toRequiredPathId(value, label) {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
throw new Error('无效' + label);
}
var text = String(value === undefined || value === null ? '' : value).trim();
if (!text) throw new Error('缺少' + label);
@@ -238,38 +241,65 @@
}
function buildMeritRecordPath(genealogyId, meritId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') +
'/merit-records/' + toRequiredPathId(meritId, '功德记录编号');
var path = '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/merit-records';
return meritId === undefined || meritId === null || meritId === ''
? path
: path + '/' + toRequiredPathId(meritId, '功德记录编号');
}
function buildArticleCollectionPath(genealogyId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/articles';
}
function buildArticlePath(genealogyId, articleId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '谱编号') +
'/articles/' + toRequiredPathId(articleId, '谱文编号');
return buildArticleCollectionPath(genealogyId) + '/' + toRequiredPathId(articleId, '谱编号');
}
function buildAlbumCollectionPath(genealogyId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/albums';
}
function buildAlbumPath(genealogyId, albumId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') +
'/albums/' + toRequiredPathId(albumId, '相册编号');
return buildAlbumCollectionPath(genealogyId) + '/' + toRequiredPathId(albumId, '相册编号');
}
function buildAlbumPhotoCollectionPath(genealogyId, albumId) {
return buildAlbumPath(genealogyId, albumId) + '/photos';
}
function buildAlbumPhotoPath(genealogyId, albumId, photoId) {
return buildAlbumPath(genealogyId, albumId) + '/photos/' + toRequiredPathId(photoId, '相册照片编号');
return buildAlbumPhotoCollectionPath(genealogyId, albumId) + '/' + toRequiredPathId(photoId, '相册照片编号');
}
function buildVideoCollectionPath(genealogyId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/videos';
}
function buildVideoPath(genealogyId, videoId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') +
'/videos/' + toRequiredPathId(videoId, '视频编号');
return buildVideoCollectionPath(genealogyId) + '/' + toRequiredPathId(videoId, '视频编号');
}
function buildCeremonyCollectionPath(genealogyId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/ceremonies';
}
function buildCeremonyPath(genealogyId, ceremonyId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') +
'/ceremonies/' + toRequiredPathId(ceremonyId, '祭祀活动编号');
return buildCeremonyCollectionPath(genealogyId) + '/' + toRequiredPathId(ceremonyId, '祭祀活动编号');
}
function buildCeremonyGiftPath(genealogyId, ceremonyId, giftId) {
return buildCeremonyPath(genealogyId, ceremonyId) + '/gifts/' + toRequiredPathId(giftId, '祭品编号');
}
function buildGenealogyMemberCollectionPath(genealogyId) {
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/members';
}
function buildGenealogyMemberPath(genealogyId, memberId) {
return buildGenealogyMemberCollectionPath(genealogyId) + '/' + toRequiredPathId(memberId, '成员编号');
}
async function login(body) {
var data = await request('POST', '/genealogy/pc/auth/login', {
auth: false,
@@ -437,8 +467,57 @@
genealogyQuota: function () {
return request('GET', '/genealogy/pc/genealogies/quota');
},
genealogiesMine: function () {
return request('GET', '/genealogy/pc/genealogies/mine');
},
genealogyOptions: function (query) {
return request('GET', '/genealogy/pc/genealogies/options', { query: query });
},
genealogyDetail: function (genealogyId) {
return request('GET', '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号'));
},
genealogyOverview: function (genealogyId) {
return request('GET', '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/overview');
},
createGenealogy: function (body) {
return request('POST', '/genealogy/pc/genealogies', {
body: pickDefined(body, [
'genealogyName', 'surname', 'regionCode', 'ancestralHall', 'originPlace',
'addressDetail', 'coverOssId', 'intro', 'visibility', 'joinMode'
])
});
},
applyToGenealogy: function (genealogyId, body) {
return request('POST', '/genealogy/pc/genealogies/' +
toRequiredPathId(genealogyId, '家谱编号') + '/join-applies', {
body: pickDefined(body, ['applicantName', 'phone', 'relationDesc', 'applyReason'])
});
},
myGenealogyJoinApplies: function () {
return request('GET', '/genealogy/pc/genealogies/join-applies/mine');
},
pendingGenealogyJoinApplies: function (genealogyId) {
return request('GET', '/genealogy/pc/genealogies/' +
toRequiredPathId(genealogyId, '家谱编号') + '/join-applies/pending');
},
auditGenealogyJoinApply: function (genealogyId, applyId, body) {
return request('PUT', '/genealogy/pc/genealogies/' +
toRequiredPathId(genealogyId, '家谱编号') + '/join-applies/' +
toRequiredPathId(applyId, '申请编号') + '/audit', {
body: pickDefined(body, ['status', 'auditRemark'])
});
},
cancelGenealogyJoinApply: function (applyId) {
return request('DELETE', '/genealogy/pc/genealogies/join-applies/' +
toRequiredPathId(applyId, '申请编号'));
},
notifications: function (query) {
return request('GET', '/genealogy/pc/notifications', { query: query });
return request('GET', '/genealogy/pc/notifications', {
query: pickDefined(query, ['readStatus'])
});
},
notificationDetail: function (notificationId) {
return request('GET', '/genealogy/pc/notifications/' + toRequiredPathId(notificationId, '通知编号'));
},
unreadNotificationCount: function () {
return request('GET', '/genealogy/pc/notifications/unread-count');
@@ -590,23 +669,131 @@
deleteMemo: function (genealogyId, memoId) {
return request('DELETE', buildMemoPath(genealogyId, memoId));
},
meritRecords: function (genealogyId) {
return request('GET', buildMeritRecordPath(genealogyId));
},
createMeritRecord: function (genealogyId, body) {
return request('POST', buildMeritRecordPath(genealogyId), {
body: pickDefined(body, ['donorName', 'meritType', 'meritTitle', 'meritContent', 'amount', 'meritTime', 'sortOrder', 'status'])
});
},
meritRecordDetail: function (genealogyId, meritId) {
return request('GET', buildMeritRecordPath(genealogyId, meritId));
},
updateMeritRecord: function (genealogyId, meritId, body) {
return request('PUT', buildMeritRecordPath(genealogyId, meritId), {
body: pickDefined(body, ['donorName', 'meritType', 'meritTitle', 'meritContent', 'amount', 'meritTime', 'sortOrder', 'status'])
});
},
deleteMeritRecord: function (genealogyId, meritId) {
return request('DELETE', buildMeritRecordPath(genealogyId, meritId));
},
articles: function (genealogyId) {
return request('GET', buildArticleCollectionPath(genealogyId));
},
createArticle: function (genealogyId, body) {
return request('POST', buildArticleCollectionPath(genealogyId), {
body: pickDefined(body, [
'categoryId', 'articleTitle', 'articleSummary', 'coverOssId',
'articleContent', 'authorName', 'sortOrder', 'status'
])
});
},
articleDetail: function (genealogyId, articleId) {
return request('GET', buildArticlePath(genealogyId, articleId));
},
updateArticle: function (genealogyId, articleId, body) {
return request('PUT', buildArticlePath(genealogyId, articleId), {
body: pickDefined(body, [
'categoryId', 'articleTitle', 'articleSummary', 'coverOssId',
'articleContent', 'authorName', 'sortOrder', 'status'
])
});
},
deleteArticle: function (genealogyId, articleId) {
return request('DELETE', buildArticlePath(genealogyId, articleId));
},
albums: function (genealogyId) {
return request('GET', buildAlbumCollectionPath(genealogyId));
},
createAlbum: function (genealogyId, body) {
return request('POST', buildAlbumCollectionPath(genealogyId), {
body: pickDefined(body, ['albumName', 'albumDesc', 'coverOssId', 'sortOrder', 'status'])
});
},
updateAlbum: function (genealogyId, albumId, body) {
return request('PUT', buildAlbumPath(genealogyId, albumId), {
body: pickDefined(body, ['albumName', 'albumDesc', 'coverOssId', 'sortOrder', 'status'])
});
},
albumPhotos: function (genealogyId, albumId) {
return request('GET', buildAlbumPhotoCollectionPath(genealogyId, albumId));
},
createAlbumPhoto: function (genealogyId, albumId, body) {
return request('POST', buildAlbumPhotoCollectionPath(genealogyId, albumId), {
body: pickDefined(body, [
'ossId', 'photoTitle', 'photoDesc', 'photographer',
'shootTime', 'sortOrder', 'status'
])
});
},
deleteAlbum: function (genealogyId, albumId) {
return request('DELETE', buildAlbumPath(genealogyId, albumId));
},
deleteAlbumPhoto: function (genealogyId, albumId, photoId) {
return request('DELETE', buildAlbumPhotoPath(genealogyId, albumId, photoId));
},
videos: function (genealogyId) {
return request('GET', buildVideoCollectionPath(genealogyId));
},
createVideo: function (genealogyId, body) {
return request('POST', buildVideoCollectionPath(genealogyId), { body: body });
},
videoDetail: function (genealogyId, videoId) {
return request('GET', buildVideoPath(genealogyId, videoId));
},
updateVideo: function (genealogyId, videoId, body) {
return request('PUT', buildVideoPath(genealogyId, videoId), { body: body });
},
deleteVideo: function (genealogyId, videoId) {
return request('DELETE', buildVideoPath(genealogyId, videoId));
},
replaceCeremonyInvitees: function (genealogyId, ceremonyId, body) {
return request('PUT', buildCeremonyPath(genealogyId, ceremonyId) + '/invitees', { body: body });
ceremonies: function (genealogyId) {
return request('GET', buildCeremonyCollectionPath(genealogyId));
},
createCeremony: function (genealogyId, body) {
return request('POST', buildCeremonyCollectionPath(genealogyId), {
body: pickDefined(body, [
'ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'ceremonyTime',
'location', 'locationAddress', 'longitude', 'latitude',
'coverOssId', 'sortOrder', 'status'
])
});
},
ceremonyDetail: function (genealogyId, ceremonyId) {
return request('GET', buildCeremonyPath(genealogyId, ceremonyId));
},
updateCeremony: function (genealogyId, ceremonyId, body) {
return request('PUT', buildCeremonyPath(genealogyId, ceremonyId), {
body: pickDefined(body, [
'ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'ceremonyTime',
'location', 'locationAddress', 'longitude', 'latitude',
'coverOssId', 'sortOrder', 'status'
])
});
},
ceremonyGifts: function (genealogyId, ceremonyId) {
return request('GET', buildCeremonyPath(genealogyId, ceremonyId) + '/gifts');
},
createCeremonyGift: function (genealogyId, ceremonyId, body) {
return request('POST', buildCeremonyPath(genealogyId, ceremonyId) + '/gifts', {
body: pickDefined(body, ['giverName', 'giftAmount', 'giftMessage'])
});
},
replaceCeremonyInvitees: function (genealogyId, ceremonyId, inviteeUserIds) {
return request('PUT', buildCeremonyPath(genealogyId, ceremonyId) + '/invitees', {
body: { inviteeUserIds: Array.isArray(inviteeUserIds) ? inviteeUserIds : [] }
});
},
ceremonyInvitations: function (genealogyId, ceremonyId) {
return request('GET', buildCeremonyPath(genealogyId, ceremonyId) + '/invitations');
@@ -622,6 +809,28 @@
},
deleteCeremonyGift: function (genealogyId, ceremonyId, giftId) {
return request('DELETE', buildCeremonyGiftPath(genealogyId, ceremonyId, giftId));
},
genealogyMembers: function (genealogyId) {
return request('GET', buildGenealogyMemberCollectionPath(genealogyId));
},
genealogyMemberOptions: function (genealogyId) {
return request('GET', buildGenealogyMemberCollectionPath(genealogyId) + '/options');
},
updateGenealogyMember: function (genealogyId, memberId, body) {
return request('PUT', buildGenealogyMemberPath(genealogyId, memberId), {
body: pickDefined(body, ['memberName', 'relationName', 'roleType', 'lineagePersonId'])
});
},
removeGenealogyMember: function (genealogyId, memberId) {
return request('DELETE', buildGenealogyMemberPath(genealogyId, memberId));
},
leaveGenealogy: function (genealogyId) {
return request('DELETE', buildGenealogyMemberCollectionPath(genealogyId) + '/me');
},
transferGenealogyOwner: function (genealogyId, body) {
return request('PUT', buildGenealogyMemberCollectionPath(genealogyId) + '/owner-transfer', {
body: pickDefined(body, ['targetMemberId'])
});
}
};
}