待测试

This commit is contained in:
rain
2026-07-27 17:17:31 +08:00
parent 1eae3bbef4
commit 04287e6777
35 changed files with 4238 additions and 22750 deletions
-11160
View File
File diff suppressed because it is too large Load Diff
-7932
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -401,18 +401,14 @@ export const findTreeMemberPresentationFixture = (genealogyId, personId) => {
// mock API 只能通过下方 list/find 查询取得深拷贝,不能把页面草稿写回正式列表。 // mock API 只能通过下方 list/find 查询取得深拷贝,不能把页面草稿写回正式列表。
const familyFeeds = [ const familyFeeds = [
{ {
id: '1', genealogyId: '1001', tag: '团圆记忆', time: '今天 10:24', feedId: '1', genealogyId: '1001', feedType: '团圆记忆', createTime: '今天 10:24',
title: '端午家宴', content: '今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。', feedContent: '今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。',
author: '汤正国', publisherNickName: '汤正国', mediaOssIds: '', likedByMe: false, likeCount: 0, commentCount: 2, pinned: '0'
comments: [
{ id: '1', author: '汤淑华', time: '今天 10:42', content: '一家人能常常相聚,就是最珍贵的福气。' },
{ id: '2', author: '汤文清', time: '今天 11:08', content: '照片已经整理好了,晚些时候放进春节团圆相册。' }
]
}, },
{ {
id: '2', genealogyId: '1001', tag: '家族通知', time: '昨天 18:02', feedId: '2', genealogyId: '1001', feedType: '家族通知', createTime: '昨天 18:02',
title: '修谱资料征集', content: '请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。', feedContent: '请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。',
author: '谱主', comments: [] publisherNickName: '谱主', mediaOssIds: '', likedByMe: false, likeCount: 0, commentCount: 0, pinned: '0'
} }
] ]
@@ -476,10 +472,7 @@ const familyAlbums = [
} }
] ]
const cloneFamilyFeedFixture = (feed) => ({ const cloneFamilyFeedFixture = (feed) => ({ ...feed })
...feed,
comments: feed.comments.map((comment) => ({ ...comment }))
})
const cloneFamilyArticleFixture = (article) => ({ const cloneFamilyArticleFixture = (article) => ({
...article, ...article,
paragraphs: [...article.paragraphs] paragraphs: [...article.paragraphs]
@@ -510,8 +503,15 @@ const findScopedFamilyFixture = (items, clone, genealogyId, entityId) => {
export const listFamilyFeedFixtures = (genealogyId) => export const listFamilyFeedFixtures = (genealogyId) =>
listScopedFamilyFixtures(familyFeeds, cloneFamilyFeedFixture, genealogyId) listScopedFamilyFixtures(familyFeeds, cloneFamilyFeedFixture, genealogyId)
export const findFamilyFeedFixture = (genealogyId, feedId) => export const findFamilyFeedFixture = (genealogyId, feedId) => {
findScopedFamilyFixture(familyFeeds, cloneFamilyFeedFixture, genealogyId, feedId) const normalizedGenealogyId = String(genealogyId || '')
const normalizedFeedId = String(feedId || '')
if (!normalizedGenealogyId || !normalizedFeedId) return null
const feed = familyFeeds.find(
(candidate) => candidate.genealogyId === normalizedGenealogyId && candidate.feedId === normalizedFeedId
)
return feed ? cloneFamilyFeedFixture(feed) : null
}
export const listFamilyArticleFixtures = (genealogyId) => export const listFamilyArticleFixtures = (genealogyId) =>
listScopedFamilyFixtures(familyArticles, cloneFamilyArticleFixture, genealogyId) listScopedFamilyFixtures(familyArticles, cloneFamilyArticleFixture, genealogyId)
export const findFamilyArticleFixture = (genealogyId, articleId) => export const findFamilyArticleFixture = (genealogyId, articleId) =>
@@ -49,7 +49,7 @@
| `GenealogyOwnerTransferBody` | 无 | `targetMemberId` | — | 缺成员候选与产品入口,不能手填 ID | | `GenealogyOwnerTransferBody` | 无 | `targetMemberId` | — | 缺成员候选与产品入口,不能手填 ID |
| `GenerationPoemBody` | G12 | `generationNo``generationText` | `description``sortOrder``status` | 已真实维护/回读 | | `GenerationPoemBody` | G12 | `generationNo``generationText` | `description``sortOrder``status` | 已真实维护/回读 |
| `GenerationPoemBatchBody` | G12 | `poemText` | `disableMissing` | 已真实预览/保存回读 | | `GenerationPoemBatchBody` | G12 | `poemText` | `disableMissing` | 已真实预览/保存回读 |
| `LineagePersonBody` | T04/T05/T06、R02 | `name` | `appUserId``personNo``aliasName``sex``generation``generationName``fatherId``motherId``avatarOssId``birthDate``birthLunar``birthPlace``deathDate``deathLunar``deathPlace``burialPlace``personStatus``biography``sortOrder``remark``relationName` | 首位成员最小合法请求仍 `code:500`;业务用户/父母候选、性别/状态字典、头像 `ossId` 均无安全闭环 | | `LineagePersonBody` | T04/T05/T06、R02 | `name``bindingMode` | `appUserId``personNo``aliasName``sex``generation``generationName``fatherId``motherId``avatarOssId``birthDate``birthLunar``birthPlace``deathDate``deathLunar``deathPlace``burialPlace``personStatus``biography``sortOrder``remark``relationName` | `bindingMode` 固定为 `NONE` / `SELF` / `SPECIFIED`:前两者禁止提交 `appUserId`,后者必须提交;`SELF` 后端从 Token 获取当前用户。页面默认 `NONE``SPECIFIED` 仍缺可信候选接口。实测 `NONE``appUserId` 的新增子女仍返回业务 `code:500`,等待后端排查新版写接口 |
| `FamilyFeedBody` | F02/F03 编辑入口缺失 | `feedContent` | `feedType``mediaOssIds``sortOrder``status` | 文本动态已真实创建/回读;媒体受 `ossId` 阻塞;编辑无产品入口 | | `FamilyFeedBody` | F02/F03 编辑入口缺失 | `feedContent` | `feedType``mediaOssIds``sortOrder``status` | 文本动态已真实创建/回读;媒体受 `ossId` 阻塞;编辑无产品入口 |
| `FamilyFeedCommentBody` | F03 | `commentContent` | `parentCommentId` | 一级评论已真实创建/回读;回复 UI 未设计 | | `FamilyFeedCommentBody` | F03 | `commentContent` | `parentCommentId` | 一级评论已真实创建/回读;回复 UI 未设计 |
| `ArticleBody` | F06 | `articleTitle``articleContent` | `categoryId``articleSummary``coverOssId``authorName``sortOrder``status` | 已真实创建/回读;分类条目 DTO、封面 `ossId` 阻塞 | | `ArticleBody` | F06 | `articleTitle``articleContent` | `categoryId``articleSummary``coverOssId``authorName``sortOrder``status` | 已真实创建/回读;分类条目 DTO、封面 `ossId` 阻塞 |
@@ -0,0 +1,195 @@
# Apifox 写接口字段—表单控件映射
## 口径与来源
- 核对时间:2026-07-27。
- 主源:桌面 Apifox 当前 `APP` 项目中的接口详情;根目录 `家谱.openapi.json` 只用于补全接口路径和没有展示在当前屏幕中的普通字段。
- `必填` 以 Apifox `必需` 标记为准;没有 `必需` 的字段均应允许不填,提交时传空值或省略由当前 API 适配器统一处理。
- `枚举选择` 只能提交下表列出的 value,不能把中文标签提交给后端。
- `候选选择` 只能从对应选项接口选取,页面不得暴露裸露的内部 ID 输入框。
- `上传` 必须先走统一上传,提交上传回执中的 `ossId`;不能让用户手输 OSS ID。
- 所有 `sortOrder` 是整数数字框;所有 `amount/giftAmount` 是金额数字框;示例为 `yyyy-MM-dd HH:mm:ss` 的时间字段用“日期 + 时间”控件拼接后提交。
## 已在 Apifox 字段详情中确认的字典
| 字段/字典 | value → 中文 | 页面控件 |
| --- | --- | --- |
| `sys_user_sex` | `0` 男;`1` 女;`2` 未知 | 单选/下拉选择 |
| `gen_number_yes_no` | `0` 否;`1` 是 | 单选/下拉选择 |
| `gen_lineage_person_status` | `0` 健在;`1` 已故;`2` 未知 | 单选/下拉选择 |
| `sys_normal_disable` | `0` 正常;`1` 停用 | 单选/开关;默认 `0` |
| `gen_genealogy_visibility` | `0` 私密;`1` 公开;`2` 成员可见 | 单选/下拉选择 |
| `gen_genealogy_join_mode` | `0` 关闭加入;`1` 申请审核;`2` 邀请加入 | 单选/下拉选择 |
| `gen_merit_type` | `donation` 捐赠;`repair` 修祠;`public` 公益;`other` 其他 | 单选/下拉选择 |
| 活动邀请响应 | `ACCEPTED` 接受;`DECLINED` 拒绝 | 二选一 |
`completed` 在 Apifox 当前详情中是 `string`,只给出示例 `0`,没有挂字典/允许值;因此不能伪造“完成/未完成”的值表。当前页面应默认省略,待后端给出该字段的字典定义后再开启开关。
## 已有页面:字段到控件映射
### M02 修改用户资料 — `PUT /genealogy/app/auth/profile`
| 字段 | 必填 | Apifox 详情 | 正确控件与提交 |
| --- | --- | --- | --- |
| `nickName` | 否 | string,用户昵称,最多 30 字 | 文本框,最多 30 字 |
| `realName` | 否 | string,真实姓名,最多 30 字 | 文本框,最多 30 字 |
| `avatar` | 否 | int64,头像文件 OSS ID | 图片上传;仅提交可安全表示的数值 OSS ID |
| `sex` | 否 | `sys_user_sex` | 枚举选择 `0/1/2` |
| `birthday` | 否 | date,生日,格式 `yyyy-MM-dd` | 日期选择器 |
| `email` | 否 | email,最多 100 字 | email 文本框 |
### G03/G11 创建、修改家谱 — `GenealogyCreateBody` / `GenealogyUpdateBody`
| 字段 | 创建必填 | 正确控件与提交 |
| --- | --- | --- |
| `genealogyName` | 是 | 文本框 |
| `surname` | 是 | 文本框 |
| `ancestralHall` | 否 | 文本框 |
| `originPlace` | 否 | 文本框 |
| `regionCode` | 是 | 省市区级联候选选择,提交行政区划 code |
| `addressDetail` | 否 | 文本框 |
| `coverOssId` | 否 | 图片上传,提交字符串 OSS ID |
| `intro` | 否 | 多行文本 |
| `visibility` | 否 | 枚举选择:私密 `0` / 公开 `1` / 成员可见 `2` |
| `joinMode` | 否 | 枚举选择:关闭加入 `0` / 申请审核 `1` / 邀请加入 `2` |
### T04/T05 录入、修改世系人物 — `LineagePersonBody`
同一 body 还用于“添加子女、父母、兄弟姐妹、配偶”。`name` 是唯一 body 必填字段;关系路径由 URL 决定,不能把中文关系标签当成接口字段替代。
| 字段 | 必填 | Apifox 详情/限制 | 正确控件与提交 |
| --- | --- | --- | --- |
| `bindingMode` | 是 | 身份认领方式:`NONE` / `SELF` / `SPECIFIED` | 固定英文枚举选择:不绑定账号 / 绑定当前账号 / 绑定指定用户;不发送中文值 |
| `appUserId` | 视 `bindingMode` 而定 | `NONE` 不绑定账号且不传;`SELF` 绑定当前登录账号且不传,后端从 Token 获取;`SPECIFIED` 绑定指定业务用户且必须传 | 仅 `SPECIFIED` 由管理员选择可信业务用户候选后提交;当前没有候选接口时禁止该选项保存,绝不手输 ID |
| `personNo` | 否 | 人物编号;不传由服务端生成 | 可选文本框;留空时省略 |
| `name` | 是 | 姓名 | 文本框 + 必填校验 |
| `aliasName` | 否 | 别名或曾用名 | 文本框 |
| `sex` | 否 | `sys_user_sex` | 枚举选择:男 `0` / 女 `1` / 未知 `2` |
| `generation` | 否 | int64,世代序号 | 正整数数字框;首位成员固定为 `1` |
| `generationName` | 否 | 字辈或辈分 | 文本框 |
| `fatherId` | 否 | 父亲人物 ID,必须属于当前家谱 | 从 `/lineage/persons/options` 选择父亲,提交人物 ID |
| `motherId` | 否 | 母亲人物 ID,必须属于当前家谱 | 从 `/lineage/persons/options` 选择母亲,提交人物 ID |
| `avatarOssId` | 否 | string/null,头像文件 OSS ID;说明明确要求统一上传组件取得,不允许手工录入 | 图片上传,提交字符串 OSS ID |
| `birthDate` | 否 | date-time/null(写接口示例为 `yyyy-MM-dd` | 日期选择;未录入不传 |
| `birthLunar` | 否 | `gen_number_yes_no` | 枚举选择:否 `0` / 是 `1` |
| `birthPlace` | 否 | 出生地 | 文本框 |
| `deathDate` | 否 | date-time/null(写接口示例为 `yyyy-MM-dd` | 日期选择;未录入不传 |
| `deathLunar` | 否 | `gen_number_yes_no` | 枚举选择:否 `0` / 是 `1` |
| `deathPlace` | 否 | 逝世地 | 文本框 |
| `burialPlace` | 否 | 安葬地 | 文本框 |
| `personStatus` | 否 | `gen_lineage_person_status` | 枚举选择:健在 `0` / 已故 `1` / 未知 `2` |
| `biography` | 否 | 人物简介 | 多行文本 |
| `sortOrder` | 否 | int64,排序值 | 整数数字框 |
| `remark` | 否 | 备注 | 多行文本 |
| `relationName` | 否 | 关系名称 | 新增亲属时由关系选择派生;编辑时可作为文本修订 |
#### 页面实测(2026-07-27
- 此接口字段标为 `date-time`,但四个写接口示例使用 `yyyy-MM-dd`。页面此前把日期追加为 `yyyy-MM-dd HH:mm:ss`,会在客户端 `normalizeLineagePersonDate` 校验阶段被拒绝,尚未发起请求;现已改为只传日期。
- 后端于 2026-07-27 明确补齐身份认领合同:`NONE``SELF` 禁止提交 `appUserId``SELF` 由后端从 Token 取当前 APP 用户;仅 `SPECIFIED` 必须提交 `appUserId`。页面默认 `NONE`,不再自动读取或提交 `profile.userId`
- 同日页面实测 `POST .../children`:请求体为 `bindingMode: "NONE"` 且不存在 `appUserId`,后端仍返回 HTTP 200 / envelope `code:500``发生未知异常,请联系管理员`;三世数据未落库,需后端确认新版写接口是否已部署并排查该业务异常。
### F02 家族圈动态 — `FamilyFeedBody`
| 字段 | 必填 | Apifox 详情 | 正确控件与提交 |
| --- | --- | --- | --- |
| `feedType` | 否 | 动态类型;未传默认 `text` | 当前页面固定传 `text`,不让用户输入代码 |
| `feedContent` | 是 | 动态内容,不允许为空 | 多行文本 + 必填校验 |
| `mediaOssIds` | 否 | 多个文件 OSS ID 用英文逗号分隔 | 多图上传;回执 ID 以 `,` 拼接 |
| `sortOrder` | 否 | int64;未传默认 `0` | 整数数字框 |
| `status` | 否 | `sys_normal_disable` | 正常 `0` / 停用 `1`;创建页默认 `0`,非管理页不暴露停用操作 |
### F06 谱文、F07 相册、F09 相册照片
| body.字段 | 必填 | 正确控件与提交 |
| --- | --- | --- |
| `ArticleBody.categoryId` | 否 | 从“谱文分类”接口候选选择;不手填分类 ID |
| `articleTitle` / `articleSummary` / `authorName` | 标题是 | 分别为文本、多行摘要、文本 |
| `articleContent` | 是 | 富文本/多行内容编辑 |
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
| `ArticleBody.sortOrder` | 否 | 整数数字框 |
| `ArticleBody.status` | 否 | `sys_normal_disable` 选择,默认 `0` |
| `AlbumBody.albumName` | 是 | 文本框 |
| `albumDesc` | 否 | 多行文本 |
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
| `AlbumBody.sortOrder` | 否 | 整数数字框 |
| `AlbumBody.status` | 否 | `sys_normal_disable` 选择,默认 `0` |
| `AlbumPhotoBody.ossId` | 是 | 图片上传;提交字符串 OSS ID |
| `photoTitle` / `photoDesc` / `photographer` | 否 | 文本、多行文本、文本 |
| `shootTime` | 否 | 拍摄时间;示例按标准时间字符串 | 日期 + 时间选择 |
| `AlbumPhotoBody.sortOrder` | 否 | 整数数字框 |
| `AlbumPhotoBody.status` | 否 | `sys_normal_disable` 选择,默认 `0` |
### R07 祭祀活动、R04 献礼
| body.字段 | 必填 | 正确控件与提交 |
| --- | --- | --- |
| `CeremonyBody.ceremonyType` | 是 | Apifox 当前为普通 string;文本框,不能自行伪造枚举 |
| `ceremonyTitle` | 是 | 文本框 |
| `ceremonyDesc` | 否 | 多行文本 |
| `ceremonyTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
| `location` / `locationAddress` | 否 | 地点名称、详细地址文本框;两项都应保留 |
| `longitude` / `latitude` | 否 | decimal 数字框;应由地图选点回填,不能把经纬度当文本 |
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
| `sortOrder` | 否 | 整数数字框 |
| `status` | 否 | `sys_normal_disable` 选择,默认 `0` |
| `CeremonyGiftBody.giverName` | 否 | 文本框 |
| `giftAmount` | 是 | 金额数字框 |
| `giftMessage` | 否 | 多行文本 |
### R08 成长记录、R09 亲友记录、R10 备忘、R11 功德
| body.字段 | 必填 | Apifox 详情/正确控件 |
| --- | --- | --- |
| `GrowthRecordBody.lineagePersonId` | 否 | 世系人物候选选择,提交人物 ID |
| `recordType` | 否 | 当前详情为普通 string,文本框 |
| `recordTitle` | 是 | 文本框 |
| `recordContent` | 否 | 多行文本 |
| `recordDate` / `remindTime` | 否 | 示例均为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
| `mediaOssIds` | 否 | 多图上传,逗号分隔 OSS ID |
| `GrowthRecordBody.sortOrder` / `status` | 否 | 整数数字框;`sys_normal_disable` 选择 |
| `MemoBody.memoTitle` | 是 | 文本框 |
| `memoContent` | 否 | 多行文本 |
| `remindTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
| `completed` | 否 | 当前仅 string 示例 `0`,无字典;默认省略,不能私设枚举 |
| `MemoBody.mediaOssIds` / `sortOrder` / `status` | 否 | 多图上传;整数数字框;`sys_normal_disable` 选择 |
| `RelativeRecordBody.relativeName` | 是 | 文本框 |
| `relationName` / `eventName` | 否 | 文本框 |
| `eventTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
| `giftAmount` | 否 | 金额数字框 |
| `recordContent` | 否 | 多行文本 |
| `RelativeRecordBody.mediaOssIds` / `sortOrder` / `status` | 否 | 多图上传;整数数字框;`sys_normal_disable` 选择 |
| `MeritRecordBody.donorName` / `meritTitle` | 是 | 文本框 |
| `meritType` | 否 | 枚举选择:捐赠 `donation` / 修祠 `repair` / 公益 `public` / 其他 `other` |
| `meritContent` | 否 | 多行文本 |
| `amount` | 否 | 金额数字框 |
| `meritTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
| `MeritRecordBody.sortOrder` / `status` | 否 | 整数数字框;`sys_normal_disable` 选择 |
## 其余写接口:同样必须按字段类型建控件
| 接口 body | 字段映射 |
| --- | --- |
| `GenealogyJoinApplyBody` | `applicantName``phone``relationDesc``applyReason` 为文本/多行文本;`inviterUserId` 必须是业务用户候选选择,当前缺候选接口时不暴露裸 ID 输入。 |
| `GenealogyJoinAuditBody` | `status` 是审核结果,必须由 Apifox 的审核状态字典提供选项后才做选择器;`auditRemark` 多行文本。 |
| `GenealogyMemberUpdateBody` | `memberName``relationName``roleType` 文本;`lineagePersonId` 为世系人物候选选择。 |
| `GenealogyOwnerTransferBody` | `targetMemberId` 必填,家谱成员候选选择。 |
| `GenerationPoemBody` | `generationNo` 必填正整数;`generationText` 必填文本;`description` 多行文本;`sortOrder` 数字;`status``sys_normal_disable`。 |
| `GenerationPoemBatchBody` | `poemText` 必填多行文本;`disableMissing` 布尔开关。 |
| `FamilyFeedCommentBody` | `parentCommentId` 为评论候选/回复上下文,不能输入 ID;`commentContent` 必填多行文本。 |
| `FeedbackBody` | `feedbackType` 当前详情为 string,待字典明确前为文本;`feedbackContent` 必填多行文本;`contactInfo` 文本。 |
| `VipOrderBody` | `packageId` 必填,VIP 套餐候选选择;`genealogyId` 为当前家谱上下文选择;`payType` 只能使用支付方式字典,不能手填代码。 |
| `VideoBody` | `videoTitle` 必填文本,`videoDesc` 多行文本,`coverOssId`/`videoOssId` 为上传,`durationSeconds`/`sortOrder` 为数字,`status``sys_normal_disable`。 |
| `CeremonyInviteesBody` | `inviteeUserIds` 必填多选业务用户候选;无候选接口不能用逗号文本代替数组。 |
| `CeremonyInvitationResponseBody` | `inviteStatus` 必填二选一:接受 `ACCEPTED`、拒绝 `DECLINED`。 |
## 非页面直接填写的底层接口
认证挑战、验证码、分片上传初始化/完成、文件引用等 body 由登录/上传流程生成,不应渲染为业务表单。`grantType``tenantId``challengeId``validToken`、文件 hash/分片大小等由对应流程拥有,不能让用户在页面中编辑。
## 当前改造判定
1. 已经可以立即改为选择控件的字段:`sex`、出生/逝世农历、`personStatus``visibility``joinMode`、所有 `status``meritType`、邀请响应。
2. 必须改为候选选择的字段:所有家谱/成员/世系人物/分类/套餐/受邀用户的 ID 字段。
3. 必须改为上传的字段:所有 `*OssId``mediaOssIds`、头像、封面、照片、视频文件。
4. `completed``feedbackType``payType`、审核 `status` 等尚未在当前 Apifox 字段详情给出允许值;在后端未提供字典或候选接口前,不增加猜测性选择项。
@@ -514,6 +514,16 @@ HTTP 200 / code:200 / data: []
| B11 | 谱文分类仍返回 `HTTP 200 / code:200 / data:[]` | 测试谱没有分类;仍需分类 DTO 与可用测试候选才能验证选择/写入 | | B11 | 谱文分类仍返回 `HTTP 200 / code:200 / data:[]` | 测试谱没有分类;仍需分类 DTO 与可用测试候选才能验证选择/写入 |
| B12 | `GET /genealogy/region/path/110101``GET /genealogy/region/110101``GET /genealogy/region/search?keyword=北京&limit=10` 均返回包含 `value``label``regionCode`、层级等字段的真实数据 | 后端已补齐可消费条目;待产品确认搜索/回填交互后接入页面 | | B12 | `GET /genealogy/region/path/110101``GET /genealogy/region/110101``GET /genealogy/region/search?keyword=北京&limit=10` 均返回包含 `value``label``regionCode`、层级等字段的真实数据 | 后端已补齐可消费条目;待产品确认搜索/回填交互后接入页面 |
### 2.2 世系人物身份认领合同增补(2026-07-27)
后端新增 `bindingMode`,所有 T04/T05 人物新增、修改及父母/子女/兄弟姐妹/配偶关系写入统一使用:
- `NONE`:不绑定账号,禁止提交 `appUserId`
- `SELF`:绑定当前登录账号,禁止提交 `appUserId`,后端从 Token 获取。
- `SPECIFIED`:绑定指定业务用户,必须提交 `appUserId`
页面已默认 `NONE` 并移除“自动读取 profile.userId 后提交”的旧路径。真实 `POST .../children` 已按 `bindingMode:"NONE"`、无 `appUserId` 发出,但仍收到 HTTP `200` / envelope `code:500` / `发生未知异常,请联系管理员`,人物未落库。B06 在新版合同下仍未闭环;需后端确认新包部署状态并给出该异常的业务原因。
### 3. 未发起写入请求的原因(不是漏测) ### 3. 未发起写入请求的原因(不是漏测)
| 编号 | 接口/功能 | 未发起的原因 | 后端需要提供 | | 编号 | 接口/功能 | 未发起的原因 | 后端需要提供 |
+2 -2
View File
@@ -79,7 +79,7 @@ import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js"; import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const editorState = ref("form"); const editorState = ref("form");
@@ -135,7 +135,7 @@ const uploadCover = async () => {
uploadError.value = ""; uploadError.value = "";
try { try {
const receipt = await pickAndUploadImage({ requestController }); const receipt = await pickAndUploadImage({ requestController });
coverOssId.value = toConsumerOssId(receipt.ossId); coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "封面图片"; coverFileName.value = receipt.fileName || "封面图片";
} catch (error) { } catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
+2 -2
View File
@@ -31,7 +31,7 @@ import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js"; import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js"; import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref(""); const genealogyId = ref("");
@@ -78,7 +78,7 @@ const uploadCover = async () => {
if (uploading.value || isSubmitting.value) return; if (uploading.value || isSubmitting.value) return;
uploading.value = true; uploading.value = true;
uploadError.value = ""; uploadError.value = "";
try { const receipt = await pickAndUploadImage({ requestController: controller }); coverOssId.value = toConsumerOssId(receipt.ossId); coverFileName.value = receipt.fileName || "封面图片"; } try { const receipt = await pickAndUploadImage({ requestController: controller }); coverOssId.value = receipt.ossId; coverFileName.value = receipt.fileName || "封面图片"; }
catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; } catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; }
finally { uploading.value = false; } finally { uploading.value = false; }
}; };
+2 -2
View File
@@ -75,7 +75,7 @@ import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js"; import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
@@ -135,7 +135,7 @@ const submitPhoto = async () => {
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, { await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
...photoForm, ...photoForm,
shootTime: shootTime.value, shootTime: shootTime.value,
ossId: toConsumerOssId(receipt.value.ossId), ossId: receipt.value.ossId,
}, { requestController: controller }); }, { requestController: controller });
pageState.value = "success"; pageState.value = "success";
} catch (error) { } catch (error) {
+2 -2
View File
@@ -137,7 +137,7 @@ import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js"; import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js"; import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { finishPage, handleBackPress, runBackGuard } from "@/utils/navigation.js"; import { finishPage, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const form = reactive({ const form = reactive({
@@ -307,7 +307,7 @@ const uploadCover = async () => {
try { try {
const receipt = await pickAndUploadImage({ requestController: createController }); const receipt = await pickAndUploadImage({ requestController: createController });
if (!pageActive) return; if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId); coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "封面图片"; coverFileName.value = receipt.fileName || "封面图片";
} catch (error) { } catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) { if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
+2 -2
View File
@@ -113,7 +113,7 @@ import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js"; import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js"; import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, returnTo } from "@/utils/navigation.js"; import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const genealogyId = ref("");
@@ -284,7 +284,7 @@ const uploadCover = async () => {
try { try {
const receipt = await pickAndUploadImage({ requestController: controller }); const receipt = await pickAndUploadImage({ requestController: controller });
if (!pageActive) return; if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId); coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "封面图片"; coverFileName.value = receipt.fileName || "封面图片";
} catch (error) { } catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) { if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
+69 -4
View File
@@ -1,11 +1,76 @@
<!-- 页面编号N-02用途通知详情没有详情读取 operation 时保持关闭 --> <!-- 页面编号N-02用途通知 ID 读取服务端通知详情 -->
<template> <template>
<view class="message-detail-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view><view class="page-content"><view class="state-card"><text>暂无法查看消息详情</text><text>请返回消息中心查看已收到的消息</text><AppButton block label="返回消息中心" @click="backToMessages" /></view></view></view> <view class="message-detail-page">
<ModulePageBackground module="notification" />
<view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view>
<view class="page-content">
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取消息详情" /></view>
<view v-else-if="state === 'error'" class="state-card">
<text>暂时无法读取消息详情</text>
<text>{{ loadError || "请稍后重试。" }}</text>
<AppButton block type="secondary" label="重新加载" @click="loadDetail" />
<AppButton block label="返回消息中心" @click="backToMessages" />
</view>
<view v-else class="detail-card">
<text class="detail-title">{{ detail.noticeTitle || "通知消息" }}</text>
<text v-if="detail.publishTime" class="detail-time">{{ detail.publishTime }}</text>
<view class="detail-content"><text>{{ detail.noticeContent || "暂无通知正文" }}</text></view>
<view v-if="detail.noticeType || detail.genealogyName || detail.senderNickName || detail.bizSummary" class="detail-meta">
<view v-if="detail.noticeType" class="detail-meta__row"><text>通知类型</text><text>{{ detail.noticeType }}</text></view>
<view v-if="detail.genealogyName" class="detail-meta__row"><text>所属家谱</text><text>{{ detail.genealogyName }}</text></view>
<view v-if="detail.senderNickName" class="detail-meta__row"><text>发送人</text><text>{{ detail.senderNickName }}</text></view>
<view v-if="detail.bizSummary" class="detail-meta__row"><text>关联事项</text><text>{{ detail.bizSummary }}</text></view>
</view>
</view>
</view>
</view>
</template> </template>
<script setup> <script setup>
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToMessages=()=>returnTo("N01"); import { ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { returnTo } from "@/utils/navigation.js";
const notificationId = ref("");
const detail = ref({});
const state = ref("loading");
const loadError = ref("");
const controller = createRequestController();
let active = true;
const loadDetail = async () => {
controller.abort();
state.value = "loading";
loadError.value = "";
try {
detail.value = await appApi.getNotificationDetail(notificationId.value, { requestController: controller });
if (!active) return;
state.value = "ready";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
loadError.value = error?.message || "请稍后重试。";
state.value = "error";
}
};
const backToMessages = () => returnTo("N01");
onLoad((options) => {
notificationId.value = options?.id || "";
loadDetail();
});
onUnload(() => {
active = false;
controller.abort();
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive; @use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.message-detail-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx} .message-detail-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{flex:1;padding:18rpx 24rpx 72rpx}.state-card,.detail-card{box-sizing:border-box;@include adaptive.adaptive-family-content}.state-card{min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center}.state-card text{display:block}.state-card text:first-child,.detail-title{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}.detail-card{padding:44rpx 38rpx}.detail-title,.detail-time{display:block}.detail-time{margin-top:16rpx;color:$ink-muted;font-size:24rpx}.detail-content{margin-top:36rpx;color:$ink;font-size:29rpx;line-height:1.8;white-space:pre-wrap}.detail-meta{margin-top:42rpx;padding-top:24rpx;border-top:1rpx solid rgba(91,70,42,.16)}.detail-meta__row{display:flex;gap:22rpx;padding:10rpx 0;color:$ink-muted;font-size:24rpx;line-height:1.5}.detail-meta__row text:first-child{flex:0 0 116rpx}.detail-meta__row text:last-child{flex:1;color:$ink}
</style> </style>
+1 -1
View File
@@ -131,7 +131,7 @@ const buildUpdatePayload = () => {
for (const field of ["nickName", "realName", "sex", "email"]) { for (const field of ["nickName", "realName", "sex", "email"]) {
if (form[field] && form[field] !== original[field]) data[field] = form[field]; if (form[field] && form[field] !== original[field]) data[field] = form[field];
} }
if (form.birthday && form.birthday !== original.birthday) data.birthday = `${form.birthday}T00:00:00`; if (form.birthday && form.birthday !== original.birthday) data.birthday = form.birthday;
if (avatarId.value && avatarId.value !== original.avatar) data.avatar = avatarId.value; if (avatarId.value && avatarId.value !== original.avatar) data.avatar = avatarId.value;
return data; return data;
}; };
+22 -4
View File
@@ -32,6 +32,18 @@
<text class="field-row__label">地点</text> <text class="field-row__label">地点</text>
<input v-model="form.location" maxlength="60" placeholder="请输入地点" placeholder-class="placeholder" @input="clearError" /> <input v-model="form.location" maxlength="60" placeholder="请输入地点" placeholder-class="placeholder" @input="clearError" />
</view> </view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input v-model="form.locationAddress" maxlength="120" placeholder="请输入详细地址" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="field-row">
<text class="field-row__label">经度</text>
<input v-model="form.longitude" type="digit" placeholder="地图选点后填写" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="field-row">
<text class="field-row__label">纬度</text>
<input v-model="form.latitude" type="digit" placeholder="地图选点后填写" placeholder-class="placeholder" @input="clearError" />
</view>
<view class="cover-field"> <view class="cover-field">
<view> <view>
<text class="cover-field__label">封面图片</text> <text class="cover-field__label">封面图片</text>
@@ -66,13 +78,13 @@ import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js"; import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref(""); const genealogyId = ref("");
const pageState = ref("form"); const pageState = ref("form");
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyDate: "", ceremonyClock: "", location: "", sortOrder: "" }); const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyDate: "", ceremonyClock: "", location: "", locationAddress: "", longitude: "", latitude: "", sortOrder: "" });
const coverOssId = ref(null); const coverOssId = ref(null);
const coverFileName = ref(""); const coverFileName = ref("");
const uploadError = ref(""); const uploadError = ref("");
@@ -103,7 +115,7 @@ const uploadCover = async () => {
uploadError.value = ""; uploadError.value = "";
try { try {
const receipt = await pickAndUploadImage({ requestController: controller }); const receipt = await pickAndUploadImage({ requestController: controller });
coverOssId.value = toConsumerOssId(receipt.ossId); coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "活动封面"; coverFileName.value = receipt.fileName || "活动封面";
} catch (error) { } catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试"; if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
@@ -121,7 +133,13 @@ const submit = async () => {
submitError.value = ""; submitError.value = "";
try { try {
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form; const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
await appApi.createCeremony(genealogyId.value, { ...ceremonyForm, ceremonyTime: ceremonyTime.value, ...(coverOssId.value ? { coverOssId: coverOssId.value } : {}) }, { requestController: controller }); await appApi.createCeremony(genealogyId.value, {
...ceremonyForm,
ceremonyTime: ceremonyTime.value,
...(form.longitude ? { longitude: Number(form.longitude) } : {}),
...(form.latitude ? { latitude: Number(form.latitude) } : {}),
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
}, { requestController: controller });
pageState.value = "success"; pageState.value = "success";
} catch (error) { } catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试"; if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
+10 -1
View File
@@ -8,6 +8,7 @@
<text>新建功德记录</text> <text>新建功德记录</text>
<text class="form-copy">红色 * 为必填项其余内容可按需补充</text> <text class="form-copy">红色 * 为必填项其余内容可按需补充</text>
<view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view> <view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view>
<view class="field field--picker"><text>功德类型</text><picker :range="meritTypeOptions.map((item) => item.label)" :value="meritTypeIndex" @change="selectMeritType"><view :class="{ placeholder: !form.type }">{{ meritTypeLabel || '请选择' }}</view></picker></view>
<view class="field field--picker"><text>记录日期</text><picker mode="date" :value="form.meritDate" @change="selectMeritDate"><view :class="{ placeholder: !form.meritDate }">{{ form.meritDate || '请选择' }}</view></picker></view> <view class="field field--picker"><text>记录日期</text><picker mode="date" :value="form.meritDate" @change="selectMeritDate"><view :class="{ placeholder: !form.meritDate }">{{ form.meritDate || '请选择' }}</view></picker></view>
<view class="field field--picker"><text>记录时间</text><picker mode="time" :value="form.meritClock" @change="selectMeritClock"><view :class="{ placeholder: !form.meritClock }">{{ form.meritClock || '请选择' }}</view></picker></view> <view class="field field--picker"><text>记录时间</text><picker mode="time" :value="form.meritClock" @change="selectMeritClock"><view :class="{ placeholder: !form.meritClock }">{{ form.meritClock || '请选择' }}</view></picker></view>
<text v-if="error" class="error">{{ error }}</text> <text v-if="error" class="error">{{ error }}</text>
@@ -51,16 +52,23 @@ const form = reactive({ donor: "", title: "", type: "", amount: "", meritDate: "
const fields = [ const fields = [
{ key: "donor", label: "捐赠人", required: true }, { key: "donor", label: "捐赠人", required: true },
{ key: "title", label: "功德标题", required: true }, { key: "title", label: "功德标题", required: true },
{ key: "type", label: "功德类型" },
{ key: "amount", inputType: "digit", label: "金额" }, { key: "amount", inputType: "digit", label: "金额" },
{ key: "content", label: "记录内容" }, { key: "content", label: "记录内容" },
{ key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" }, { key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" },
]; ];
const meritTypeOptions = Object.freeze([
{ value: "donation", label: "捐赠" },
{ value: "repair", label: "修祠" },
{ value: "public", label: "公益" },
{ value: "other", label: "其他" },
]);
const controller = createRequestController(); const controller = createRequestController();
let active = true; let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value)); const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const dirty = computed(() => Object.values(form).some((value) => String(value).trim())); const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
const meritTime = computed(() => form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : ""); const meritTime = computed(() => form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "");
const meritTypeIndex = computed(() => Math.max(0, meritTypeOptions.findIndex((item) => item.value === form.type)));
const meritTypeLabel = computed(() => meritTypeOptions.find((item) => item.value === form.type)?.label || "");
const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2)); const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2));
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; }); const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; }; const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
@@ -93,6 +101,7 @@ const openCreate = () => { if (!valid.value) return; saveNotice.value = false; r
const cancelCreate = () => { resetForm(); view.value = "list"; }; const cancelCreate = () => { resetForm(); view.value = "list"; };
const selectMeritDate = (event) => { form.meritDate = event.detail.value || ""; error.value = ""; }; const selectMeritDate = (event) => { form.meritDate = event.detail.value || ""; error.value = ""; };
const selectMeritClock = (event) => { form.meritClock = event.detail.value || ""; error.value = ""; }; const selectMeritClock = (event) => { form.meritClock = event.detail.value || ""; error.value = ""; };
const selectMeritType = (event) => { form.type = meritTypeOptions[Number(event.detail.value)]?.value || ""; error.value = ""; };
const submit = async () => { const submit = async () => {
if (submitting.value || !valid.value) return; if (submitting.value || !valid.value) return;
const donorName = form.donor.trim(); const donorName = form.donor.trim();
+193 -58
View File
@@ -89,6 +89,7 @@
v-for="connector in lineageConnectors" v-for="connector in lineageConnectors"
:key="connector.id" :key="connector.id"
class="lineage-connector" class="lineage-connector"
:class="{ 'lineage-connector--spouse': connector.kind === 'spouse' }"
:style="connector.style" :style="connector.style"
/> />
@@ -97,32 +98,35 @@
:key="member.id" :key="member.id"
:id="`tree-member-${member.id}`" :id="`tree-member-${member.id}`"
class="member-node" class="member-node"
:class="{ 'member-node--selected': selected?.id === member.id }" :class="{
'member-node--selected': selected?.id === member.id,
'member-node--spouse': Boolean(member.spouseOf),
}"
:style="nodeGridStyle(member)" :style="nodeGridStyle(member)"
@click="openMemberPanel(member)" @click="openMemberPanel(member)"
> >
<image <view class="member-node__surface">
class="member-node__skin" <view class="member-node__portrait">
:src="
selected?.id === member.id
? '/static/assets/modules/tree/transparent/t01-member-node-selected.png'
: '/static/assets/modules/tree/transparent/t01-member-node-standard.png'
"
mode="scaleToFill"
/>
<image <image
class="member-node__avatar" class="member-node__avatar"
src="/static/assets/foundation/transparent/meta-member.png" src="/static/assets/foundation/transparent/tab-profile.png"
mode="aspectFit"
/>
</view>
<image
class="member-node__divider"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit" mode="aspectFit"
/> />
<view class="member-node__copy"> <view class="member-node__copy">
<text class="node-name">{{ member.name }}</text> <text class="node-name">{{ member.name }}</text>
<text class="node-relation" <text class="node-relation"
>{{ member.relation }} · {{ member.branch }}</text >{{ nodeRelationText(member) }}</text
> >
<text class="node-years">{{ member.years }}</text> <text class="node-years">{{ member.years }}</text>
</view> </view>
</view> </view>
</view>
</template> </template>
<view v-else class="tree-state-card"> <view v-else class="tree-state-card">
@@ -168,7 +172,7 @@
> >
<image <image
class="member-action-profile__avatar" class="member-action-profile__avatar"
src="/static/assets/foundation/transparent/meta-member.png" src="/static/assets/foundation/transparent/tab-profile.png"
mode="aspectFit" mode="aspectFit"
/> />
<view class="member-action-profile__copy"> <view class="member-action-profile__copy">
@@ -202,7 +206,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from "vue"; import { computed, nextTick, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app"; import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue"; import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue"; import AppDialog from "@/components/AppDialog.vue";
@@ -234,9 +238,11 @@ let treeLoadGeneration = 0;
let pageActive = true; let pageActive = true;
let skipNextShowRefresh = true; let skipNextShowRefresh = true;
const GRID_UNIT = 5; const GRID_UNIT = 5;
const NODE_HALF_HEIGHT = 47; const NODE_HALF_WIDTH = 80;
const MEMBER_GAP = 250; const NODE_HALF_HEIGHT = 94;
const GENERATION_GAP = 220; const FAMILY_LINK_OFFSET = 18;
const MEMBER_GAP = 178;
const GENERATION_GAP = 260;
const members = ref([]); const members = ref([]);
const memberActions = Object.freeze([ const memberActions = Object.freeze([
@@ -257,6 +263,10 @@ const memberActions = Object.freeze([
]); ]);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT; const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const nodeRelationText = (member) => {
const branch = String(member.branch || "").replace(/字辈$/, "");
return [member.relation, branch].filter(Boolean).join(" · ");
};
const layoutMembers = computed(() => { const layoutMembers = computed(() => {
const generations = Array.from( const generations = Array.from(
new Set(members.value.map((member) => Number(member.generation))), new Set(members.value.map((member) => Number(member.generation))),
@@ -269,12 +279,14 @@ const layoutMembers = computed(() => {
.sort( .sort(
(left, right) => (left, right) =>
String(left.parentId || "").localeCompare(String(right.parentId || "")) || String(left.parentId || "").localeCompare(String(right.parentId || "")) ||
String(left.spouseOf || left.id).localeCompare(String(right.spouseOf || right.id)) ||
Number(Boolean(left.spouseOf)) - Number(Boolean(right.spouseOf)) ||
String(left.id).localeCompare(String(right.id)), String(left.id).localeCompare(String(right.id)),
), ),
]), ]),
); );
const maxCount = Math.max(1, ...Array.from(groups.values()).map((group) => group.length)); const maxCount = Math.max(1, ...Array.from(groups.values()).map((group) => group.length));
const canvasWidth = Math.max(720, maxCount * MEMBER_GAP + 100); const canvasWidth = Math.max(660, maxCount * MEMBER_GAP + 80);
return generations.flatMap((generation, generationIndex) => { return generations.flatMap((generation, generationIndex) => {
const group = groups.get(generation) || []; const group = groups.get(generation) || [];
const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP); const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP);
@@ -282,7 +294,7 @@ const layoutMembers = computed(() => {
return group.map((member, memberIndex) => ({ return group.map((member, memberIndex) => ({
...member, ...member,
x: snapToGrid(startX + memberIndex * MEMBER_GAP), x: snapToGrid(startX + memberIndex * MEMBER_GAP),
y: snapToGrid(135 + generationIndex * GENERATION_GAP), y: snapToGrid(200 + generationIndex * GENERATION_GAP),
})); }));
}); });
}); });
@@ -290,7 +302,7 @@ const treeMetrics = computed(() => {
const memberList = layoutMembers.value; const memberList = layoutMembers.value;
const maxX = Math.max(0, ...memberList.map((member) => member.x)); const maxX = Math.max(0, ...memberList.map((member) => member.x));
const maxY = Math.max(0, ...memberList.map((member) => member.y)); const maxY = Math.max(0, ...memberList.map((member) => member.y));
const width = Math.max(720, snapToGrid(maxX + 210)); const width = Math.max(660, snapToGrid(maxX + NODE_HALF_WIDTH + 50));
const height = Math.max(640, snapToGrid(maxY + 250)); const height = Math.max(640, snapToGrid(maxY + 250));
return { return {
width, width,
@@ -345,9 +357,15 @@ const lineageConnectors = computed(() => {
const memberById = new Map( const memberById = new Map(
layoutMembers.value.map((member) => [member.id, member]), layoutMembers.value.map((member) => [member.id, member]),
); );
const spouseByPersonId = new Map();
layoutMembers.value.forEach((member) => {
if (member.spouseOf && memberById.has(member.spouseOf)) {
spouseByPersonId.set(member.spouseOf, member);
}
});
const childrenByParent = new Map(); const childrenByParent = new Map();
layoutMembers.value.forEach((member) => { layoutMembers.value.forEach((member) => {
if (!member.parentId || !memberById.has(member.parentId)) return; if (member.spouseOf || !member.parentId || !memberById.has(member.parentId)) return;
const children = childrenByParent.get(member.parentId) || []; const children = childrenByParent.get(member.parentId) || [];
children.push(member); children.push(member);
childrenByParent.set(member.parentId, children); childrenByParent.set(member.parentId, children);
@@ -356,18 +374,46 @@ const lineageConnectors = computed(() => {
const connectors = []; const connectors = [];
childrenByParent.forEach((children, parentId) => { childrenByParent.forEach((children, parentId) => {
const parent = memberById.get(parentId); const parent = memberById.get(parentId);
const childTop = Math.min(...children.map((child) => child.y - NODE_HALF_HEIGHT)); const spouse = spouseByPersonId.get(parentId);
const parentAnchorX = spouse
? snapToGrid((parent.x + spouse.x) / 2)
: parent.x;
const parentBottom = parent.y + NODE_HALF_HEIGHT; const parentBottom = parent.y + NODE_HALF_HEIGHT;
const branchY = snapToGrid((parentBottom + childTop) / 2); const parentAnchorY = spouse
? snapToGrid(parentBottom + FAMILY_LINK_OFFSET)
: parentBottom;
const childTop = Math.min(...children.map((child) => child.y - NODE_HALF_HEIGHT));
const branchY = snapToGrid((parentAnchorY + childTop) / 2);
const minX = Math.min(...children.map((child) => child.x)); const minX = Math.min(...children.map((child) => child.x));
const maxX = Math.max(...children.map((child) => child.x)); const maxX = Math.max(...children.map((child) => child.x));
const verticalStyle = (x, top, bottom) => ({ const verticalStyle = (x, top, bottom) => ({
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`, gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`, gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
}); });
const horizontalStyle = (left, right, y) => ({
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
gridRow: `${Math.round(y / GRID_UNIT) + 1} / span 1`,
});
if (spouse) {
connectors.push({
id: `${parentId}-${spouse.id}-family-parent`,
kind: "family",
style: verticalStyle(parent.x, parentBottom, parentAnchorY),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-spouse`,
kind: "family",
style: verticalStyle(spouse.x, spouse.y + NODE_HALF_HEIGHT, parentAnchorY),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-bridge`,
kind: "family",
style: horizontalStyle(Math.min(parent.x, spouse.x), Math.max(parent.x, spouse.x), parentAnchorY),
});
}
connectors.push({ connectors.push({
id: `${parentId}-trunk`, id: `${parentId}-trunk`,
style: verticalStyle(parent.x, parentBottom, branchY), style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
}); });
connectors.push({ connectors.push({
id: `${parentId}-branch`, id: `${parentId}-branch`,
@@ -381,6 +427,23 @@ const lineageConnectors = computed(() => {
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT), style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
})); }));
}); });
layoutMembers.value.forEach((member) => {
if (!member.spouseOf) return;
const partner = memberById.get(member.spouseOf);
if (!partner || partner.generation !== member.generation) return;
if (childrenByParent.has(partner.id)) return;
const left = Math.min(member.x, partner.x) + NODE_HALF_WIDTH;
const right = Math.max(member.x, partner.x) - NODE_HALF_WIDTH;
if (right <= left) return;
connectors.push({
id: `${partner.id}-${member.id}-spouse`,
kind: "spouse",
style: {
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
gridRow: `${Math.round(member.y / GRID_UNIT) + 1} / span 1`,
},
});
});
return connectors; return connectors;
}); });
@@ -444,6 +507,10 @@ const loadTree = async (query = {}) => {
(item) => String(item.id) === String(query.selectedId), (item) => String(item.id) === String(query.selectedId),
) || layoutMembers.value[0] || null; ) || layoutMembers.value[0] || null;
treeState.value = members.value.length ? "tree" : "empty"; treeState.value = members.value.length ? "tree" : "empty";
if (treeState.value === "tree" && selected.value) {
await nextTick();
recenterSelectedMember();
}
} catch (error) { } catch (error) {
if ( if (
!pageActive || !pageActive ||
@@ -481,22 +548,46 @@ const handleTreeScroll = (event) => {
treeHasDrifted.value = Math.abs(scrollLeft - centeredTreeScrollLeft.value) > 80; treeHasDrifted.value = Math.abs(scrollLeft - centeredTreeScrollLeft.value) > 80;
}; };
const getFamilyPartner = (member) => {
if (!member) return null;
const partnerId = member.spouseOf || layoutMembers.value.find(
(item) => String(item.spouseOf || "") === String(member.id),
)?.id;
const partner = layoutMembers.value.find(
(item) => String(item.id) === String(partnerId || ""),
);
return partner && partner.generation === member.generation ? partner : null;
};
const recenterSelectedMember = async () => { const recenterSelectedMember = async () => {
if (!selected.value) return; if (!selected.value) return;
const spouse = getFamilyPartner(selected.value);
ignoreTreeScroll = true; ignoreTreeScroll = true;
const [scrollRect, nodeRect] = await new Promise((resolve) => { const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
const query = uni.createSelectorQuery(); const query = uni.createSelectorQuery();
query.select(".tree-scroll").boundingClientRect(); query.select(".tree-scroll").boundingClientRect();
query query
.select(`#tree-member-${selected.value.id}`) .select(`#tree-member-${selected.value.id}`)
.boundingClientRect(); .boundingClientRect();
if (spouse) {
query.select(`#tree-member-${spouse.id}`).boundingClientRect();
}
query.exec(resolve); query.exec(resolve);
}); });
if (scrollRect && nodeRect) { if (scrollRect && nodeRect) {
const targetLeft = spouseRect
? Math.min(nodeRect.left, spouseRect.left)
: nodeRect.left;
const targetRight = spouseRect
? Math.max(
nodeRect.left + nodeRect.width,
spouseRect.left + spouseRect.width,
)
: nodeRect.left + nodeRect.width;
const centeredScrollLeft = Math.max( const centeredScrollLeft = Math.max(
0, 0,
currentTreeScrollLeft.value + currentTreeScrollLeft.value +
nodeRect.left + nodeRect.width / 2 - (scrollRect.left + scrollRect.width / 2), (targetLeft + targetRight) / 2 - (scrollRect.left + scrollRect.width / 2),
); );
treeScrollLeft.value = centeredScrollLeft; treeScrollLeft.value = centeredScrollLeft;
currentTreeScrollLeft.value = centeredScrollLeft; currentTreeScrollLeft.value = centeredScrollLeft;
@@ -613,7 +704,7 @@ const openMemberAction = (action) => {
} }
.tree-stage--lineage { .tree-stage--lineage {
display: grid; display: grid;
grid-template-columns: 190rpx minmax(0, 1fr); grid-template-columns: 144rpx minmax(0, 1fr);
align-items: start; align-items: start;
} }
.tree-scroll { .tree-scroll {
@@ -624,7 +715,7 @@ const openMemberAction = (action) => {
.generation-rail { .generation-rail {
display: grid; display: grid;
z-index: 4; z-index: 4;
width: 190rpx; width: 144rpx;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2); border-right: 1rpx solid rgba(143, 108, 63, 0.2);
@@ -635,7 +726,7 @@ const openMemberAction = (action) => {
} }
.tree-canvas { .tree-canvas {
display: grid; display: grid;
margin: 0 16rpx; margin: 0 12rpx;
} }
.tree-canvas--state { .tree-canvas--state {
display: block; display: block;
@@ -655,7 +746,7 @@ const openMemberAction = (action) => {
display: grid; display: grid;
justify-self: center; justify-self: center;
z-index: 3; z-index: 3;
width: 166rpx; width: 126rpx;
height: 58rpx; height: 58rpx;
color: #8f6c3f; color: #8f6c3f;
font-size: 22rpx; font-size: 22rpx;
@@ -679,7 +770,7 @@ const openMemberAction = (action) => {
} }
.generation-band__copy text:last-child { .generation-band__copy text:last-child {
margin-top: 1rpx; margin-top: 1rpx;
font-size: 19rpx; font-size: 18rpx;
} }
.lineage-connector { .lineage-connector {
align-self: stretch; align-self: stretch;
@@ -687,36 +778,57 @@ const openMemberAction = (action) => {
z-index: 1; z-index: 1;
background: #b78a42; background: #b78a42;
} }
.lineage-connector--spouse {
background: rgba(183, 117, 76, 0.82);
}
.member-node { .member-node {
display: grid; position: relative;
align-self: start; align-self: start;
justify-self: start; justify-self: start;
z-index: 2; z-index: 2;
width: 224rpx; width: 160rpx;
height: 86rpx; height: 188rpx;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
text-align: center; text-align: center;
} }
.member-node__skin { .member-node__surface {
display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 9rpx 8rpx 8rpx;
border: 1rpx solid #b78a42;
border-radius: 14rpx;
background: rgba(255, 252, 244, 0.96);
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
} }
.member-node__skin, .member-node__portrait {
.member-node__copy { display: flex;
grid-area: 1 / 1; width: 62rpx;
height: 62rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1rpx solid #c49a57;
border-radius: 50%;
background: #fffdf7;
overflow: hidden;
} }
.member-node__avatar { .member-node__avatar {
grid-area: 1 / 1; width: 48rpx;
z-index: 3; height: 48rpx;
align-self: start; }
justify-self: start; .member-node__divider {
width: 42rpx; width: 56rpx;
height: 42rpx; height: 14rpx;
margin: 13rpx 0 0 18rpx; margin-top: 3rpx;
} }
.member-node__copy { .member-node__copy {
z-index: 1; width: 100%;
padding-left: 44rpx; margin-top: 1rpx;
padding: 0;
} }
.node-name, .node-name,
.node-relation, .node-relation,
@@ -724,25 +836,48 @@ const openMemberAction = (action) => {
display: block; display: block;
} }
.node-name { .node-name {
padding-top: 5rpx; display: -webkit-box;
color: $ink; color: #6b2419;
font-family: "STKaiti", "KaiTi", serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 25rpx; overflow: hidden;
font-size: 24rpx;
font-weight: 700; font-weight: 700;
line-height: 1.1;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
} }
.node-relation { .node-relation {
margin-top: 0; overflow: hidden;
color: $brand-red; margin-top: 2rpx;
font-size: 21rpx; color: #644b2e;
font-size: 18rpx;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
} }
.node-years { .node-years {
margin-top: 0; overflow: hidden;
color: $ink-muted; margin-top: 2rpx;
font-size: 20rpx; color: #695b4c;
font-size: 17rpx;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-node--spouse .member-node__surface {
border-color: #c39a61;
background: rgba(255, 250, 241, 0.98);
} }
.member-node--selected { .member-node--selected {
width: 236rpx; z-index: 3;
height: 94rpx; }
.member-node--selected .member-node__surface {
border-color: #c55344;
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
}
.member-node--selected .member-node__portrait {
border-color: #c55344;
} }
.member-node--selected .node-name { .member-node--selected .node-name {
color: $brand-red; color: $brand-red;
+184 -14
View File
@@ -37,6 +37,34 @@
</view> </view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text> <text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<view class="form-field">
<text>人物编号</text>
<input v-model="addForm.personNo" maxlength="40" placeholder="不填则由服务端生成" placeholder-class="form-placeholder" />
</view>
<picker :range="sexOptions.map((item) => item.label)" :value="optionIndex(sexOptions, addForm.sex)" @change="selectOption('sex', sexOptions, $event)">
<view class="form-field form-field--picker">
<text>性别</text><text>{{ optionLabel(sexOptions, addForm.sex) || "请选择" }}</text>
</view>
</picker>
<picker :range="bindingModeOptions.map((item) => item.label)" :value="optionIndex(bindingModeOptions, addForm.bindingMode)" @change="selectBindingMode">
<view class="form-field form-field--picker">
<text>身份认领</text><text>{{ optionLabel(bindingModeOptions, addForm.bindingMode) }}</text>
</view>
</picker>
<view class="form-field">
<text>业务用户</text>
<text class="form-field__hint">{{ appUserBindingHint }}</text>
</view>
<text v-if="fieldErrors.bindingMode" class="field-error">{{ fieldErrors.bindingMode }}</text>
<view class="form-field form-field--upload">
<text>头像</text>
<view>
<button class="upload-button" :disabled="isAvatarUploading || isSubmitting" @click="uploadAvatar">{{ isAvatarUploading ? "上传中" : "选择图片" }}</button>
<text v-if="avatarFileName" class="upload-receipt">已上传{{ avatarFileName }}</text>
</view>
</view>
<text v-if="avatarUploadError" class="field-error">{{ avatarUploadError }}</text>
<picker <picker
v-if="!isFirstMember" v-if="!isFirstMember"
:range="relationOptions" :range="relationOptions"
@@ -58,16 +86,28 @@
<text>字辈</text> <text>字辈</text>
<input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view> </view>
<view class="form-field">
<text>世代</text>
<input v-if="!isFirstMember" v-model="addForm.generation" type="number" placeholder="可选正整数" placeholder-class="form-placeholder" />
<text v-else>首位成员固定为 1</text>
</view>
<picker :range="personOptionLabels" :value="personOptionIndex(addForm.fatherId)" @change="selectPerson('fatherId', $event)">
<view class="form-field form-field--picker"><text>父亲</text><text>{{ personOptionLabel(addForm.fatherId) }}</text></view>
</picker>
<picker :range="personOptionLabels" :value="personOptionIndex(addForm.motherId)" @change="selectPerson('motherId', $event)">
<view class="form-field form-field--picker"><text>母亲</text><text>{{ personOptionLabel(addForm.motherId) }}</text></view>
</picker>
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate"> <picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
<view class="form-field form-field--picker"> <view class="form-field form-field--picker">
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text> <text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
</view> </view>
</picker> </picker>
<view class="form-field"> <picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, addForm.birthLunar)" @change="selectOption('birthLunar', lunarOptions, $event)">
<text>出生农历</text> <view class="form-field form-field--picker">
<input v-model="addForm.birthLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <text>出生农历</text><text>{{ optionLabel(lunarOptions, addForm.birthLunar) || "请选择" }}</text>
</view> </view>
</picker>
<view class="form-field"> <view class="form-field">
<text>出生地</text> <text>出生地</text>
<input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
@@ -78,10 +118,11 @@
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text> <text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
</view> </view>
</picker> </picker>
<view class="form-field"> <picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, addForm.deathLunar)" @change="selectOption('deathLunar', lunarOptions, $event)">
<text>逝世农历</text> <view class="form-field form-field--picker">
<input v-model="addForm.deathLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <text>逝世农历</text><text>{{ optionLabel(lunarOptions, addForm.deathLunar) || "请选择" }}</text>
</view> </view>
</picker>
<view class="form-field"> <view class="form-field">
<text>逝世地</text> <text>逝世地</text>
<input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
@@ -90,6 +131,20 @@
<text>安葬地</text> <text>安葬地</text>
<input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view> </view>
<picker :range="personStatusOptions.map((item) => item.label)" :value="optionIndex(personStatusOptions, addForm.personStatus)" @change="selectOption('personStatus', personStatusOptions, $event)">
<view class="form-field form-field--picker">
<text>人物状态</text><text>{{ optionLabel(personStatusOptions, addForm.personStatus) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>排序值</text>
<input v-model="addForm.sortOrder" type="number" placeholder="可选整数,值越小越靠前" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>关系名称</text>
<input v-if="isFirstMember" v-model="addForm.relationName" maxlength="30" placeholder="可选关系显示名称" placeholder-class="form-placeholder" />
<text v-else>{{ relationLabel }}</text>
</view>
<view class="form-field form-field--summary"> <view class="form-field form-field--summary">
<text>人物简介</text> <text>人物简介</text>
@@ -153,6 +208,7 @@ import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { import {
handleBackPress, handleBackPress,
@@ -166,10 +222,14 @@ const personId = ref("");
const mode = ref("relative"); const mode = ref("relative");
const relationType = ref(""); const relationType = ref("");
const isSubmitting = ref(false); const isSubmitting = ref(false);
const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false); const discardDialogVisible = ref(false);
const currentMember = ref(null); const currentMember = ref(null);
const errorMessage = ref(""); const errorMessage = ref("");
const addRequestController = createRequestController(); const addRequestController = createRequestController();
const personOptionsRequestController = createRequestController();
let loadSequence = 0; let loadSequence = 0;
const relationIntents = Object.freeze({ const relationIntents = Object.freeze({
@@ -181,11 +241,39 @@ const relationIntents = Object.freeze({
DAUGHTER: { label: "女儿" }, DAUGHTER: { label: "女儿" },
}); });
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]); const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
const sexOptions = Object.freeze([
{ label: "男", value: "0" },
{ label: "女", value: "1" },
{ label: "未知", value: "2" },
]);
const lunarOptions = Object.freeze([
{ label: "否", value: "0" },
{ label: "是", value: "1" },
]);
const personStatusOptions = Object.freeze([
{ label: "健在", value: "0" },
{ label: "已故", value: "1" },
{ label: "未知", value: "2" },
]);
const bindingModeOptions = Object.freeze([
{ label: "不绑定账号", value: "NONE" },
{ label: "绑定当前账号", value: "SELF" },
{ label: "绑定指定用户", value: "SPECIFIED" },
]);
const personOptions = ref([{ label: "不选择", value: "" }]);
const personOptionLabels = computed(() => personOptions.value.map((item) => item.label));
const addForm = reactive({ const addForm = reactive({
bindingMode: "NONE",
personNo: "",
name: "", name: "",
relation: "", relation: "",
aliasName: "", aliasName: "",
sex: "",
generation: "",
generationName: "", generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "",
birthDate: "", birthDate: "",
birthLunar: "", birthLunar: "",
birthPlace: "", birthPlace: "",
@@ -193,10 +281,13 @@ const addForm = reactive({
deathLunar: "", deathLunar: "",
deathPlace: "", deathPlace: "",
burialPlace: "", burialPlace: "",
personStatus: "",
biography: "", biography: "",
remark: "", remark: "",
sortOrder: "",
relationName: "",
}); });
const fieldErrors = reactive({ name: "", relation: "" }); const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
const isFirstMember = computed(() => mode.value === "first"); const isFirstMember = computed(() => mode.value === "first");
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null); const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
@@ -205,6 +296,11 @@ const relationOptions = computed(() => {
return types.map((type) => relationIntents[type].label); return types.map((type) => relationIntents[type].label);
}); });
const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系"); const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系");
const appUserBindingHint = computed(() => {
if (addForm.bindingMode === "NONE") return "不绑定账号,不提交业务用户 ID";
if (addForm.bindingMode === "SELF") return "由服务端从 Token 绑定当前登录账号,不提交业务用户 ID";
return "需先选择指定业务用户;当前没有可用候选接口";
});
const hasValidContext = computed( const hasValidContext = computed(
() => () =>
Boolean(genealogyId.value) && Boolean(genealogyId.value) &&
@@ -239,7 +335,7 @@ const resultCopy = computed(() => {
}; };
}); });
const hasDraft = computed(() => const hasDraft = computed(() =>
Object.values(addForm).some((value) => String(value).trim()), Object.entries(addForm).some(([field, value]) => field !== "bindingMode" && String(value).trim()),
); );
const discardConfirmation = createDiscardConfirmation((visible) => { const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible; discardDialogVisible.value = visible;
@@ -265,7 +361,22 @@ const loadCurrentMember = async () => {
errorMessage.value = error?.message || "当前成员暂不可用。"; errorMessage.value = error?.message || "当前成员暂不可用。";
} }
}; };
const loadPersonOptions = async () => {
try {
const rows = await appApi.getLineagePersonOptions(genealogyId.value, { requestController: personOptionsRequestController });
const options = rows
.map((item) => {
const value = String(item?.personId ?? item?.id ?? "");
if (!/^[1-9]\d*$/.test(value)) return null;
const name = String(item?.name || item?.personName || item?.personNo || "未命名人物");
return { value, label: `${name}${value}` };
})
.filter(Boolean);
personOptions.value = [{ label: "不选择", value: "" }, ...options];
} catch (error) {
if (!isRequestCancelled(error)) personOptions.value = [{ label: "暂无可选人物", value: "" }];
}
};
onLoad((query) => { onLoad((query) => {
genealogyId.value = String(query.genealogyId || ""); genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || ""); personId.value = String(query.personId || "");
@@ -284,6 +395,7 @@ onLoad((query) => {
if (activeRelationIntent.value) { if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label; addForm.relation = activeRelationIntent.value.label;
} }
void loadPersonOptions();
if (isFirstMember.value) { if (isFirstMember.value) {
addState.value = "form"; addState.value = "form";
return; return;
@@ -293,6 +405,7 @@ onLoad((query) => {
onUnload(() => { onUnload(() => {
loadSequence += 1; loadSequence += 1;
addRequestController.abort(); addRequestController.abort();
personOptionsRequestController.abort();
discardConfirmation.dispose(); discardConfirmation.dispose();
}); });
@@ -300,7 +413,7 @@ const requestBack = () =>
runBackGuard({ runBackGuard({
transientOpen: discardDialogVisible.value, transientOpen: discardDialogVisible.value,
dirty: addState.value === "form" && hasDraft.value, dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value, submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard, "close-transient": cancelDiscard,
"block-submitting": () => true, "block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation, "confirm-discard": requestDiscardConfirmation,
@@ -313,21 +426,58 @@ const selectRelation = (event) => {
addForm.relation = relationOptions.value[Number(event.detail.value)] || ""; addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
clearError("relation"); clearError("relation");
}; };
const optionIndex = (options, value) => Math.max(0, options.findIndex((item) => item.value === value));
const optionLabel = (options, value) => options.find((item) => item.value === value)?.label || "";
const selectOption = (field, options, event) => {
addForm[field] = options[Number(event.detail.value)]?.value || "";
};
const selectBindingMode = (event) => {
addForm.bindingMode = bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
fieldErrors.bindingMode = "";
};
const personOptionIndex = (value) => Math.max(0, personOptions.value.findIndex((item) => item.value === value));
const personOptionLabel = (value) => personOptions.value.find((item) => item.value === value)?.label || "不选择";
const selectPerson = (field, event) => {
addForm[field] = personOptions.value[Number(event.detail.value)]?.value || "";
};
const uploadAvatar = async () => {
if (isAvatarUploading.value || isSubmitting.value) return;
isAvatarUploading.value = true;
avatarUploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: addRequestController });
addForm.avatarOssId = receipt.ossId;
avatarFileName.value = receipt.fileName || "头像图片";
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
} finally {
isAvatarUploading.value = false;
}
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; }; const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; }; const selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; };
const validateAddForm = () => { const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名"; fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系"; fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
return !fieldErrors.name && !fieldErrors.relation; fieldErrors.bindingMode = addForm.bindingMode === "SPECIFIED"
? "当前未提供可选业务用户,不能指定认领"
: "";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
}; };
const submitAdd = async () => { const submitAdd = async () => {
if (isSubmitting.value || !validateAddForm()) return; if (isSubmitting.value || isAvatarUploading.value || !validateAddForm()) return;
isSubmitting.value = true; isSubmitting.value = true;
try { try {
const payload = { const payload = {
bindingMode: addForm.bindingMode,
personNo: addForm.personNo,
name: addForm.name, name: addForm.name,
aliasName: addForm.aliasName, aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName, generationName: addForm.generationName,
fatherId: addForm.fatherId,
motherId: addForm.motherId,
avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate, birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar, birthLunar: addForm.birthLunar,
birthPlace: addForm.birthPlace, birthPlace: addForm.birthPlace,
@@ -335,10 +485,16 @@ const submitAdd = async () => {
deathLunar: addForm.deathLunar, deathLunar: addForm.deathLunar,
deathPlace: addForm.deathPlace, deathPlace: addForm.deathPlace,
burialPlace: addForm.burialPlace, burialPlace: addForm.burialPlace,
personStatus: addForm.personStatus,
biography: addForm.biography, biography: addForm.biography,
remark: addForm.remark, remark: addForm.remark,
...(isFirstMember.value ? { generation: 1 } : {}), sortOrder: addForm.sortOrder,
...(isFirstMember.value ? {} : { relationName: relationLabel.value }), ...(isFirstMember.value
? { generation: 1, relationName: addForm.relationName }
: {
...(addForm.generation ? { generation: Number(addForm.generation) } : {}),
relationName: relationLabel.value,
}),
}; };
if (isFirstMember.value) { if (isFirstMember.value) {
await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController }); await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController });
@@ -349,10 +505,17 @@ const submitAdd = async () => {
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController }); await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
} }
Object.assign(addForm, { Object.assign(addForm, {
bindingMode: "NONE",
personNo: "",
name: "", name: "",
relation: "", relation: "",
aliasName: "", aliasName: "",
sex: "",
generation: "",
generationName: "", generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "",
birthDate: "", birthDate: "",
birthLunar: "", birthLunar: "",
birthPlace: "", birthPlace: "",
@@ -360,9 +523,13 @@ const submitAdd = async () => {
deathLunar: "", deathLunar: "",
deathPlace: "", deathPlace: "",
burialPlace: "", burialPlace: "",
personStatus: "",
biography: "", biography: "",
remark: "", remark: "",
sortOrder: "",
relationName: "",
}); });
avatarFileName.value = "";
await returnTo("T01", { genealogyId: genealogyId.value }); await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) { } catch (error) {
if (isRequestCancelled(error)) return; if (isRequestCancelled(error)) return;
@@ -396,6 +563,9 @@ const returnToTree = async () => {
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; } .member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 22rpx; line-height: 1; transform: translateY(-2rpx); } .required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 22rpx; line-height: 1; transform: translateY(-2rpx); }
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; } .form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
.form-field__hint, .upload-receipt { color: $ink-muted; font-size: 21rpx; line-height: 1.4; text-align: right; }
.form-field--upload > view { display: grid; justify-items: end; gap: 6rpx; }
.upload-button { min-height: 54rpx; margin: 0; padding: 0 16rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.form-field--summary { align-items: start; } .form-field--summary { align-items: start; }
.form-placeholder { color: #a79884; } .form-placeholder { color: #a79884; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; } .field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
+182 -12
View File
@@ -27,6 +27,34 @@
</view> </view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text> <text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<view class="form-field">
<text>人物编号</text>
<input v-model="editForm.personNo" maxlength="40" placeholder="不填则由服务端生成" placeholder-class="form-placeholder" />
</view>
<picker :range="sexOptions.map((item) => item.label)" :value="optionIndex(sexOptions, editForm.sex)" @change="selectOption('sex', sexOptions, $event)">
<view class="form-field form-field--picker">
<text>性别</text><text>{{ optionLabel(sexOptions, editForm.sex) || "请选择" }}</text>
</view>
</picker>
<picker :range="bindingModeOptions.map((item) => item.label)" :value="optionIndex(bindingModeOptions, editForm.bindingMode)" @change="selectBindingMode">
<view class="form-field form-field--picker">
<text>身份认领</text><text>{{ optionLabel(bindingModeOptions, editForm.bindingMode) }}</text>
</view>
</picker>
<view class="form-field">
<text>业务用户</text>
<text class="form-field__hint">{{ appUserBindingHint }}</text>
</view>
<text v-if="fieldErrors.bindingMode" class="field-error">{{ fieldErrors.bindingMode }}</text>
<view class="form-field form-field--upload">
<text>头像</text>
<view>
<button class="upload-button" :disabled="isAvatarUploading || isSubmitting" @click="uploadAvatar">{{ isAvatarUploading ? "上传中" : "选择图片" }}</button>
<text v-if="avatarFileName" class="upload-receipt">已上传{{ avatarFileName }}</text>
</view>
</view>
<text v-if="avatarUploadError" class="field-error">{{ avatarUploadError }}</text>
<view class="form-field"> <view class="form-field">
<text>别名</text> <text>别名</text>
<input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" /> <input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" />
@@ -35,13 +63,24 @@
<text>字辈</text> <text>字辈</text>
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" /> <input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
</view> </view>
<view class="form-field">
<text>世代</text>
<input v-model="editForm.generation" type="number" placeholder="正整数" placeholder-class="form-placeholder" />
</view>
<picker :range="personOptionLabels" :value="personOptionIndex(editForm.fatherId)" @change="selectPerson('fatherId', $event)">
<view class="form-field form-field--picker"><text>父亲</text><text>{{ personOptionLabel(editForm.fatherId) }}</text></view>
</picker>
<picker :range="personOptionLabels" :value="personOptionIndex(editForm.motherId)" @change="selectPerson('motherId', $event)">
<view class="form-field form-field--picker"><text>母亲</text><text>{{ personOptionLabel(editForm.motherId) }}</text></view>
</picker>
<picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)"> <picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)">
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view> <view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
</picker> </picker>
<view class="form-field"> <picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, editForm.birthLunar)" @change="selectOption('birthLunar', lunarOptions, $event)">
<text>出生农历</text> <view class="form-field form-field--picker">
<input v-model="editForm.birthLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <text>出生农历</text><text>{{ optionLabel(lunarOptions, editForm.birthLunar) || "请选择" }}</text>
</view> </view>
</picker>
<view class="form-field"> <view class="form-field">
<text>出生地</text> <text>出生地</text>
<input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
@@ -49,10 +88,11 @@
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)"> <picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view> <view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
</picker> </picker>
<view class="form-field"> <picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, editForm.deathLunar)" @change="selectOption('deathLunar', lunarOptions, $event)">
<text>逝世农历</text> <view class="form-field form-field--picker">
<input v-model="editForm.deathLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <text>逝世农历</text><text>{{ optionLabel(lunarOptions, editForm.deathLunar) || "请选择" }}</text>
</view> </view>
</picker>
<view class="form-field"> <view class="form-field">
<text>逝世地</text> <text>逝世地</text>
<input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
@@ -61,6 +101,19 @@
<text>安葬地</text> <text>安葬地</text>
<input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" /> <input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view> </view>
<picker :range="personStatusOptions.map((item) => item.label)" :value="optionIndex(personStatusOptions, editForm.personStatus)" @change="selectOption('personStatus', personStatusOptions, $event)">
<view class="form-field form-field--picker">
<text>人物状态</text><text>{{ optionLabel(personStatusOptions, editForm.personStatus) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>排序值</text>
<input v-model="editForm.sortOrder" type="number" placeholder="可选整数,值越小越靠前" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>关系名称</text>
<input v-model="editForm.relationName" maxlength="30" placeholder="可选关系显示名称" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary"> <view class="form-field form-field--summary">
<text>人物简介</text> <text>人物简介</text>
<textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" /> <textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" />
@@ -112,6 +165,7 @@ import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { import {
goBack, goBack,
@@ -123,18 +177,30 @@ const editState = ref("loading");
const genealogyId = ref(""); const genealogyId = ref("");
const personId = ref(""); const personId = ref("");
const isSubmitting = ref(false); const isSubmitting = ref(false);
const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false); const discardDialogVisible = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const failedAction = ref("load"); const failedAction = ref("load");
const editRequestController = createRequestController(); const editRequestController = createRequestController();
const personOptionsRequestController = createRequestController();
let loadSequence = 0; let loadSequence = 0;
const originalMember = ref(null); const originalMember = ref(null);
const baseline = ref(""); const baseline = ref("");
const editForm = reactive({ const editForm = reactive({
appUserId: "",
bindingMode: "NONE",
personNo: "",
name: "", name: "",
aliasName: "", aliasName: "",
sex: "",
generation: "",
generationName: "", generationName: "",
fatherId: "",
motherId: "",
avatarOssId: "",
birthDate: "", birthDate: "",
birthLunar: "", birthLunar: "",
birthPlace: "", birthPlace: "",
@@ -142,10 +208,39 @@ const editForm = reactive({
deathLunar: "", deathLunar: "",
deathPlace: "", deathPlace: "",
burialPlace: "", burialPlace: "",
personStatus: "",
summary: "", summary: "",
remark: "", remark: "",
sortOrder: "",
relationName: "",
});
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
const sexOptions = Object.freeze([
{ label: "男", value: "0" },
{ label: "女", value: "1" },
{ label: "未知", value: "2" },
]);
const lunarOptions = Object.freeze([
{ label: "否", value: "0" },
{ label: "是", value: "1" },
]);
const personStatusOptions = Object.freeze([
{ label: "健在", value: "0" },
{ label: "已故", value: "1" },
{ label: "未知", value: "2" },
]);
const bindingModeOptions = Object.freeze([
{ label: "不绑定账号", value: "NONE" },
{ label: "绑定当前账号", value: "SELF" },
{ label: "绑定指定用户", value: "SPECIFIED" },
]);
const personOptions = ref([{ label: "不选择", value: "" }]);
const personOptionLabels = computed(() => personOptions.value.map((item) => item.label));
const appUserBindingHint = computed(() => {
if (editForm.bindingMode === "NONE") return "不绑定账号,保存时不提交业务用户 ID";
if (editForm.bindingMode === "SELF") return "由服务端从 Token 绑定当前登录账号,保存时不提交业务用户 ID";
return editForm.appUserId ? "保留当前已认领的指定业务用户" : "需先选择指定业务用户;当前没有可用候选接口";
}); });
const fieldErrors = reactive({ name: "", dates: "" });
const formSnapshot = computed(() => JSON.stringify(editForm)); const formSnapshot = computed(() => JSON.stringify(editForm));
const hasValidContext = computed(() => const hasValidContext = computed(() =>
@@ -170,6 +265,7 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
const requestDiscardConfirmation = discardConfirmation.request; const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm; const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel; const cancelDiscard = discardConfirmation.cancel;
const datePart = (value) => String(value || "").slice(0, 10);
const loadMember = async () => { const loadMember = async () => {
const activeLoad = ++loadSequence; const activeLoad = ++loadSequence;
@@ -181,18 +277,29 @@ const loadMember = async () => {
if (activeLoad !== loadSequence) return; if (activeLoad !== loadSequence) return;
originalMember.value = member; originalMember.value = member;
Object.assign(editForm, { Object.assign(editForm, {
appUserId: member.appUserId || "",
bindingMode: member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
personNo: member.personNo || "",
name: member.name, name: member.name,
aliasName: member.aliasName || "", aliasName: member.aliasName || "",
sex: member.sex || "",
generation: member.generation || "",
generationName: member.generationName || "", generationName: member.generationName || "",
birthDate: member.birthDate || "", fatherId: member.relatives.find((item) => item.relation === "父亲")?.id || "",
motherId: member.relatives.find((item) => item.relation === "母亲")?.id || "",
avatarOssId: member.avatarOssId || "",
birthDate: datePart(member.birthDate),
birthLunar: member.birthLunar || "", birthLunar: member.birthLunar || "",
birthPlace: member.birthplace || "", birthPlace: member.birthplace || "",
deathDate: member.deathDate || "", deathDate: datePart(member.deathDate),
deathLunar: member.deathLunar || "", deathLunar: member.deathLunar || "",
deathPlace: member.deathPlace || "", deathPlace: member.deathPlace || "",
burialPlace: member.burialPlace || "", burialPlace: member.burialPlace || "",
personStatus: member.personStatus || "",
summary: member.biography || "", summary: member.biography || "",
remark: member.remark || "", remark: member.remark || "",
sortOrder: member.sortOrder ?? "",
relationName: member.relationName || "",
}); });
baseline.value = formSnapshot.value; baseline.value = formSnapshot.value;
errorMessage.value = ""; errorMessage.value = "";
@@ -206,6 +313,22 @@ const loadMember = async () => {
editState.value = "error"; editState.value = "error";
} }
}; };
const loadPersonOptions = async () => {
try {
const rows = await appApi.getLineagePersonOptions(genealogyId.value, { requestController: personOptionsRequestController });
const options = rows
.map((item) => {
const value = String(item?.personId ?? item?.id ?? "");
if (!/^[1-9]\d*$/.test(value) || value === personId.value) return null;
const name = String(item?.name || item?.personName || item?.personNo || "未命名人物");
return { value, label: `${name}${value}` };
})
.filter(Boolean);
personOptions.value = [{ label: "不选择", value: "" }, ...options];
} catch (error) {
if (!isRequestCancelled(error)) personOptions.value = [{ label: "暂无可选人物", value: "" }];
}
};
onLoad((query) => { onLoad((query) => {
genealogyId.value = String(query.genealogyId || ""); genealogyId.value = String(query.genealogyId || "");
@@ -216,10 +339,12 @@ onLoad((query) => {
return; return;
} }
void loadMember(); void loadMember();
void loadPersonOptions();
}); });
onUnload(() => { onUnload(() => {
loadSequence += 1; loadSequence += 1;
editRequestController.abort(); editRequestController.abort();
personOptionsRequestController.abort();
discardConfirmation.dispose(); discardConfirmation.dispose();
}); });
@@ -227,7 +352,7 @@ const requestBack = () =>
runBackGuard({ runBackGuard({
transientOpen: discardDialogVisible.value, transientOpen: discardDialogVisible.value,
dirty: isDirty.value, dirty: isDirty.value,
submitting: isSubmitting.value, submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard, "close-transient": cancelDiscard,
"block-submitting": () => true, "block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation, "confirm-discard": requestDiscardConfirmation,
@@ -236,6 +361,34 @@ const requestBack = () =>
onBackPress((event) => handleBackPress(event, requestBack)); onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => { fieldErrors[field] = ""; }; const clearError = (field) => { fieldErrors[field] = ""; };
const optionIndex = (options, value) => Math.max(0, options.findIndex((item) => item.value === value));
const optionLabel = (options, value) => options.find((item) => item.value === value)?.label || "";
const selectOption = (field, options, event) => {
editForm[field] = options[Number(event.detail.value)]?.value || "";
};
const selectBindingMode = (event) => {
editForm.bindingMode = bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
fieldErrors.bindingMode = "";
};
const personOptionIndex = (value) => Math.max(0, personOptions.value.findIndex((item) => item.value === value));
const personOptionLabel = (value) => personOptions.value.find((item) => item.value === value)?.label || "不选择";
const selectPerson = (field, event) => {
editForm[field] = personOptions.value[Number(event.detail.value)]?.value || "";
};
const uploadAvatar = async () => {
if (isAvatarUploading.value || isSubmitting.value) return;
isAvatarUploading.value = true;
avatarUploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController: editRequestController });
editForm.avatarOssId = receipt.ossId;
avatarFileName.value = receipt.fileName || "头像图片";
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
} finally {
isAvatarUploading.value = false;
}
};
const selectDate = (field, event) => { const selectDate = (field, event) => {
editForm[field] = event.detail.value || ""; editForm[field] = event.detail.value || "";
fieldErrors.dates = ""; fieldErrors.dates = "";
@@ -243,16 +396,27 @@ const selectDate = (field, event) => {
const validateEditForm = () => { const validateEditForm = () => {
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名"; fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : ""; fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : "";
return !fieldErrors.name && !fieldErrors.dates; fieldErrors.bindingMode = editForm.bindingMode === "SPECIFIED" && !editForm.appUserId
? "当前未提供可选业务用户,不能指定认领"
: "";
return !fieldErrors.name && !fieldErrors.dates && !fieldErrors.bindingMode;
}; };
const saveMember = async () => { const saveMember = async () => {
if (isSubmitting.value || !validateEditForm()) return; if (isSubmitting.value || isAvatarUploading.value || !validateEditForm()) return;
isSubmitting.value = true; isSubmitting.value = true;
try { try {
await appApi.updatePerson(genealogyId.value, personId.value, { await appApi.updatePerson(genealogyId.value, personId.value, {
bindingMode: editForm.bindingMode,
...(editForm.bindingMode === "SPECIFIED" ? { appUserId: editForm.appUserId } : {}),
personNo: editForm.personNo,
name: editForm.name, name: editForm.name,
aliasName: editForm.aliasName, aliasName: editForm.aliasName,
sex: editForm.sex,
generation: editForm.generation ? Number(editForm.generation) : undefined,
generationName: editForm.generationName, generationName: editForm.generationName,
fatherId: editForm.fatherId,
motherId: editForm.motherId,
avatarOssId: editForm.avatarOssId,
birthDate: editForm.birthDate, birthDate: editForm.birthDate,
birthLunar: editForm.birthLunar, birthLunar: editForm.birthLunar,
birthPlace: editForm.birthPlace, birthPlace: editForm.birthPlace,
@@ -260,8 +424,11 @@ const saveMember = async () => {
deathLunar: editForm.deathLunar, deathLunar: editForm.deathLunar,
deathPlace: editForm.deathPlace, deathPlace: editForm.deathPlace,
burialPlace: editForm.burialPlace, burialPlace: editForm.burialPlace,
personStatus: editForm.personStatus,
biography: editForm.summary, biography: editForm.summary,
remark: editForm.remark, remark: editForm.remark,
sortOrder: editForm.sortOrder,
relationName: editForm.relationName,
}, { requestController: editRequestController }); }, { requestController: editRequestController });
baseline.value = formSnapshot.value; baseline.value = formSnapshot.value;
await goBack(); await goBack();
@@ -307,6 +474,9 @@ const handleResultAction = () => {
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; } .member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; } .member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; } .form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
.form-field__hint, .upload-receipt { color: $ink-muted; font-size: 21rpx; line-height: 1.4; text-align: right; }
.form-field--upload > view { display: grid; justify-items: end; gap: 6rpx; }
.upload-button { min-height: 54rpx; margin: 0; padding: 0 16rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.form-field--summary { align-items: start; } .form-field--summary { align-items: start; }
.form-placeholder { color: #a79884; } .form-placeholder { color: #a79884; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; } .field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
+1 -1
View File
@@ -17,7 +17,7 @@ foreach ($expected in @(
'v-model="form.authorName"', 'v-model="form.authorName"',
'v-model="form.sortOrder"', 'v-model="form.sortOrder"',
'pickAndUploadImage', 'pickAndUploadImage',
'toConsumerOssId', 'coverOssId.value = receipt.ossId',
'coverOssId: coverOssId.value', 'coverOssId: coverOssId.value',
'class="required-mark"', 'class="required-mark"',
'editor-control--unavailable', 'editor-control--unavailable',
+42 -8
View File
@@ -34,22 +34,49 @@ const run = async () => {
`; `;
const requests = []; const requests = [];
const feedResponse = {
feedId: "9001",
genealogyId: "1001",
publisherNickName: "家谱用户",
feedType: "photo",
feedContent: "family memory",
mediaOssIds: "2060000000000000001,2060000000000000002",
likedByMe: false,
likeCount: 0,
commentCount: 0,
pinned: "0",
createTime: "2026-07-27T10:00:00+08:00",
};
let response = { statusCode: 200, data: { code: 200, data: feedResponse } };
globalThis.uni = { globalThis.uni = {
request(options) { request(options) {
requests.push(options); requests.push(options);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: {} } })); queueMicrotask(() => options.success(response));
return { abort() {} }; return { abort() {} };
}, },
}; };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`)); const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
await appApi.createFeed("1001", { const createdFeed = await appApi.createFeed("1001", {
feedType: " photo ", feedType: " photo ",
feedContent: " family memory ", feedContent: " family memory ",
mediaOssIds: "2060000000000000001,2060000000000000002", mediaOssIds: "2060000000000000001,2060000000000000002",
sortOrder: "0", sortOrder: "0",
status: " 0 ", status: " 0 ",
}); });
assert.deepStrictEqual(createdFeed, {
id: "9001",
genealogyId: "1001",
content: "family memory",
publisher: "家谱用户",
type: "photo",
time: "2026-07-27T10:00:00+08:00",
mediaOssIds: "2060000000000000001,2060000000000000002",
likeCount: 0,
commentCount: 0,
likedByMe: false,
pinned: "0",
});
assert.deepStrictEqual(requests.at(-1).data, { assert.deepStrictEqual(requests.at(-1).data, {
feedType: "photo", feedType: "photo",
feedContent: "family memory", feedContent: "family memory",
@@ -59,6 +86,7 @@ const run = async () => {
}); });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/feeds"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/feeds");
response = { statusCode: 200, data: { code: 200, data: { ...feedResponse, feedContent: "only required" } } };
await appApi.createFeed("1001", { feedContent: "only required" }); await appApi.createFeed("1001", { feedContent: "only required" });
assert.deepStrictEqual(requests.at(-1).data, { feedContent: "only required" }); assert.deepStrictEqual(requests.at(-1).data, { feedContent: "only required" });
await assert.rejects( await assert.rejects(
@@ -66,11 +94,17 @@ const run = async () => {
/mediaOssIds/, /mediaOssIds/,
); );
const updatedFeed = await appApi.updateFeed("1001", "9001", { feedContent: "family memory" });
assert.strictEqual(updatedFeed.id, "9001");
assert.strictEqual(requests.at(-1).method, "PUT");
response = { statusCode: 200, data: { code: 200, data: {} } };
await appApi.createArticle("1001", { await appApi.createArticle("1001", {
categoryId: "900040001", categoryId: "900040001",
articleTitle: " title ", articleTitle: " title ",
articleSummary: " summary ", articleSummary: " summary ",
coverOssId: 900001, coverOssId: "9007199254740993",
articleContent: " body ", articleContent: " body ",
authorName: " author ", authorName: " author ",
sortOrder: "1", sortOrder: "1",
@@ -80,7 +114,7 @@ const run = async () => {
categoryId: 900040001, categoryId: 900040001,
articleTitle: "title", articleTitle: "title",
articleSummary: "summary", articleSummary: "summary",
coverOssId: 900001, coverOssId: "9007199254740993",
articleContent: "body", articleContent: "body",
authorName: "author", authorName: "author",
sortOrder: 1, sortOrder: 1,
@@ -91,21 +125,21 @@ const run = async () => {
await appApi.createAlbum("1001", { await appApi.createAlbum("1001", {
albumName: " old photos ", albumName: " old photos ",
albumDesc: " family album ", albumDesc: " family album ",
coverOssId: 900001, coverOssId: "9007199254740993",
sortOrder: "1", sortOrder: "1",
status: " 0 ", status: " 0 ",
}); });
assert.deepStrictEqual(requests.at(-1).data, { assert.deepStrictEqual(requests.at(-1).data, {
albumName: "old photos", albumName: "old photos",
albumDesc: "family album", albumDesc: "family album",
coverOssId: 900001, coverOssId: "9007199254740993",
sortOrder: 1, sortOrder: 1,
status: "0", status: "0",
}); });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/albums"); assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/albums");
await assert.rejects( await assert.rejects(
appApi.createAlbum("1001", { albumName: "bad id", coverOssId: "9007199254740992" }), appApi.createAlbum("1001", { albumName: "bad id", coverOssId: 900001 }),
/coverOssId/, /OSS ID 字符串/,
); );
delete globalThis.uni; delete globalThis.uni;
+2 -2
View File
@@ -14,13 +14,13 @@ foreach ($token in @('form.feedType', 'mediaReceipts', 'mediaOssIds', 'form.sort
} }
$f06 = Read-Utf8 'pages/family/f06-article-editor.vue' $f06 = Read-Utf8 'pages/family/f06-article-editor.vue'
foreach ($token in @('form.articleSummary', 'coverOssId', 'form.authorName', 'form.sortOrder', 'pickAndUploadImage', 'toConsumerOssId', 'class="required-mark"')) { foreach ($token in @('form.articleSummary', 'coverOssId', 'form.authorName', 'form.sortOrder', 'pickAndUploadImage', 'coverOssId.value = receipt.ossId', 'class="required-mark"')) {
Assert-Contains $f06 $token "F06 is missing full article field owner: $token" Assert-Contains $f06 $token "F06 is missing full article field owner: $token"
} }
if ($f06 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F06 must not expose a raw cover OSS ID input' } if ($f06 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F06 must not expose a raw cover OSS ID input' }
$f07 = Read-Utf8 'pages/family/f07-album-list.vue' $f07 = Read-Utf8 'pages/family/f07-album-list.vue'
foreach ($token in @('form.albumDesc', 'coverOssId', 'form.sortOrder', 'pickAndUploadImage', 'toConsumerOssId', 'class="required-mark"')) { foreach ($token in @('form.albumDesc', 'coverOssId', 'form.sortOrder', 'pickAndUploadImage', 'coverOssId.value = receipt.ossId', 'class="required-mark"')) {
Assert-Contains $f07 $token "F07 is missing full album field owner: $token" Assert-Contains $f07 $token "F07 is missing full album field owner: $token"
} }
if ($f07 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F07 must not expose a raw cover OSS ID input' } if ($f07 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F07 must not expose a raw cover OSS ID input' }
+15 -11
View File
@@ -15,8 +15,8 @@ const loadMockModule = async () => {
return import(moduleUrl); return import(moduleUrl);
}; };
const assertScopedOwner = ({ list, find, ids, nestedField }) => { const assertScopedOwner = ({ list, find, ids, idField = "id", mutationField = "title", nestedField }) => {
assert.deepStrictEqual(list("1001").map((item) => item.id), ids); assert.deepStrictEqual(list("1001").map((item) => item[idField]), ids);
assert.deepStrictEqual(list("1002"), [], "另一家谱不得读取 1001 的内容夹具"); assert.deepStrictEqual(list("1002"), [], "另一家谱不得读取 1001 的内容夹具");
assert.strictEqual(find("1002", ids[0]), null, "已知 ID 换谱后必须失败关闭"); assert.strictEqual(find("1002", ids[0]), null, "已知 ID 换谱后必须失败关闭");
assert.strictEqual(find("1001", "unknown"), null, "未知实体不得回退首条记录"); assert.strictEqual(find("1001", "unknown"), null, "未知实体不得回退首条记录");
@@ -25,8 +25,8 @@ const assertScopedOwner = ({ list, find, ids, nestedField }) => {
const listed = list("1001"); const listed = list("1001");
const original = find("1001", ids[0]); const original = find("1001", ids[0]);
listed[0].title = "被列表调用方修改"; listed[0][mutationField] = "被列表调用方修改";
assert.notStrictEqual(find("1001", ids[0]).title, "被列表调用方修改"); assert.notStrictEqual(find("1001", ids[0])[mutationField], "被列表调用方修改");
if (nestedField) { if (nestedField) {
const mutateNested = (item, value) => { const mutateNested = (item, value) => {
if (item[nestedField][0] && typeof item[nestedField][0] === "object") { if (item[nestedField][0] && typeof item[nestedField][0] === "object") {
@@ -68,7 +68,8 @@ const run = async () => {
list: listFamilyFeedFixtures, list: listFamilyFeedFixtures,
find: findFamilyFeedFixture, find: findFamilyFeedFixture,
ids: ["1", "2"], ids: ["1", "2"],
nestedField: "comments", idField: "feedId",
mutationField: "feedContent",
}); });
assertScopedOwner({ assertScopedOwner({
list: listFamilyArticleFixtures, list: listFamilyArticleFixtures,
@@ -83,8 +84,11 @@ const run = async () => {
nestedField: "photos", nestedField: "photos",
}); });
for (const item of listFamilyFeedFixtures("1001")) {
assert.strictEqual(typeof item.feedId, "string", "feed.feedId 必须保持词法字符串");
assert.strictEqual(item.genealogyId, "1001", "feed 必须显式持有家谱身份");
}
for (const [list, entityName] of [ for (const [list, entityName] of [
[listFamilyFeedFixtures, "feed"],
[listFamilyArticleFixtures, "article"], [listFamilyArticleFixtures, "article"],
[listFamilyAlbumFixtures, "album"], [listFamilyAlbumFixtures, "album"],
]) { ]) {
@@ -95,9 +99,9 @@ const run = async () => {
} }
const firstFeed = findFamilyFeedFixture("1001", "1"); const firstFeed = findFamilyFeedFixture("1001", "1");
firstFeed.comments[0].content = "被详情调用方修改"; firstFeed.feedContent = "被详情调用方修改";
assert.notStrictEqual( assert.notStrictEqual(
findFamilyFeedFixture("1001", "1").comments[0].content, findFamilyFeedFixture("1001", "1").feedContent,
"被详情调用方修改", "被详情调用方修改",
); );
@@ -113,8 +117,8 @@ const run = async () => {
"const hasRemoteConfig = () => false; const resolveRuntimeMode = () => 'mock'; const runtimeConfig = { baseUrl: '', clientId: 'test', tenantId: 'test' };\n", "const hasRemoteConfig = () => false; const resolveRuntimeMode = () => 'mock'; const runtimeConfig = { baseUrl: '', clientId: 'test', tenantId: 'test' };\n",
) )
.replace( .replace(
/^import \{ AUTH_TAC_SCENE, assertSmsCode \}[^\n]+\r?\n/m, /^import \{ AUTH_VERIFICATION_OPERATION, assertSmsCode \}[^\n]+\r?\n/m,
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n", "const AUTH_VERIFICATION_OPERATION = {}; const assertSmsCode = (value) => value;\n",
) )
.replace( .replace(
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m, /^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
@@ -135,7 +139,7 @@ const run = async () => {
(error) => error?.code === "WRITE_UNAVAILABLE" && /不会保存/.test(error.message), (error) => error?.code === "WRITE_UNAVAILABLE" && /不会保存/.test(error.message),
"mock createFeed 必须明确失败,不能污染只读内容 owner", "mock createFeed 必须明确失败,不能污染只读内容 owner",
); );
assert.deepStrictEqual(listFamilyFeedFixtures("1001").map((item) => item.id), ["1", "2"]); assert.deepStrictEqual(listFamilyFeedFixtures("1001").map((item) => item.feedId), ["1", "2"]);
process.stdout.write("FAMILY-FIXTURE-RUNTIME-SMOKE PASS\n"); process.stdout.write("FAMILY-FIXTURE-RUNTIME-SMOKE PASS\n");
}; };
+2
View File
@@ -48,6 +48,7 @@ const run = async () => {
genealogyName: "王氏家谱", genealogyName: "王氏家谱",
surname: "王", surname: "王",
regionCode: "110101", regionCode: "110101",
coverOssId: "9007199254740993",
}); });
const request = requests.at(-1); const request = requests.at(-1);
@@ -62,6 +63,7 @@ const run = async () => {
genealogyName: "王氏家谱", genealogyName: "王氏家谱",
surname: "王", surname: "王",
regionCode: "110101", regionCode: "110101",
coverOssId: "9007199254740993",
}); });
delete globalThis.uni; delete globalThis.uni;
+2 -2
View File
@@ -28,7 +28,7 @@ foreach ($required in @(
'v-model="form.addressDetail"', 'v-model="form.addressDetail"',
'v-model="form.intro"', 'v-model="form.intro"',
'pickAndUploadImage', 'pickAndUploadImage',
'toConsumerOssId', 'coverOssId.value = receipt.ossId',
'coverOssId.value', 'coverOssId.value',
'GENEALOGY_ACCESS_PRESET_OPTIONS', 'GENEALOGY_ACCESS_PRESET_OPTIONS',
'toApiGenealogyAccess', 'toApiGenealogyAccess',
@@ -42,7 +42,7 @@ foreach ($required in @(
} }
if ($g03 -match 'v-model="(?:form\.)?regionCode"') { throw 'G03 must not expose a raw region code input' } if ($g03 -match 'v-model="(?:form\.)?regionCode"') { throw 'G03 must not expose a raw region code input' }
if ($g03 -match 'v-model="(?:form\.)?coverOssId"') { throw 'G03 must not expose a raw cover OSS ID input' } if ($g03 -match 'v-model="(?:form\.)?coverOssId"') { throw 'G03 must not expose a raw cover OSS ID input' }
if ($g03.Contains('AppDialog')) { throw 'G03 region selector must not use the generic dialog card wall' } if ($g03 -match '(?s)<AppDialog[^>]*:visible="regionPickerOpen"') { throw 'G03 region selector must not use the generic dialog card wall' }
if ($g03.Contains('继续选择')) { throw 'G03 region selector must not repeat a continuation label on every option' } if ($g03.Contains('继续选择')) { throw 'G03 region selector must not repeat a continuation label on every option' }
foreach ($forbidden in @( foreach ($forbidden in @(
File diff suppressed because it is too large Load Diff
+74 -4
View File
@@ -14,35 +14,105 @@ foreach ($required in @(
"SON: 'children'", "SON: 'children'",
'async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {})', 'async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {})',
'async updatePerson(genealogyId, personId, payload, requestOptions = {})', 'async updatePerson(genealogyId, personId, payload, requestOptions = {})',
"'bindingMode',",
"['NONE', 'SELF', 'SPECIFIED']",
'method: ''PUT''', 'method: ''PUT''',
'requestStrict({' 'requestStrict({'
)) { )) {
if (-not $api.Contains($required)) { throw "Lineage Apifox write contract missing: $required" } if (-not $api.Contains($required)) { throw "Lineage Apifox write contract missing: $required" }
} }
foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gender', 'sortOrder:')) { foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gender')) {
if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" } if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" }
} }
foreach ($source in @($t04, $t05)) {
if ($source.Contains('birthClock') -or $source.Contains('deathClock') -or $source.Contains('toDateTime(')) {
throw 'Lineage birth/death dates must send the documented yyyy-MM-dd value, without a client-added time component'
}
}
foreach ($required in @( foreach ($required in @(
'v-model="addForm.personNo"',
'v-model="addForm.aliasName"', 'v-model="addForm.aliasName"',
'v-model="addForm.generation"',
'v-model="addForm.generationName"', 'v-model="addForm.generationName"',
'v-model="addForm.birthLunar"',
'v-model="addForm.birthPlace"', 'v-model="addForm.birthPlace"',
'v-model="addForm.deathLunar"',
'v-model="addForm.deathPlace"', 'v-model="addForm.deathPlace"',
'v-model="addForm.burialPlace"', 'v-model="addForm.burialPlace"',
'v-model="addForm.biography"', 'v-model="addForm.biography"',
'v-model="addForm.remark"', 'v-model="addForm.remark"',
'v-model="addForm.sortOrder"',
'v-model="addForm.relationName"',
'personNo: addForm.personNo',
'sex: addForm.sex',
'avatarOssId: addForm.avatarOssId',
'personStatus: addForm.personStatus',
'sortOrder: addForm.sortOrder',
'aliasName: addForm.aliasName', 'aliasName: addForm.aliasName',
'generationName: addForm.generationName', 'generationName: addForm.generationName',
'biography: addForm.biography', 'biography: addForm.biography',
'remark: addForm.remark', 'remark: addForm.remark',
'...(isFirstMember.value ? { generation: 1 } : {})' '? { generation: 1, relationName: addForm.relationName }',
'const sexOptions = Object.freeze([',
'const lunarOptions = Object.freeze([',
'const personStatusOptions = Object.freeze([',
'personOptionLabels',
'appApi.getLineagePersonOptions(',
'const personOptionsRequestController = createRequestController();',
'requestController: personOptionsRequestController',
'const bindingModeOptions = Object.freeze([',
'bindingMode: addForm.bindingMode',
'addForm.bindingMode === "SPECIFIED"',
'pickAndUploadImage('
)) { )) {
if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" } if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" }
} }
foreach ($required in @(
'v-model="editForm.personNo"',
'v-model="editForm.generation"',
'v-model="editForm.sortOrder"',
'v-model="editForm.relationName"',
'personNo: editForm.personNo',
'sex: editForm.sex',
'avatarOssId: editForm.avatarOssId',
'personStatus: editForm.personStatus',
'sortOrder: editForm.sortOrder',
'const sexOptions = Object.freeze([',
'const lunarOptions = Object.freeze([',
'const personStatusOptions = Object.freeze([',
'personOptionLabels',
'appApi.getLineagePersonOptions(',
'const personOptionsRequestController = createRequestController();',
'requestController: personOptionsRequestController',
'const bindingModeOptions = Object.freeze([',
'bindingMode: editForm.bindingMode',
'editForm.bindingMode === "SPECIFIED"',
'pickAndUploadImage('
)) {
if (-not $t05.Contains($required)) { throw "T05 LineagePersonBody field missing: $required" }
}
foreach ($forbidden in @(
'v-model="addForm.appUserId"',
'v-model="addForm.fatherId"',
'v-model="addForm.motherId"',
'v-model="addForm.avatarOssId"',
'v-model="editForm.appUserId"',
'v-model="editForm.fatherId"',
'v-model="editForm.motherId"',
'v-model="editForm.avatarOssId"'
)) {
if ($t04.Contains($forbidden) -or $t05.Contains($forbidden)) { throw "Lineage ID or OSS field must not be a raw input: $forbidden" }
}
foreach ($source in @($t04, $t05)) {
if ($source.Contains('getProfile({ requestController: profileRequestController })')) {
throw 'Lineage identity binding must not fetch and submit the current appUserId for NONE or SELF'
}
}
foreach ($required in @('appApi.createRelatedPerson(', 'appApi.createPerson(', 'appApi.updatePerson(', 'failedAction.value = "save"')) { foreach ($required in @('appApi.createRelatedPerson(', 'appApi.createPerson(', 'appApi.updatePerson(', 'failedAction.value = "save"')) {
$source = if ($required -eq 'appApi.updatePerson(' -or $required -eq 'failedAction.value = "save"') { $t05 } else { $t04 } $source = if ($required -eq 'appApi.updatePerson(' -or $required -eq 'failedAction.value = "save"') { $t05 } else { $t04 }
if (-not $source.Contains($required)) { throw "Lineage write page missing: $required" } if (-not $source.Contains($required)) { throw "Lineage write page missing: $required" }
+15 -23
View File
@@ -2,33 +2,25 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$list = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding UTF8 $list = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding UTF8
$detail = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n02-message-detail.vue') -Raw -Encoding UTF8 $detail = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n02-message-detail.vue') -Raw -Encoding UTF8
foreach ($token in @('listNotificationFixtures', 'unreadCount', 'openNotice', ':action="unreadCount > 0')) { $api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
if (-not $list.Contains($token)) { throw "N01-N02 flow missing: $token" }
foreach ($token in @('appApi.getNotifications', 'notifications.length', 'createRequestController', 'isRequestCancelled')) {
if (-not $list.Contains($token)) { throw "N01 notification list contract missing: $token" }
} }
if ($list -notmatch '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)') { foreach ($token in @('appApi.getNotificationDetail', 'notificationId.value', 'createRequestController', 'onLoad((options)', 'controller.abort()', 'returnTo("N01")')) {
throw 'N01 must open N02 through the registered gateway with only lexical id and N01 source' if (-not $detail.Contains($token)) { throw "N02 notification detail contract missing: $token" }
} }
foreach ($token in @('findNotificationFixture', 'noticeDetail.unread', 'markAsRead', 'getGenealogyFixtureAccess', 'openNoticeTarget as navigateNoticeTarget', 'noticeDetail.value.targetType', 'noticeDetail.value.targetParams')) { foreach ($token in @('normalizeNotificationDetail', 'NOTIFICATION_DETAIL_RESPONSE_INVALID', 'async getNotificationDetail', 'url: `/genealogy/app/notifications/${normalizedNotificationId}`', "method: 'GET'")) {
if (-not $detail.Contains($token)) { throw "N02 business detail missing: $token" } if (-not $api.Contains($token)) { throw "Notification API contract missing: $token" }
} }
if ($detail -notmatch '(?s)const selectedNotice = findNotificationFixture\(noticeId\.value\);.*?if \(.*?!selectedNotice.*?\) \{.*?noticeState\.value = "expired";.*?return;') {
throw 'N02 unknown notice identity must fail closed' foreach ($source in @($list, $detail, $api)) {
} foreach ($forbidden in @('findNotificationFixture', 'noticeDetail.unread', 'markAsRead', 'targetUrl', 'returnUrl')) {
if ($detail.Contains('noticeDetail.read') -or -not $detail.Contains('noticeDetail.value.unread = false;')) { if ($source.Contains($forbidden)) { throw "Notification flow retains retired fixture contract: $forbidden" }
throw 'N02 must consume and update the notification owner unread field'
}
if (-not $detail.Contains('const backToMessages = () => returnTo("N01", {});')) {
throw 'N02 must return to N01 through the registered return gateway'
}
if (-not $detail.Contains('onBackPress((event) => handleBackPress(event, backToMessages));')) {
throw 'N02 Android back must reuse the guarded N01 return'
}
foreach ($source in @($list, $detail)) {
foreach ($forbidden in @('detailId', 'const noticeDetails', 'target: "/pages/', 'targetUrl', 'returnUrl', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/')) {
if ($source.Contains($forbidden)) { throw "N01-N02 retains a duplicate owner or navigation bypass: $forbidden" }
} }
} }
if ($detail -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') { if ($list.Contains('openPage("N02"')) {
throw 'N02 must not consume an executable notification target field' throw 'N01 must not invent a detail link from the generic notification-list response'
} }
Write-Output 'N01-N02-MESSAGE-FLOW-CONTRACT PASS' Write-Output 'N01-N02-MESSAGE-FLOW-CONTRACT PASS'
@@ -0,0 +1,87 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
let response;
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success(response));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const detail = {
notificationId: "900001",
genealogyId: "1001",
genealogyNo: "J-1001",
genealogyName: "王氏家谱",
senderUserId: "101",
senderNickName: "管理员",
senderPhone: "138****8000",
noticeType: "SYSTEM",
noticeTitle: "审核完成",
noticeContent: "您的申请已审核完成。",
bizType: "APPLICATION",
bizId: "3001",
bizSummary: "入谱申请",
publishTime: "2026-07-27T08:00:00+08:00",
readStatus: "0",
readTime: null,
status: "0",
remark: "",
};
response = { statusCode: 200, data: { code: 200, data: detail } };
const result = await appApi.getNotificationDetail("900001");
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/notifications/900001");
assert.strictEqual(requests.at(-1).method, "GET");
assert.strictEqual(result.notificationId, "900001");
assert.strictEqual(result.noticeTitle, "审核完成");
assert.strictEqual(result.genealogyId, "1001");
assert.strictEqual(result.readTime, "");
response = { statusCode: 200, data: { code: 200, data: { ...detail, notificationId: "900002" } } };
await assert.rejects(appApi.getNotificationDetail("900001"), (error) => error.code === "NOTIFICATION_DETAIL_RESPONSE_INVALID");
await assert.rejects(appApi.getNotificationDetail("invalid"), /正整数/);
delete globalThis.uni;
process.stdout.write("NOTIFICATION-DETAIL-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+69
View File
@@ -0,0 +1,69 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const fromApiGenealogyAccess = () => null;
const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: {} } }));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const ossId = "9007199254740993";
await appApi.updateGenealogy("1001", { coverOssId: ossId });
assert.strictEqual(requests.at(-1).data.coverOssId, ossId);
await appApi.createArticle("1001", { articleTitle: "家风", articleContent: "正文", coverOssId: ossId });
assert.strictEqual(requests.at(-1).data.coverOssId, ossId);
await appApi.createAlbum("1001", { albumName: "相册", coverOssId: ossId });
assert.strictEqual(requests.at(-1).data.coverOssId, ossId);
await appApi.createAlbumPhoto("1001", "2001", { ossId });
assert.strictEqual(requests.at(-1).data.ossId, ossId);
await appApi.createCeremony("1001", { ceremonyType: "祭祖", ceremonyTitle: "清明祭祖", coverOssId: ossId });
assert.strictEqual(requests.at(-1).data.coverOssId, ossId);
await assert.rejects(
appApi.createAlbumPhoto("1001", "2001", { ossId: 900001 }),
/OSS ID 字符串/,
);
delete globalThis.uni;
process.stdout.write("OSS-ID-PAYLOAD-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+13 -5
View File
@@ -44,7 +44,7 @@ const run = async () => {
}; };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`)); const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const profile = { const profile = {
userId: 101, userId: "101",
tenantId: "000000", tenantId: "000000",
userNo: "U-101", userNo: "U-101",
phone: "13800138000", phone: "13800138000",
@@ -52,7 +52,7 @@ const run = async () => {
realName: "王小明", realName: "王小明",
avatar: 900001, avatar: 900001,
sex: "男", sex: "男",
birthday: "1990-01-02T00:00:00", birthday: "1990-01-02",
email: "ming@example.com", email: "ming@example.com",
registerSource: "APP", registerSource: "APP",
status: "0", status: "0",
@@ -66,13 +66,21 @@ const run = async () => {
assert.strictEqual(profileRequest.timeout, 15000); assert.strictEqual(profileRequest.timeout, 15000);
assert.deepStrictEqual(profileRequest.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" }); assert.deepStrictEqual(profileRequest.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
const highPrecisionUserId = "9007199254740993";
response = { statusCode: 200, data: { code: 200, data: { ...profile, userId: highPrecisionUserId } } };
assert.strictEqual(
(await appApi.getProfile()).userId,
highPrecisionUserId,
"业务用户 ID 必须保留为精确字符串,不能按头像数值转换",
);
response = { statusCode: 200, data: { code: 200, data: profile } }; response = { statusCode: 200, data: { code: 200, data: profile } };
await appApi.updateProfile({ await appApi.updateProfile({
nickName: " 新昵称 ", nickName: " 新昵称 ",
realName: "王小明", realName: "王小明",
avatar: 900001, avatar: 900001,
sex: "女", sex: "女",
birthday: "1991-02-03T00:00:00+08:00", birthday: "1991-02-03",
email: "new@example.com", email: "new@example.com",
}); });
assert.deepStrictEqual(requests.at(-1).data, { assert.deepStrictEqual(requests.at(-1).data, {
@@ -80,12 +88,12 @@ const run = async () => {
realName: "王小明", realName: "王小明",
avatar: 900001, avatar: 900001,
sex: "女", sex: "女",
birthday: "1991-02-03T00:00:00+08:00", birthday: "1991-02-03",
email: "new@example.com", email: "new@example.com",
}); });
await assert.rejects(appApi.updateProfile({}), /至少需要一个/); await assert.rejects(appApi.updateProfile({}), /至少需要一个/);
await assert.rejects(appApi.updateProfile({ avatar: Number.MAX_SAFE_INTEGER + 1 }), /安全整数/); await assert.rejects(appApi.updateProfile({ avatar: Number.MAX_SAFE_INTEGER + 1 }), /安全整数/);
await assert.rejects(appApi.updateProfile({ birthday: "1991-02-03" }), /ISO/); await assert.rejects(appApi.updateProfile({ birthday: "1991-02-03T00:00:00+08:00" }), /yyyy-MM-dd/);
await assert.rejects(appApi.updateProfile({ email: "bad-email" }), /email/); await assert.rejects(appApi.updateProfile({ email: "bad-email" }), /email/);
response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: 0 } } }; response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: 0 } } };
+1 -1
View File
@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8 $page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) { foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'spouseByPersonId', 'parentAnchorX', 'FAMILY_LINK_OFFSET', 'kind: "family"', 'getFamilyPartner', 'spouseRect', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) {
if (-not $page.Contains($token)) { throw "T01 relation layout contract missing: $token" } if (-not $page.Contains($token)) { throw "T01 relation layout contract missing: $token" }
} }
if ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' } if ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' }
+26 -2
View File
@@ -58,14 +58,14 @@ const run = async () => {
name: "汤文远", name: "汤文远",
generation: 12, generation: 12,
generationName: "文", generationName: "文",
birthDate: "1900-01-02T00:00:00+08:00", birthDate: "1900-01-01T16:00:00.000Z",
relationName: "始祖", relationName: "始祖",
spouses: [{ spouses: [{
personId: 1002, personId: 1002,
name: "李氏", name: "李氏",
generation: 12, generation: 12,
generationName: "文", generationName: "文",
birthDate: "1905-03-04T00:00:00+08:00", birthDate: "1905-03-03T16:00:00.000Z",
}], }],
children: [{ children: [{
personId: 1011, personId: 1011,
@@ -81,6 +81,17 @@ const run = async () => {
fatherId: 1011, fatherId: 1011,
deathDate: "2020-05-06T00:00:00+08:00", deathDate: "2020-05-06T00:00:00+08:00",
}], }],
}, {
personId: 1031,
name: "周明轩",
generation: 13,
generationName: "正",
spouses: [{
personId: 1011,
name: "汤正明",
generation: 13,
generationName: "正",
}],
}], }],
}], }],
}, },
@@ -102,6 +113,7 @@ const run = async () => {
{ {
id: "1002", id: "1002",
parentId: null, parentId: null,
spouseOf: "1001",
name: "李氏", name: "李氏",
relation: "配偶", relation: "配偶",
generation: 12, generation: 12,
@@ -132,6 +144,18 @@ const run = async () => {
sex: "", sex: "",
personStatus: "", personStatus: "",
}, },
{
id: "1031",
parentId: "1001",
spouseOf: "1011",
name: "周明轩",
relation: "配偶",
generation: 13,
branch: "正字辈",
years: "生卒待补",
sex: "",
personStatus: "",
},
]); ]);
assert.strictEqual( assert.strictEqual(
requests[0].url, requests[0].url,
+97
View File
@@ -79,16 +79,30 @@ const run = async () => {
genealogyId: "900001001", genealogyId: "900001001",
genealogyName: "联调测试家谱", genealogyName: "联调测试家谱",
name: "汤文远", name: "汤文远",
appUserId: "",
bindingMode: "NONE",
personNo: "",
aliasName: "",
generation: 12, generation: 12,
generationName: "文", generationName: "文",
avatarOssId: "",
relation: "家谱成员", relation: "家谱成员",
branch: "文字辈", branch: "文字辈",
sex: "MALE", sex: "MALE",
birthDate: "1900-01-02", birthDate: "1900-01-02",
birthLunar: "",
deathDate: "", deathDate: "",
deathLunar: "",
years: "1900-01-02—", years: "1900-01-02—",
birthplace: "祖居地", birthplace: "祖居地",
deathPlace: "",
burialPlace: "",
spouseNames: "",
personStatus: "ALIVE",
biography: "生平资料", biography: "生平资料",
remark: "",
relationName: "",
sortOrder: null,
status: "normal", status: "normal",
relatives: [ relatives: [
{ id: "900", name: "汤世德", relation: "父亲" }, { id: "900", name: "汤世德", relation: "父亲" },
@@ -103,6 +117,89 @@ const run = async () => {
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1"); assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
assert.strictEqual(requests[0].timeout, 15000); assert.strictEqual(requests[0].timeout, 15000);
nextResponse.data.data.birthDate = "1900-01-02 00:00:00";
const spaceDatePerson = await appApi.getPerson("900001001", "1001");
assert.strictEqual(spaceDatePerson.birthDate, "1900-01-02");
nextResponse = {
statusCode: 200,
data: { code: 200, data: null },
};
await appApi.createPerson("900001001", {
bindingMode: "SPECIFIED",
appUserId: "3001",
personNo: "P-001",
name: "OSS头像成员",
aliasName: "阿汤",
sex: "0",
generation: 1,
generationName: "文",
fatherId: "3002",
motherId: "3003",
avatarOssId: "9007199254740993",
birthDate: "1900-01-02",
birthLunar: "0",
birthPlace: "祖居地",
deathDate: "2000-01-02",
deathLunar: "1",
deathPlace: "故乡",
burialPlace: "祖茔",
personStatus: "0",
biography: "成员简介",
remark: "测试备注",
sortOrder: "2",
relationName: "配偶",
});
assert.deepStrictEqual(requests.at(-1).data, {
bindingMode: "SPECIFIED",
appUserId: "3001",
personNo: "P-001",
name: "OSS头像成员",
aliasName: "阿汤",
sex: "0",
generation: 1,
generationName: "文",
fatherId: "3002",
motherId: "3003",
avatarOssId: "9007199254740993",
birthDate: "1900-01-02",
birthLunar: "0",
birthPlace: "祖居地",
deathDate: "2000-01-02",
deathLunar: "1",
deathPlace: "故乡",
burialPlace: "祖茔",
personStatus: "0",
biography: "成员简介",
remark: "测试备注",
sortOrder: 2,
relationName: "配偶",
});
await assert.rejects(
appApi.createPerson("900001001", {
bindingMode: "NONE",
name: "错误头像成员",
generation: 1,
avatarOssId: 9007199254740992,
}),
/OSS ID 字符串/,
);
await assert.rejects(
appApi.createPerson("900001001", {
bindingMode: "SELF",
appUserId: "3001",
name: "错误绑定成员",
}),
/SELF 身份认领不能提供业务用户标识/,
);
await assert.rejects(
appApi.createPerson("900001001", {
bindingMode: "SPECIFIED",
name: "缺少指定用户成员",
}),
/指定业务用户绑定必须提供业务用户标识/,
);
nextResponse = { nextResponse = {
statusCode: 200, statusCode: 200,
data: { data: {
+184 -46
View File
@@ -479,12 +479,19 @@ const normalizeProfileAvatar = (value) => {
return Number.isSafeInteger(numericValue) ? numericValue : null return Number.isSafeInteger(numericValue) ? numericValue : null
} }
const normalizeProfileUserId = (value) => {
if (value === undefined || value === null || value === '') return ''
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError('个人资料字段 userId 无效', 'PROFILE_RESPONSE_INVALID')
}
const normalizeAppProfile = (payload) => { const normalizeAppProfile = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw createRequestError('个人资料响应格式无效', 'PROFILE_RESPONSE_INVALID') throw createRequestError('个人资料响应格式无效', 'PROFILE_RESPONSE_INVALID')
} }
return { return {
userId: normalizeProfileAvatar(payload.userId), userId: normalizeProfileUserId(payload.userId),
tenantId: normalizeProfileResponseText(payload.tenantId, 'tenantId'), tenantId: normalizeProfileResponseText(payload.tenantId, 'tenantId'),
userNo: normalizeProfileResponseText(payload.userNo, 'userNo'), userNo: normalizeProfileResponseText(payload.userNo, 'userNo'),
phone: normalizeProfileResponseText(payload.phone, 'phone'), phone: normalizeProfileResponseText(payload.phone, 'phone'),
@@ -524,8 +531,8 @@ const normalizeProfileUpdatePayload = (payload) => {
if (Object.prototype.hasOwnProperty.call(payload, 'birthday')) { if (Object.prototype.hasOwnProperty.call(payload, 'birthday')) {
const value = normalizeOptionalAuthText(payload.birthday, 'birthday') const value = normalizeOptionalAuthText(payload.birthday, 'birthday')
if (value) { if (value) {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new TypeError('birthday 必须是 ISO 日期时间') throw new TypeError('birthday 必须是 yyyy-MM-dd 日期')
} }
normalized.birthday = value normalized.birthday = value
} }
@@ -633,6 +640,13 @@ const normalizeUploadOssId = (value, field) => {
return id return id
} }
const normalizeOssIdString = (value, field) => {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
throw new TypeError(`${field} 必须是正整数 OSS ID 字符串`)
}
return value
}
const normalizeResumableInitResult = (payload) => { const normalizeResumableInitResult = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw createRequestError('文件初始化响应格式无效', 'FILE_RESPONSE_INVALID') throw createRequestError('文件初始化响应格式无效', 'FILE_RESPONSE_INVALID')
@@ -838,13 +852,7 @@ const normalizeGenealogyCreatePayload = (payload) => {
if (value) normalized[field] = value if (value) normalized[field] = value
} }
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') { if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
const numericOssId = typeof payload.coverOssId === 'number' normalized.coverOssId = normalizeOssIdString(payload.coverOssId, '创建家谱字段 coverOssId')
? payload.coverOssId
: Number(payload.coverOssId)
if (!Number.isSafeInteger(numericOssId) || numericOssId <= 0) {
throw new TypeError('创建家谱字段 coverOssId 必须是正整数')
}
normalized.coverOssId = numericOssId
} }
return normalized return normalized
} }
@@ -887,13 +895,7 @@ const normalizeGenealogyUpdatePayload = (payload) => {
if (value) normalized[field] = value if (value) normalized[field] = value
} }
if (Object.prototype.hasOwnProperty.call(payload, 'coverOssId') && payload.coverOssId !== null && payload.coverOssId !== '') { if (Object.prototype.hasOwnProperty.call(payload, 'coverOssId') && payload.coverOssId !== null && payload.coverOssId !== '') {
const numericOssId = typeof payload.coverOssId === 'number' normalized.coverOssId = normalizeOssIdString(payload.coverOssId, '更新家谱字段 coverOssId')
? payload.coverOssId
: Number(payload.coverOssId)
if (!Number.isSafeInteger(numericOssId) || numericOssId <= 0) {
throw new TypeError('更新家谱字段 coverOssId 必须是正整数')
}
normalized.coverOssId = numericOssId
} }
if (!Object.keys(normalized).length) throw new TypeError('请至少填写一项需要更新的家谱信息') if (!Object.keys(normalized).length) throw new TypeError('请至少填写一项需要更新的家谱信息')
return normalized return normalized
@@ -1082,14 +1084,28 @@ const lineageDatePart = (value, label) => {
const normalized = normalizeLineageText(value, label) const normalized = normalizeLineageText(value, label)
if (!normalized) return '' if (!normalized) return ''
if (!/^\d{4}-\d{2}-\d{2}/.test(normalized)) throw lineageTreeError(`世系树${label}无效`) if (!/^\d{4}-\d{2}-\d{2}/.test(normalized)) throw lineageTreeError(`世系树${label}无效`)
return normalized.slice(0, 10) if (normalized.length === 10) return normalized
const instant = new Date(normalized)
if (Number.isNaN(instant.getTime())) throw lineageTreeError(`世系树${label}无效`)
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(instant)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value])
)
return `${parts.year}-${parts.month}-${parts.day}`
} }
const normalizeLineageTree = (value) => { const normalizeLineageTree = (value) => {
if (!Array.isArray(value)) throw lineageTreeError('世系树响应不是列表') if (!Array.isArray(value)) throw lineageTreeError('世系树响应不是列表')
const seen = new Set() const seen = new Set()
const normalized = [] const normalized = []
const appendNode = (node, parentId, relationOverride = '') => { const normalizedById = new Map()
const appendNode = (node, parentId, relationOverride = '', spouseOf = '') => {
if (!node || typeof node !== 'object' || Array.isArray(node)) { if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw lineageTreeError('世系树包含无效节点') throw lineageTreeError('世系树包含无效节点')
} }
@@ -1101,16 +1117,19 @@ const normalizeLineageTree = (value) => {
throw lineageTreeError('世系树人物世代无效') throw lineageTreeError('世系树人物世代无效')
} }
const generationName = normalizeLineageText(node.generationName, '字辈') const generationName = normalizeLineageText(node.generationName, '字辈')
const relationName = normalizeLineageText(node.relationName, '人物关系')
const birthDate = lineageDatePart(node.birthDate, '出生日期') const birthDate = lineageDatePart(node.birthDate, '出生日期')
const deathDate = lineageDatePart(node.deathDate, '逝世日期') const deathDate = lineageDatePart(node.deathDate, '逝世日期')
normalized.push({ const normalizedNode = {
id, id,
parentId, parentId,
...(spouseOf ? { spouseOf } : {}),
name: normalizeLineageText(node.name, '人物姓名', { required: true }), name: normalizeLineageText(node.name, '人物姓名', { required: true }),
relation: relation:
relationOverride || relationOverride ||
normalizeLineageText(node.relationName, '人物关系') || (parentId
(parentId ? '后代' : '始祖'), ? (relationName && relationName !== '配偶' ? relationName : '后代')
: relationName || '始祖'),
generation: node.generation, generation: node.generation,
branch: generationName branch: generationName
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`) ? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
@@ -1121,19 +1140,38 @@ const normalizeLineageTree = (value) => {
: '生卒待补', : '生卒待补',
sex: normalizeLineageText(node.sex, '性别'), sex: normalizeLineageText(node.sex, '性别'),
personStatus: normalizeLineageText(node.personStatus, '人物状态') personStatus: normalizeLineageText(node.personStatus, '人物状态')
}) }
normalized.push(normalizedNode)
normalizedById.set(id, normalizedNode)
return id return id
} }
const walk = (node, parentId = null, depth = 0) => { const walk = (node, parentId = null, depth = 0) => {
if (depth > 64) throw lineageTreeError('世系树深度超出客户端上限') if (depth > 64) throw lineageTreeError('世系树深度超出客户端上限')
const id = appendNode(node, parentId) if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw lineageTreeError('世系树包含无效节点')
}
const spouses = node.spouses ?? [] const spouses = node.spouses ?? []
const children = node.children ?? [] const children = node.children ?? []
if (!Array.isArray(spouses) || !Array.isArray(children)) { if (!Array.isArray(spouses) || !Array.isArray(children)) {
throw lineageTreeError('世系树亲属集合无效') throw lineageTreeError('世系树亲属集合无效')
} }
spouses.forEach((spouse) => appendNode(spouse, parentId, '配偶')) const id = normalizeLineagePersonId(node.personId)
children.forEach((child) => walk(child, id, depth + 1)) const knownSpouseId = spouses
.map((spouse) => normalizeLineagePersonId(spouse?.personId))
.find((spouseId) => normalizedById.has(spouseId)) || ''
if (seen.has(id)) {
if (knownSpouseId) return id
throw lineageTreeError('世系树包含重复人物标识')
}
const knownSpouse = knownSpouseId ? normalizedById.get(knownSpouseId) : null
const nodeParentId = knownSpouse ? knownSpouse.parentId : parentId
const appendedId = appendNode(node, nodeParentId, knownSpouse ? '配偶' : '', knownSpouseId)
spouses.forEach((spouse) => {
const spouseId = normalizeLineagePersonId(spouse?.personId)
if (seen.has(spouseId)) return
appendNode(spouse, nodeParentId, '配偶', appendedId)
})
children.forEach((child) => walk(child, knownSpouseId || appendedId, depth + 1))
} }
value.forEach((root) => walk(root)) value.forEach((root) => walk(root))
return normalized return normalized
@@ -1148,6 +1186,11 @@ const normalizeLineagePersonIdentity = (value, label) => {
throw lineagePersonError(`成员详情${label}无效`) throw lineagePersonError(`成员详情${label}无效`)
} }
const normalizeOptionalLineagePersonIdentity = (value, label) => {
if (value === undefined || value === null || value === '') return ''
return normalizeLineagePersonIdentity(value, label)
}
const normalizeLineagePersonText = (value, label, { required = false } = {}) => { const normalizeLineagePersonText = (value, label, { required = false } = {}) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
if (required) throw lineagePersonError(`成员详情缺少${label}`) if (required) throw lineagePersonError(`成员详情缺少${label}`)
@@ -1162,7 +1205,7 @@ const normalizeLineagePersonText = (value, label, { required = false } = {}) =>
const normalizeLineagePersonDate = (value, label) => { const normalizeLineagePersonDate = (value, label) => {
const normalized = normalizeLineagePersonText(value, label) const normalized = normalizeLineagePersonText(value, label)
if (!normalized) return '' if (!normalized) return ''
if (!/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(normalized)) { if (!/^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/.test(normalized)) {
throw lineagePersonError(`成员详情${label}无效`) throw lineagePersonError(`成员详情${label}无效`)
} }
const datePart = normalized.slice(0, 10) const datePart = normalized.slice(0, 10)
@@ -1193,8 +1236,17 @@ const normalizeLineagePersonDetail = (
throw lineagePersonError('成员详情世代无效') throw lineagePersonError('成员详情世代无效')
} }
const name = normalizeLineagePersonText(value.name, '姓名', { required: true }) const name = normalizeLineagePersonText(value.name, '姓名', { required: true })
const appUserId = normalizeOptionalLineagePersonIdentity(value.appUserId, '绑定用户标识')
const bindingMode = value.bindingMode === undefined || value.bindingMode === null || value.bindingMode === ''
? (appUserId ? 'SPECIFIED' : 'NONE')
: normalizeLineagePersonText(value.bindingMode, '身份认领方式')
if (!['NONE', 'SELF', 'SPECIFIED'].includes(bindingMode)) {
throw lineagePersonError('成员详情身份认领方式无效')
}
const personNo = normalizeLineagePersonText(value.personNo, '人物编号')
const generationName = normalizeLineagePersonText(value.generationName, '字辈') const generationName = normalizeLineagePersonText(value.generationName, '字辈')
const aliasName = normalizeLineagePersonText(value.aliasName, '别名或曾用名') const aliasName = normalizeLineagePersonText(value.aliasName, '别名或曾用名')
const avatarOssId = normalizeOptionalLineagePersonIdentity(value.avatarOssId, '头像文件标识')
const birthDate = normalizeLineagePersonDate(value.birthDate, '出生日期') const birthDate = normalizeLineagePersonDate(value.birthDate, '出生日期')
const deathDate = normalizeLineagePersonDate(value.deathDate, '逝世日期') const deathDate = normalizeLineagePersonDate(value.deathDate, '逝世日期')
const birthLunar = normalizeLineagePersonText(value.birthLunar, '出生农历') const birthLunar = normalizeLineagePersonText(value.birthLunar, '出生农历')
@@ -1206,6 +1258,10 @@ const normalizeLineagePersonDetail = (
const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态') const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态')
const biography = normalizeLineagePersonText(value.biography, '生平') const biography = normalizeLineagePersonText(value.biography, '生平')
const remark = normalizeLineagePersonText(value.remark, '备注') const remark = normalizeLineagePersonText(value.remark, '备注')
const relationName = normalizeLineagePersonText(value.relationName, '关系显示名称')
const sortOrder = value.sortOrder === undefined || value.sortOrder === null
? null
: normalizeOptionalSafeInteger(value.sortOrder, '人物排序值')
const relatives = [] const relatives = []
for (const relation of [ for (const relation of [
{ id: value.fatherId, name: value.fatherName, label: '父亲' }, { id: value.fatherId, name: value.fatherName, label: '父亲' },
@@ -1227,9 +1283,13 @@ const normalizeLineagePersonDetail = (
genealogyId, genealogyId,
genealogyName: normalizeLineagePersonText(value.genealogyName, '家谱名称') || '当前家谱', genealogyName: normalizeLineagePersonText(value.genealogyName, '家谱名称') || '当前家谱',
name, name,
appUserId,
bindingMode,
personNo,
aliasName, aliasName,
generation: value.generation, generation: value.generation,
generationName, generationName,
avatarOssId,
relation: value.generation === 1 ? '始祖' : '家谱成员', relation: value.generation === 1 ? '始祖' : '家谱成员',
branch: generationName branch: generationName
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`) ? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
@@ -1247,6 +1307,8 @@ const normalizeLineagePersonDetail = (
personStatus, personStatus,
biography, biography,
remark, remark,
relationName,
sortOrder,
status: ['DECEASED', 'DEAD'].includes(personStatus.toUpperCase()) ? 'deceased' : 'normal', status: ['DECEASED', 'DEAD'].includes(personStatus.toUpperCase()) ? 'deceased' : 'normal',
relatives relatives
} }
@@ -1376,8 +1438,8 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
if (genealogyId && genealogyId !== expectedGenealogyId) { if (genealogyId && genealogyId !== expectedGenealogyId) {
throw createRequestError('家族动态归属与请求不匹配', 'FEED_RESPONSE_INVALID') throw createRequestError('家族动态归属与请求不匹配', 'FEED_RESPONSE_INVALID')
} }
const id = normalizeOptionalNumericId(item.feedId ?? item.id, '动态标识', 'FEED_RESPONSE_INVALID') const id = normalizeOptionalNumericId(item.feedId, '动态标识', 'FEED_RESPONSE_INVALID')
const content = normalizeOptionalGenealogyText(item.feedContent ?? item.content, 'feedContent') const content = normalizeOptionalGenealogyText(item.feedContent, 'feedContent')
if (!id || !content) { if (!id || !content) {
throw createRequestError('家族动态响应缺少标识或内容', 'FEED_RESPONSE_INVALID') throw createRequestError('家族动态响应缺少标识或内容', 'FEED_RESPONSE_INVALID')
} }
@@ -1385,14 +1447,14 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
id, id,
genealogyId: expectedGenealogyId, genealogyId: expectedGenealogyId,
content, content,
publisher: normalizeOptionalGenealogyText(item.publisherNickName ?? item.author, 'publisherNickName') || '家族成员', publisher: normalizeOptionalGenealogyText(item.publisherNickName, 'publisherNickName') || '家族成员',
type: normalizeOptionalGenealogyText(item.feedType ?? item.tag, 'feedType') || '文字动态', type: normalizeOptionalGenealogyText(item.feedType, 'feedType') || '文字动态',
time: normalizeOptionalGenealogyText(item.createTime ?? item.time, 'createTime'), time: normalizeOptionalGenealogyText(item.createTime, 'createTime'),
mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'), mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'),
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'), likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'), commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
likedByMe: typeof item.likedByMe === 'boolean' ? item.likedByMe : null, likedByMe: typeof item.likedByMe === 'boolean' ? item.likedByMe : null,
pinned: item.pinned === true pinned: normalizeOptionalGenealogyText(item.pinned, 'pinned')
} }
}) })
if (new Set(rows.map((item) => item.id)).size !== rows.length) { if (new Set(rows.map((item) => item.id)).size !== rows.length) {
@@ -1475,12 +1537,15 @@ const normalizeArticleCreatePayload = (payload) => {
const value = normalizeOptionalAuthText(payload[field], field) const value = normalizeOptionalAuthText(payload[field], field)
if (value) data[field] = value if (value) data[field] = value
} }
for (const field of ['categoryId', 'coverOssId', 'sortOrder']) { for (const field of ['categoryId', 'sortOrder']) {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
const value = typeof payload[field] === 'number' ? payload[field] : Number(payload[field]) const value = typeof payload[field] === 'number' ? payload[field] : Number(payload[field])
if (!Number.isSafeInteger(value)) throw new TypeError(`${field} 必须是安全整数`) if (!Number.isSafeInteger(value)) throw new TypeError(`${field} 必须是安全整数`)
data[field] = value data[field] = value
} }
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
data.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
}
return data return data
} }
@@ -1495,7 +1560,10 @@ const normalizeAlbumCreatePayload = (payload) => {
const value = normalizeOptionalAuthText(payload[field], field) const value = normalizeOptionalAuthText(payload[field], field)
if (value) data[field] = value if (value) data[field] = value
} }
for (const field of ['coverOssId', 'sortOrder']) { if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
data.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
}
for (const field of ['sortOrder']) {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
const value = typeof payload[field] === 'number' ? payload[field] : Number(payload[field]) const value = typeof payload[field] === 'number' ? payload[field] : Number(payload[field])
if (!Number.isSafeInteger(value)) throw new TypeError(`${field} 必须是安全整数`) if (!Number.isSafeInteger(value)) throw new TypeError(`${field} 必须是安全整数`)
@@ -1507,8 +1575,7 @@ const normalizeAlbumCreatePayload = (payload) => {
const normalizeAlbumPhotoCreatePayload = (payload) => { const normalizeAlbumPhotoCreatePayload = (payload) => {
const allowedFields = new Set(['ossId', 'photoTitle', 'photoDesc', 'photographer', 'shootTime', 'sortOrder', 'status']) const allowedFields = new Set(['ossId', 'photoTitle', 'photoDesc', 'photographer', 'shootTime', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '相册照片请求') assertPlainPayload(payload, allowedFields, '相册照片请求')
const ossId = normalizeOptionalSafeInteger(payload.ossId, 'ossId') const ossId = normalizeOssIdString(payload.ossId, '相册照片 ossId')
if (!ossId || ossId < 1) throw new TypeError('相册照片需要真实上传回执中的 ossId')
const data = { ossId } const data = { ossId }
for (const field of ['photoTitle', 'photoDesc', 'photographer', 'shootTime', 'status']) { for (const field of ['photoTitle', 'photoDesc', 'photographer', 'shootTime', 'status']) {
const value = normalizeOptionalAuthText(payload[field], field) const value = normalizeOptionalAuthText(payload[field], field)
@@ -1530,9 +1597,12 @@ const normalizeCeremonyPayload = (payload) => {
const value = normalizeOptionalAuthText(payload[field], field) const value = normalizeOptionalAuthText(payload[field], field)
if (value) data[field] = value if (value) data[field] = value
} }
for (const field of ['coverOssId', 'sortOrder']) { if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
const value = normalizeOptionalSafeInteger(payload[field], field) data.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
if (value !== undefined) data[field] = value }
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) {
data.sortOrder = sortOrder
} }
return data return data
} }
@@ -1657,6 +1727,44 @@ const normalizeResourcePathId = (value, label) => {
throw new TypeError(`${label}必须是有效的正整数标识`) throw new TypeError(`${label}必须是有效的正整数标识`)
} }
const normalizeNotificationText = (value, field) => {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') {
throw createRequestError(`通知详情字段 ${field} 无效`, 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
return value
}
const normalizeNotificationDetail = (value, expectedNotificationId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('通知详情响应格式无效', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
const notificationId = normalizeOptionalNumericId(value.notificationId, '通知标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
if (!notificationId || notificationId !== expectedNotificationId) {
throw createRequestError('通知详情响应标识不匹配', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
return {
notificationId,
genealogyId: normalizeOptionalNumericId(value.genealogyId, '通知家谱标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID'),
genealogyNo: normalizeNotificationText(value.genealogyNo, 'genealogyNo'),
genealogyName: normalizeNotificationText(value.genealogyName, 'genealogyName'),
senderUserId: normalizeOptionalNumericId(value.senderUserId, '通知发送人标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID'),
senderNickName: normalizeNotificationText(value.senderNickName, 'senderNickName'),
senderPhone: normalizeNotificationText(value.senderPhone, 'senderPhone'),
noticeType: normalizeNotificationText(value.noticeType, 'noticeType'),
noticeTitle: normalizeNotificationText(value.noticeTitle, 'noticeTitle'),
noticeContent: normalizeNotificationText(value.noticeContent, 'noticeContent'),
bizType: normalizeNotificationText(value.bizType, 'bizType'),
bizId: normalizeOptionalNumericId(value.bizId, '通知关联业务标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID'),
bizSummary: normalizeNotificationText(value.bizSummary, 'bizSummary'),
publishTime: normalizeNotificationText(value.publishTime, 'publishTime'),
readStatus: normalizeNotificationText(value.readStatus, 'readStatus'),
readTime: normalizeNotificationText(value.readTime, 'readTime'),
status: normalizeNotificationText(value.status, 'status'),
remark: normalizeNotificationText(value.remark, 'remark')
}
}
const requireRemoteResource = (label, operation) => { const requireRemoteResource = (label, operation) => {
if (hasRemoteConfig()) return if (hasRemoteConfig()) return
throw createRequestError(`${label}${operation}需要真实服务,当前本地预览不会伪造结果`, operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED') throw createRequestError(`${label}${operation}需要真实服务,当前本地预览不会伪造结果`, operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED')
@@ -1947,6 +2055,7 @@ const normalizeLineageWritePayload = (payload) => {
throw new TypeError('人物写入请求必须是普通对象') throw new TypeError('人物写入请求必须是普通对象')
} }
const allowedFields = new Set([ const allowedFields = new Set([
'bindingMode',
'appUserId', 'appUserId',
'personNo', 'personNo',
'name', 'name',
@@ -1975,16 +2084,29 @@ const normalizeLineageWritePayload = (payload) => {
} }
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true }) const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同') if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
const data = { name } const bindingMode = normalizeLineagePersonText(payload.bindingMode, '身份认领方式')
if (!['NONE', 'SELF', 'SPECIFIED'].includes(bindingMode)) {
throw new TypeError('人物身份认领方式必须是 NONE、SELF 或 SPECIFIED')
}
const data = { bindingMode, name }
if (bindingMode === 'SPECIFIED') {
if (payload.appUserId === undefined || payload.appUserId === null || payload.appUserId === '') {
throw new TypeError('指定业务用户绑定必须提供业务用户标识')
}
data.appUserId = normalizeLineagePersonIdentity(payload.appUserId, '绑定用户标识')
} else if (payload.appUserId !== undefined && payload.appUserId !== null && payload.appUserId !== '') {
throw new TypeError(`${bindingMode} 身份认领不能提供业务用户标识`)
}
for (const [field, label] of [ for (const [field, label] of [
['appUserId', '绑定用户标识'],
['fatherId', '父亲标识'], ['fatherId', '父亲标识'],
['motherId', '母亲标识'], ['motherId', '母亲标识']
['avatarOssId', '头像文件标识']
]) { ]) {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
data[field] = normalizeLineagePersonIdentity(payload[field], label) data[field] = normalizeLineagePersonIdentity(payload[field], label)
} }
if (payload.avatarOssId !== undefined && payload.avatarOssId !== null && payload.avatarOssId !== '') {
data.avatarOssId = normalizeOssIdString(payload.avatarOssId, '头像文件标识')
}
if (payload.generation !== undefined) { if (payload.generation !== undefined) {
if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) { if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
throw new TypeError('人物世代必须是正安全整数') throw new TypeError('人物世代必须是正安全整数')
@@ -2708,13 +2830,14 @@ export const appApi = {
if (!hasRemoteConfig()) { if (!hasRemoteConfig()) {
throw createRequestError('修改动态需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED') throw createRequestError('修改动态需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
} }
return requestStrict({ const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}`, url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}`,
method: 'PUT', method: 'PUT',
data: normalizeFeedCreatePayload(payload) data: normalizeFeedCreatePayload(payload)
}, { }, {
requestController: requestOptions.requestController ?? null requestController: requestOptions.requestController ?? null
}) })
return normalizeFamilyFeed(result, normalizedGenealogyId, normalizedFeedId)
}, },
async deleteFeed(genealogyId, feedId, requestOptions = {}) { async deleteFeed(genealogyId, feedId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId) const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
@@ -2750,13 +2873,15 @@ export const appApi = {
async createFeed(genealogyId, payload, requestOptions = {}) { async createFeed(genealogyId, payload, requestOptions = {}) {
if (hasRemoteConfig()) { if (hasRemoteConfig()) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId) const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({ const result = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds`, url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds`,
method: 'POST', method: 'POST',
data: normalizeFeedCreatePayload(payload) data: normalizeFeedCreatePayload(payload)
}, { }, {
requestController: requestOptions.requestController ?? null requestController: requestOptions.requestController ?? null
}) })
const [feed] = normalizeFamilyFeedRows([result], normalizedGenealogyId)
return feed
} }
const error = new Error('动态发布接口尚未接入,当前内容不会保存') const error = new Error('动态发布接口尚未接入,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE' error.code = 'WRITE_UNAVAILABLE'
@@ -3313,6 +3438,19 @@ export const appApi = {
if (!hasRemoteConfig()) return listNotificationFixtures() if (!hasRemoteConfig()) return listNotificationFixtures()
return readRemoteList('/genealogy/app/notifications', '消息通知', requestOptions) return readRemoteList('/genealogy/app/notifications', '消息通知', requestOptions)
}, },
async getNotificationDetail(notificationId, requestOptions = {}) {
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
if (!hasRemoteConfig()) {
throw createRequestError('通知详情需要真实服务读取,当前本地预览不伪造通知正文', 'REMOTE_READ_REQUIRED')
}
const result = await requestStrict({
url: `/genealogy/app/notifications/${normalizedNotificationId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeNotificationDetail(result, normalizedNotificationId)
},
async getUnreadNotificationCount(requestOptions = {}) { async getUnreadNotificationCount(requestOptions = {}) {
requireRemoteResource('未读通知数量', '读取') requireRemoteResource('未读通知数量', '读取')
const value = await requestStrict({ const value = await requestStrict({
+2534 -2113
View File
File diff suppressed because it is too large Load Diff