待测试
This commit is contained in:
-11160
File diff suppressed because it is too large
Load Diff
-7932
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -401,18 +401,14 @@ export const findTreeMemberPresentationFixture = (genealogyId, personId) => {
|
||||
// mock API 只能通过下方 list/find 查询取得深拷贝,不能把页面草稿写回正式列表。
|
||||
const familyFeeds = [
|
||||
{
|
||||
id: '1', genealogyId: '1001', tag: '团圆记忆', time: '今天 10:24',
|
||||
title: '端午家宴', content: '今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。',
|
||||
author: '汤正国',
|
||||
comments: [
|
||||
{ id: '1', author: '汤淑华', time: '今天 10:42', content: '一家人能常常相聚,就是最珍贵的福气。' },
|
||||
{ id: '2', author: '汤文清', time: '今天 11:08', content: '照片已经整理好了,晚些时候放进春节团圆相册。' }
|
||||
]
|
||||
feedId: '1', genealogyId: '1001', feedType: '团圆记忆', createTime: '今天 10:24',
|
||||
feedContent: '今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。',
|
||||
publisherNickName: '汤正国', mediaOssIds: '', likedByMe: false, likeCount: 0, commentCount: 2, pinned: '0'
|
||||
},
|
||||
{
|
||||
id: '2', genealogyId: '1001', tag: '家族通知', time: '昨天 18:02',
|
||||
title: '修谱资料征集', content: '请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。',
|
||||
author: '谱主', comments: []
|
||||
feedId: '2', genealogyId: '1001', feedType: '家族通知', createTime: '昨天 18:02',
|
||||
feedContent: '请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。',
|
||||
publisherNickName: '谱主', mediaOssIds: '', likedByMe: false, likeCount: 0, commentCount: 0, pinned: '0'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -476,10 +472,7 @@ const familyAlbums = [
|
||||
}
|
||||
]
|
||||
|
||||
const cloneFamilyFeedFixture = (feed) => ({
|
||||
...feed,
|
||||
comments: feed.comments.map((comment) => ({ ...comment }))
|
||||
})
|
||||
const cloneFamilyFeedFixture = (feed) => ({ ...feed })
|
||||
const cloneFamilyArticleFixture = (article) => ({
|
||||
...article,
|
||||
paragraphs: [...article.paragraphs]
|
||||
@@ -510,8 +503,15 @@ const findScopedFamilyFixture = (items, clone, genealogyId, entityId) => {
|
||||
|
||||
export const listFamilyFeedFixtures = (genealogyId) =>
|
||||
listScopedFamilyFixtures(familyFeeds, cloneFamilyFeedFixture, genealogyId)
|
||||
export const findFamilyFeedFixture = (genealogyId, feedId) =>
|
||||
findScopedFamilyFixture(familyFeeds, cloneFamilyFeedFixture, genealogyId, feedId)
|
||||
export const findFamilyFeedFixture = (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) =>
|
||||
listScopedFamilyFixtures(familyArticles, cloneFamilyArticleFixture, genealogyId)
|
||||
export const findFamilyArticleFixture = (genealogyId, articleId) =>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
| `GenealogyOwnerTransferBody` | 无 | `targetMemberId` | — | 缺成员候选与产品入口,不能手填 ID |
|
||||
| `GenerationPoemBody` | G12 | `generationNo`、`generationText` | `description`、`sortOrder`、`status` | 已真实维护/回读 |
|
||||
| `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` 阻塞;编辑无产品入口 |
|
||||
| `FamilyFeedCommentBody` | F03 | `commentContent` | `parentCommentId` | 一级评论已真实创建/回读;回复 UI 未设计 |
|
||||
| `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 与可用测试候选才能验证选择/写入 |
|
||||
| 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. 未发起写入请求的原因(不是漏测)
|
||||
|
||||
| 编号 | 接口/功能 | 未发起的原因 | 后端需要提供 |
|
||||
|
||||
@@ -79,7 +79,7 @@ import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.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";
|
||||
|
||||
const editorState = ref("form");
|
||||
@@ -135,7 +135,7 @@ const uploadCover = async () => {
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController });
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ 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 { 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";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -78,7 +78,7 @@ const uploadCover = async () => {
|
||||
if (uploading.value || isSubmitting.value) return;
|
||||
uploading.value = true;
|
||||
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 || "封面图片上传失败,请稍后重试。"; }
|
||||
finally { uploading.value = false; }
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
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 { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
@@ -135,7 +135,7 @@ const submitPhoto = async () => {
|
||||
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: toConsumerOssId(receipt.value.ossId),
|
||||
ossId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
|
||||
@@ -137,7 +137,7 @@ import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.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";
|
||||
|
||||
const form = reactive({
|
||||
@@ -307,7 +307,7 @@ const uploadCover = async () => {
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: createController });
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
|
||||
@@ -113,7 +113,7 @@ import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.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";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -284,7 +284,7 @@ const uploadCover = async () => {
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||
|
||||
@@ -1,11 +1,76 @@
|
||||
<!-- 页面编号:N-02;用途:通知详情。没有详情读取 operation 时保持关闭。 -->
|
||||
<!-- 页面编号:N-02;用途:按通知 ID 读取服务端通知详情。 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@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>
|
||||
|
||||
@@ -131,7 +131,7 @@ const buildUpdatePayload = () => {
|
||||
for (const field of ["nickName", "realName", "sex", "email"]) {
|
||||
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;
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
<text class="field-row__label">地点</text>
|
||||
<input v-model="form.location" maxlength="60" placeholder="请输入地点" placeholder-class="placeholder" @input="clearError" />
|
||||
</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>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
@@ -66,13 +78,13 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
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 { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
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 coverFileName = ref("");
|
||||
const uploadError = ref("");
|
||||
@@ -103,7 +115,7 @@ const uploadCover = async () => {
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
coverOssId.value = toConsumerOssId(receipt.ossId);
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "活动封面";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
@@ -121,7 +133,13 @@ const submit = async () => {
|
||||
submitError.value = "";
|
||||
try {
|
||||
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";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<text>新建功德记录</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 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="time" :value="form.meritClock" @change="selectMeritClock"><view :class="{ placeholder: !form.meritClock }">{{ form.meritClock || '请选择' }}</view></picker></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
@@ -51,16 +52,23 @@ const form = reactive({ donor: "", title: "", type: "", amount: "", meritDate: "
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "type", label: "功德类型" },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
{ 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();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
|
||||
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 confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
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 selectMeritDate = (event) => { form.meritDate = 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 () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
v-for="connector in lineageConnectors"
|
||||
:key="connector.id"
|
||||
class="lineage-connector"
|
||||
:class="{ 'lineage-connector--spouse': connector.kind === 'spouse' }"
|
||||
:style="connector.style"
|
||||
/>
|
||||
|
||||
@@ -97,32 +98,35 @@
|
||||
:key="member.id"
|
||||
:id="`tree-member-${member.id}`"
|
||||
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)"
|
||||
@click="openMemberPanel(member)"
|
||||
>
|
||||
<image
|
||||
class="member-node__skin"
|
||||
: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"
|
||||
/>
|
||||
<view class="member-node__surface">
|
||||
<view class="member-node__portrait">
|
||||
<image
|
||||
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"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation"
|
||||
>{{ member.relation }} · {{ member.branch }}</text
|
||||
>{{ nodeRelationText(member) }}</text
|
||||
>
|
||||
<text class="node-years">{{ member.years }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="tree-state-card">
|
||||
@@ -168,7 +172,7 @@
|
||||
>
|
||||
<image
|
||||
class="member-action-profile__avatar"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
src="/static/assets/foundation/transparent/tab-profile.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-action-profile__copy">
|
||||
@@ -202,7 +206,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
@@ -234,9 +238,11 @@ let treeLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const GRID_UNIT = 5;
|
||||
const NODE_HALF_HEIGHT = 47;
|
||||
const MEMBER_GAP = 250;
|
||||
const GENERATION_GAP = 220;
|
||||
const NODE_HALF_WIDTH = 80;
|
||||
const NODE_HALF_HEIGHT = 94;
|
||||
const FAMILY_LINK_OFFSET = 18;
|
||||
const MEMBER_GAP = 178;
|
||||
const GENERATION_GAP = 260;
|
||||
const members = ref([]);
|
||||
|
||||
const memberActions = Object.freeze([
|
||||
@@ -257,6 +263,10 @@ const memberActions = Object.freeze([
|
||||
]);
|
||||
|
||||
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 generations = Array.from(
|
||||
new Set(members.value.map((member) => Number(member.generation))),
|
||||
@@ -269,12 +279,14 @@ const layoutMembers = computed(() => {
|
||||
.sort(
|
||||
(left, right) =>
|
||||
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)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
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) => {
|
||||
const group = groups.get(generation) || [];
|
||||
const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP);
|
||||
@@ -282,7 +294,7 @@ const layoutMembers = computed(() => {
|
||||
return group.map((member, memberIndex) => ({
|
||||
...member,
|
||||
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 maxX = Math.max(0, ...memberList.map((member) => member.x));
|
||||
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));
|
||||
return {
|
||||
width,
|
||||
@@ -345,9 +357,15 @@ const lineageConnectors = computed(() => {
|
||||
const memberById = new Map(
|
||||
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();
|
||||
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) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(member.parentId, children);
|
||||
@@ -356,18 +374,46 @@ const lineageConnectors = computed(() => {
|
||||
const connectors = [];
|
||||
childrenByParent.forEach((children, 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 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 maxX = Math.max(...children.map((child) => child.x));
|
||||
const verticalStyle = (x, top, bottom) => ({
|
||||
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 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({
|
||||
id: `${parentId}-trunk`,
|
||||
style: verticalStyle(parent.x, parentBottom, branchY),
|
||||
style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
|
||||
});
|
||||
connectors.push({
|
||||
id: `${parentId}-branch`,
|
||||
@@ -381,6 +427,23 @@ const lineageConnectors = computed(() => {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -444,6 +507,10 @@ const loadTree = async (query = {}) => {
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || layoutMembers.value[0] || null;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
if (treeState.value === "tree" && selected.value) {
|
||||
await nextTick();
|
||||
recenterSelectedMember();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
@@ -481,22 +548,46 @@ const handleTreeScroll = (event) => {
|
||||
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 () => {
|
||||
if (!selected.value) return;
|
||||
const spouse = getFamilyPartner(selected.value);
|
||||
ignoreTreeScroll = true;
|
||||
const [scrollRect, nodeRect] = await new Promise((resolve) => {
|
||||
const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(".tree-scroll").boundingClientRect();
|
||||
query
|
||||
.select(`#tree-member-${selected.value.id}`)
|
||||
.boundingClientRect();
|
||||
if (spouse) {
|
||||
query.select(`#tree-member-${spouse.id}`).boundingClientRect();
|
||||
}
|
||||
query.exec(resolve);
|
||||
});
|
||||
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(
|
||||
0,
|
||||
currentTreeScrollLeft.value +
|
||||
nodeRect.left + nodeRect.width / 2 - (scrollRect.left + scrollRect.width / 2),
|
||||
(targetLeft + targetRight) / 2 - (scrollRect.left + scrollRect.width / 2),
|
||||
);
|
||||
treeScrollLeft.value = centeredScrollLeft;
|
||||
currentTreeScrollLeft.value = centeredScrollLeft;
|
||||
@@ -613,7 +704,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.tree-stage--lineage {
|
||||
display: grid;
|
||||
grid-template-columns: 190rpx minmax(0, 1fr);
|
||||
grid-template-columns: 144rpx minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.tree-scroll {
|
||||
@@ -624,7 +715,7 @@ const openMemberAction = (action) => {
|
||||
.generation-rail {
|
||||
display: grid;
|
||||
z-index: 4;
|
||||
width: 190rpx;
|
||||
width: 144rpx;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
||||
@@ -635,7 +726,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.tree-canvas {
|
||||
display: grid;
|
||||
margin: 0 16rpx;
|
||||
margin: 0 12rpx;
|
||||
}
|
||||
.tree-canvas--state {
|
||||
display: block;
|
||||
@@ -655,7 +746,7 @@ const openMemberAction = (action) => {
|
||||
display: grid;
|
||||
justify-self: center;
|
||||
z-index: 3;
|
||||
width: 166rpx;
|
||||
width: 126rpx;
|
||||
height: 58rpx;
|
||||
color: #8f6c3f;
|
||||
font-size: 22rpx;
|
||||
@@ -679,7 +770,7 @@ const openMemberAction = (action) => {
|
||||
}
|
||||
.generation-band__copy text:last-child {
|
||||
margin-top: 1rpx;
|
||||
font-size: 19rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
.lineage-connector {
|
||||
align-self: stretch;
|
||||
@@ -687,36 +778,57 @@ const openMemberAction = (action) => {
|
||||
z-index: 1;
|
||||
background: #b78a42;
|
||||
}
|
||||
.lineage-connector--spouse {
|
||||
background: rgba(183, 117, 76, 0.82);
|
||||
}
|
||||
.member-node {
|
||||
display: grid;
|
||||
position: relative;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
z-index: 2;
|
||||
width: 224rpx;
|
||||
height: 86rpx;
|
||||
width: 160rpx;
|
||||
height: 188rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.member-node__skin {
|
||||
.member-node__surface {
|
||||
display: flex;
|
||||
width: 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__copy {
|
||||
grid-area: 1 / 1;
|
||||
.member-node__portrait {
|
||||
display: flex;
|
||||
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 {
|
||||
grid-area: 1 / 1;
|
||||
z-index: 3;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
margin: 13rpx 0 0 18rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.member-node__divider {
|
||||
width: 56rpx;
|
||||
height: 14rpx;
|
||||
margin-top: 3rpx;
|
||||
}
|
||||
.member-node__copy {
|
||||
z-index: 1;
|
||||
padding-left: 44rpx;
|
||||
width: 100%;
|
||||
margin-top: 1rpx;
|
||||
padding: 0;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
@@ -724,25 +836,48 @@ const openMemberAction = (action) => {
|
||||
display: block;
|
||||
}
|
||||
.node-name {
|
||||
padding-top: 5rpx;
|
||||
color: $ink;
|
||||
display: -webkit-box;
|
||||
color: #6b2419;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 25rpx;
|
||||
overflow: hidden;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.node-relation {
|
||||
margin-top: 0;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #644b2e;
|
||||
font-size: 18rpx;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-years {
|
||||
margin-top: 0;
|
||||
color: $ink-muted;
|
||||
font-size: 20rpx;
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
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 {
|
||||
width: 236rpx;
|
||||
height: 94rpx;
|
||||
z-index: 3;
|
||||
}
|
||||
.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 {
|
||||
color: $brand-red;
|
||||
|
||||
+184
-14
@@ -37,6 +37,34 @@
|
||||
</view>
|
||||
<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
|
||||
v-if="!isFirstMember"
|
||||
:range="relationOptions"
|
||||
@@ -58,16 +86,28 @@
|
||||
<text>字辈</text>
|
||||
<input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</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">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生农历</text>
|
||||
<input v-model="addForm.birthLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, addForm.birthLunar)" @change="selectOption('birthLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生农历</text><text>{{ optionLabel(lunarOptions, addForm.birthLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生地</text>
|
||||
<input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -78,10 +118,11 @@
|
||||
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世农历</text>
|
||||
<input v-model="addForm.deathLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, addForm.deathLunar)" @change="selectOption('deathLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世农历</text><text>{{ optionLabel(lunarOptions, addForm.deathLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -90,6 +131,20 @@
|
||||
<text>安葬地</text>
|
||||
<input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</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">
|
||||
<text>人物简介</text>
|
||||
@@ -153,6 +208,7 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
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 {
|
||||
handleBackPress,
|
||||
@@ -166,10 +222,14 @@ const personId = ref("");
|
||||
const mode = ref("relative");
|
||||
const relationType = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const addRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const relationIntents = Object.freeze({
|
||||
@@ -181,11 +241,39 @@ const relationIntents = Object.freeze({
|
||||
DAUGHTER: { label: "女儿" },
|
||||
});
|
||||
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({
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
@@ -193,10 +281,13 @@ const addForm = reactive({
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "" });
|
||||
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
|
||||
@@ -205,6 +296,11 @@ const relationOptions = computed(() => {
|
||||
return types.map((type) => relationIntents[type].label);
|
||||
});
|
||||
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(
|
||||
() =>
|
||||
Boolean(genealogyId.value) &&
|
||||
@@ -239,7 +335,7 @@ const resultCopy = 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) => {
|
||||
discardDialogVisible.value = visible;
|
||||
@@ -265,7 +361,22 @@ const loadCurrentMember = async () => {
|
||||
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) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
@@ -284,6 +395,7 @@ onLoad((query) => {
|
||||
if (activeRelationIntent.value) {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
void loadPersonOptions();
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
@@ -293,6 +405,7 @@ onLoad((query) => {
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
addRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -300,7 +413,7 @@ const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
dirty: addState.value === "form" && hasDraft.value,
|
||||
submitting: isSubmitting.value,
|
||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -313,21 +426,58 @@ const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
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 selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; };
|
||||
const validateAddForm = () => {
|
||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||
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 () => {
|
||||
if (isSubmitting.value || !validateAddForm()) return;
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm()) return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
personNo: addForm.personNo,
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
fatherId: addForm.fatherId,
|
||||
motherId: addForm.motherId,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
@@ -335,10 +485,16 @@ const submitAdd = async () => {
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
...(isFirstMember.value ? { generation: 1 } : {}),
|
||||
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1, relationName: addForm.relationName }
|
||||
: {
|
||||
...(addForm.generation ? { generation: Number(addForm.generation) } : {}),
|
||||
relationName: relationLabel.value,
|
||||
}),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
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 });
|
||||
}
|
||||
Object.assign(addForm, {
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
@@ -360,9 +523,13 @@ const submitAdd = async () => {
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
relationName: "",
|
||||
});
|
||||
avatarFileName.value = "";
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
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; }
|
||||
.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__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-placeholder { color: #a79884; }
|
||||
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
|
||||
|
||||
+182
-12
@@ -27,6 +27,34 @@
|
||||
</view>
|
||||
<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">
|
||||
<text>别名</text>
|
||||
<input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" />
|
||||
@@ -35,13 +63,24 @@
|
||||
<text>字辈</text>
|
||||
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
|
||||
</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)">
|
||||
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生农历</text>
|
||||
<input v-model="editForm.birthLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, editForm.birthLunar)" @change="selectOption('birthLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生农历</text><text>{{ optionLabel(lunarOptions, editForm.birthLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生地</text>
|
||||
<input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -49,10 +88,11 @@
|
||||
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
|
||||
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世农历</text>
|
||||
<input v-model="editForm.deathLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
<picker :range="lunarOptions.map((item) => item.label)" :value="optionIndex(lunarOptions, editForm.deathLunar)" @change="selectOption('deathLunar', lunarOptions, $event)">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>逝世农历</text><text>{{ optionLabel(lunarOptions, editForm.deathLunar) || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
@@ -61,6 +101,19 @@
|
||||
<text>安葬地</text>
|
||||
<input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</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">
|
||||
<text>人物简介</text>
|
||||
<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 PageHeader from "@/components/PageHeader.vue";
|
||||
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 {
|
||||
goBack,
|
||||
@@ -123,18 +177,30 @@ const editState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const failedAction = ref("load");
|
||||
const editRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const originalMember = ref(null);
|
||||
const baseline = ref("");
|
||||
const editForm = reactive({
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
name: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
fatherId: "",
|
||||
motherId: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
@@ -142,10 +208,39 @@ const editForm = reactive({
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
summary: "",
|
||||
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 hasValidContext = computed(() =>
|
||||
@@ -170,6 +265,7 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const datePart = (value) => String(value || "").slice(0, 10);
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
@@ -181,18 +277,29 @@ const loadMember = async () => {
|
||||
if (activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
Object.assign(editForm, {
|
||||
appUserId: member.appUserId || "",
|
||||
bindingMode: member.bindingMode || (member.appUserId ? "SPECIFIED" : "NONE"),
|
||||
personNo: member.personNo || "",
|
||||
name: member.name,
|
||||
aliasName: member.aliasName || "",
|
||||
sex: member.sex || "",
|
||||
generation: member.generation || "",
|
||||
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 || "",
|
||||
birthPlace: member.birthplace || "",
|
||||
deathDate: member.deathDate || "",
|
||||
deathDate: datePart(member.deathDate),
|
||||
deathLunar: member.deathLunar || "",
|
||||
deathPlace: member.deathPlace || "",
|
||||
burialPlace: member.burialPlace || "",
|
||||
personStatus: member.personStatus || "",
|
||||
summary: member.biography || "",
|
||||
remark: member.remark || "",
|
||||
sortOrder: member.sortOrder ?? "",
|
||||
relationName: member.relationName || "",
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
errorMessage.value = "";
|
||||
@@ -206,6 +313,22 @@ const loadMember = async () => {
|
||||
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) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
@@ -216,10 +339,12 @@ onLoad((query) => {
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
void loadPersonOptions();
|
||||
});
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
editRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -227,7 +352,7 @@ const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
submitting: isSubmitting.value || isAvatarUploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -236,6 +361,34 @@ const requestBack = () =>
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
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) => {
|
||||
editForm[field] = event.detail.value || "";
|
||||
fieldErrors.dates = "";
|
||||
@@ -243,16 +396,27 @@ const selectDate = (field, event) => {
|
||||
const validateEditForm = () => {
|
||||
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
|
||||
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 () => {
|
||||
if (isSubmitting.value || !validateEditForm()) return;
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateEditForm()) return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await appApi.updatePerson(genealogyId.value, personId.value, {
|
||||
bindingMode: editForm.bindingMode,
|
||||
...(editForm.bindingMode === "SPECIFIED" ? { appUserId: editForm.appUserId } : {}),
|
||||
personNo: editForm.personNo,
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation ? Number(editForm.generation) : undefined,
|
||||
generationName: editForm.generationName,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
@@ -260,8 +424,11 @@ const saveMember = async () => {
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
personStatus: editForm.personStatus,
|
||||
biography: editForm.summary,
|
||||
remark: editForm.remark,
|
||||
sortOrder: editForm.sortOrder,
|
||||
relationName: editForm.relationName,
|
||||
}, { requestController: editRequestController });
|
||||
baseline.value = formSnapshot.value;
|
||||
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: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__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-placeholder { color: #a79884; }
|
||||
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
|
||||
|
||||
@@ -17,7 +17,7 @@ foreach ($expected in @(
|
||||
'v-model="form.authorName"',
|
||||
'v-model="form.sortOrder"',
|
||||
'pickAndUploadImage',
|
||||
'toConsumerOssId',
|
||||
'coverOssId.value = receipt.ossId',
|
||||
'coverOssId: coverOssId.value',
|
||||
'class="required-mark"',
|
||||
'editor-control--unavailable',
|
||||
|
||||
@@ -34,22 +34,49 @@ const run = async () => {
|
||||
`;
|
||||
|
||||
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 = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: {} } }));
|
||||
queueMicrotask(() => options.success(response));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
|
||||
await appApi.createFeed("1001", {
|
||||
const createdFeed = await appApi.createFeed("1001", {
|
||||
feedType: " photo ",
|
||||
feedContent: " family memory ",
|
||||
mediaOssIds: "2060000000000000001,2060000000000000002",
|
||||
sortOrder: "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, {
|
||||
feedType: "photo",
|
||||
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");
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { ...feedResponse, feedContent: "only required" } } };
|
||||
await appApi.createFeed("1001", { feedContent: "only required" });
|
||||
assert.deepStrictEqual(requests.at(-1).data, { feedContent: "only required" });
|
||||
await assert.rejects(
|
||||
@@ -66,11 +94,17 @@ const run = async () => {
|
||||
/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", {
|
||||
categoryId: "900040001",
|
||||
articleTitle: " title ",
|
||||
articleSummary: " summary ",
|
||||
coverOssId: 900001,
|
||||
coverOssId: "9007199254740993",
|
||||
articleContent: " body ",
|
||||
authorName: " author ",
|
||||
sortOrder: "1",
|
||||
@@ -80,7 +114,7 @@ const run = async () => {
|
||||
categoryId: 900040001,
|
||||
articleTitle: "title",
|
||||
articleSummary: "summary",
|
||||
coverOssId: 900001,
|
||||
coverOssId: "9007199254740993",
|
||||
articleContent: "body",
|
||||
authorName: "author",
|
||||
sortOrder: 1,
|
||||
@@ -91,21 +125,21 @@ const run = async () => {
|
||||
await appApi.createAlbum("1001", {
|
||||
albumName: " old photos ",
|
||||
albumDesc: " family album ",
|
||||
coverOssId: 900001,
|
||||
coverOssId: "9007199254740993",
|
||||
sortOrder: "1",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
albumName: "old photos",
|
||||
albumDesc: "family album",
|
||||
coverOssId: 900001,
|
||||
coverOssId: "9007199254740993",
|
||||
sortOrder: 1,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/albums");
|
||||
await assert.rejects(
|
||||
appApi.createAlbum("1001", { albumName: "bad id", coverOssId: "9007199254740992" }),
|
||||
/coverOssId/,
|
||||
appApi.createAlbum("1001", { albumName: "bad id", coverOssId: 900001 }),
|
||||
/OSS ID 字符串/,
|
||||
);
|
||||
|
||||
delete globalThis.uni;
|
||||
|
||||
@@ -14,13 +14,13 @@ foreach ($token in @('form.feedType', 'mediaReceipts', 'mediaOssIds', 'form.sort
|
||||
}
|
||||
|
||||
$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"
|
||||
}
|
||||
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'
|
||||
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"
|
||||
}
|
||||
if ($f07 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F07 must not expose a raw cover OSS ID input' }
|
||||
|
||||
@@ -15,8 +15,8 @@ const loadMockModule = async () => {
|
||||
return import(moduleUrl);
|
||||
};
|
||||
|
||||
const assertScopedOwner = ({ list, find, ids, nestedField }) => {
|
||||
assert.deepStrictEqual(list("1001").map((item) => item.id), ids);
|
||||
const assertScopedOwner = ({ list, find, ids, idField = "id", mutationField = "title", nestedField }) => {
|
||||
assert.deepStrictEqual(list("1001").map((item) => item[idField]), ids);
|
||||
assert.deepStrictEqual(list("1002"), [], "另一家谱不得读取 1001 的内容夹具");
|
||||
assert.strictEqual(find("1002", ids[0]), null, "已知 ID 换谱后必须失败关闭");
|
||||
assert.strictEqual(find("1001", "unknown"), null, "未知实体不得回退首条记录");
|
||||
@@ -25,8 +25,8 @@ const assertScopedOwner = ({ list, find, ids, nestedField }) => {
|
||||
|
||||
const listed = list("1001");
|
||||
const original = find("1001", ids[0]);
|
||||
listed[0].title = "被列表调用方修改";
|
||||
assert.notStrictEqual(find("1001", ids[0]).title, "被列表调用方修改");
|
||||
listed[0][mutationField] = "被列表调用方修改";
|
||||
assert.notStrictEqual(find("1001", ids[0])[mutationField], "被列表调用方修改");
|
||||
if (nestedField) {
|
||||
const mutateNested = (item, value) => {
|
||||
if (item[nestedField][0] && typeof item[nestedField][0] === "object") {
|
||||
@@ -68,7 +68,8 @@ const run = async () => {
|
||||
list: listFamilyFeedFixtures,
|
||||
find: findFamilyFeedFixture,
|
||||
ids: ["1", "2"],
|
||||
nestedField: "comments",
|
||||
idField: "feedId",
|
||||
mutationField: "feedContent",
|
||||
});
|
||||
assertScopedOwner({
|
||||
list: listFamilyArticleFixtures,
|
||||
@@ -83,8 +84,11 @@ const run = async () => {
|
||||
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 [
|
||||
[listFamilyFeedFixtures, "feed"],
|
||||
[listFamilyArticleFixtures, "article"],
|
||||
[listFamilyAlbumFixtures, "album"],
|
||||
]) {
|
||||
@@ -95,9 +99,9 @@ const run = async () => {
|
||||
}
|
||||
|
||||
const firstFeed = findFamilyFeedFixture("1001", "1");
|
||||
firstFeed.comments[0].content = "被详情调用方修改";
|
||||
firstFeed.feedContent = "被详情调用方修改";
|
||||
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",
|
||||
)
|
||||
.replace(
|
||||
/^import \{ AUTH_TAC_SCENE, assertSmsCode \}[^\n]+\r?\n/m,
|
||||
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n",
|
||||
/^import \{ AUTH_VERIFICATION_OPERATION, assertSmsCode \}[^\n]+\r?\n/m,
|
||||
"const AUTH_VERIFICATION_OPERATION = {}; const assertSmsCode = (value) => value;\n",
|
||||
)
|
||||
.replace(
|
||||
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
|
||||
@@ -135,7 +139,7 @@ const run = async () => {
|
||||
(error) => error?.code === "WRITE_UNAVAILABLE" && /不会保存/.test(error.message),
|
||||
"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");
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ const run = async () => {
|
||||
genealogyName: "王氏家谱",
|
||||
surname: "王",
|
||||
regionCode: "110101",
|
||||
coverOssId: "9007199254740993",
|
||||
});
|
||||
|
||||
const request = requests.at(-1);
|
||||
@@ -62,6 +63,7 @@ const run = async () => {
|
||||
genealogyName: "王氏家谱",
|
||||
surname: "王",
|
||||
regionCode: "110101",
|
||||
coverOssId: "9007199254740993",
|
||||
});
|
||||
|
||||
delete globalThis.uni;
|
||||
|
||||
@@ -28,7 +28,7 @@ foreach ($required in @(
|
||||
'v-model="form.addressDetail"',
|
||||
'v-model="form.intro"',
|
||||
'pickAndUploadImage',
|
||||
'toConsumerOssId',
|
||||
'coverOssId.value = receipt.ossId',
|
||||
'coverOssId.value',
|
||||
'GENEALOGY_ACCESS_PRESET_OPTIONS',
|
||||
'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\.)?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' }
|
||||
|
||||
foreach ($forbidden in @(
|
||||
|
||||
+43
-1181
File diff suppressed because it is too large
Load Diff
@@ -14,35 +14,105 @@ foreach ($required in @(
|
||||
"SON: 'children'",
|
||||
'async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {})',
|
||||
'async updatePerson(genealogyId, personId, payload, requestOptions = {})',
|
||||
"'bindingMode',",
|
||||
"['NONE', 'SELF', 'SPECIFIED']",
|
||||
'method: ''PUT''',
|
||||
'requestStrict({'
|
||||
)) {
|
||||
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" }
|
||||
}
|
||||
|
||||
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 @(
|
||||
'v-model="addForm.personNo"',
|
||||
'v-model="addForm.aliasName"',
|
||||
'v-model="addForm.generation"',
|
||||
'v-model="addForm.generationName"',
|
||||
'v-model="addForm.birthLunar"',
|
||||
'v-model="addForm.birthPlace"',
|
||||
'v-model="addForm.deathLunar"',
|
||||
'v-model="addForm.deathPlace"',
|
||||
'v-model="addForm.burialPlace"',
|
||||
'v-model="addForm.biography"',
|
||||
'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',
|
||||
'generationName: addForm.generationName',
|
||||
'biography: addForm.biography',
|
||||
'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" }
|
||||
}
|
||||
|
||||
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"')) {
|
||||
$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" }
|
||||
|
||||
@@ -2,33 +2,25 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$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
|
||||
foreach ($token in @('listNotificationFixtures', 'unreadCount', 'openNotice', ':action="unreadCount > 0')) {
|
||||
if (-not $list.Contains($token)) { throw "N01-N02 flow missing: $token" }
|
||||
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
|
||||
|
||||
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*\)') {
|
||||
throw 'N01 must open N02 through the registered gateway with only lexical id and N01 source'
|
||||
foreach ($token in @('appApi.getNotificationDetail', 'notificationId.value', 'createRequestController', 'onLoad((options)', 'controller.abort()', 'returnTo("N01")')) {
|
||||
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')) {
|
||||
if (-not $detail.Contains($token)) { throw "N02 business detail missing: $token" }
|
||||
foreach ($token in @('normalizeNotificationDetail', 'NOTIFICATION_DETAIL_RESPONSE_INVALID', 'async getNotificationDetail', 'url: `/genealogy/app/notifications/${normalizedNotificationId}`', "method: 'GET'")) {
|
||||
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'
|
||||
}
|
||||
if ($detail.Contains('noticeDetail.read') -or -not $detail.Contains('noticeDetail.value.unread = false;')) {
|
||||
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" }
|
||||
|
||||
foreach ($source in @($list, $detail, $api)) {
|
||||
foreach ($forbidden in @('findNotificationFixture', 'noticeDetail.unread', 'markAsRead', 'targetUrl', 'returnUrl')) {
|
||||
if ($source.Contains($forbidden)) { throw "Notification flow retains retired fixture contract: $forbidden" }
|
||||
}
|
||||
}
|
||||
if ($detail -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') {
|
||||
throw 'N02 must not consume an executable notification target field'
|
||||
if ($list.Contains('openPage("N02"')) {
|
||||
throw 'N01 must not invent a detail link from the generic notification-list response'
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const run = async () => {
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
const profile = {
|
||||
userId: 101,
|
||||
userId: "101",
|
||||
tenantId: "000000",
|
||||
userNo: "U-101",
|
||||
phone: "13800138000",
|
||||
@@ -52,7 +52,7 @@ const run = async () => {
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "男",
|
||||
birthday: "1990-01-02T00:00:00",
|
||||
birthday: "1990-01-02",
|
||||
email: "ming@example.com",
|
||||
registerSource: "APP",
|
||||
status: "0",
|
||||
@@ -66,13 +66,21 @@ const run = async () => {
|
||||
assert.strictEqual(profileRequest.timeout, 15000);
|
||||
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 } };
|
||||
await appApi.updateProfile({
|
||||
nickName: " 新昵称 ",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "女",
|
||||
birthday: "1991-02-03T00:00:00+08:00",
|
||||
birthday: "1991-02-03",
|
||||
email: "new@example.com",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
@@ -80,12 +88,12 @@ const run = async () => {
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "女",
|
||||
birthday: "1991-02-03T00:00:00+08:00",
|
||||
birthday: "1991-02-03",
|
||||
email: "new@example.com",
|
||||
});
|
||||
await assert.rejects(appApi.updateProfile({}), /至少需要一个/);
|
||||
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/);
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: 0 } } };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$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 ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' }
|
||||
|
||||
@@ -58,14 +58,14 @@ const run = async () => {
|
||||
name: "汤文远",
|
||||
generation: 12,
|
||||
generationName: "文",
|
||||
birthDate: "1900-01-02T00:00:00+08:00",
|
||||
birthDate: "1900-01-01T16:00:00.000Z",
|
||||
relationName: "始祖",
|
||||
spouses: [{
|
||||
personId: 1002,
|
||||
name: "李氏",
|
||||
generation: 12,
|
||||
generationName: "文",
|
||||
birthDate: "1905-03-04T00:00:00+08:00",
|
||||
birthDate: "1905-03-03T16:00:00.000Z",
|
||||
}],
|
||||
children: [{
|
||||
personId: 1011,
|
||||
@@ -81,6 +81,17 @@ const run = async () => {
|
||||
fatherId: 1011,
|
||||
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",
|
||||
parentId: null,
|
||||
spouseOf: "1001",
|
||||
name: "李氏",
|
||||
relation: "配偶",
|
||||
generation: 12,
|
||||
@@ -132,6 +144,18 @@ const run = async () => {
|
||||
sex: "",
|
||||
personStatus: "",
|
||||
},
|
||||
{
|
||||
id: "1031",
|
||||
parentId: "1001",
|
||||
spouseOf: "1011",
|
||||
name: "周明轩",
|
||||
relation: "配偶",
|
||||
generation: 13,
|
||||
branch: "正字辈",
|
||||
years: "生卒待补",
|
||||
sex: "",
|
||||
personStatus: "",
|
||||
},
|
||||
]);
|
||||
assert.strictEqual(
|
||||
requests[0].url,
|
||||
|
||||
@@ -79,16 +79,30 @@ const run = async () => {
|
||||
genealogyId: "900001001",
|
||||
genealogyName: "联调测试家谱",
|
||||
name: "汤文远",
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
personNo: "",
|
||||
aliasName: "",
|
||||
generation: 12,
|
||||
generationName: "文",
|
||||
avatarOssId: "",
|
||||
relation: "家谱成员",
|
||||
branch: "文字辈",
|
||||
sex: "MALE",
|
||||
birthDate: "1900-01-02",
|
||||
birthLunar: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
years: "1900-01-02—",
|
||||
birthplace: "祖居地",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
spouseNames: "",
|
||||
personStatus: "ALIVE",
|
||||
biography: "生平资料",
|
||||
remark: "",
|
||||
relationName: "",
|
||||
sortOrder: null,
|
||||
status: "normal",
|
||||
relatives: [
|
||||
{ id: "900", name: "汤世德", relation: "父亲" },
|
||||
@@ -103,6 +117,89 @@ const run = async () => {
|
||||
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
|
||||
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 = {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
|
||||
+184
-46
@@ -479,12 +479,19 @@ const normalizeProfileAvatar = (value) => {
|
||||
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) => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw createRequestError('个人资料响应格式无效', 'PROFILE_RESPONSE_INVALID')
|
||||
}
|
||||
return {
|
||||
userId: normalizeProfileAvatar(payload.userId),
|
||||
userId: normalizeProfileUserId(payload.userId),
|
||||
tenantId: normalizeProfileResponseText(payload.tenantId, 'tenantId'),
|
||||
userNo: normalizeProfileResponseText(payload.userNo, 'userNo'),
|
||||
phone: normalizeProfileResponseText(payload.phone, 'phone'),
|
||||
@@ -524,8 +531,8 @@ const normalizeProfileUpdatePayload = (payload) => {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'birthday')) {
|
||||
const value = normalizeOptionalAuthText(payload.birthday, 'birthday')
|
||||
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)) {
|
||||
throw new TypeError('birthday 必须是 ISO 日期时间')
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new TypeError('birthday 必须是 yyyy-MM-dd 日期')
|
||||
}
|
||||
normalized.birthday = value
|
||||
}
|
||||
@@ -633,6 +640,13 @@ const normalizeUploadOssId = (value, field) => {
|
||||
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) => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw createRequestError('文件初始化响应格式无效', 'FILE_RESPONSE_INVALID')
|
||||
@@ -838,13 +852,7 @@ const normalizeGenealogyCreatePayload = (payload) => {
|
||||
if (value) normalized[field] = value
|
||||
}
|
||||
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
|
||||
const numericOssId = typeof payload.coverOssId === 'number'
|
||||
? payload.coverOssId
|
||||
: Number(payload.coverOssId)
|
||||
if (!Number.isSafeInteger(numericOssId) || numericOssId <= 0) {
|
||||
throw new TypeError('创建家谱字段 coverOssId 必须是正整数')
|
||||
}
|
||||
normalized.coverOssId = numericOssId
|
||||
normalized.coverOssId = normalizeOssIdString(payload.coverOssId, '创建家谱字段 coverOssId')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -887,13 +895,7 @@ const normalizeGenealogyUpdatePayload = (payload) => {
|
||||
if (value) normalized[field] = value
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'coverOssId') && payload.coverOssId !== null && payload.coverOssId !== '') {
|
||||
const numericOssId = typeof payload.coverOssId === 'number'
|
||||
? payload.coverOssId
|
||||
: Number(payload.coverOssId)
|
||||
if (!Number.isSafeInteger(numericOssId) || numericOssId <= 0) {
|
||||
throw new TypeError('更新家谱字段 coverOssId 必须是正整数')
|
||||
}
|
||||
normalized.coverOssId = numericOssId
|
||||
normalized.coverOssId = normalizeOssIdString(payload.coverOssId, '更新家谱字段 coverOssId')
|
||||
}
|
||||
if (!Object.keys(normalized).length) throw new TypeError('请至少填写一项需要更新的家谱信息')
|
||||
return normalized
|
||||
@@ -1082,14 +1084,28 @@ const lineageDatePart = (value, label) => {
|
||||
const normalized = normalizeLineageText(value, label)
|
||||
if (!normalized) return ''
|
||||
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) => {
|
||||
if (!Array.isArray(value)) throw lineageTreeError('世系树响应不是列表')
|
||||
const seen = new Set()
|
||||
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)) {
|
||||
throw lineageTreeError('世系树包含无效节点')
|
||||
}
|
||||
@@ -1101,16 +1117,19 @@ const normalizeLineageTree = (value) => {
|
||||
throw lineageTreeError('世系树人物世代无效')
|
||||
}
|
||||
const generationName = normalizeLineageText(node.generationName, '字辈')
|
||||
const relationName = normalizeLineageText(node.relationName, '人物关系')
|
||||
const birthDate = lineageDatePart(node.birthDate, '出生日期')
|
||||
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
|
||||
normalized.push({
|
||||
const normalizedNode = {
|
||||
id,
|
||||
parentId,
|
||||
...(spouseOf ? { spouseOf } : {}),
|
||||
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
|
||||
relation:
|
||||
relationOverride ||
|
||||
normalizeLineageText(node.relationName, '人物关系') ||
|
||||
(parentId ? '后代' : '始祖'),
|
||||
(parentId
|
||||
? (relationName && relationName !== '配偶' ? relationName : '后代')
|
||||
: relationName || '始祖'),
|
||||
generation: node.generation,
|
||||
branch: generationName
|
||||
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
|
||||
@@ -1121,19 +1140,38 @@ const normalizeLineageTree = (value) => {
|
||||
: '生卒待补',
|
||||
sex: normalizeLineageText(node.sex, '性别'),
|
||||
personStatus: normalizeLineageText(node.personStatus, '人物状态')
|
||||
})
|
||||
}
|
||||
normalized.push(normalizedNode)
|
||||
normalizedById.set(id, normalizedNode)
|
||||
return id
|
||||
}
|
||||
const walk = (node, parentId = null, depth = 0) => {
|
||||
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 children = node.children ?? []
|
||||
if (!Array.isArray(spouses) || !Array.isArray(children)) {
|
||||
throw lineageTreeError('世系树亲属集合无效')
|
||||
}
|
||||
spouses.forEach((spouse) => appendNode(spouse, parentId, '配偶'))
|
||||
children.forEach((child) => walk(child, id, depth + 1))
|
||||
const id = normalizeLineagePersonId(node.personId)
|
||||
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))
|
||||
return normalized
|
||||
@@ -1148,6 +1186,11 @@ const normalizeLineagePersonIdentity = (value, 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 } = {}) => {
|
||||
if (value === undefined || value === null) {
|
||||
if (required) throw lineagePersonError(`成员详情缺少${label}`)
|
||||
@@ -1162,7 +1205,7 @@ const normalizeLineagePersonText = (value, label, { required = false } = {}) =>
|
||||
const normalizeLineagePersonDate = (value, label) => {
|
||||
const normalized = normalizeLineagePersonText(value, label)
|
||||
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}无效`)
|
||||
}
|
||||
const datePart = normalized.slice(0, 10)
|
||||
@@ -1193,8 +1236,17 @@ const normalizeLineagePersonDetail = (
|
||||
throw lineagePersonError('成员详情世代无效')
|
||||
}
|
||||
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 aliasName = normalizeLineagePersonText(value.aliasName, '别名或曾用名')
|
||||
const avatarOssId = normalizeOptionalLineagePersonIdentity(value.avatarOssId, '头像文件标识')
|
||||
const birthDate = normalizeLineagePersonDate(value.birthDate, '出生日期')
|
||||
const deathDate = normalizeLineagePersonDate(value.deathDate, '逝世日期')
|
||||
const birthLunar = normalizeLineagePersonText(value.birthLunar, '出生农历')
|
||||
@@ -1206,6 +1258,10 @@ const normalizeLineagePersonDetail = (
|
||||
const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态')
|
||||
const biography = normalizeLineagePersonText(value.biography, '生平')
|
||||
const remark = normalizeLineagePersonText(value.remark, '备注')
|
||||
const relationName = normalizeLineagePersonText(value.relationName, '关系显示名称')
|
||||
const sortOrder = value.sortOrder === undefined || value.sortOrder === null
|
||||
? null
|
||||
: normalizeOptionalSafeInteger(value.sortOrder, '人物排序值')
|
||||
const relatives = []
|
||||
for (const relation of [
|
||||
{ id: value.fatherId, name: value.fatherName, label: '父亲' },
|
||||
@@ -1227,9 +1283,13 @@ const normalizeLineagePersonDetail = (
|
||||
genealogyId,
|
||||
genealogyName: normalizeLineagePersonText(value.genealogyName, '家谱名称') || '当前家谱',
|
||||
name,
|
||||
appUserId,
|
||||
bindingMode,
|
||||
personNo,
|
||||
aliasName,
|
||||
generation: value.generation,
|
||||
generationName,
|
||||
avatarOssId,
|
||||
relation: value.generation === 1 ? '始祖' : '家谱成员',
|
||||
branch: generationName
|
||||
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
|
||||
@@ -1247,6 +1307,8 @@ const normalizeLineagePersonDetail = (
|
||||
personStatus,
|
||||
biography,
|
||||
remark,
|
||||
relationName,
|
||||
sortOrder,
|
||||
status: ['DECEASED', 'DEAD'].includes(personStatus.toUpperCase()) ? 'deceased' : 'normal',
|
||||
relatives
|
||||
}
|
||||
@@ -1376,8 +1438,8 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
|
||||
if (genealogyId && genealogyId !== expectedGenealogyId) {
|
||||
throw createRequestError('家族动态归属与请求不匹配', 'FEED_RESPONSE_INVALID')
|
||||
}
|
||||
const id = normalizeOptionalNumericId(item.feedId ?? item.id, '动态标识', 'FEED_RESPONSE_INVALID')
|
||||
const content = normalizeOptionalGenealogyText(item.feedContent ?? item.content, 'feedContent')
|
||||
const id = normalizeOptionalNumericId(item.feedId, '动态标识', 'FEED_RESPONSE_INVALID')
|
||||
const content = normalizeOptionalGenealogyText(item.feedContent, 'feedContent')
|
||||
if (!id || !content) {
|
||||
throw createRequestError('家族动态响应缺少标识或内容', 'FEED_RESPONSE_INVALID')
|
||||
}
|
||||
@@ -1385,14 +1447,14 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
|
||||
id,
|
||||
genealogyId: expectedGenealogyId,
|
||||
content,
|
||||
publisher: normalizeOptionalGenealogyText(item.publisherNickName ?? item.author, 'publisherNickName') || '家族成员',
|
||||
type: normalizeOptionalGenealogyText(item.feedType ?? item.tag, 'feedType') || '文字动态',
|
||||
time: normalizeOptionalGenealogyText(item.createTime ?? item.time, 'createTime'),
|
||||
publisher: normalizeOptionalGenealogyText(item.publisherNickName, 'publisherNickName') || '家族成员',
|
||||
type: normalizeOptionalGenealogyText(item.feedType, 'feedType') || '文字动态',
|
||||
time: normalizeOptionalGenealogyText(item.createTime, 'createTime'),
|
||||
mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'),
|
||||
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
|
||||
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
|
||||
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) {
|
||||
@@ -1475,12 +1537,15 @@ const normalizeArticleCreatePayload = (payload) => {
|
||||
const value = normalizeOptionalAuthText(payload[field], field)
|
||||
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
|
||||
const value = typeof payload[field] === 'number' ? payload[field] : Number(payload[field])
|
||||
if (!Number.isSafeInteger(value)) throw new TypeError(`${field} 必须是安全整数`)
|
||||
data[field] = value
|
||||
}
|
||||
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
|
||||
data.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1495,7 +1560,10 @@ const normalizeAlbumCreatePayload = (payload) => {
|
||||
const value = normalizeOptionalAuthText(payload[field], field)
|
||||
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
|
||||
const value = typeof payload[field] === 'number' ? payload[field] : Number(payload[field])
|
||||
if (!Number.isSafeInteger(value)) throw new TypeError(`${field} 必须是安全整数`)
|
||||
@@ -1507,8 +1575,7 @@ const normalizeAlbumCreatePayload = (payload) => {
|
||||
const normalizeAlbumPhotoCreatePayload = (payload) => {
|
||||
const allowedFields = new Set(['ossId', 'photoTitle', 'photoDesc', 'photographer', 'shootTime', 'sortOrder', 'status'])
|
||||
assertPlainPayload(payload, allowedFields, '相册照片请求')
|
||||
const ossId = normalizeOptionalSafeInteger(payload.ossId, 'ossId')
|
||||
if (!ossId || ossId < 1) throw new TypeError('相册照片需要真实上传回执中的 ossId')
|
||||
const ossId = normalizeOssIdString(payload.ossId, '相册照片 ossId')
|
||||
const data = { ossId }
|
||||
for (const field of ['photoTitle', 'photoDesc', 'photographer', 'shootTime', 'status']) {
|
||||
const value = normalizeOptionalAuthText(payload[field], field)
|
||||
@@ -1530,9 +1597,12 @@ const normalizeCeremonyPayload = (payload) => {
|
||||
const value = normalizeOptionalAuthText(payload[field], field)
|
||||
if (value) data[field] = value
|
||||
}
|
||||
for (const field of ['coverOssId', 'sortOrder']) {
|
||||
const value = normalizeOptionalSafeInteger(payload[field], field)
|
||||
if (value !== undefined) data[field] = value
|
||||
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
|
||||
data.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
|
||||
}
|
||||
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
|
||||
if (sortOrder !== undefined) {
|
||||
data.sortOrder = sortOrder
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -1657,6 +1727,44 @@ const normalizeResourcePathId = (value, 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) => {
|
||||
if (hasRemoteConfig()) return
|
||||
throw createRequestError(`${label}${operation}需要真实服务,当前本地预览不会伪造结果`, operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED')
|
||||
@@ -1947,6 +2055,7 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
throw new TypeError('人物写入请求必须是普通对象')
|
||||
}
|
||||
const allowedFields = new Set([
|
||||
'bindingMode',
|
||||
'appUserId',
|
||||
'personNo',
|
||||
'name',
|
||||
@@ -1975,16 +2084,29 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
}
|
||||
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
|
||||
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 [
|
||||
['appUserId', '绑定用户标识'],
|
||||
['fatherId', '父亲标识'],
|
||||
['motherId', '母亲标识'],
|
||||
['avatarOssId', '头像文件标识']
|
||||
['motherId', '母亲标识']
|
||||
]) {
|
||||
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
|
||||
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 (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
|
||||
throw new TypeError('人物世代必须是正安全整数')
|
||||
@@ -2708,13 +2830,14 @@ export const appApi = {
|
||||
if (!hasRemoteConfig()) {
|
||||
throw createRequestError('修改动态需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
|
||||
}
|
||||
return requestStrict({
|
||||
const result = await requestStrict({
|
||||
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}`,
|
||||
method: 'PUT',
|
||||
data: normalizeFeedCreatePayload(payload)
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
return normalizeFamilyFeed(result, normalizedGenealogyId, normalizedFeedId)
|
||||
},
|
||||
async deleteFeed(genealogyId, feedId, requestOptions = {}) {
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
@@ -2750,13 +2873,15 @@ export const appApi = {
|
||||
async createFeed(genealogyId, payload, requestOptions = {}) {
|
||||
if (hasRemoteConfig()) {
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
return requestStrict({
|
||||
const result = await requestStrict({
|
||||
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds`,
|
||||
method: 'POST',
|
||||
data: normalizeFeedCreatePayload(payload)
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
const [feed] = normalizeFamilyFeedRows([result], normalizedGenealogyId)
|
||||
return feed
|
||||
}
|
||||
const error = new Error('动态发布接口尚未接入,当前内容不会保存')
|
||||
error.code = 'WRITE_UNAVAILABLE'
|
||||
@@ -3313,6 +3438,19 @@ export const appApi = {
|
||||
if (!hasRemoteConfig()) return listNotificationFixtures()
|
||||
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 = {}) {
|
||||
requireRemoteResource('未读通知数量', '读取')
|
||||
const value = await requestStrict({
|
||||
|
||||
+2534
-2113
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user