From 6fbcf21024a90306c0d99e37b1e7078a60555386 Mon Sep 17 00:00:00 2001
From: rain <982766701@qq.com>
Date: Tue, 28 Jul 2026 07:58:26 +0800
Subject: [PATCH] =?UTF-8?q?=E5=BE=85=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...PP-136接口功能归属与重复关系-2026-07-26.md | 2 +
...49接口页面归属与表单字段审计-2026-07-27.md | 84 +++
...项目接口覆盖与功能缺口总清单-2026-07-28.md | 118 +++++
...pifox写接口字段—表单控件映射-2026-07-27.md | 10 +-
pages/family/f10-video-list.vue | 189 +++++--
pages/genealogy/g08-join-application.vue | 8 -
pages/profile/m02-edit-profile.vue | 53 +-
pages/profile/m07-feedback.vue | 17 +-
pages/records/r07-ritual-editor.vue | 24 -
pages/tree/t01-tree-overview.vue | 174 +++++--
tests/current-openapi-inventory-contract.ps1 | 57 +++
tests/feedback-openapi-contract.ps1 | 18 +-
tests/form-enum-api-runtime-smoke.js | 62 +++
tests/g08-g10-application-flow-contract.ps1 | 3 +-
tests/lineage-openapi-contract.ps1 | 9 +-
tests/m07-feedback-submit-contract.ps1 | 7 +-
tests/oss-id-payload-api-runtime-smoke.js | 34 ++
tests/profile-pages-contract.ps1 | 6 +
tests/r-business-flow-contract.ps1 | 9 +-
utils/api.js | 83 ++-
utils/resumable-image-upload.js | 107 +++-
家谱.openapi.json | 484 +++++++++++++++---
22 files changed, 1294 insertions(+), 264 deletions(-)
create mode 100644 docs/APP-149接口页面归属与表单字段审计-2026-07-27.md
create mode 100644 docs/APP-150项目接口覆盖与功能缺口总清单-2026-07-28.md
create mode 100644 tests/current-openapi-inventory-contract.ps1
create mode 100644 tests/form-enum-api-runtime-smoke.js
diff --git a/docs/APP-136接口功能归属与重复关系-2026-07-26.md b/docs/APP-136接口功能归属与重复关系-2026-07-26.md
index 3f7702f..5101d9f 100644
--- a/docs/APP-136接口功能归属与重复关系-2026-07-26.md
+++ b/docs/APP-136接口功能归属与重复关系-2026-07-26.md
@@ -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
## 结论
diff --git a/docs/APP-149接口页面归属与表单字段审计-2026-07-27.md b/docs/APP-149接口页面归属与表单字段审计-2026-07-27.md
new file mode 100644
index 0000000..981c887
--- /dev/null
+++ b/docs/APP-149接口页面归属与表单字段审计-2026-07-27.md
@@ -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`。
+- 这些验证不替代真实写入。下一阶段在浏览器完成接口详情核对后,仅在测试账号的页面内输入、选择、提交和回读;敏感操作继续跳过。
diff --git a/docs/APP-150项目接口覆盖与功能缺口总清单-2026-07-28.md b/docs/APP-150项目接口覆盖与功能缺口总清单-2026-07-28.md
new file mode 100644
index 0000000..c9a3966
--- /dev/null
+++ b/docs/APP-150项目接口覆盖与功能缺口总清单-2026-07-28.md
@@ -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 的系统文件选择器选择它。
diff --git a/docs/Apifox写接口字段—表单控件映射-2026-07-27.md b/docs/Apifox写接口字段—表单控件映射-2026-07-27.md
index f007c8f..9e1f421 100644
--- a/docs/Apifox写接口字段—表单控件映射-2026-07-27.md
+++ b/docs/Apifox写接口字段—表单控件映射-2026-07-27.md
@@ -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` 已有四个枚举值,已改为选择控件。
diff --git a/pages/family/f10-video-list.vue b/pages/family/f10-video-list.vue
index 984ab08..b1b9b50 100644
--- a/pages/family/f10-video-list.vue
+++ b/pages/family/f10-video-list.vue
@@ -1,73 +1,150 @@
-
- 家族视频暂未开放视频功能正在准备中。开放后,家人的影像会在这里展示。
+
+
+
+
+
+ 发布家族视频
+ 视频文件由上传回执自动关联,发布时不需要填写文件 ID。
+
+
+ *视频文件
+
+ 已上传:{{ receipt.fileName || "视频" }}
+
+ {{ uploadError }}
+
+
+ *视频标题
+
+
+
+ 视频说明
+
+
+ {{ submitError }}
+
+
+
+
+ {{ stateCopy.title }}
+ {{ stateCopy.copy }}
+
+
+
+
+
+
diff --git a/pages/genealogy/g08-join-application.vue b/pages/genealogy/g08-join-application.vue
index fe0c9c4..c8d4104 100644
--- a/pages/genealogy/g08-join-application.vue
+++ b/pages/genealogy/g08-join-application.vue
@@ -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));
diff --git a/pages/profile/m02-edit-profile.vue b/pages/profile/m02-edit-profile.vue
index 59b989a..5253647 100644
--- a/pages/profile/m02-edit-profile.vue
+++ b/pages/profile/m02-edit-profile.vue
@@ -48,13 +48,21 @@
placeholder-class="placeholder"
:disabled="saving || uploading"
/>
- 性别{{ form.sex ? "待确认" : "未设置" }}选项待提供
+
+ 性别
+ {{ sexOptions[sexIndex].label }}
+
生日
{
+ 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;
diff --git a/pages/profile/m07-feedback.vue b/pages/profile/m07-feedback.vue
index d425f48..88cce9b 100644
--- a/pages/profile/m07-feedback.vue
+++ b/pages/profile/m07-feedback.vue
@@ -28,14 +28,14 @@
@@ -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: "",
diff --git a/pages/records/r07-ritual-editor.vue b/pages/records/r07-ritual-editor.vue
index 7402e8d..228583b 100644
--- a/pages/records/r07-ritual-editor.vue
+++ b/pages/records/r07-ritual-editor.vue
@@ -89,26 +89,6 @@
@input="clearError"
/>
-
- 经度
-
-
-
- 纬度
-
-
封面图片
@@ -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 },
diff --git a/pages/tree/t01-tree-overview.vue b/pages/tree/t01-tree-overview.vue
index f38cb91..b7695db 100644
--- a/pages/tree/t01-tree-overview.vue
+++ b/pages/tree/t01-tree-overview.vue
@@ -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 = [];
diff --git a/tests/current-openapi-inventory-contract.ps1 b/tests/current-openapi-inventory-contract.ps1
new file mode 100644
index 0000000..bf82e17
--- /dev/null
+++ b/tests/current-openapi-inventory-contract.ps1
@@ -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'
diff --git a/tests/feedback-openapi-contract.ps1 b/tests/feedback-openapi-contract.ps1
index 2e41fd1..095f85f 100644
--- a/tests/feedback-openapi-contract.ps1
+++ b/tests/feedback-openapi-contract.ps1
@@ -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'
diff --git a/tests/form-enum-api-runtime-smoke.js b/tests/form-enum-api-runtime-smoke.js
new file mode 100644
index 0000000..ce61708
--- /dev/null
+++ b/tests/form-enum-api-runtime-smoke.js
@@ -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);
+});
diff --git a/tests/g08-g10-application-flow-contract.ps1 b/tests/g08-g10-application-flow-contract.ps1
index 2d7c1a1..673d5d0 100644
--- a/tests/g08-g10-application-flow-contract.ps1
+++ b/tests/g08-g10-application-flow-contract.ps1
@@ -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" }
}
diff --git a/tests/lineage-openapi-contract.ps1 b/tests/lineage-openapi-contract.ps1
index d044d2b..d2ef279 100644
--- a/tests/lineage-openapi-contract.ps1
+++ b/tests/lineage-openapi-contract.ps1
@@ -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'
diff --git a/tests/m07-feedback-submit-contract.ps1 b/tests/m07-feedback-submit-contract.ps1
index fc21676..9b450ac 100644
--- a/tests/m07-feedback-submit-contract.ps1
+++ b/tests/m07-feedback-submit-contract.ps1
@@ -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" }
}
diff --git a/tests/oss-id-payload-api-runtime-smoke.js b/tests/oss-id-payload-api-runtime-smoke.js
index dadb4ef..6f68ddd 100644
--- a/tests/oss-id-payload-api-runtime-smoke.js
+++ b/tests/oss-id-payload-api-runtime-smoke.js
@@ -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");
diff --git a/tests/profile-pages-contract.ps1 b/tests/profile-pages-contract.ps1
index e6fd523..6e413ef 100644
--- a/tests/profile-pages-contract.ps1
+++ b/tests/profile-pages-contract.ps1
@@ -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'
}
diff --git a/tests/r-business-flow-contract.ps1 b/tests/r-business-flow-contract.ps1
index 57067cd..65ead83 100644
--- a/tests/r-business-flow-contract.ps1
+++ b/tests/r-business-flow-contract.ps1
@@ -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'
diff --git a/utils/api.js b/utils/api.js
index acd7f3a..1d7a263 100644
--- a/utils/api.js
+++ b/utils/api.js
@@ -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, '视频标识')
diff --git a/utils/resumable-image-upload.js b/utils/resumable-image-upload.js
index 6c58179..e3f7acb 100644
--- a/utils/resumable-image-upload.js
+++ b/utils/resumable-image-upload.js
@@ -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);
+};
diff --git a/家谱.openapi.json b/家谱.openapi.json
index 3be8d0e..d1e4fab 100644
--- a/家谱.openapi.json
+++ b/家谱.openapi.json
@@ -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": "备注",