待测试
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# APP 136 接口功能归属与重复关系
|
||||
|
||||
> 历史快照(2026-07-26,136 条 operation),不再作为当前实施依据。当前根目录导出与桌面 Apifox 均为 149 条 operation,请使用 [APP-149接口页面归属与表单字段审计-2026-07-27.md](APP-149接口页面归属与表单字段审计-2026-07-27.md)。
|
||||
|
||||
更新时间:2026-07-26
|
||||
|
||||
## 结论
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# APP 149 接口页面归属与表单字段审计
|
||||
|
||||
更新时间:2026-07-27
|
||||
|
||||
## 一、唯一依据与结论
|
||||
|
||||
- 唯一接口源:根目录 `家谱.openapi.json`;桌面 Apifox `APP` 概览同步显示 **149** 条 operation、88 个数据模型。
|
||||
- 这 149 条不是 149 个页面。页面、认证/上传底层流程、管理操作、兼容旧接口共同构成目录,不能因为没有对应按钮就假称“未对接”,也不能把内部 ID、验证码或支付参数渲染成普通表单。
|
||||
- 当前核对结果:已存在页面和 API owner 的资源继续按现有页面操作验证;本次已修复 M02、M07、G08、R07 四个确定的字段控件/适配错误。视频发布已接入 F10;视频和官网内容的读取响应仍没有可安全消费 DTO,因此不能伪造列表或详情卡片。
|
||||
- 所有真实写入验证只允许在页面中选择、输入、提交并做列表/详情回读;不使用后台请求构造数据。短信、改密、换绑、退出、删除、审核、支付、注销不做测试写操作。
|
||||
|
||||
## 二、149 条按 Apifox 模块归属
|
||||
|
||||
| 模块 | 条数 | 页面或唯一 owner | 当前判定 |
|
||||
| --- | ---: | --- | --- |
|
||||
| 验证中心 | 7 | A01/A04/A05,`utils/auth-verification.js` | APP 行为验证为认证流程内部参数;旧 `/captcha/*` 与 `/auth/code` 是兼容链路,无新页面入口。 |
|
||||
| 认证登录 | 12 | A01/A04/A05、M01、M02、M04/M05/M10 | 登录、注册、资料读取/更新有页面;敏感账号写操作保留但不测。 |
|
||||
| 文件上传 | 6 | `utils/resumable-image-upload.js`、各图片选择页 | 上传初始化、分片、完成、引用绑定/释放是技术流程,`ossId` 由回执拥有,不是手填字段。 |
|
||||
| 行政区划 | 8 | G03/G11,`appApi.getRegion*` | `/genealogy/region/*` 是 APP/PC 共享 4 条;`/genealogy/app/region/*` 为同能力 APP 副本,当前统一用共享 owner,不能双接线。 |
|
||||
| 家谱 | 13 | G01、G03、G05–G11 | 列表、详情、公开搜索、申请、审核、配额、options 分属不同交互,不能互相替代。 |
|
||||
| 家谱成员 | 6 | API owner 已有;暂无独立账号成员管理页 | `memberId` 不等于世系 `personId`;成员管理/转让/移除不映射到 T 系页面。 |
|
||||
| 字辈谱 | 6 | G12 | 列表、管理、单条维护、批量预览和批量保存均保留。 |
|
||||
| 世系人物 | 12 | T01、T03–T08、R01/R02 | 首位成员创建仍须以页面实测为准;未得到真实 `personId` 前不伪造成员或关系。 |
|
||||
| 家族圈 | 14 | F01–F03 | 动态、评论、回复、点赞、分页是不同资源动作。删除不测试。 |
|
||||
| 内容文章 | 9 | F04–F06、M06、M08 | 谱文已有创建/读取;分类列表条目 DTO 未声明,分类 ID 不能手输或猜字段。 |
|
||||
| 相册 | 7 | F07–F09 | 相册和照片是两级资源;上传回执后写入,删除不测试。 |
|
||||
| 祭祀 | 8 | R05–R07 | 活动及献礼分别由列表、详情、编辑页拥有。 |
|
||||
| 族务记录 | 20 | R03/R04、R08、R10、R11 | 亲友、成长、备忘、功德是四类独立资源;人生事件没有单独写资源时不伪造。 |
|
||||
| 消息通知 | 5 | N01、G01 未读数 | 读取有 owner;标读为状态写,不在本轮测试。 |
|
||||
| 意见反馈 | 2 | M07 | 已修正为接口枚举 value 提交。 |
|
||||
| VIP | 3 | M09 | 套餐/订单读取可用;下单属于支付链路,不测试。 |
|
||||
| 视频 | 5 | F10 | 已接入上传、创建及 API owner 的 CRUD 路径;列表/详情响应未声明 DTO,页面不假造卡片字段。 |
|
||||
| 贺礼邀约 | 4 | R06 及未来“我的邀请”页 | 受邀人数组必须来自业务用户候选;不能用成员/人物 ID 冒充。 |
|
||||
| 官网内容 | 2 | M10 的协议/说明未来可用 | 见“阻塞”B05:响应体未声明,不能将当前静态说明误报为已接线。 |
|
||||
|
||||
## 三、表单字段的统一处理规则
|
||||
|
||||
| 字段类别 | 页面处理 | 传输规则 |
|
||||
| --- | --- | --- |
|
||||
| `required` 文本/多行文本 | 显示必填标识并做空值校验 | 只传去空白后的真实输入。 |
|
||||
| 有 `enum` 的 string | 中文标签的单选/下拉 | 只提交 enum value,绝不提交中文标签。 |
|
||||
| `date` 或写例 `yyyy-MM-dd` | 日期选择器 | 提交 `yyyy-MM-dd`。 |
|
||||
| 日期时间字符串 | 日期 + 时间选择器 | 只有用户已选择日期时组装 `yyyy-MM-dd HH:mm:ss`。 |
|
||||
| 候选 ID | 由 options/list 返回项选择 | 不显示内部 ID 自由输入;没有候选 DTO/接口则隐藏该字段并记录阻塞。 |
|
||||
| `*OssId`、`mediaOssIds` | 统一上传控件 | 只使用上传回执;不允许键盘输入。 |
|
||||
| `status`、`completed` 等管理/字典字段 | 创建表单默认省略,除非当前页面有明确管理权限且文档给出枚举 | 不把“停用”“完成”猜成前台默认开关。 |
|
||||
| token、`clientid`、`tenantId`、`grantType`、验证码票据、分片 hash | 永不显示 | 由 session、运行时配置、验证或上传 owner 自动生成。 |
|
||||
|
||||
## 四、已有页面的重点 body 字段矩阵
|
||||
|
||||
| 页面/接口 | 必填字段 | 可填写字段 | 选择/自动/隐藏字段 | 当前实现结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| M02 `PUT /auth/profile` | 无 | `nickName`、`realName`、`email` | `sex`:男 `0`/女 `1`/未知 `2`;`birthday`:日期;`avatar`:上传回执 | 已修正性别选择器,适配器拒绝非 `0/1/2`。 |
|
||||
| M07 `POST /feedback` | `feedbackContent` | `contactInfo` | `feedbackType`:建议 `advice`/功能问题 `bug`/投诉 `complaint`/其他 `other` | 已修正为中文显示、枚举 value 提交。 |
|
||||
| G03/G11 家谱创建/修改 | 创建:`genealogyName`、`surname`、`regionCode` | 堂号、籍贯、地址、简介 | 地区:三级行政区划选择;封面:上传;可见性/加入方式:枚举选择 | 保留页面已有 owner。 |
|
||||
| G08 `POST .../join-applies` | 无 | 申请人姓名、手机号、关系说明、申请理由 | `inviterUserId`:只有业务用户候选时才可选;当前隐藏 | 已删除“邀请人编号”手输框;适配器保留字段以供未来真实候选使用。 |
|
||||
| T04/T05 与亲属写入 `LineagePersonBody` | `name`、`bindingMode` | 姓名、别名、编号、字辈、地点、生平、备注、排序 | 性别 `0/1/2`、农历 `0/1`、人物状态 `0/1/2`、父母为人物 options、头像上传;`SELF`/`NONE` 不传 `appUserId`,`SPECIFIED` 必须由可信业务用户候选选择 | 页面已有选择器与白名单;首位成员后端业务 `code:500` 曾阻塞,必须用页面重试和回读确认。 |
|
||||
| F02 动态 | `feedContent` | 内容、排序 | 类型固定自动 `text`;多媒体由上传回执;状态不在普通创建页展示 | 页面显示“文字动态”。 |
|
||||
| F06 谱文 | `articleTitle`、`articleContent` | 摘要、作者、正文、排序 | 封面上传;`categoryId` 必须分类候选选择 | 分类列表没有条目 DTO,不能猜 `id/label`,暂不展示分类选择。 |
|
||||
| F07/F09 相册与照片 | 相册名;照片 `ossId` | 描述、标题、摄影者、拍摄时间、排序 | OSS 均来自上传;状态默认省略 | 已有创建后回读路径。 |
|
||||
| R04 亲友往来 | `relativeName` | 关系、事件、时间、金额、正文、排序 | 多媒体上传;状态默认省略 | 可选字段不被前端强制必填。 |
|
||||
| R07 礼仪活动 | `ceremonyType`、`ceremonyTitle` | 说明、日期时间、地点、详细地址、排序 | 封面上传;经纬度仅地图组件成对回填,当前隐藏;状态默认省略 | 适配器已接收 `locationAddress`,若未来传坐标会校验经纬度成对。 |
|
||||
| R08 成长记录 | `recordTitle` | 类型、正文、日期、提醒、排序 | `lineagePersonId` 只可选真实人物;媒体上传;状态默认省略 | 无真实人物前保留为空。 |
|
||||
| R10 备忘 | `memoTitle` | 正文、提醒、排序 | `completed` 无 enum,默认省略;媒体上传 | 不伪造完成开关。 |
|
||||
| R11 功德 | `donorName`、`meritTitle` | 正文、金额、时间、排序 | 类型:`donation`/`repair`/`public`/`other`;状态默认省略 | 现有选择器使用接口 value。 |
|
||||
| F10 视频 | `videoTitle`、`videoOssId` | 视频标题、说明 | 视频文件:上传回执自动填充;封面、时长、排序、状态:创建页隐藏 | 已接入视频选择、分片上传和创建;不允许手填 OSS ID。 |
|
||||
|
||||
## 五、不能直接补成页面的阻塞项
|
||||
|
||||
| 编号 | 接口/字段 | 已知请求或返回事实 | 前端处理与需要后端补充 |
|
||||
| --- | --- | --- | --- |
|
||||
| B01 | 首位成员 `POST .../lineage/persons` | 页面最小合法 body 曾返回 HTTP 200、envelope `code:500`、`发生未知异常,请联系管理员`;没有成功返回 `personId` | 后端修复后,必须从 T04 页面提交并回读 T01/T03,不能后台造成员。 |
|
||||
| B02 | `LineagePersonBody.bindingMode=SPECIFIED` | `appUserId` 条件必填,但现有 APP 没有“可信业务用户候选”读取合同 | 保留 NONE/SELF;补业务用户 options DTO 后才开放指定绑定。 |
|
||||
| B03 | 谱文分类 `GET .../article-categories` | operation 有列表路径,但当前导出没有可消费的条目字段模型 | 后端给出分类项的 `id`、显示名及响应 schema;前端再做选择器。 |
|
||||
| B04 | 视频 GET/GET detail | `VideoBody` 请求要求 `videoTitle`、`videoOssId`;F10 已可选择视频、上传并创建,当前 200 读取响应仍未声明字段 | 后端补 `VideoView`/列表 rows 的完整响应 DTO,并确认播放地址与封面读取合同;前端再展示视频列表、详情和播放。 |
|
||||
| B05 | 官网内容 GET `/site/articles`、`/site/pages/{pageKey}` | 当前 200 响应 schema 未声明 | 后端补文章/页面 key、标题、正文、更新时间等 DTO;M10 才能替换静态说明。 |
|
||||
| B06 | 礼仪坐标 | `longitude`、`latitude` 可选且必须成对;没有地图选点或坐标来源 operation | 提供地图/地理编码集成合同,或明确允许何种受控坐标来源;不让用户手输。 |
|
||||
| B07 | 贺礼邀约 `inviteeUserIds` | 必填业务用户 ID 数组;成员和世系人物 options 不能证明等同业务用户 | 发布受邀业务用户候选接口与 DTO。 |
|
||||
| B08 | `completed`、`payType`、审核 status | 当前 schema 未给可安全映射的全部 value-label 语义 | 后端补 enum 或字典 options;前端保持隐藏/不测。 |
|
||||
|
||||
## 六、验证记录
|
||||
|
||||
- 本轮代码静态/适配器验证通过:`lineage-openapi-contract`、`feedback-openapi-contract`、`profile-pages-contract`、`m07-feedback-submit-contract`、`g08-g10-application-flow-contract`、`r-business-flow-contract`、`form-enum-api-runtime-smoke`、`oss-id-payload-api-runtime-smoke`、`compile-audit`。
|
||||
- 这些验证不替代真实写入。下一阶段在浏览器完成接口详情核对后,仅在测试账号的页面内输入、选择、提交和回读;敏感操作继续跳过。
|
||||
@@ -0,0 +1,118 @@
|
||||
# APP-150 项目接口覆盖与功能缺口总清单
|
||||
|
||||
更新时间:2026-07-28
|
||||
唯一接口依据:仓库根目录 `家谱.openapi.json`(149 个 operation)。
|
||||
|
||||
## 1. 阅读方式与结论
|
||||
|
||||
“没有接口”“接口已接但没功能”“接口不应有单独页面”是三件不同的事。本清单将其分开记录:
|
||||
|
||||
- **后端合同缺口**:OpenAPI 没有足够的请求/响应字段,前端不能猜测实现。
|
||||
- **前端功能缺口**:`utils/api.js` 已有请求 owner,但没有页面入口或用户动作调用它。
|
||||
- **技术/敏感操作**:由登录、上传、会话、确认弹窗等 owner 自动调用;不做独立表单。
|
||||
|
||||
当前结论:149 个 operation 均已完成归属;视频发布已接通。项目仍有 9 项后端合同缺口、14 组 API 已有但页面功能未接通的缺口,详见第 3、4 节。
|
||||
|
||||
## 2. 全量模块归属(149 个 operation)
|
||||
|
||||
| 模块 | 数量 | 当前 owner | 状态 | 说明 |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| 验证中心 | 7 | A01/A04/A05、`auth-verification.js` | 已归属 | APP 验证是认证内部流程;`/captcha/*`、`/auth/code` 为兼容接口。 |
|
||||
| 认证登录 | 12 | A01/A04/A05、M01/M02/M04/M05/M10 | 部分功能缺口 | 登录、注册、资料、改密、退出有 owner;换绑/注销见第 4 节。 |
|
||||
| 文件上传 | 6 | `resumable-image-upload.js` | 已归属 | 初始化、分片、完成、引用释放均是上传 owner;OSS ID 不可手填。 |
|
||||
| 行政区划 | 8 | G03/G11、`appApi.getRegion*` | 已归属 | APP 与共享版本各 4 条,当前只消费一套,不能双接。 |
|
||||
| 家谱 | 13 | G01/G03/G05–G12 | 部分功能缺口 | 创建、读取、设置、申请流程有 owner;申请审核/撤销需补实际动作。 |
|
||||
| 家谱成员 | 6 | `appApi` | 无页面 owner | `memberId` 是账号成员,不是世系 `personId`;不能放进 T 系页面。 |
|
||||
| 字辈谱 | 6 | G12 | 部分功能缺口 | 读取、创建、批量预览/保存有 owner;单条修改/停用需补页面动作。 |
|
||||
| 世系人物 | 12 | T01、T03–T08、R01/R02 | 后端阻塞 | 页面/API 已有;首位人物创建、指定绑定候选仍受合同阻塞。 |
|
||||
| 家族圈 | 14 | F01–F03 | 部分功能缺口 | 发布、读取、点赞、评论有 owner;编辑/删除与回复发布尚未形成页面流程。 |
|
||||
| 内容文章 | 9 | F04–F06、M06/M08 | 部分功能缺口 | 读取/新增有 owner;分类 DTO、编辑/删除动作待补。 |
|
||||
| 相册 | 7 | F07–F09 | 部分功能缺口 | 创建、读取、上传照片有 owner;相册编辑/删除、照片删除待补。 |
|
||||
| 祭祀 | 8 | R05–R07 | 部分功能缺口 | 创建、读取、献礼新增有 owner;修改/删除、地图与邀请待补。 |
|
||||
| 族务记录 | 20 | R03/R04、R08、R10/R11 | 部分功能缺口 | 新增/读取有 owner;多项编辑、删除、媒体入口待补。 |
|
||||
| 消息通知 | 5 | N01/N02、G01 | 部分功能缺口 | 列表、详情、未读数有 owner;标记已读动作待补。 |
|
||||
| 意见反馈 | 2 | M07 | 部分功能缺口 | 提交有 owner;“我的反馈”列表无页面。 |
|
||||
| VIP | 3 | M09 | 部分功能缺口 | 套餐/订单读取有 owner;创建订单未形成受控支付流程。 |
|
||||
| 视频 | 5 | F10、`appApi` | 后端合同缺口 | 上传、创建和 CRUD API owner 已接;列表/详情 DTO 缺失。 |
|
||||
| 贺礼邀约 | 4 | R06、`appApi` | 后端合同缺口 | API owner 有;缺业务用户候选与“我的邀请”页面。 |
|
||||
| 官网内容 | 2 | M10 | 后端合同缺口 | 响应无 DTO,不能替换当前静态说明。 |
|
||||
|
||||
## 3. 后端 / OpenAPI 必须补充的合同(不能由前端猜)
|
||||
|
||||
| ID | 相关接口/字段 | 现状 | 缺少的合同 | 前端当前处理 | 优先级 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| C01 | `GET /genealogies/{genealogyId}/videos`、`GET .../videos/{videoId}` | 只有通用响应,无可消费视频字段 | `VideoView`:`videoId`、标题、说明、视频播放 URL、封面 URL、时长、创建时间、状态 | F10 可上传并创建,不渲染列表/播放器 | P0 |
|
||||
| C02 | `POST /lineage/persons` 首位成员 | 页面最小合法请求曾收到业务 `code:500` | 后端修复创建逻辑,并返回可回读的 `personId` | T04 不伪造首位人物 | P0 |
|
||||
| C03 | `LineagePersonBody.bindingMode=SPECIFIED` | `appUserId` 条件必填 | 可信业务用户候选接口及 DTO,明确用户 ID 与人物关系 | 仅开放 `NONE` / `SELF` | P1 |
|
||||
| C04 | `GET .../article-categories` | 列表 response 未定义条目字段 | 分类 `id`、显示名、状态、排序 DTO | F06 隐藏分类选择,不能手输 `categoryId` | P1 |
|
||||
| C05 | `GET /site/articles`、`GET /site/pages/{pageKey}` | response 未定义 | 文章/页面 key、标题、正文、更新时间、链接 DTO | M10 保留静态内容 | P1 |
|
||||
| C06 | 礼仪 `longitude`、`latitude` | 两字段须成对,但无位置来源 | 地图选点或地理编码合同;坐标精度/坐标系说明 | R07 隐藏坐标手输 | P2 |
|
||||
| C07 | 活动 `inviteeUserIds` | 需要业务用户 ID 数组 | 可邀请业务用户候选接口、显示名、可邀请条件 DTO | R06 不提交猜测 ID | P1 |
|
||||
| C08 | `completed`、`payType`、部分审核/管理状态 | 字典含义或可用范围不足 | 完整 enum/value-label 与权限规则 | 创建页默认不显示管理开关 | P2 |
|
||||
| C09 | 家族圈评论回复 | 只有回复列表接口,没有声明创建回复的操作或父评论字段 | 回复创建 endpoint 与请求体(至少评论内容、父评论 ID) | F03 仅展示现有评论,不提供假回复提交 | P1 |
|
||||
|
||||
## 4. API 已有,但页面功能尚未接通
|
||||
|
||||
以下项的 `appApi` 已有对应请求 owner;静态检索未发现页面调用,或页面只展示静态/只读状态。实现前需确认权限与交互,不应直接暴露内部 ID。
|
||||
|
||||
| ID | 操作 | 当前缺少的页面功能 | 建议页面/入口 | 前置条件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F01 | `PUT/DELETE .../feeds/{feedId}` | 编辑、删除本人动态 | F03 动态详情 | 必须仅显示本人有权操作的按钮。 |
|
||||
| F02 | `DELETE .../comments/{commentId}` | 删除本人评论/删除占位展示 | F03 评论区 | 有回复时按后端返回的删除占位渲染。 |
|
||||
| F03 | `PUT/DELETE .../articles/{articleId}` | 谱文编辑、删除 | F05/F06 | 分类 DTO 到位后,编辑页才开放分类选择。 |
|
||||
| F04 | `PUT/DELETE .../albums/{albumId}` | 相册编辑、删除 | F07/F08 | 删除需二次确认并遵循后端权限。 |
|
||||
| F05 | `DELETE .../albums/{albumId}/photos/{photoId}` | 删除照片 | F08 | 仅作者/管理者显示。 |
|
||||
| F06 | `PUT/DELETE .../ceremonies/{ceremonyId}` | 礼仪编辑、删除 | R06/R07 | 地图字段仍按 C06 处理。 |
|
||||
| F07 | `DELETE .../ceremonies/{ceremonyId}/gifts/{giftId}` | 删除献礼 | R06 | 需明确献礼人/管理员权限。 |
|
||||
| F08 | 成长、备忘、亲友、功德的 `PUT/DELETE` | 记录编辑、删除 | R08/R10/R04/R11 | 目前新建/读取与编辑动作未闭环。 |
|
||||
| F09 | `PUT .../generation-poems/{poemId}` | 修改、停用、恢复单条字辈 | G12 | 需按 `status` 枚举和管理权限显示。 |
|
||||
| F10 | 家谱成员 6 条 | 成员列表、成员资料、移除、退出、转让 | 新建“成员管理”页 | 严格使用 `memberId`,绝不能复用世系人物 UI。 |
|
||||
| F11 | 加入申请审核、撤销 | 审核/撤销提交动作与结果回读 | G09/G10 | 需确认当前用户角色,敏感写操作必须二次确认。 |
|
||||
| F12 | 通知标已读、全部已读 | 单条/全部已读行为 | N01/N02 | 标读应静默回写列表,不弹假成功。 |
|
||||
| F13 | `GET /feedback` | “我的反馈”列表 | M07 或新建 M11 | response DTO 足够时展示,不开放删除。 |
|
||||
| F14 | 换绑、注销、VIP 创建订单 | 安全表单/支付确认链路 | M05/M10/M09 | 短信验证、支付回调、二次确认不可省略。 |
|
||||
|
||||
## 5. 没有可新增接口的页面功能
|
||||
|
||||
| 页面 | 原因 | 需要的后端资源 |
|
||||
| --- | --- | --- |
|
||||
| R09 人生大事 | OpenAPI 149 条中没有人生事件资源的 CRUD | 人生事件列表、详情、新增、修改、删除;需定义归属人物、事件日期、事件类型、内容、媒体字段。 |
|
||||
| F10 视频列表/播放 | 已有路径但读取 DTO 不完整,等同不可安全实现 | 见 C01。 |
|
||||
| “我的活动邀请” | 读取接口有 owner,但没有页面和邀请候选数据 | 邀请页面、C07 候选 DTO;现有 `GET .../ceremony-invitations/mine` 可作为入口。 |
|
||||
| 家谱成员管理 | 后端路径完整但无页面信息架构 | 成员管理页面及权限/成员 DTO展示规则。 |
|
||||
|
||||
## 6. 不是“少页面”的接口(保持自动或受控)
|
||||
|
||||
| 接口类别 | 正确 owner | 不应暴露的字段/原因 |
|
||||
| --- | --- | --- |
|
||||
| 验证挑战、验证校验、短信发送 | `auth-verification.js`、认证页 | `validToken`、challenge、验证码票据由认证流程持有。 |
|
||||
| 文件初始化、分片、完成、引用 | 上传工具 | `uploadId`、MD5、chunkIndex、OSS ID 都是上传回执/技术参数。 |
|
||||
| `clientid`、token、tenantId | `config.js`、session | 运行时认证信息,绝不能做表单字段。 |
|
||||
| 行政区划 APP/共享重复路径 | `appApi.getRegion*` | 只选择一个 owner,避免同一功能双请求。 |
|
||||
| 删除、审核、退出、注销、支付 | 现有页面的明确二次确认流程 | 不应为“接口覆盖率”而自动触发真实写操作。 |
|
||||
|
||||
## 7. 表单字段总规则
|
||||
|
||||
| 字段类型 | 表单处理 |
|
||||
| --- | --- |
|
||||
| OpenAPI `required` 文本 | 显示必填标识,提交前 trim 校验。 |
|
||||
| `enum` 字符串 | 使用中文标签的选择器,仅提交 enum value。 |
|
||||
| 日期/日期时间 | 使用日期或日期+时间选择器;不能自由输入格式。 |
|
||||
| 候选 ID | 必须由 options/list 选择;缺候选 DTO 时隐藏,不允许手输。 |
|
||||
| `*OssId`、`mediaOssIds` | 只从上传回执获取;视频同样适用。 |
|
||||
| `status`、`completed`、排序、审核、支付字段 | 默认不出现在普通创建表单;只在明确管理权限和完整字典合同下出现。 |
|
||||
| 认证、分片、路径 ID | 自动生成/路由携带;不可显示或编辑。 |
|
||||
|
||||
## 8. 推荐实施顺序
|
||||
|
||||
1. 后端先完成 C01、C02:视频可展示播放、世系首位人物可创建,才能闭合两个主要入口。
|
||||
2. 补 C03、C04、C07:所有候选 ID 都能选择而不是手填。
|
||||
3. 实施 F11(成员管理)和 F12(申请审核/撤销),先补清晰的权限和确认流程。
|
||||
4. 实施 F01–F10 的编辑/删除闭环;每一项均先做“当前用户是否有权”的详情回读。
|
||||
5. 处理 C05、C06、C08 与 R09,完成官网内容、地图、字典和人生事件的新增合同。
|
||||
|
||||
## 9. 验证边界
|
||||
|
||||
- 本文是接口和代码静态审计,不代表所有写接口已经在生产环境执行。
|
||||
- 真实写入必须在测试账号、对应页面中操作,再通过列表/详情回读验证;不得用脚本绕过页面构造业务数据。
|
||||
- 当前视频测试文件为 `C:\Users\Rain\Desktop\9d063f4536624f6b1ccb4d2cb9e9786c.mp4`;已确认存在且为非空 MP4。真实上传需在登录态下从 F10 的系统文件选择器选择它。
|
||||
@@ -1,9 +1,11 @@
|
||||
# Apifox 写接口字段—表单控件映射
|
||||
|
||||
> 当前有效的 149 operation 总表、页面 owner 与阻塞项见 [APP-149接口页面归属与表单字段审计-2026-07-27.md](APP-149接口页面归属与表单字段审计-2026-07-27.md)。本文件保留为字段控件速查表;其中以下修订以根目录最新 `家谱.openapi.json` 为准。
|
||||
|
||||
## 口径与来源
|
||||
|
||||
- 核对时间:2026-07-27。
|
||||
- 主源:桌面 Apifox 当前 `APP` 项目中的接口详情;根目录 `家谱.openapi.json` 只用于补全接口路径和没有展示在当前屏幕中的普通字段。
|
||||
- 主源:桌面 Apifox 当前 `APP` 项目(149 条 operation)及同一时刻导出的根目录 `家谱.openapi.json`;两者的 operation 数量一致。
|
||||
- `必填` 以 Apifox `必需` 标记为准;没有 `必需` 的字段均应允许不填,提交时传空值或省略由当前 API 适配器统一处理。
|
||||
- `枚举选择` 只能提交下表列出的 value,不能把中文标签提交给后端。
|
||||
- `候选选择` 只能从对应选项接口选取,页面不得暴露裸露的内部 ID 输入框。
|
||||
@@ -129,7 +131,7 @@
|
||||
| `ceremonyDesc` | 否 | 多行文本 |
|
||||
| `ceremonyTime` | 否 | 示例为 `yyyy-MM-dd HH:mm:ss`;日期 + 时间选择 |
|
||||
| `location` / `locationAddress` | 否 | 地点名称、详细地址文本框;两项都应保留 |
|
||||
| `longitude` / `latitude` | 否 | decimal 数字框;应由地图选点回填,不能把经纬度当文本 |
|
||||
| `longitude` / `latitude` | 否 | 仅可由地图选点组件成对回填;当前没有地图候选/选点合同,R07 不暴露手输框,也不传这两个字段。API 适配层只接受成对有限数字,供后续真实地图组件使用。 |
|
||||
| `coverOssId` | 否 | 图片上传,字符串 OSS ID |
|
||||
| `sortOrder` | 否 | 整数数字框 |
|
||||
| `status` | 否 | `sys_normal_disable` 选择,默认 `0` |
|
||||
@@ -177,7 +179,7 @@
|
||||
| `GenerationPoemBody` | `generationNo` 必填正整数;`generationText` 必填文本;`description` 多行文本;`sortOrder` 数字;`status` 用 `sys_normal_disable`。 |
|
||||
| `GenerationPoemBatchBody` | `poemText` 必填多行文本;`disableMissing` 布尔开关。 |
|
||||
| `FamilyFeedCommentBody` | `parentCommentId` 为评论候选/回复上下文,不能输入 ID;`commentContent` 必填多行文本。 |
|
||||
| `FeedbackBody` | `feedbackType` 当前详情为 string,待字典明确前为文本;`feedbackContent` 必填多行文本;`contactInfo` 文本。 |
|
||||
| `FeedbackBody` | `feedbackType` 选填枚举选择:`advice` 建议、`bug` 功能问题、`complaint` 投诉反馈、`other` 其他;界面显示中文标签,提交 value。`feedbackContent` 必填多行文本;`contactInfo` 文本。 |
|
||||
| `VipOrderBody` | `packageId` 必填,VIP 套餐候选选择;`genealogyId` 为当前家谱上下文选择;`payType` 只能使用支付方式字典,不能手填代码。 |
|
||||
| `VideoBody` | `videoTitle` 必填文本,`videoDesc` 多行文本,`coverOssId`/`videoOssId` 为上传,`durationSeconds`/`sortOrder` 为数字,`status` 为 `sys_normal_disable`。 |
|
||||
| `CeremonyInviteesBody` | `inviteeUserIds` 必填多选业务用户候选;无候选接口不能用逗号文本代替数组。 |
|
||||
@@ -192,4 +194,4 @@
|
||||
1. 已经可以立即改为选择控件的字段:`sex`、出生/逝世农历、`personStatus`、`visibility`、`joinMode`、所有 `status`、`meritType`、邀请响应。
|
||||
2. 必须改为候选选择的字段:所有家谱/成员/世系人物/分类/套餐/受邀用户的 ID 字段。
|
||||
3. 必须改为上传的字段:所有 `*OssId`、`mediaOssIds`、头像、封面、照片、视频文件。
|
||||
4. `completed`、`feedbackType`、`payType`、审核 `status` 等尚未在当前 Apifox 字段详情给出允许值;在后端未提供字典或候选接口前,不增加猜测性选择项。
|
||||
4. `completed`、`payType`、审核 `status` 等尚未在当前 Apifox 字段详情给出允许值;在后端未提供字典或候选接口前,不增加猜测性选择项。`feedbackType` 已有四个枚举值,已改为选择控件。
|
||||
|
||||
+133
-56
@@ -1,73 +1,150 @@
|
||||
<!-- 页面编号:F-10;用途:家族视频。当前缺读取、发布和播放业务 owner。 -->
|
||||
<template>
|
||||
<view class="video-page"
|
||||
><ModulePageBackground module="family" /><view class="page-header"
|
||||
><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>家族视频暂未开放</text
|
||||
><text>视频功能正在准备中。开放后,家人的影像会在这里展示。</text
|
||||
><AppButton
|
||||
block
|
||||
:label="hasValidContext ? '返回家族动态' : '返回上一页'"
|
||||
@click="returnToFamily" /></view></view
|
||||
></view>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家族视频" custom-back @back="returnToFamily" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">发布家族视频</text>
|
||||
<text class="video-panel__note">视频文件由上传回执自动关联,发布时不需要填写文件 ID。</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频文件</text>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="selectVideo">
|
||||
{{ uploading ? "上传中…" : receipt ? "重新选择视频" : "选择视频" }}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt">已上传:{{ receipt.fileName || "视频" }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频标题</text>
|
||||
<input v-model="form.videoTitle" maxlength="100" placeholder="例如:2026 年清明祭祖活动" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea v-model="form.videoDesc" maxlength="500" auto-height placeholder="补充视频中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在发布…' : '发布视频'" @click="submitVideo" />
|
||||
</view>
|
||||
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isVideoPickCancelled, pickAndUploadVideo } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const receipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const stateCopy = computed(() => pageState.value === "success"
|
||||
? {
|
||||
title: "视频已提交服务端",
|
||||
copy: "视频已按上传回执关联到当前家谱。视频列表接口尚未提供可消费的返回字段,因此此处不猜测播放地址或卡片内容。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "视频入口无效",
|
||||
copy: "没有取得有效的家谱标识。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
});
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onUnmounted(() => controller.abort());
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadVideo({ requestController: controller });
|
||||
} catch (error) {
|
||||
if (!isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "视频上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createVideo(genealogyId.value, {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "视频发布失败,请稍后重试";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () => hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.video-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;
|
||||
}
|
||||
.video-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; }
|
||||
.video-panel, .video-state-card { box-sizing: border-box; @include adaptive.adaptive-family-content; }
|
||||
.video-panel { padding: 30rpx; }
|
||||
.video-panel__title, .video-panel__note, .video-field__label, .video-state-card text { display: block; }
|
||||
.video-panel__title, .video-state-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; }
|
||||
.video-panel__note, .video-state-card__copy { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
|
||||
.video-field { margin-top: 24rpx; }
|
||||
.video-field__label { margin-bottom: 10rpx; color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.video-field input, .video-field textarea { width: 100%; box-sizing: border-box; border: 1rpx solid rgba(143, 108, 63, .34); border-radius: 8rpx; background: rgba(255, 253, 247, .8); color: $ink; font-size: 24rpx; }
|
||||
.video-field input { height: 76rpx; padding: 0 18rpx; }
|
||||
.video-field textarea { min-height: 140rpx; padding: 16rpx 18rpx; }
|
||||
.video-field--upload { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
|
||||
.video-field--upload .video-field__label { width: 100%; }
|
||||
.upload-button { margin: 0; padding: 0 26rpx; border: 1rpx solid #b78a42; border-radius: 8rpx; background: #fffaf0; color: #805723; font-size: 23rpx; line-height: 64rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; }
|
||||
.required-mark, .field-error { color: $brand-red; }
|
||||
.field-error { display: block; margin-top: 12rpx; font-size: 22rpx; }
|
||||
.video-panel .app-button { margin-top: 28rpx; }
|
||||
.video-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.video-state-card .app-button { margin-top: 28rpx; }
|
||||
</style>
|
||||
|
||||
@@ -91,7 +91,6 @@ const form = reactive({
|
||||
phone: "",
|
||||
relationDesc: "",
|
||||
applyReason: "",
|
||||
inviterUserId: "",
|
||||
});
|
||||
const fields = [
|
||||
{
|
||||
@@ -120,13 +119,6 @@ const fields = [
|
||||
maxlength: 500,
|
||||
placeholder: "说明申请加入的原因",
|
||||
},
|
||||
{
|
||||
key: "inviterUserId",
|
||||
label: "邀请人编号",
|
||||
inputType: "number",
|
||||
maxlength: 20,
|
||||
placeholder: "如有邀请人可填写编号",
|
||||
},
|
||||
];
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
|
||||
@@ -48,13 +48,21 @@
|
||||
placeholder-class="placeholder"
|
||||
:disabled="saving || uploading"
|
||||
/></view>
|
||||
<view class="form-row form-row--readonly"
|
||||
><text>性别</text
|
||||
><view class="readonly-value"
|
||||
><text>{{ form.sex ? "待确认" : "未设置" }}</text
|
||||
><text>选项待提供</text></view
|
||||
></view
|
||||
>
|
||||
<view class="form-row">
|
||||
<text>性别</text>
|
||||
<picker
|
||||
:range="sexOptions"
|
||||
range-key="label"
|
||||
:value="sexIndex"
|
||||
:disabled="saving || uploading"
|
||||
@change="changeSex"
|
||||
><view
|
||||
class="picker-value"
|
||||
:class="{ 'picker-value--placeholder': !form.sex }"
|
||||
>{{ sexOptions[sexIndex].label }}</view
|
||||
></picker
|
||||
>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>生日</text>
|
||||
<picker
|
||||
@@ -142,6 +150,17 @@ const feedbackMessage = ref("");
|
||||
const requestController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let pageActive = true;
|
||||
const sexOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
{ value: "0", label: "男" },
|
||||
{ value: "1", label: "女" },
|
||||
{ value: "2", label: "未知" },
|
||||
];
|
||||
|
||||
const sexIndex = computed(() => {
|
||||
const index = sexOptions.findIndex((option) => option.value === form.sex);
|
||||
return index === -1 ? 0 : index;
|
||||
});
|
||||
|
||||
const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
@@ -190,6 +209,10 @@ const changeBirthday = (event) => {
|
||||
form.birthday = event?.detail?.value || "";
|
||||
};
|
||||
|
||||
const changeSex = (event) => {
|
||||
form.sex = sexOptions[Number(event?.detail?.value)]?.value || "";
|
||||
};
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
if (uploading.value || saving.value) return;
|
||||
uploading.value = true;
|
||||
@@ -359,22 +382,6 @@ onUnload(() => {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.readonly-value {
|
||||
display: flex;
|
||||
min-height: 68rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12rpx;
|
||||
color: $ink-muted;
|
||||
}
|
||||
.readonly-value text:first-child {
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.readonly-value text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: 19rpx;
|
||||
}
|
||||
.picker-value--placeholder,
|
||||
.placeholder {
|
||||
color: #ab9a86;
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
<view class="type-grid">
|
||||
<button
|
||||
v-for="type in feedbackTypes"
|
||||
:key="type"
|
||||
:key="type.value"
|
||||
class="type-chip"
|
||||
:class="{ active: feedbackForm.feedbackType === type }"
|
||||
:aria-pressed="feedbackForm.feedbackType === type"
|
||||
:class="{ active: feedbackForm.feedbackType === type.value }"
|
||||
:aria-pressed="feedbackForm.feedbackType === type.value"
|
||||
:disabled="feedbackState === 'submitting'"
|
||||
@click="selectFeedbackType(type)"
|
||||
@click="selectFeedbackType(type.value)"
|
||||
>
|
||||
{{ type }}
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -131,7 +131,12 @@ import {
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const feedbackTypes = ["功能问题", "使用建议", "内容纠错", "其他"];
|
||||
const feedbackTypes = [
|
||||
{ value: "bug", label: "功能问题" },
|
||||
{ value: "advice", label: "使用建议" },
|
||||
{ value: "complaint", label: "投诉反馈" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
const feedbackForm = reactive({
|
||||
feedbackType: "",
|
||||
feedbackContent: "",
|
||||
|
||||
@@ -89,26 +89,6 @@
|
||||
@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>
|
||||
@@ -200,8 +180,6 @@ const form = reactive({
|
||||
ceremonyClock: "",
|
||||
location: "",
|
||||
locationAddress: "",
|
||||
longitude: "",
|
||||
latitude: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const coverOssId = ref(null);
|
||||
@@ -289,8 +267,6 @@ const submit = async () => {
|
||||
{
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(form.longitude ? { longitude: Number(form.longitude) } : {}),
|
||||
...(form.latitude ? { latitude: Number(form.latitude) } : {}),
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
},
|
||||
{ requestController: controller },
|
||||
|
||||
@@ -301,42 +301,134 @@ const nodeRelationText = (member) => {
|
||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
||||
};
|
||||
const layoutMembers = computed(() => {
|
||||
const generations = Array.from(
|
||||
new Set(members.value.map((member) => Number(member.generation))),
|
||||
).sort((left, right) => left - right);
|
||||
const groups = new Map(
|
||||
generations.map((generation) => [
|
||||
generation,
|
||||
members.value
|
||||
.filter((member) => Number(member.generation) === generation)
|
||||
.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 memberById = new Map(
|
||||
members.value.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const maxCount = Math.max(
|
||||
1,
|
||||
...Array.from(groups.values()).map((group) => group.length),
|
||||
);
|
||||
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);
|
||||
const startX = (canvasWidth - occupiedWidth) / 2;
|
||||
return group.map((member, memberIndex) => ({
|
||||
...member,
|
||||
x: snapToGrid(startX + memberIndex * MEMBER_GAP),
|
||||
y: snapToGrid(200 + generationIndex * GENERATION_GAP),
|
||||
}));
|
||||
const compareMembers = (left, right) =>
|
||||
Number(left.generation) - Number(right.generation) ||
|
||||
String(left.id).localeCompare(String(right.id));
|
||||
const spousesByPersonId = new Map();
|
||||
members.value.forEach((member) => {
|
||||
const partnerId = String(member.spouseOf || "");
|
||||
if (partnerId && memberById.has(partnerId)) {
|
||||
spousesByPersonId.set(partnerId, member);
|
||||
}
|
||||
});
|
||||
const primaryMembers = members.value
|
||||
.filter((member) => !member.spouseOf)
|
||||
.sort(compareMembers);
|
||||
const primaryById = new Map(
|
||||
primaryMembers.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const childrenByParent = new Map();
|
||||
primaryMembers.forEach((member) => {
|
||||
const parentId = String(member.parentId || "");
|
||||
if (!parentId || !primaryById.has(parentId)) return;
|
||||
const children = childrenByParent.get(parentId) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(parentId, children);
|
||||
});
|
||||
childrenByParent.forEach((children) => children.sort(compareMembers));
|
||||
|
||||
// A family owns one contiguous horizontal span. Parents are centered in
|
||||
// that span, so a later generation cannot pull a branch beneath another one.
|
||||
const subtreeWidthById = new Map();
|
||||
const measureSubtree = (member, ancestors = new Set()) => {
|
||||
const id = String(member.id);
|
||||
if (subtreeWidthById.has(id)) return subtreeWidthById.get(id);
|
||||
if (ancestors.has(id)) return 1;
|
||||
const nextAncestors = new Set(ancestors);
|
||||
nextAncestors.add(id);
|
||||
const children = childrenByParent.get(id) || [];
|
||||
const descendantsWidth = children.reduce(
|
||||
(sum, child) => sum + measureSubtree(child, nextAncestors),
|
||||
0,
|
||||
);
|
||||
const ownWidth = spousesByPersonId.has(id) ? 2 : 1;
|
||||
const width = Math.max(ownWidth, descendantsWidth || 1);
|
||||
subtreeWidthById.set(id, width);
|
||||
return width;
|
||||
};
|
||||
const roots = primaryMembers.filter(
|
||||
(member) => !primaryById.has(String(member.parentId || "")),
|
||||
);
|
||||
const rootIds = new Set(roots.map((member) => String(member.id)));
|
||||
primaryMembers.forEach((member) => {
|
||||
if (!rootIds.has(String(member.id))) return;
|
||||
measureSubtree(member);
|
||||
});
|
||||
|
||||
const totalWidth = roots.reduce(
|
||||
(sum, root) => sum + (subtreeWidthById.get(String(root.id)) || 1),
|
||||
0,
|
||||
);
|
||||
const generations = Array.from(
|
||||
new Set(primaryMembers.map((member) => Number(member.generation))),
|
||||
).sort((left, right) => left - right);
|
||||
const generationIndexByNumber = new Map(
|
||||
generations.map((generation, index) => [generation, index]),
|
||||
);
|
||||
const positionedMembers = [];
|
||||
const positionedIds = new Set();
|
||||
const placeSubtree = (member, start, width, ancestors = new Set()) => {
|
||||
const id = String(member.id);
|
||||
if (ancestors.has(id)) return;
|
||||
const nextAncestors = new Set(ancestors);
|
||||
nextAncestors.add(id);
|
||||
const spouse = spousesByPersonId.get(id);
|
||||
const center = start + width / 2;
|
||||
const memberUnitX = spouse ? center - 0.5 : center;
|
||||
const y = snapToGrid(
|
||||
200 + (generationIndexByNumber.get(Number(member.generation)) || 0) * GENERATION_GAP,
|
||||
);
|
||||
positionedMembers.push({
|
||||
...member,
|
||||
x: snapToGrid(80 + memberUnitX * MEMBER_GAP),
|
||||
y,
|
||||
});
|
||||
positionedIds.add(id);
|
||||
if (spouse) {
|
||||
positionedMembers.push({
|
||||
...spouse,
|
||||
x: snapToGrid(80 + (memberUnitX + 1) * MEMBER_GAP),
|
||||
y,
|
||||
});
|
||||
positionedIds.add(String(spouse.id));
|
||||
}
|
||||
const children = childrenByParent.get(id) || [];
|
||||
const childrenWidth = children.reduce(
|
||||
(sum, child) => sum + subtreeWidthById.get(String(child.id)),
|
||||
0,
|
||||
);
|
||||
let childStart = start + (width - childrenWidth) / 2;
|
||||
children.forEach((child) => {
|
||||
const childWidth = subtreeWidthById.get(String(child.id));
|
||||
placeSubtree(child, childStart, childWidth, nextAncestors);
|
||||
childStart += childWidth;
|
||||
});
|
||||
};
|
||||
let rootStart = 0;
|
||||
roots.forEach((root) => {
|
||||
const width = subtreeWidthById.get(String(root.id)) || 1;
|
||||
placeSubtree(root, rootStart, width);
|
||||
rootStart += width;
|
||||
});
|
||||
return positionedMembers.length === members.value.length
|
||||
? positionedMembers
|
||||
: members.value
|
||||
.filter((member) => !positionedIds.has(String(member.id)))
|
||||
.reduce((result, member, index) => {
|
||||
result.push({
|
||||
...member,
|
||||
x: snapToGrid(80 + (totalWidth + index + 0.5) * MEMBER_GAP),
|
||||
y: snapToGrid(
|
||||
200 +
|
||||
(generationIndexByNumber.get(Number(member.generation)) || 0) *
|
||||
GENERATION_GAP,
|
||||
),
|
||||
});
|
||||
return result;
|
||||
}, positionedMembers);
|
||||
});
|
||||
const treeMetrics = computed(() => {
|
||||
const memberList = layoutMembers.value;
|
||||
@@ -395,21 +487,23 @@ const generationBandStyle = (row) => ({
|
||||
});
|
||||
const lineageConnectors = computed(() => {
|
||||
const memberById = new Map(
|
||||
layoutMembers.value.map((member) => [member.id, member]),
|
||||
layoutMembers.value.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const spouseByPersonId = new Map();
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (member.spouseOf && memberById.has(member.spouseOf)) {
|
||||
spouseByPersonId.set(member.spouseOf, member);
|
||||
const partnerId = String(member.spouseOf || "");
|
||||
if (partnerId && memberById.has(partnerId)) {
|
||||
spouseByPersonId.set(partnerId, member);
|
||||
}
|
||||
});
|
||||
const childrenByParent = new Map();
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (member.spouseOf || !member.parentId || !memberById.has(member.parentId))
|
||||
const parentId = String(member.parentId || "");
|
||||
if (member.spouseOf || !parentId || !memberById.has(parentId))
|
||||
return;
|
||||
const children = childrenByParent.get(member.parentId) || [];
|
||||
const children = childrenByParent.get(parentId) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(member.parentId, children);
|
||||
childrenByParent.set(parentId, children);
|
||||
});
|
||||
|
||||
const connectors = [];
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$documents = @(Get-ChildItem -LiteralPath $root -File -Filter '*.openapi.json')
|
||||
if ($documents.Count -ne 1) { throw 'Exactly one current OpenAPI JSON export is required at the repository root' }
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $documents[0].FullName | ConvertFrom-Json
|
||||
|
||||
$operations = @(
|
||||
foreach ($pathProperty in $document.paths.PSObject.Properties) {
|
||||
foreach ($methodProperty in $pathProperty.Value.PSObject.Properties) {
|
||||
if ($methodProperty.Name -in @('get', 'post', 'put', 'delete', 'patch')) {
|
||||
[PSCustomObject]@{ Path = $pathProperty.Name; Method = $methodProperty.Name }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if ($operations.Count -ne 149) { throw "Current OpenAPI operation count drifted: $($operations.Count)" }
|
||||
|
||||
function Get-Operation {
|
||||
param([string]$Path, [string]$Method)
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if (-not $pathProperty) { throw "Missing path: $Path" }
|
||||
$operation = $pathProperty.Value.PSObject.Properties[$Method]
|
||||
if (-not $operation) { throw "Missing operation: $Method $Path" }
|
||||
return $operation.Value
|
||||
}
|
||||
|
||||
$videoList = Get-Operation -Path '/genealogy/app/genealogies/{genealogyId}/videos' -Method 'post'
|
||||
if ($videoList.requestBody.content.'application/json'.schema.'$ref' -ne '#/components/schemas/VideoBody') {
|
||||
throw 'Video create must consume VideoBody'
|
||||
}
|
||||
$videoBody = $document.components.schemas.VideoBody
|
||||
if ((@($videoBody.required) -join ',') -ne 'videoTitle,videoOssId') {
|
||||
throw 'VideoBody required fields drifted'
|
||||
}
|
||||
if ($videoList.responses.'200'.content.'application/json'.schema) {
|
||||
throw 'Video create unexpectedly gained a response DTO; review F10 integration'
|
||||
}
|
||||
$videoPage = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue')
|
||||
foreach ($token in @('pickAndUploadVideo', 'appApi.createVideo', 'videoTitle', 'videoOssId: receipt.value.ossId')) {
|
||||
if (-not $videoPage.Contains($token)) { throw "F10 video publish contract missing: $token" }
|
||||
}
|
||||
foreach ($forbidden in @('v-model="form.videoOssId"', 'v-model="form.status"', 'v-model="form.durationSeconds"')) {
|
||||
if ($videoPage.Contains($forbidden)) { throw "F10 must not expose auto or management field: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($path in @('/genealogy/app/site/articles', '/genealogy/app/site/pages/{pageKey}')) {
|
||||
$operation = Get-Operation -Path $path -Method 'get'
|
||||
if ($operation.responses.'200'.content.'application/json'.schema) {
|
||||
throw "Site content response DTO changed for $path; review M10 integration"
|
||||
}
|
||||
}
|
||||
|
||||
$auditDocs = @(Get-ChildItem -LiteralPath (Join-Path $root 'docs') -File -Filter '*149*.md')
|
||||
if ($auditDocs.Count -lt 1) { throw 'Current 149 operation audit document is missing' }
|
||||
|
||||
Write-Output 'CURRENT-OPENAPI-INVENTORY-CONTRACT PASS'
|
||||
@@ -1,8 +1,9 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$json = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
|
||||
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
|
||||
$documents = @(Get-ChildItem -LiteralPath $root -File -Filter '*.openapi.json')
|
||||
if ($documents.Count -ne 1) { throw 'Exactly one current OpenAPI JSON export is required at the repository root' }
|
||||
$json = Get-Content -Raw -Encoding UTF8 -LiteralPath $documents[0].FullName | ConvertFrom-Json
|
||||
|
||||
$path = '/genealogy/app/feedback'
|
||||
$pathProperty = $json.paths.PSObject.Properties[$path]
|
||||
@@ -33,17 +34,8 @@ foreach ($field in $fields) {
|
||||
throw "FeedbackBody.$field must be a string"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yamlFact in @(
|
||||
' /genealogy/app/feedback:',
|
||||
"`$ref: '#/components/schemas/FeedbackBody'",
|
||||
' FeedbackBody:',
|
||||
' - feedbackContent',
|
||||
' feedbackType:',
|
||||
' feedbackContent:',
|
||||
' contactInfo:'
|
||||
)) {
|
||||
if (-not $yaml.Contains($yamlFact)) { throw "Feedback YAML fact is missing: $yamlFact" }
|
||||
if ((@($body.properties.feedbackType.enum) -join ',') -ne 'advice,bug,complaint,other') {
|
||||
throw 'FeedbackBody.feedbackType must use advice,bug,complaint,other'
|
||||
}
|
||||
|
||||
Write-Output 'FEEDBACK-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"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 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_VERIFICATION_OPERATION = 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}`));
|
||||
|
||||
await appApi.updateProfile({ sex: "2" });
|
||||
assert.strictEqual(requests.at(-1).data.sex, "2");
|
||||
await assert.rejects(appApi.updateProfile({ sex: "男" }), /sex 必须为 0、1 或 2/);
|
||||
|
||||
await appApi.submitFeedback({ feedbackType: "bug", feedbackContent: "保存时出现错误" });
|
||||
assert.strictEqual(requests.at(-1).data.feedbackType, "bug");
|
||||
await assert.rejects(
|
||||
appApi.submitFeedback({ feedbackType: "功能问题", feedbackContent: "保存时出现错误" }),
|
||||
/feedbackType 必须为 advice、bug、complaint 或 other/,
|
||||
);
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("FORM-ENUM-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -17,9 +17,10 @@ foreach ($page in @($g08, $g09, $g10)) {
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @('appApi.applyToJoin', 'createRequestController', 'applicantName', 'phone', 'relationDesc', 'applyReason', 'inviterUserId')) {
|
||||
foreach ($required in @('appApi.applyToJoin', 'createRequestController', 'applicantName', 'phone', 'relationDesc', 'applyReason')) {
|
||||
Assert-Contains $g08 $required "G08 must submit the APP join-application contract field: $required"
|
||||
}
|
||||
if ($g08 -match [regex]::Escape('inviterUserId')) { throw 'G08 must not expose a raw inviter user ID without a candidate selector' }
|
||||
foreach ($retired in @('findGenealogyFixture', 'joinSamples', 'LOCAL_WITHDRAWN', 'applicationSamples')) {
|
||||
if ($g08 -match [regex]::Escape($retired)) { throw "G08 must not retain local application fixture state: $retired" }
|
||||
}
|
||||
|
||||
@@ -58,7 +58,14 @@ if (($avatar.type -join ',') -ne 'string,null' -or $avatar.pattern -ne '^[1-9][0
|
||||
foreach ($field in @('birthLunar', 'deathLunar', 'personStatus', 'sex')) {
|
||||
$schema = $body.properties.$field
|
||||
if ($schema.type -ne 'string') { throw "LineagePersonBody.$field must be a dictionary string" }
|
||||
if ($schema.enum) { throw "LineagePersonBody.$field enum changed; update the T-series selector contract" }
|
||||
}
|
||||
|
||||
if ((@($body.properties.sex.enum) -join ',') -ne '0,1,2') { throw 'LineagePersonBody.sex must use 0,1,2' }
|
||||
if ((@($body.properties.birthLunar.enum) -join ',') -ne '0,1') { throw 'LineagePersonBody.birthLunar must use 0,1' }
|
||||
if ((@($body.properties.deathLunar.enum) -join ',') -ne '0,1') { throw 'LineagePersonBody.deathLunar must use 0,1' }
|
||||
if ((@($body.properties.personStatus.enum) -join ',') -ne '0,1,2') { throw 'LineagePersonBody.personStatus must use 0,1,2' }
|
||||
if ((@($body.required) -notcontains 'bindingMode' -or (@($body.properties.bindingMode.enum) -join ',') -ne 'NONE,SELF,SPECIFIED')) {
|
||||
throw 'LineagePersonBody.bindingMode must be required and use NONE,SELF,SPECIFIED'
|
||||
}
|
||||
|
||||
Write-Output 'LINEAGE-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -27,7 +27,12 @@ foreach ($required in @(
|
||||
'baseline.value = submittedSnapshot',
|
||||
'requestController.abort()',
|
||||
'pageActive = false',
|
||||
':disabled="feedbackState === ''submitting''"'
|
||||
':disabled="feedbackState === ''submitting''"',
|
||||
'value: "bug"',
|
||||
'value: "advice"',
|
||||
'value: "complaint"',
|
||||
'value: "other"',
|
||||
'@click="selectFeedbackType(type.value)"'
|
||||
)) {
|
||||
if (-not $page.Contains($required)) { throw "M07 contract is missing: $required" }
|
||||
}
|
||||
|
||||
@@ -51,13 +51,47 @@ const run = async () => {
|
||||
assert.strictEqual(requests.at(-1).data.coverOssId, ossId);
|
||||
await appApi.createAlbumPhoto("1001", "2001", { ossId });
|
||||
assert.strictEqual(requests.at(-1).data.ossId, ossId);
|
||||
await appApi.createVideo("1001", {
|
||||
videoTitle: "清明祭祖",
|
||||
videoDesc: "家族活动记录",
|
||||
videoOssId: ossId,
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
videoTitle: "清明祭祖",
|
||||
videoDesc: "家族活动记录",
|
||||
videoOssId: ossId,
|
||||
});
|
||||
await appApi.createCeremony("1001", { ceremonyType: "祭祖", ceremonyTitle: "清明祭祖", coverOssId: ossId });
|
||||
assert.strictEqual(requests.at(-1).data.coverOssId, ossId);
|
||||
await appApi.createCeremony("1001", {
|
||||
ceremonyType: "祭祖",
|
||||
ceremonyTitle: "清明祭祖",
|
||||
locationAddress: "北京市朝阳区测试路",
|
||||
longitude: "116.4074",
|
||||
latitude: "39.9042",
|
||||
status: "0",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
ceremonyType: "祭祖",
|
||||
ceremonyTitle: "清明祭祖",
|
||||
locationAddress: "北京市朝阳区测试路",
|
||||
longitude: 116.4074,
|
||||
latitude: 39.9042,
|
||||
status: "0",
|
||||
});
|
||||
await assert.rejects(
|
||||
appApi.createCeremony("1001", { ceremonyType: "祭祖", ceremonyTitle: "清明祭祖", longitude: 116.4074 }),
|
||||
/longitude 和 latitude 必须同时提供/,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
appApi.createAlbumPhoto("1001", "2001", { ossId: 900001 }),
|
||||
/OSS ID 字符串/,
|
||||
);
|
||||
await assert.rejects(
|
||||
appApi.createVideo("1001", { videoTitle: "测试视频", videoOssId: 900001 }),
|
||||
/OSS ID 字符串/,
|
||||
);
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("OSS-ID-PAYLOAD-API-RUNTIME-SMOKE PASS\n");
|
||||
|
||||
@@ -30,6 +30,12 @@ foreach ($required in @('appApi.getProfile', 'onShow(loadProfile)', 'profile.nic
|
||||
foreach ($required in @('appApi.getProfile', 'appApi.updateProfile', 'pickAndUploadImage', 'form.nickName', 'form.realName', 'form.sex', 'form.birthday', 'form.email', 'avatarId', 'buildUpdatePayload')) {
|
||||
Require-Text $m02 $required 'M02'
|
||||
}
|
||||
foreach ($required in @('const sexOptions', 'value: "0"', 'value: "1"', 'value: "2"', 'range-key="label"', '@change="changeSex"')) {
|
||||
Require-Text $m02 $required 'M02'
|
||||
}
|
||||
foreach ($forbidden in @('readonly-value', 'form-row--readonly')) {
|
||||
Forbid-Text $m02 $forbidden 'M02'
|
||||
}
|
||||
foreach ($forbidden in @('avatarOssId', 'coverOssId', 'fileId')) {
|
||||
Forbid-Text $m02 $forbidden 'M02'
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ function Read-Page([string]$path) { Get-Content -LiteralPath (Join-Path $root $p
|
||||
$contracts = [ordered]@{
|
||||
'pages/records/r03-gift-list.vue' = @('appApi.getRelativeRecords', 'relativeId', 'openPage("R04"', 'record-list')
|
||||
'pages/records/r04-gift-editor.vue' = @('appApi.createRelativeRecord', 'relativeName', 'mediaOssIds', 'pickAndUploadImage')
|
||||
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'ceremonyId', 'openPage("R06"', 'openPage("R07"')
|
||||
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'ceremonyId', 'openPage(', '"R06"', '"R07"')
|
||||
'pages/records/r06-ritual-detail.vue' = @('appApi.getCeremonyDetail', 'ceremonyId')
|
||||
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'ceremonyType', 'ceremonyTitle')
|
||||
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'ceremonyType', 'ceremonyTitle', 'locationAddress')
|
||||
'pages/records/r08-growth-journal.vue' = @('appApi.getGrowthRecords', 'appApi.createGrowthRecord', 'recordId', 'recordTitle', 'view.value = "list"')
|
||||
'pages/records/r09-life-events.vue' = @('hasValidContext', 'returnToRecords')
|
||||
'pages/records/r10-memo-list.vue' = @('appApi.getMemos', 'appApi.createMemo', 'memoId', 'memoTitle', 'view.value = "list"')
|
||||
@@ -51,4 +51,9 @@ foreach ($forbidden in @('lifeEvents', 'createLifeEvent', 'saveLifeEvent', 'grow
|
||||
if ($r09.Contains($forbidden)) { throw "R09 must remain closed without a backend contract: $forbidden" }
|
||||
}
|
||||
|
||||
$r07 = Read-Page 'pages/records/r07-ritual-editor.vue'
|
||||
foreach ($forbidden in @('form.longitude', 'form.latitude')) {
|
||||
if ($r07.Contains($forbidden)) { throw "R07 must not expose raw map coordinates without a map picker: $forbidden" }
|
||||
}
|
||||
|
||||
Write-Output 'R-BUSINESS-FLOW-CONTRACT PASS'
|
||||
|
||||
+79
-4
@@ -521,13 +521,20 @@ const normalizeProfileUpdatePayload = (payload) => {
|
||||
throw new TypeError('个人资料请求包含未声明字段')
|
||||
}
|
||||
const normalized = {}
|
||||
for (const [field, limit] of [['nickName', 30], ['realName', 30], ['sex', Infinity]]) {
|
||||
for (const [field, limit] of [['nickName', 30], ['realName', 30]]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
|
||||
const value = normalizeOptionalAuthText(payload[field], field)
|
||||
if (!value) continue
|
||||
if (value.length > limit) throw new TypeError(`${field} 超出长度限制`)
|
||||
normalized[field] = value
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'sex')) {
|
||||
const sex = normalizeOptionalAuthText(payload.sex, 'sex')
|
||||
if (sex) {
|
||||
if (!['0', '1', '2'].includes(sex)) throw new TypeError('sex 必须为 0、1 或 2')
|
||||
normalized.sex = sex
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'birthday')) {
|
||||
const value = normalizeOptionalAuthText(payload.birthday, 'birthday')
|
||||
if (value) {
|
||||
@@ -705,7 +712,12 @@ const normalizeFeedbackPayload = (payload) => {
|
||||
throw new TypeError(`反馈字段 ${optionalField} 必须是字符串`)
|
||||
}
|
||||
const value = payload[optionalField].trim()
|
||||
if (value) normalized[optionalField] = value
|
||||
if (value) {
|
||||
if (optionalField === 'feedbackType' && !['advice', 'bug', 'complaint', 'other'].includes(value)) {
|
||||
throw new TypeError('feedbackType 必须为 advice、bug、complaint 或 other')
|
||||
}
|
||||
normalized[optionalField] = value
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1586,17 +1598,62 @@ const normalizeAlbumPhotoCreatePayload = (payload) => {
|
||||
return data
|
||||
}
|
||||
|
||||
const normalizeVideoPayload = (payload) => {
|
||||
const allowedFields = new Set(['videoTitle', 'videoDesc', 'coverOssId', 'videoOssId', 'durationSeconds', 'sortOrder', 'status'])
|
||||
assertPlainPayload(payload, allowedFields, '视频请求')
|
||||
const videoTitle = normalizeOptionalAuthText(payload.videoTitle, '视频标题')
|
||||
if (!videoTitle) throw new TypeError('视频标题不能为空')
|
||||
const videoOssId = normalizeOssIdString(payload.videoOssId, '视频文件 OSS ID')
|
||||
const data = { videoTitle, videoOssId }
|
||||
const videoDesc = normalizeOptionalAuthText(payload.videoDesc, '视频说明')
|
||||
if (videoDesc) data.videoDesc = videoDesc
|
||||
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
|
||||
data.coverOssId = normalizeOssIdString(payload.coverOssId, '视频封面 OSS ID')
|
||||
}
|
||||
for (const field of ['durationSeconds', 'sortOrder']) {
|
||||
const value = normalizeOptionalSafeInteger(payload[field], field)
|
||||
if (value !== undefined) data[field] = value
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'status')) {
|
||||
const status = normalizeOptionalAuthText(payload.status, 'status')
|
||||
if (status) {
|
||||
if (!['0', '1'].includes(status)) throw new TypeError('status 必须为 0 或 1')
|
||||
data.status = status
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const normalizeCeremonyPayload = (payload) => {
|
||||
const allowedFields = new Set(['ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'ceremonyTime', 'location', 'coverOssId', 'sortOrder', 'status'])
|
||||
const allowedFields = new Set(['ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'ceremonyTime', 'location', 'locationAddress', 'longitude', 'latitude', 'coverOssId', 'sortOrder', 'status'])
|
||||
assertPlainPayload(payload, allowedFields, '礼仪活动请求')
|
||||
const ceremonyType = normalizeOptionalAuthText(payload.ceremonyType, 'ceremonyType')
|
||||
const ceremonyTitle = normalizeOptionalAuthText(payload.ceremonyTitle, 'ceremonyTitle')
|
||||
if (!ceremonyType || !ceremonyTitle) throw new TypeError('请填写活动类型和活动标题')
|
||||
const data = { ceremonyType, ceremonyTitle }
|
||||
for (const field of ['ceremonyDesc', 'ceremonyTime', 'location', 'status']) {
|
||||
for (const field of ['ceremonyDesc', 'ceremonyTime', 'location', 'locationAddress']) {
|
||||
const value = normalizeOptionalAuthText(payload[field], field)
|
||||
if (value) data[field] = value
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'status')) {
|
||||
const status = normalizeOptionalAuthText(payload.status, 'status')
|
||||
if (status) {
|
||||
if (!['0', '1'].includes(status)) throw new TypeError('status 必须为 0 或 1')
|
||||
data.status = status
|
||||
}
|
||||
}
|
||||
const hasLongitude = payload.longitude !== undefined && payload.longitude !== null && payload.longitude !== ''
|
||||
const hasLatitude = payload.latitude !== undefined && payload.latitude !== null && payload.latitude !== ''
|
||||
if (hasLongitude !== hasLatitude) throw new TypeError('longitude 和 latitude 必须同时提供')
|
||||
if (hasLongitude) {
|
||||
const longitude = typeof payload.longitude === 'number' ? payload.longitude : Number(payload.longitude)
|
||||
const latitude = typeof payload.latitude === 'number' ? payload.latitude : Number(payload.latitude)
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||
throw new TypeError('longitude 和 latitude 必须是有限数字')
|
||||
}
|
||||
data.longitude = longitude
|
||||
data.latitude = latitude
|
||||
}
|
||||
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
|
||||
data.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
|
||||
}
|
||||
@@ -3301,6 +3358,24 @@ export const appApi = {
|
||||
async getFeedback(requestOptions = {}) {
|
||||
return readRemoteList('/genealogy/app/feedback', '我的反馈', requestOptions)
|
||||
},
|
||||
async getVideos(genealogyId, requestOptions = {}) {
|
||||
const id = normalizeGenealogyPathId(genealogyId)
|
||||
return readRemoteList(`/genealogy/app/genealogies/${id}/videos`, '家族视频', requestOptions)
|
||||
},
|
||||
async getVideoDetail(genealogyId, videoId, requestOptions = {}) {
|
||||
const id = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedVideoId = normalizeResourcePathId(videoId, '视频标识')
|
||||
return readRemoteObject(`/genealogy/app/genealogies/${id}/videos/${normalizedVideoId}`, '家族视频详情', requestOptions)
|
||||
},
|
||||
async createVideo(genealogyId, payload, requestOptions = {}) {
|
||||
const id = normalizeGenealogyPathId(genealogyId)
|
||||
return writeRemoteObject(`/genealogy/app/genealogies/${id}/videos`, 'POST', normalizeVideoPayload(payload), '家族视频', requestOptions)
|
||||
},
|
||||
async updateVideo(genealogyId, videoId, payload, requestOptions = {}) {
|
||||
const id = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedVideoId = normalizeResourcePathId(videoId, '视频标识')
|
||||
return writeRemoteObject(`/genealogy/app/genealogies/${id}/videos/${normalizedVideoId}`, 'PUT', normalizeVideoPayload(payload), '家族视频', requestOptions)
|
||||
},
|
||||
async deleteVideo(genealogyId, videoId, requestOptions = {}) {
|
||||
const id = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedVideoId = normalizeResourcePathId(videoId, '视频标识')
|
||||
|
||||
@@ -8,6 +8,7 @@ const createUploadError = (message, code) => {
|
||||
};
|
||||
|
||||
export const isImagePickCancelled = (error) => error?.code === "IMAGE_PICK_CANCELLED";
|
||||
export const isVideoPickCancelled = (error) => error?.code === "VIDEO_PICK_CANCELLED";
|
||||
|
||||
const createUploadId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
@@ -73,7 +74,66 @@ const pickBrowserImage = () => new Promise((resolve, reject) => {
|
||||
input.click();
|
||||
});
|
||||
|
||||
const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
||||
const pickNativeVideo = () => new Promise((resolve, reject) => {
|
||||
if (!globalThis.plus?.gallery?.pick) {
|
||||
reject(createUploadError("当前运行环境不支持从相册选择视频", "VIDEO_PICK_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
globalThis.plus.gallery.pick(
|
||||
(filePath) => resolve(filePath),
|
||||
() => reject(createUploadError("已取消选择视频", "VIDEO_PICK_CANCELLED")),
|
||||
{ filter: "video", multiple: false },
|
||||
);
|
||||
});
|
||||
|
||||
const pickBrowserVideo = () => new Promise((resolve, reject) => {
|
||||
if (typeof globalThis.document?.createElement !== "function" || typeof globalThis.FileReader !== "function") {
|
||||
reject(createUploadError("当前运行环境不支持选择视频", "VIDEO_PICK_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
const input = globalThis.document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.style.display = "none";
|
||||
const cleanup = () => input.remove();
|
||||
input.addEventListener("cancel", () => {
|
||||
cleanup();
|
||||
reject(createUploadError("已取消选择视频", "VIDEO_PICK_CANCELLED"));
|
||||
}, { once: true });
|
||||
input.addEventListener("change", () => {
|
||||
const browserFile = input.files?.[0];
|
||||
if (!browserFile) {
|
||||
cleanup();
|
||||
reject(createUploadError("已取消选择视频", "VIDEO_PICK_CANCELLED"));
|
||||
return;
|
||||
}
|
||||
const reader = new globalThis.FileReader();
|
||||
reader.onload = () => {
|
||||
const data = reader.result;
|
||||
cleanup();
|
||||
if (!(data instanceof ArrayBuffer)) {
|
||||
reject(createUploadError("读取视频数据失败", "VIDEO_READ_INVALID"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
browserFile,
|
||||
fileName: browserFile.name || "video",
|
||||
size: browserFile.size,
|
||||
contentType: browserFile.type || "video/*",
|
||||
data,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => {
|
||||
cleanup();
|
||||
reject(createUploadError("读取视频数据失败", "VIDEO_READ_FAILED"));
|
||||
};
|
||||
reader.readAsArrayBuffer(browserFile);
|
||||
}, { once: true });
|
||||
globalThis.document.body?.append(input);
|
||||
input.click();
|
||||
});
|
||||
|
||||
const readNativeFile = (filePath) => new Promise((resolve, reject) => {
|
||||
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
|
||||
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
|
||||
return;
|
||||
@@ -137,7 +197,7 @@ const toUploadReceipt = ({ ossId, url = "", thumbnailUrl = "", fileName = "" })
|
||||
|
||||
export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
const file = globalThis.plus?.gallery?.pick
|
||||
? await readNativeImage(await pickNativeImage())
|
||||
? await readNativeFile(await pickNativeImage())
|
||||
: await pickBrowserImage();
|
||||
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
|
||||
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
|
||||
@@ -173,3 +233,46 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
}, { requestController });
|
||||
return toUploadReceipt(completed);
|
||||
};
|
||||
|
||||
export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
|
||||
const file = globalThis.plus?.gallery?.pick
|
||||
? await readNativeFile(await pickNativeVideo())
|
||||
: await pickBrowserVideo();
|
||||
const fileName = String(file.fileName || "");
|
||||
if (!String(file.contentType || "").startsWith("video/") && !/\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(fileName)) {
|
||||
throw createUploadError("请选择视频文件", "VIDEO_TYPE_INVALID");
|
||||
}
|
||||
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
|
||||
throw createUploadError("所选视频大小无效", "VIDEO_SIZE_INVALID");
|
||||
}
|
||||
const fileMd5 = calcMD5Bytes(file.data);
|
||||
const initPayload = {
|
||||
uploadId: createUploadId(),
|
||||
fileName,
|
||||
fileMd5,
|
||||
totalSize: file.size,
|
||||
totalChunks: 1,
|
||||
chunkSize: file.size,
|
||||
contentType: file.contentType || "video/*",
|
||||
};
|
||||
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
||||
if (initialized.instant) return toUploadReceipt(initialized);
|
||||
const chunkPayload = {
|
||||
uploadId: initialized.uploadId,
|
||||
chunkIndex: 0,
|
||||
chunkMd5: fileMd5,
|
||||
};
|
||||
if (file.browserFile) {
|
||||
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||
} else {
|
||||
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||
}
|
||||
const completed = await appApi.completeResumableUpload({
|
||||
uploadId: initialized.uploadId,
|
||||
fileName,
|
||||
fileMd5,
|
||||
totalSize: file.size,
|
||||
totalChunks: 1,
|
||||
}, { requestController });
|
||||
return toUploadReceipt(completed);
|
||||
};
|
||||
|
||||
+405
-79
@@ -3260,7 +3260,7 @@
|
||||
"$ref": "#/components/schemas/LineagePersonBody"
|
||||
},
|
||||
"example": {
|
||||
"appUserId": 900000001,
|
||||
"bindingMode": "NONE",
|
||||
"personNo": "P202607090001",
|
||||
"name": "彭德明",
|
||||
"aliasName": "德明",
|
||||
@@ -3354,10 +3354,19 @@
|
||||
{
|
||||
"name": "personStatus",
|
||||
"in": "query",
|
||||
"description": "人物状态字典值",
|
||||
"description": "字典 gen_lineage_person_status:0=健在,1=已故,2=未知",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_lineage_person_status",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3583,7 +3592,7 @@
|
||||
"$ref": "#/components/schemas/LineagePersonBody"
|
||||
},
|
||||
"example": {
|
||||
"appUserId": 900000001,
|
||||
"bindingMode": "NONE",
|
||||
"personNo": "P202607090001",
|
||||
"name": "彭德明",
|
||||
"aliasName": "德明",
|
||||
@@ -3717,7 +3726,7 @@
|
||||
"$ref": "#/components/schemas/LineagePersonBody"
|
||||
},
|
||||
"example": {
|
||||
"appUserId": 900000001,
|
||||
"bindingMode": "NONE",
|
||||
"personNo": "P202607090001",
|
||||
"name": "彭德明",
|
||||
"aliasName": "德明",
|
||||
@@ -3798,7 +3807,7 @@
|
||||
"$ref": "#/components/schemas/LineagePersonBody"
|
||||
},
|
||||
"example": {
|
||||
"appUserId": 900000001,
|
||||
"bindingMode": "NONE",
|
||||
"personNo": "P202607090001",
|
||||
"name": "彭德明",
|
||||
"aliasName": "德明",
|
||||
@@ -3879,7 +3888,7 @@
|
||||
"$ref": "#/components/schemas/LineagePersonBody"
|
||||
},
|
||||
"example": {
|
||||
"appUserId": 900000001,
|
||||
"bindingMode": "NONE",
|
||||
"personNo": "P202607090001",
|
||||
"name": "彭德明",
|
||||
"aliasName": "德明",
|
||||
@@ -3960,7 +3969,7 @@
|
||||
"$ref": "#/components/schemas/LineagePersonBody"
|
||||
},
|
||||
"example": {
|
||||
"appUserId": 900000001,
|
||||
"bindingMode": "NONE",
|
||||
"personNo": "P202607090001",
|
||||
"name": "彭德明",
|
||||
"aliasName": "德明",
|
||||
@@ -7366,13 +7375,17 @@
|
||||
{
|
||||
"name": "readStatus",
|
||||
"in": "query",
|
||||
"description": "阅读状态,0未读,1已读;不传则查询全部",
|
||||
"description": "字典 gen_notification_read_status:0=未读,1=已读;不传则查询全部",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_notification_read_status",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -8577,8 +8590,17 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"sex": {
|
||||
"description": "性别,建议使用系统字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_user_sex:0=男,1=女,2=未知",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "sys_user_sex",
|
||||
"examples": [
|
||||
"2"
|
||||
]
|
||||
},
|
||||
"birthday": {
|
||||
"description": "生日,格式 yyyy-MM-dd",
|
||||
@@ -8869,11 +8891,24 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"bindingMode",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"bindingMode": {
|
||||
"description": "账号绑定方式:NONE=不绑定账号;SELF=绑定当前登录业务用户;SPECIFIED=由有管理权限的操作者指定 appUserId",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"NONE",
|
||||
"SELF",
|
||||
"SPECIFIED"
|
||||
],
|
||||
"examples": [
|
||||
"NONE"
|
||||
]
|
||||
},
|
||||
"appUserId": {
|
||||
"description": "绑定的业务用户ID;仅家谱管理员可绑定,且用户必须属于当前租户并处于正常状态",
|
||||
"description": "仅 bindingMode=SPECIFIED 时必填;NONE/SELF 时必须不传。指定用户必须属于当前租户、状态正常,且未绑定当前家谱的其他有效世系人物",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
@@ -8890,8 +8925,14 @@
|
||||
"type": "string"
|
||||
},
|
||||
"sex": {
|
||||
"description": "性别,建议使用系统字典值",
|
||||
"description": "字典 sys_user_sex:0=男,1=女,2=未知",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "sys_user_sex",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
@@ -8931,8 +8972,16 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"birthLunar": {
|
||||
"description": "生日是否农历,使用项目字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_number_yes_no:0=否,1=是",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_number_yes_no",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"birthPlace": {
|
||||
"description": "出生地",
|
||||
@@ -8946,8 +8995,16 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"deathLunar": {
|
||||
"description": "逝世日期是否农历,使用项目字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_number_yes_no:0=否,1=是",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_number_yes_no",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"deathPlace": {
|
||||
"description": "逝世地",
|
||||
@@ -8958,8 +9015,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"personStatus": {
|
||||
"description": "人物状态字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_lineage_person_status:0=健在,1=已故,2=未知",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_lineage_person_status",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"biography": {
|
||||
"description": "人物简介",
|
||||
@@ -9016,8 +9082,13 @@
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"description": "动态状态,未传时默认为正常状态",
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用;未传时默认为0",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
@@ -9086,12 +9157,30 @@
|
||||
"type": "string"
|
||||
},
|
||||
"visibility": {
|
||||
"description": "可见范围,例如0私密、1公开、2成员可见",
|
||||
"type": "string"
|
||||
"description": "字典 gen_genealogy_visibility:0=私密,1=公开,2=成员可见",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_genealogy_visibility",
|
||||
"examples": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"joinMode": {
|
||||
"description": "加入方式,例如0关闭、1审核、2邀请码",
|
||||
"type": "string"
|
||||
"description": "字典 gen_genealogy_join_mode:0=关闭加入,1=申请审核,2=邀请码加入",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_genealogy_join_mode",
|
||||
"examples": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9146,12 +9235,30 @@
|
||||
"type": "string"
|
||||
},
|
||||
"visibility": {
|
||||
"description": "可见范围,例如0私密、1公开、2成员可见",
|
||||
"type": "string"
|
||||
"description": "字典 gen_genealogy_visibility:0=私密,1=公开,2=成员可见",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_genealogy_visibility",
|
||||
"examples": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"joinMode": {
|
||||
"description": "加入方式,例如0关闭、1审核、2邀请码",
|
||||
"type": "string"
|
||||
"description": "字典 gen_genealogy_join_mode:0=关闭加入,1=申请审核,2=邀请码加入",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_genealogy_join_mode",
|
||||
"examples": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9211,8 +9318,18 @@
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_genealogy_join_apply_status:0=待审核,1=通过,2=拒绝,3=取消",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"x-dict-type": "gen_genealogy_join_apply_status",
|
||||
"examples": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"auditRemark": {
|
||||
"description": "审核备注",
|
||||
@@ -9238,8 +9355,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"roleType": {
|
||||
"description": "成员角色类型,例如创建者、管理员、普通成员",
|
||||
"type": "string"
|
||||
"description": "字典 gen_genealogy_member_role:owner=谱主,admin=管理员,editor=编辑,member=成员,visitor=访客",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"owner",
|
||||
"admin",
|
||||
"editor",
|
||||
"member",
|
||||
"visitor"
|
||||
],
|
||||
"x-dict-type": "gen_genealogy_member_role",
|
||||
"examples": [
|
||||
"member"
|
||||
]
|
||||
},
|
||||
"lineagePersonId": {
|
||||
"description": "绑定的世系人物ID",
|
||||
@@ -9306,8 +9434,16 @@
|
||||
"maximum": 2147483647
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,0正常,1停用",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9359,8 +9495,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9401,8 +9545,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9448,8 +9600,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9519,8 +9679,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9609,8 +9777,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9660,8 +9836,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9688,8 +9872,18 @@
|
||||
"type": "string"
|
||||
},
|
||||
"meritType": {
|
||||
"description": "功德类型",
|
||||
"type": "string"
|
||||
"description": "字典 gen_merit_type:donation=捐赠,repair=修祠,public=公益,other=其他",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"donation",
|
||||
"repair",
|
||||
"public",
|
||||
"other"
|
||||
],
|
||||
"x-dict-type": "gen_merit_type",
|
||||
"examples": [
|
||||
"donation"
|
||||
]
|
||||
},
|
||||
"meritTitle": {
|
||||
"description": "功德标题",
|
||||
@@ -9714,8 +9908,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
@@ -9738,8 +9940,18 @@
|
||||
],
|
||||
"properties": {
|
||||
"feedbackType": {
|
||||
"description": "反馈类型",
|
||||
"type": "string"
|
||||
"description": "字典 gen_feedback_type:advice=建议,bug=故障,complaint=投诉,other=其他",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"advice",
|
||||
"bug",
|
||||
"complaint",
|
||||
"other"
|
||||
],
|
||||
"x-dict-type": "gen_feedback_type",
|
||||
"examples": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
"feedbackContent": {
|
||||
"description": "反馈内容",
|
||||
@@ -9894,8 +10106,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10104,8 +10324,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,建议使用对应字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10554,8 +10782,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态,0正常,1停用",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10748,8 +10984,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"sex": {
|
||||
"description": "性别字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_user_sex:0=男,1=女,2=未知",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "sys_user_sex",
|
||||
"examples": [
|
||||
"2"
|
||||
]
|
||||
},
|
||||
"generation": {
|
||||
"description": "世代序号",
|
||||
@@ -10805,8 +11050,16 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"birthLunar": {
|
||||
"description": "生日是否农历字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_number_yes_no:0=否,1=是",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_number_yes_no",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"birthPlace": {
|
||||
"description": "出生地",
|
||||
@@ -10821,8 +11074,16 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"deathLunar": {
|
||||
"description": "逝世日期是否农历字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_number_yes_no:0=否,1=是",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_number_yes_no",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"deathPlace": {
|
||||
"description": "逝世地",
|
||||
@@ -10833,8 +11094,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"personStatus": {
|
||||
"description": "人物状态字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_lineage_person_status:0=健在,1=已故,2=未知",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"x-dict-type": "gen_lineage_person_status",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"biography": {
|
||||
"description": "人物简介",
|
||||
@@ -10846,8 +11116,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "数据状态,0正常、1停用",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"remark": {
|
||||
"description": "备注",
|
||||
@@ -10914,8 +11192,19 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relationType": {
|
||||
"description": "与当前树节点的关系类型",
|
||||
"type": "string"
|
||||
"description": "字典 gen_lineage_relation_type:father=父亲,mother=母亲,spouse=配偶,child=子女,adoptive=收养",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"father",
|
||||
"mother",
|
||||
"spouse",
|
||||
"child",
|
||||
"adoptive"
|
||||
],
|
||||
"x-dict-type": "gen_lineage_relation_type",
|
||||
"examples": [
|
||||
"spouse"
|
||||
]
|
||||
},
|
||||
"relationName": {
|
||||
"description": "关系显示名称",
|
||||
@@ -11082,8 +11371,13 @@
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"description": "可见状态;会员接口仅返回 0(正常展示)",
|
||||
"description": "字典 sys_normal_disable:0=正常展示,1=停用;会员接口仅返回0",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
@@ -11490,8 +11784,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"pinned": {
|
||||
"description": "是否置顶字典值",
|
||||
"type": "string"
|
||||
"description": "字典 gen_number_yes_no:0=否,1=是",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_number_yes_no",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"pinnedTime": {
|
||||
"description": "置顶时间",
|
||||
@@ -11507,8 +11809,16 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"description": "状态字典值",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"remark": {
|
||||
"description": "备注",
|
||||
@@ -11680,8 +11990,16 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"readStatus": {
|
||||
"description": "阅读状态,0未读、1已读",
|
||||
"type": "string"
|
||||
"description": "字典 gen_notification_read_status:0=未读,1=已读",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "gen_notification_read_status",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"readTime": {
|
||||
"description": "阅读时间",
|
||||
@@ -11692,8 +12010,16 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"description": "通知状态",
|
||||
"type": "string"
|
||||
"description": "字典 sys_normal_disable:0=正常,1=停用",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"x-dict-type": "sys_normal_disable",
|
||||
"examples": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
"remark": {
|
||||
"description": "备注",
|
||||
|
||||
Reference in New Issue
Block a user