完成80%
This commit is contained in:
@@ -239,8 +239,6 @@ export default {
|
|||||||
if (type === "requestCaptchaData") {
|
if (type === "requestCaptchaData") {
|
||||||
request.data = {
|
request.data = {
|
||||||
tenantId: this.requestContext.tenantId,
|
tenantId: this.requestContext.tenantId,
|
||||||
clientId: this.requestContext.clientId,
|
|
||||||
sceneCode: this.requestContext.sceneCode,
|
|
||||||
subject: this.requestContext.subject,
|
subject: this.requestContext.subject,
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# APP 136 接口功能归属与重复关系
|
||||||
|
|
||||||
|
更新时间:2026-07-26
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
Apifox 当前目录共 136 条:128 条 APP 接口、4 条 APP/PC 共用行政区划接口、4 条旧系统兼容验证接口。
|
||||||
|
|
||||||
|
“136 条”不等于 136 个独立页面功能。真正功能重复的只有旧兼容验证/发码链路;列表、分页、详情、选项、管理态接口是同一资源在不同交互下的不同合同,不能擅自删掉或混用。
|
||||||
|
|
||||||
|
字段、必填、枚举、请求/响应 DTO 以桌面 Apifox 为准。根目录 `家谱.openapi.json` 只用于核对目录数量和路径,不用其缺失字段推断前端 DTO。
|
||||||
|
|
||||||
|
## 一、唯一入口与功能重复
|
||||||
|
|
||||||
|
| 分类 | 接口 | 当前唯一使用策略 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| APP 人机验证 | `GET /genealogy/app/auth/verification/{operationCode}/require`、`POST .../challenge`、`POST .../verify` | A01 密码/短信登录、A04 注册、A05 找回密码,通过 `TacVerification` 与 `utils/auth-verification.js` | 当前 APP 唯一验证链路 |
|
||||||
|
| 旧验证码兼容 | `GET /captcha/require`、`POST /captcha/challenge`、`POST /captcha/verify`、`GET /auth/code` | 当前 APP 不调用 | 与 APP 人机验证功能重叠,保留 API 目录记录但不能再接回 A01/A04/A05,避免重复验证 |
|
||||||
|
| APP 场景发码 | `POST /genealogy/app/auth/sms/{operationCode}/code` | A01/A04/A05;`operationCode` 是 `password-login`、`sms-login`、`register`、`forgot-password` | 当前 APP 唯一发码链路 |
|
||||||
|
| 旧短信发码兼容 | `POST /genealogy/app/auth/sms/code` | 仅 `appApi.sendLegacySmsCode` API 层 owner;没有页面入口 | 与上项发码功能重叠。保留兼容方法供后端指定旧调用方,不得在现有认证页重复发短信 |
|
||||||
|
| 行政区划(共享) | `GET /genealogy/region/children`、`/path/{regionCode}`、`/search`、`/{regionCode}` | APP 与 PC 共用;当前 G03/G11 使用 children | 不是旧接口、不是重复接口。其余三条在对应 UI 出现“回填路径/搜索/单项校验”交互后接入 |
|
||||||
|
|
||||||
|
## 二、136 条按功能模块归属
|
||||||
|
|
||||||
|
| Apifox 模块 | 数量 | 应归属页面/组件 | 备注 |
|
||||||
|
| --- | ---: | --- | --- |
|
||||||
|
| 验证中心 | 7 | A01、A04、A05、`TacVerification` | 其中 4 条为旧兼容,见上表 |
|
||||||
|
| 认证登录 | 12 | A01、A04、A05、M01、M02、M04、M05、M10 | 改密、换绑、注销、退出属于敏感写操作,测试不执行 |
|
||||||
|
| 文件上传 | 6 | G03、G11、M02、F02、F06、F07、F09、R04、R07、R08、R10,通过 `resumable-image-upload.js` | 单文件、分片 init/chunk/complete、业务引用绑定/释放各自用途不同 |
|
||||||
|
| 行政区划 | 4 | G03 创建家谱、G11 家谱设置 | APP/PC 共用基础查询 |
|
||||||
|
| 家谱 | 13 | G01、G03、G05、G06、G08、G09、G10、G11 | 配额、options、详情、审核/撤销等要与具体页面按钮逐项接线,不因已有列表接口视为完成 |
|
||||||
|
| 家谱成员 | 6 | 当前没有“家谱成员(账号成员)管理”页面;不要误接到世系人物 T03–T08 | `memberId` 与 `personId` 不是同一 ID。成员管理页/候选 DTO 缺失需单列 |
|
||||||
|
| 字辈谱 | 6 | G12 | 正常列表、维护列表、批量预览、批量保存、单条新增/修改是不同操作 |
|
||||||
|
| 世系人物 | 12 | T01、T03–T08、R01、R02 | 首位成员创建后端 `code:500` 阻塞;不能伪造 personId |
|
||||||
|
| 家族圈 | 14 | F01、F02、F03 | 列表与分页、评论与评论分页、回复与回复分页均非重复;编辑/删除/点赞/回复 UI 需逐项确认 |
|
||||||
|
| 内容文章 | 9 | F04、F05、F06、M06、M08 | 谱文分类、帮助详情目前没有已确认页面入口,不能靠列表代替 |
|
||||||
|
| 相册 | 7 | F07、F08、F09 | 相册本身与相片记录是两级资源;编辑/删除动作需遵守测试禁止删除边界 |
|
||||||
|
| 祭祀 | 8 | R05、R06、R07 | 活动、献礼分别有列表和写入合同 |
|
||||||
|
| 族务记录 | 18 | R03、R04、R08、R10、R11 | 成长/备忘/亲友的详情、修改、删除需有明确详情或编辑入口;R09 人生事件没有独立资源合同 |
|
||||||
|
| 消息通知 | 4 | N01、G01 未读数 | N02 没有单条详情读取接口;标已读/全部已读不能在未授权测试中触发 |
|
||||||
|
| 意见反馈 | 2 | M07 | 列表与提交分别接线 |
|
||||||
|
| VIP | 3 | M09 | 套餐、订单列表可读取;创建订单属于支付链路,不执行 |
|
||||||
|
| 视频 | 1 | F10 | 仅有删除视频接口,缺少视频列表/详情/上传/播放合同,因此 F10 不能假装可用 |
|
||||||
|
| 贺礼邀约 | 4 | R06(活动详情)及“我的活动邀请”入口待产品页面 | 受邀人需要真实 `appUserId`;当前成员/世系 DTO 没有可确认候选来源,不能拿 `memberId` 或 `personId` 猜代 |
|
||||||
|
|
||||||
|
以上数量相加为 136。
|
||||||
|
|
||||||
|
## 三、共享行政区划的实测合同
|
||||||
|
|
||||||
|
桌面 Apifox 已核对:
|
||||||
|
|
||||||
|
| 接口 | 必填 | 可选 | 当前 API owner | 页面状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `GET /genealogy/region/children` | 无 | `parentCode`(不传或 `0` 为省级) | `appApi.getRegionChildren` | G03/G11 已真实读取 |
|
||||||
|
| `GET /genealogy/region/path/{regionCode}` | path `regionCode:string` | `clientid` header | `appApi.getRegionPath` | 暂无“按已存地区回填层级路径”控件 |
|
||||||
|
| `GET /genealogy/region/search` | query `keyword:string` | `level: 1..5`、`limit:integer`、`clientid` header | `appApi.searchRegions` | 暂无地区关键字搜索控件 |
|
||||||
|
| `GET /genealogy/region/{regionCode}` | path `regionCode:string` | `clientid` header | `appApi.getRegion` | 暂无单项详情校验控件 |
|
||||||
|
|
||||||
|
后三条返回 DTO 在桌面文档当前显示为通用对象/列表对象,API 层只校验 envelope 与最外层数组/对象,不擅自猜测内部字段。等页面要消费具体字段时,先在桌面 Apifox 展开响应模型并补严字段校验。
|
||||||
|
|
||||||
|
## 四、接线判定标准
|
||||||
|
|
||||||
|
一条接口只有同时满足以下三项才标记“页面完成”:
|
||||||
|
|
||||||
|
1. `utils/api.js` 有唯一方法 owner,按桌面 Apifox 的必填/可选参数组装请求;
|
||||||
|
2. 明确页面按钮、页面加载或组件行为调用该方法,且参数来源不是猜测 ID;
|
||||||
|
3. 用浏览器在测试账号和测试数据上做真实请求验证;写入再做列表或详情回读。
|
||||||
|
|
||||||
|
仅有 API 方法、或仅页面能打开、或只有导出文件字段,均不算页面完成。
|
||||||
|
|
||||||
|
## 五、2026-07-26 浏览器复测结论
|
||||||
|
|
||||||
|
- 从 A01 密码登录进入测试账号后,以真实 `genealogyId`、动态、谱文和相册标识运行 `tests/all-page-route-runtime-smoke.js`,52/52 页面均通过;认证态访问 A01 被守卫重定向到 G01 属于预期行为,测试已明确校验该分支。
|
||||||
|
- G01 的“世系图、成员、字辈诗、申请审核”四个快捷入口均通过浏览器页面点击复测,分别到达 T01、G05、G12、G10,未捕获运行时异常。
|
||||||
|
- T04 再次经页面输入首位成员姓名并提交。请求仍未获服务端确认,页面显示“保存失败 / 发生未知异常,请联系管理员”;未写入本地成员、未生成假 `personId`。该结果继续受“首位成员创建 code:500”阻塞。
|
||||||
|
- 该路由复测只验证真实读取、页面状态和既有测试数据;未执行短信、改密、换绑、退出、删除、审核或支付。
|
||||||
|
|
||||||
|
## 六、桌面 Apifox 与导出目录核对
|
||||||
|
|
||||||
|
桌面 Apifox 本地接口树(2026-07-26 16:56 更新)包含根目录 `家谱.openapi.json` 的全部 136 个 `HTTP method + path`;没有只存在于导出而桌面目录不存在的 operation。导出文件可用于路径、方法和数量的完整审计。
|
||||||
|
|
||||||
|
该核对不扩大为 DTO 字段已完整:页面要消费的 body/response 字段、必填、枚举和示例仍以桌面 Apifox 的接口详情为准。若桌面详情未声明条目 DTO 或枚举,前端继续把该字段标为阻塞,而不从导出占位结构猜测。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# APP 136 请求参数字段核对表
|
||||||
|
|
||||||
|
更新时间:2026-07-26
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
- 路径和 operation 数量以桌面 Apifox 当前 APP 目录为准;本机已核对根目录 `家谱.openapi.json` 的 136 个 `method + path` 均存在于桌面目录。
|
||||||
|
- 下表字段来自该次导出快照,用于逐项核对前端 consumer。桌面 Apifox 若显示更严格的 required、枚举、长度、oneOf 或条目 DTO,以桌面详情覆盖本表并重新导出。
|
||||||
|
- `int64` 在 H5 JSON 中不得强制转换为 JavaScript `number`。真实雪花 ID 超过安全整数范围时,必须由后端改为十进制字符串合同,不能截断或猜测。
|
||||||
|
- “阻塞”表示前端没有安全的参数来源、DTO、operation 或服务端成功响应;不是用本地默认值补齐的许可。
|
||||||
|
|
||||||
|
## 公共路径与查询参数
|
||||||
|
|
||||||
|
| operation 类别 | 必填 | 可选 | 当前页面/状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 绝大多数 `/genealogy/app/**` | Header `clientid` | 当前登录 bearer | API 请求层统一注入;实际浏览器验证 |
|
||||||
|
| 资源路径 `/{genealogyId}` | path `genealogyId` | — | 所有家谱内页面从真实当前家谱上下文取得 |
|
||||||
|
| 世系资源 `/{personId}` | path `personId` | — | T03–T08、R01–R02 被首位成员 `code:500` 阻塞 |
|
||||||
|
| 动态资源 `/{feedId}` | path `feedId` | — | F01/F03 实际读取;点赞状态 DTO 阻塞 |
|
||||||
|
| 谱文/相册/礼仪/记录资源 ID | 对应 path ID | — | 已有真实测试数据的列表/详情/创建按页面验证;编辑/删除需要产品入口或受测试边界限制 |
|
||||||
|
| 分页 | `pageNum`、`pageSize` 在 Apifox 均为可选 | keyword、generation、status 等按各 operation | 没有页面分页交互时不以默认假分页替代 |
|
||||||
|
| 行政区划搜索 | query `keyword` | `level` 1–5、`limit` | `children` 已在 G03/G11 实读;其余三个尚无对应交互控件 |
|
||||||
|
|
||||||
|
## 写入 body 字段
|
||||||
|
|
||||||
|
星号为当前导出中的 required 字段。没有写入页面、稳定候选 ID 或枚举的字段均明确列为阻塞,不能手填内部 ID。
|
||||||
|
|
||||||
|
| Schema / operation | 页面 | 必填字段 | 可选字段 | 当前消费结论 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `VerificationChallengeBody`(challenge) | A01/A04/A05 | `tenantId`、`subject` | — | 已接验证组件 |
|
||||||
|
| `VerificationCheckBody`(verify) | A01/A04/A05 | `tenantId`、`subject`、`challengeId` | `providerCode`、`captchaType`、`payload` | provider 细节以桌面 TAC DTO 为准 |
|
||||||
|
| `PasswordRegisterBody` | A04 | `grantType`、`tenantId`、`phone`、`password`、`smsCode` | `nickName`、`registerSource` | 短信写入未执行(测试边界) |
|
||||||
|
| `PasswordLoginBody` | A01 | `grantType`、`tenantId`、`phone`、`password` | `validToken` | 已真实登录 |
|
||||||
|
| `SmsLoginBody` | A01 | `grantType`、`tenantId`、`phone`、`smsCode` | — | 短信写入未执行 |
|
||||||
|
| `SmsCodeBody` | A01/A04/A05 | `grantType`、`tenantId`、`phone` | `validToken` | 场景路径已接;未发短信 |
|
||||||
|
| `ProfileUpdateBody` | M02 | — | `nickName`、`avatarOssId`、`sex`、`birthday`、`provinceCode`、`cityCode`、`districtCode` | 头像 `ossId` 类型冲突;字典/地区回填 DTO 需桌面详情确认 |
|
||||||
|
| `PasswordChangeBody` | M04 | `oldPassword`、`newPassword` | — | 敏感操作,不执行 |
|
||||||
|
| `PasswordResetBody` | A05 | `grantType`、`tenantId`、`phone`、`smsCode`、`newPassword` | — | 短信/改密不执行 |
|
||||||
|
| `PhoneChangeBody` | M05 | `phone`、`smsCode` | — | 敏感操作,不执行 |
|
||||||
|
| `AccountDeactivateBody` | M10 | `smsCode` | — | 敏感操作,不执行 |
|
||||||
|
| `ResumableInitBody` | 上传组件 | 实际服务端:`uploadId`、`fileName`、`fileMd5`、`totalSize`、`chunkSize`、`totalChunks` | `contentType` | 导出快照写为 `fileSize` 且漏 `uploadId`;真实请求省略 `uploadId` 返回“上传ID不能为空”,改用 `fileSize` 返回“文件大小不能为空”。前端只按已验证的实际契约发送,等待 Apifox 统一(B17) |
|
||||||
|
| `ResumableCompleteBody` | 上传组件 | 实际已验证链路:`uploadId`、`fileName`、`fileMd5`、`totalSize`、`totalChunks` | — | 导出快照与实际字段冲突待后端统一;回执 `ossId` 仍为不安全 19 位字符串 |
|
||||||
|
| `FileReferenceBody` | 业务附件 | `bizType`、`bizTable`、`bizId`、`bizField` | `bizName`、`ossId`、`ossIds`、`usageScene`、`usageName` | 缺稳定业务表/字段 owner,且 `ossId` 类型冲突;不能猜绑定 |
|
||||||
|
| `GenealogyCreateBody` | G03 | `genealogyName`、`surname`、`regionCode` | `ancestralHall`、`originPlace`、`addressDetail`、`coverOssId`、`intro`、`visibility`、`joinMode` | 已真实创建/回读;封面受 `ossId` 阻塞 |
|
||||||
|
| `GenealogyUpdateBody` | G11 | — | 与创建同名字段 | 读取已接;写入需页面确切 dirty/权限合同 |
|
||||||
|
| `GenealogyJoinApplyBody` | G08 | — | `applicantName`、`phone`、`relationDesc`、`applyReason`、`inviterUserId` | 已真实申请列表回读;内部邀请人 ID 无候选来源 |
|
||||||
|
| `GenealogyJoinAuditBody` | G10 | `status` | `auditRemark` | 审核为敏感操作,不执行 |
|
||||||
|
| `GenealogyMemberUpdateBody` | 无独立成员管理页 | — | `memberName`、`relationName`、`roleType`、`lineagePersonId` | `memberId` 非 `personId`;不得误接世系页 |
|
||||||
|
| `GenealogyOwnerTransferBody` | 无 | `targetMemberId` | — | 缺成员候选与产品入口,不能手填 ID |
|
||||||
|
| `GenerationPoemBody` | G12 | `generationNo`、`generationText` | `description`、`sortOrder`、`status` | 已真实维护/回读 |
|
||||||
|
| `GenerationPoemBatchBody` | G12 | `poemText` | `disableMissing` | 已真实预览/保存回读 |
|
||||||
|
| `LineagePersonBody` | T04/T05/T06、R02 | `name` | `appUserId`、`personNo`、`aliasName`、`sex`、`generation`、`generationName`、`fatherId`、`motherId`、`avatarOssId`、`birthDate`、`birthLunar`、`birthPlace`、`deathDate`、`deathLunar`、`deathPlace`、`burialPlace`、`personStatus`、`biography`、`sortOrder`、`remark`、`relationName` | 首位成员最小合法请求仍 `code:500`;业务用户/父母候选、性别/状态字典、头像 `ossId` 均无安全闭环 |
|
||||||
|
| `FamilyFeedBody` | F02/F03 编辑入口缺失 | `feedContent` | `feedType`、`mediaOssIds`、`sortOrder`、`status` | 文本动态已真实创建/回读;媒体受 `ossId` 阻塞;编辑无产品入口 |
|
||||||
|
| `FamilyFeedCommentBody` | F03 | `commentContent` | `parentCommentId` | 一级评论已真实创建/回读;回复 UI 未设计 |
|
||||||
|
| `ArticleBody` | F06 | `articleTitle`、`articleContent` | `categoryId`、`articleSummary`、`coverOssId`、`authorName`、`sortOrder`、`status` | 已真实创建/回读;分类条目 DTO、封面 `ossId` 阻塞 |
|
||||||
|
| `AlbumBody` | F07 | `albumName` | `albumDesc`、`coverOssId`、`sortOrder`、`status` | 已真实创建/回读;封面 `ossId` 阻塞 |
|
||||||
|
| `AlbumPhotoBody` | F09 | `ossId` | `photoTitle`、`photoDesc`、`photographer`、`shootTime`、`sortOrder`、`status` | 上传回执成功,但 `ossId` JSON 类型冲突,不能创建照片记录 |
|
||||||
|
| `CeremonyBody` | R07 | `ceremonyType`、`ceremonyTitle` | `ceremonyDesc`、`ceremonyTime`、`location`、`locationAddress`、`longitude`、`latitude`、`coverOssId`、`sortOrder`、`status` | 已真实创建/回读;封面受 `ossId` 阻塞 |
|
||||||
|
| `CeremonyGiftBody` | R06 | `giftAmount` | `giverName`、`giftMessage` | 礼仪详情/献礼读取已接;写入需从真实礼仪 ID 进入 |
|
||||||
|
| `GrowthRecordBody` | R08 | `recordTitle` | `lineagePersonId`、`recordType`、`recordContent`、`recordDate`、`remindTime`、`mediaOssIds`、`sortOrder`、`status` | 无真实 personId 时人物归属不能填;非人物字段已真实创建/回读 |
|
||||||
|
| `MemoBody` | R10 | `memoTitle` | `memoContent`、`remindTime`、`completed`、`mediaOssIds`、`sortOrder`、`status` | 已真实创建/回读;附件受 `ossId` 阻塞 |
|
||||||
|
| `RelativeRecordBody` | R04 | `relativeName` | `relationName`、`eventName`、`eventTime`、`giftAmount`、`recordContent`、`mediaOssIds`、`sortOrder`、`status` | 已真实创建/回读;附件受 `ossId` 阻塞 |
|
||||||
|
| `MeritRecordBody` | R11 | `donorName`、`meritTitle` | `meritType`、`meritContent`、`amount`、`meritTime`、`sortOrder`、`status` | 已真实创建/回读 |
|
||||||
|
| `FeedbackBody` | M07 | `feedbackContent` | `feedbackType`、`contactInfo` | 已真实提交/读取 |
|
||||||
|
| `VipOrderBody` | M09 | `packageId` | `genealogyId`、`payType` | 创建订单属于支付,不执行 |
|
||||||
|
| `CeremonyInviteesBody` | R07/R06 | `inviteeUserIds` | — | 缺 `appUserId` 候选列表;不能用 member/person ID 猜代 |
|
||||||
|
| `CeremonyInvitationResponseBody` | 我的活动邀请页缺失 | `inviteStatus` | — | 枚举仅 `ACCEPTED`/`DECLINED`;需要产品入口和真实邀请 |
|
||||||
|
|
||||||
|
## 后端需要优先确认的统一规则
|
||||||
|
|
||||||
|
1. 所有 `int64` 在 APP JSON 请求/响应中是否统一为十进制字符串;至少要覆盖 `ossId`、所有资源 ID、`appUserId`、`memberId`、`personId`、`categoryId` 和 `packageId`。
|
||||||
|
2. 所有“建议使用字典值”的字段必须在 Apifox 给出 enum 或独立 options operation,特别是 `sex`、`personStatus`、`roleType`、`status`、`visibility`、`joinMode`、`payType`。
|
||||||
|
3. 任何需要选择内部用户/成员/人物的操作必须返回明确且同类型的候选 ID,禁止要求客户端用不同资源的 ID 猜代。
|
||||||
|
4. 上传完成回执和所有消费 `ossId` 的 DTO 必须同版修复;否则上传成功并不等于业务图片创建成功。
|
||||||
|
5. 分片上传 init/complete 的 Apifox 导出字段必须与实际服务端校验一致:当前实测要求 `uploadId` 与 `totalSize`,而导出写为 `fileSize` 且未声明 `uploadId`;不得要求客户端同时发送两套互斥字段。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# APP 表单控件与接口字段审计
|
||||||
|
|
||||||
|
更新日期:2026-07-26
|
||||||
|
依据:桌面 Apifox APP 合同、`家谱.openapi.json`、当前页面真实表单代码。
|
||||||
|
|
||||||
|
## 判定规则
|
||||||
|
|
||||||
|
| 字段性质 | 页面控件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 自由文本、标题、说明、地点、姓名 | 文本输入或多行输入 |
|
||||||
|
| `date`、明确日期 | 日期选择器 |
|
||||||
|
| 明确日期时间 | 日期与时间选择器,页面组合后提交标准时间字符串 |
|
||||||
|
| 金额、排序 | 数字输入 |
|
||||||
|
| 地区、分类、人物、业务用户、成员等资源 ID | 从真实候选列表选择;禁止手填内部 ID |
|
||||||
|
| `sex`、`status`、是否农历等字典值 | value-label 选择器;必须有 Apifox enum 或字典 options 来源 |
|
||||||
|
| `ossId`、封面、头像、媒体 | 真实上传组件;禁止手填文件 ID |
|
||||||
|
|
||||||
|
## 已确认需要调整的表单
|
||||||
|
|
||||||
|
| 页面 | 字段 | 当前控件/状态 | 正确控件 | 依赖或阻塞 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| T04、T05 | `birthLunar`、`deathLunar` | 文本输入,且提示为“按家谱记载填写” | “是否农历”选择器 | B15:Apifox 只有字典值描述,无 value-label enum |
|
||||||
|
| T04、T05 | `sex`、`personStatus` | 页面缺入口 | 字典选择器 | B15 |
|
||||||
|
| T04、T05 | `fatherId`、`motherId`、`appUserId` | 页面不能从候选选择 | 人物/业务用户候选选择器 | B06、B09/B13;首位成员不能填父母 |
|
||||||
|
| T04、T05 | `avatarOssId` | 页面未接头像上传 | 真实上传组件 | B08:19 位 `ossId` 类型冲突 |
|
||||||
|
| M02 | `sex` | 文本输入 | 字典选择器 | B15 |
|
||||||
|
| M02 | `provinceCode`、`cityCode`、`districtCode` | 页面缺入口 | 行政区划级联选择器 | B12 的接口 DTO 已可读;需接入页面 |
|
||||||
|
| G08 | `inviterUserId` | 数字输入,要求用户手填内部 ID | 真实邀请人候选选择器 | B09:没有业务用户候选/映射,不能手填 |
|
||||||
|
| F06 | `categoryId` | 页面缺入口 | 谱文分类选择器 | B11:测试谱分类为空,合同没有条目字段定义 |
|
||||||
|
| F02 | `feedType` | 自由文本输入 | 动态类型选择器或固定默认类型 | 合同仅给默认 `text`,没有完整类型 enum;不能让用户随意输入未声明类型 |
|
||||||
|
| F09 | `shootTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是拍摄时间;可直接改页面控件 |
|
||||||
|
| R04 | `eventTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是事项时间;可直接改页面控件 |
|
||||||
|
| R07 | `ceremonyTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是活动时间;可直接改页面控件 |
|
||||||
|
| R08 | `recordDate`、`remindTime` | 手工文本时间 | 日期选择器;提醒用日期与时间选择器 | 合同字段分别是记录日期、提醒时间 |
|
||||||
|
| R11 | `meritTime` | 手工文本时间 | 日期与时间选择器 | 合同字段是功德时间 |
|
||||||
|
|
||||||
|
## 已经正确的控件
|
||||||
|
|
||||||
|
| 页面 | 字段 | 当前控件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| G03、G11 | `regionCode` | 行政区划级联选择器 |
|
||||||
|
| G03、G11 | `visibility`、`joinMode` | 已封装为访问规则选择 |
|
||||||
|
| M02 | `birthday` | 日期选择器 |
|
||||||
|
| R10 | `remindTime` | 日期和时间选择器组合 |
|
||||||
|
| 所有上传表单 | `coverOssId`、`mediaOssIds`、`ossId` | 真实上传回执,不提供手填文件 ID |
|
||||||
|
| R04、R11 | 金额 | 数字输入 |
|
||||||
|
| 各编辑页 | `sortOrder` | 数字输入 |
|
||||||
|
|
||||||
|
## 不应为了“字段齐全”强行显示的字段
|
||||||
|
|
||||||
|
| 字段 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| 首位成员的 `fatherId`、`motherId`、`relationName` | 与“首位成员/世系起点”语义冲突;不能伪造已有成员 ID |
|
||||||
|
| `personNo` | 合同明确不传时服务端生成,不应要求用户填写 |
|
||||||
|
| `avatarOssId`、`coverOssId`、`mediaOssIds` | 必须由上传组件产生,不允许文本输入 |
|
||||||
|
| `status`、`completed` 等服务端有默认值的可选字段 | 无业务需求和明确字典时不新增裸代码控件 |
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. 先把 F09、R04、R07、R08、R11 的明确时间字段改为选择器并进行浏览器真实提交回读。
|
||||||
|
2. 再接入 M02 的地区级联选择。
|
||||||
|
3. B15、B09、B11、B06/B08 由后端补齐枚举、候选或 ID 合同后,再补世系、性别、分类、邀请人等选择器。
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# MuMu 全页样式复核(2026-07-26)
|
||||||
|
|
||||||
|
## 范围与方法
|
||||||
|
|
||||||
|
- 设备:MuMu Android(SM-A5560),实际运行中的 APP WebView,900 × 1600 截图。
|
||||||
|
- 范围:`pages.json` 的 52 个页面路由,以及测试账号可进入的列表、空态、错误态、新建表单和原生选择器状态。
|
||||||
|
- 评审:三份独立结论——MuMu 实机逐页评审 1 份、核心表单代码/交互评审 1 份、家族与记录模块代码/交互评审 1 份。只采纳能由真实页面或已声明接口契约证实的结论。
|
||||||
|
- 不使用 mock、fixture 或本地伪造成功。全部截图在 `tmp/mumu-visual-audit-20260726/`。
|
||||||
|
|
||||||
|
## 逐页复核结果
|
||||||
|
|
||||||
|
已逐页打开并复核以下 52 条路由:
|
||||||
|
|
||||||
|
- 认证:A01、A04、A05。
|
||||||
|
- 家谱:G01、G03、G05、G06、G08、G09、G10、G11、G12。
|
||||||
|
- 世系:T01、T03、T04、T05、T06、T07、T08。
|
||||||
|
- 家族:F01、F02、F03、F04、F05、F06、F07、F08、F09、F10。
|
||||||
|
- 记录:R01、R02、R03、R04、R05、R06、R07、R08、R09、R10、R11。
|
||||||
|
- 消息:N01、N02。
|
||||||
|
- 我的:M01、M02、M03、M04、M05、M06、M07、M08、M09、M10。
|
||||||
|
|
||||||
|
结论:已进入的页面均保持朱红、金线、宣纸背景和卷轴按钮这套既有视觉系统;未发现横向页面滚动、标题遮挡、内容与底部导航重叠。长内容、空态、接口错误态和可进入的新建表单均已按实际数据复核。
|
||||||
|
|
||||||
|
## 已落地且在 MuMu 复验的改动
|
||||||
|
|
||||||
|
1. G01 当前家谱的超长名称改为单行省略,避免末尾数字孤立换行。
|
||||||
|
2. G05 长简介限制为两行,恢复被遮挡的世系树、字辈谱、审核、设置四张功能卡;家谱统计信息收回深色头图区域。
|
||||||
|
3. F02 动态类型固定显示为“文字动态”,不再把接口枚举值暴露成可编辑输入框;上传按钮增至可触控尺寸。
|
||||||
|
4. F03 未返回点赞状态时使用中性说明,不再把未知状态渲染成失败样式。
|
||||||
|
5. F10、R09、N02、M03、M05、G12、M08 移除面向用户的接口、operation、Mock 等内部术语,改为可理解的功能状态说明。
|
||||||
|
6. M01 默认头像使用项目真实 `auth-login-outline` 图;性别代码不再直接展示。M02 在没有服务端选项字典时显示“待确认 / 选项待提供”,不伪造选择项。
|
||||||
|
7. R02 的“人生大事”改为明确的“暂未开放”状态,不再提供无效可点击入口。
|
||||||
|
8. F02、F06、F07、F09、R07、R08、R10 的图片上传入口最小高度统一到 88rpx。
|
||||||
|
9. R08 成长日志的记录、提醒时间,R11 功德记录的记录时间,全部改为系统日期/时间选择器;提交时仍使用接口既有 `YYYY-MM-DD HH:mm:ss` 文本字段。
|
||||||
|
10. R08、R10、R11 的表单底部“取消 / 提交”按钮改为容器内的可收缩网格。修复了 MuMu 窄屏上右侧提交按钮被裁切的问题;三页都已实机复验。
|
||||||
|
|
||||||
|
## 选择器与输入原则
|
||||||
|
|
||||||
|
- 日期、时间:使用实际 Android 原生选择器,未改变后端字段名或格式。
|
||||||
|
- 有确定自由文本语义的字段仍使用输入框,例如姓名、标题、地点、人物简介、备注。
|
||||||
|
- 性别、人物状态、农历等需要枚举/字典的字段,当前接口未提供可用选项来源;未猜测中文标签或构造假选项。
|
||||||
|
|
||||||
|
## 不能通过前端补齐的状态
|
||||||
|
|
||||||
|
| 范围 | 真实结果 | 前端处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 首位成员创建 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons` 使用最小合法 body 仍返回业务 `code:500` | 不伪造成员;T03–T08、R01–R02 的真实成员读取/写入闭环仍需后端修复或提供可读测试成员。 |
|
||||||
|
| 性别、人物状态、农历等选择项 | 接口契约未提供枚举/字典来源 | 保留真实已返回值;未知值显示“待确认”,不将代码当中文含义解释。 |
|
||||||
|
| 消息详情 | 当前测试数据没有可读的消息详情标识 | N02 复核了无详情状态和返回路径,未伪造详情内容。 |
|
||||||
|
|
||||||
|
## 本轮验证
|
||||||
|
|
||||||
|
- `powershell -NoProfile -ExecutionPolicy Bypass -File tests/compile-audit.ps1`:通过。
|
||||||
|
- `powershell -NoProfile -ExecutionPolicy Bypass -File tests/r-business-flow-contract.ps1`:通过。
|
||||||
|
- `powershell -NoProfile -ExecutionPolicy Bypass -File tests/active-page-business-ownership-contract.ps1`:通过。
|
||||||
|
- MuMu 实机复验:R08、R10、R11 新建表单及其底部按钮;R08 日期选择器;G01、G05、F02、F03、F10、G12、N02、M01、M02、M03、M05、M08 的改动后状态。
|
||||||
|
- 最后一轮 MuMu 路由复测:52/52 条路由均已重新截图完成,结果位于 `tmp/mumu-visual-audit-20260726/final-routes/`。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 全量页面接口联调交接说明
|
# 全量页面接口联调交接说明
|
||||||
|
|
||||||
更新日期:2026-07-24
|
更新日期:2026-07-26
|
||||||
适用项目:家谱 APP 前端(`jiapuapp`)
|
适用项目:家谱 APP 前端(`jiapuapp`)
|
||||||
|
|
||||||
## 一、交接结论
|
## 一、交接结论
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
不能把本轮结论简化成“所有接口均已联调通过”。世系首位成员创建目前由服务端返回业务 `code: 500`,导致依赖真实成员的详情、编辑与关系链路没有可用测试实体,仍是明确阻塞项。
|
不能把本轮结论简化成“所有接口均已联调通过”。世系首位成员创建目前由服务端返回业务 `code: 500`,导致依赖真实成员的详情、编辑与关系链路没有可用测试实体,仍是明确阻塞项。
|
||||||
|
|
||||||
|
2026-07-26 的浏览器真实上传复测还确认了第二个合同阻塞:H5 已能从页面选择本地图片并完成 `init/chunk/complete` 上传,但 `complete.ossId` 是 19 位字符串,而照片/封面等业务 DTO 仍要求数值 `int64`。前端不能安全地把该值转成 JavaScript number,因此目前不会伪造照片创建成功。完整请求、响应和后端所需统一字段合同见 `docs/后端接口缺口与首位成员阻塞清单-2026-07-24.md` 的“八”。
|
||||||
|
|
||||||
## 二、本轮已验证的范围
|
## 二、本轮已验证的范围
|
||||||
|
|
||||||
### 2.1 页面路由与接线
|
### 2.1 页面路由与接线
|
||||||
@@ -85,7 +87,7 @@ HTTP 返回成功,但业务响应为 `code: 500`,未创建成员;之后读
|
|||||||
| F10 家族视频 | 明确提示暂未开放 | 仅见删除 operation,未见视频列表、详情、播放、发布等读取/写入合同。 |
|
| F10 家族视频 | 明确提示暂未开放 | 仅见删除 operation,未见视频列表、详情、播放、发布等读取/写入合同。 |
|
||||||
| R09 人生事件 | 明确提示暂未开放 | APP 目录中无独立人生事件资源。 |
|
| R09 人生事件 | 明确提示暂未开放 | APP 目录中无独立人生事件资源。 |
|
||||||
| N02 消息详情 | 明确提示暂未开放 | 只有通知列表和标已读,没有单条详情读取 owner。 |
|
| N02 消息详情 | 明确提示暂未开放 | 只有通知列表和标已读,没有单条详情读取 owner。 |
|
||||||
| M03 安全概览 | 不展示假账号/设备摘要 | 无安全概览、设备、登录记录读取合同。 |
|
| M03 安全概览 | 已接真实 profile 只读概览 | 2026-07-26 实测 `GET /auth/profile` 返回 `phone`、`userNo`、`status`;页面仅展示掩码手机号、账号编号和状态。设备与登录记录仍无独立读取 operation,不展示或推断。 |
|
||||||
| M05 换绑手机号 | 等待人工 TAC/短信验证 | 缺少可安全消费的当前手机号 DTO 与已核实验证码链路。 |
|
| M05 换绑手机号 | 等待人工 TAC/短信验证 | 缺少可安全消费的当前手机号 DTO 与已核实验证码链路。 |
|
||||||
| 删除、退出、改密、支付 | 未执行 | 本轮测试范围禁止敏感操作。 |
|
| 删除、退出、改密、支付 | 未执行 | 本轮测试范围禁止敏感操作。 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
# 后端接口缺口与首位成员阻塞清单
|
||||||
|
|
||||||
|
更新日期:2026-07-24
|
||||||
|
适用范围:家谱 APP 前端(`jiapuapp`)与 APP API 合同维护
|
||||||
|
|
||||||
|
## 目的与边界
|
||||||
|
|
||||||
|
本文供后端确认接口 owner、DTO 和测试数据,不要求前端猜测字段或用本地数据绕过接口。下文的“需要”分为两种:
|
||||||
|
|
||||||
|
- **已有接口但不可闭环**:路径和当前参数已存在,需修复服务端行为或补全可消费的 DTO。
|
||||||
|
- **缺少业务 owner**:现有 APP OpenAPI 没有该页面所需的读取/写入 operation;需要后端和产品先确定资源归属,再发布合同。
|
||||||
|
|
||||||
|
除非下文明确写为“当前已声明字段”,字段名、枚举、分页形式和响应结构都由后端在 APP OpenAPI 中一次性定义;前端不会从其他模块、PC 接口或示例数据推断。
|
||||||
|
|
||||||
|
## 通用请求约束
|
||||||
|
|
||||||
|
当前已声明的受保护 APP 接口均需要当前测试账号的认证会话和 `clientid` 请求头。若请求体含 `clientId`,其值必须与该 Header 一致。本文不记录真实 token、手机号或生产数据。
|
||||||
|
|
||||||
|
后端交付一个新 operation 或 DTO 时,请同时在 APP OpenAPI 的 YAML/JSON 中补齐:路径、HTTP 方法、鉴权、所有 path/query/body 参数、成功响应、可预期的 4xx/5xx 业务码及字段必填性。仅有 HTTP 200 而业务 `code` 为失败,不能视为接口成功。
|
||||||
|
|
||||||
|
## 一、缺少业务 operation 的页面
|
||||||
|
|
||||||
|
### F10 家族视频:缺视频读取 owner
|
||||||
|
|
||||||
|
现状:`pages/family/f10-video-list.vue` 已明确关闭。当前 OpenAPI 只声明:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE /genealogy/app/genealogies/{genealogyId}/videos/{videoId}
|
||||||
|
```
|
||||||
|
|
||||||
|
没有视频列表、详情、播放地址或发布 operation。删除接口不能反推出读取和播放合同。
|
||||||
|
|
||||||
|
当前页面入口可提供的业务上下文只有:
|
||||||
|
|
||||||
|
| 参数语义 | 来源 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `genealogyId` | 页面路由 | 已有,必需 |
|
||||||
|
| 当前登录账号 | 认证会话 | 已有,必需 |
|
||||||
|
| `clientid` | APP 请求头 | 已有,必需 |
|
||||||
|
|
||||||
|
请后端/产品先确认视频资源是否归属某个家谱。若是,最小可用的首个 operation 应是“读取该家谱可见视频列表”,并明确:
|
||||||
|
|
||||||
|
1. 是否分页;若分页,统一选择并声明 `pageNum/pageSize` 或 `cursor/pageSize` 之一,不能让前端猜测。
|
||||||
|
2. 每条记录的稳定视频标识,以及当前页面用于渲染列表所需的明确字段和字段类型。
|
||||||
|
3. 当前账号无权、资源不存在、空列表的响应语义。
|
||||||
|
|
||||||
|
视频详情、播放、发布、编辑、评论、点赞和分享目前没有对应页面表单或页面数据模型;若产品要开放这些能力,请为每项单独给出 operation 与 DTO,不要用相册或动态接口兼容代替。
|
||||||
|
|
||||||
|
### R09 人生大事:缺独立资源与归属定义
|
||||||
|
|
||||||
|
现状:`pages/records/r09-life-events.vue` 已明确关闭;APP/PC OpenAPI 中都未发现独立“人生事件/life event”资源。
|
||||||
|
|
||||||
|
从 R02 人物详情跳转 R09 时会传入 `genealogyId` 和 `personId`,但 R09 当前没有读取或写入表单,因此不能从 UI 推断事件到底是“人物专属”还是“家谱公共”。请后端/产品先在以下两种归属中选择一种:
|
||||||
|
|
||||||
|
| 资源归属 | 最少识别参数 |
|
||||||
|
| --- | --- |
|
||||||
|
| 人物专属事件 | `genealogyId`、`personId` |
|
||||||
|
| 家谱公共事件 | `genealogyId` |
|
||||||
|
|
||||||
|
归属确定后,请提供对应的列表/详情/创建或编辑 operation(以产品实际要开放的页面为准),并定义事件的必填业务字段、时间字段、排序/分页、权限和错误语义。归属未确定前,前端不会把成长记录、备忘或人物简介映射成人生大事。
|
||||||
|
|
||||||
|
### N02 消息详情:缺单条详情读取 operation
|
||||||
|
|
||||||
|
现状:当前已声明:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/app/notifications
|
||||||
|
POST /genealogy/app/notifications/{notificationId}/read
|
||||||
|
POST /genealogy/app/notifications/read-all
|
||||||
|
```
|
||||||
|
|
||||||
|
没有单条详情读取 owner,因此 `pages/notification/n02-message-detail.vue` 不展示 fixture 正文,也不从通知字段推断业务跳转。
|
||||||
|
|
||||||
|
建议后端补一个单条详情读取 operation;具体路径可由后端确定,但其最小输入和输出语义应为:
|
||||||
|
|
||||||
|
| 位置 | 需要项 |
|
||||||
|
| --- | --- |
|
||||||
|
| path 参数 | `notificationId`;应与现有“标记已读”接口使用同一通知标识语义 |
|
||||||
|
| 请求上下文 | 当前登录账号、`clientid` |
|
||||||
|
| 响应必需内容 | 通知标题、完整纯文本正文、发布时间、已读状态,以及供服务端内部定位的稳定通知标识 |
|
||||||
|
| 错误语义 | 当前账号无权/通知不存在时应明确失败,不能返回其他账号的通知 |
|
||||||
|
|
||||||
|
业务跳转不是本次最低需求。若后端未来要返回业务目标,需另行提供受权限约束的 `bizType → 前端路由键 + 必填参数 + 失效语义` 闭合字典;前端不会由 `bizId`、标题或正文猜路由。
|
||||||
|
|
||||||
|
## 二、已有路径但缺少可安全消费合同的页面
|
||||||
|
|
||||||
|
### M03 账号与安全:已解除(真实 profile 只读概览)
|
||||||
|
|
||||||
|
2026-07-26 已用测试账号真实读取 `GET /genealogy/app/auth/profile`。响应含 `phone`、`userNo`、`status`;M03 已接入并只显示本地掩码手机号、账号编号和账号状态。页面不把明文手机号写入路由、日志或缓存。
|
||||||
|
|
||||||
|
“设备列表”“登录记录”“安全等级”仍没有独立读取 owner,也不属于当前 M03 的数据模型;页面不会展示或推断这些内容。建议后端仍在 Apifox 为 profile 发布明确 DTO;如产品要展示上述资源,应分别定义 operation、字段、权限和保留期。
|
||||||
|
|
||||||
|
### M05 换绑手机号:已有最终 PUT,但缺可验证的安全链路与读取 DTO
|
||||||
|
|
||||||
|
当前 OpenAPI 已声明最终写入接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PUT /genealogy/app/auth/phone
|
||||||
|
```
|
||||||
|
|
||||||
|
当前已声明的请求参数如下;它们是现状记录,不是前端新增猜测:
|
||||||
|
|
||||||
|
| 位置 | 字段 | 约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Header | `clientid` | 必填 |
|
||||||
|
| Body | `clientId` | 必填,必须等于 Header `clientid` |
|
||||||
|
| Body | `phone` | 必填,新手机号 |
|
||||||
|
| Body | `smsCode` | 必填;当前导出合同为 4 位数字 |
|
||||||
|
| 请求上下文 | 当前登录账号 | 必填 |
|
||||||
|
|
||||||
|
当前 OpenAPI 还声明通用发码接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /genealogy/app/auth/sms/code
|
||||||
|
```
|
||||||
|
|
||||||
|
其当前 body 必填字段为 `clientId`、`tenantId`、`grantType`、`sceneCode`、`phone`、`validToken`;换绑场景为 `APP_PHONE_CHANGE`。当前 profile 已能只读获取并掩码显示手机号,但没有经过人工确认的换绑专用 TAC/发码/校验闭环;M05 因而继续保持关闭,且不在无人值守时发送短信或换绑。
|
||||||
|
|
||||||
|
请后端确认并在同一版本交付:
|
||||||
|
|
||||||
|
1. `APP_PHONE_CHANGE` 的 `validToken` 获取、绑定手机号主体校验、过期和一次性消费语义;如通用发码接口不适合作为换绑 owner,请提供专用 operation,而不是要求前端复用未知场景。
|
||||||
|
2. 最终 PUT 成功、验证码错误/过期/重复、手机号已占用、无权限和结果未知时的明确 HTTP 与业务码语义。
|
||||||
|
|
||||||
|
本问题单只要求合同和人工可控的测试条件,不授权前端执行短信、换绑或其他账号安全写入。
|
||||||
|
|
||||||
|
## 三、已有接口但被服务端 `code:500` 阻断的成员页面
|
||||||
|
|
||||||
|
这不是“缺接口”,而是首位成员无法创建导致没有真实 `personId`。阻断请求为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /genealogy/app/genealogies/{genealogyId}/lineage/persons
|
||||||
|
```
|
||||||
|
|
||||||
|
当前已验证的最小请求:
|
||||||
|
|
||||||
|
| 位置 | 参数 | 值/约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Path | `genealogyId` | 可访问的测试家谱 ID |
|
||||||
|
| Header | `clientid` | 必填 |
|
||||||
|
| 请求上下文 | 当前测试账号 | 必填 |
|
||||||
|
| Body | `name` | 当前 `LineagePersonBody` 唯一 required 字段;非空成员姓名 |
|
||||||
|
| Body | `generation` | 首位成员固定为整数 `1` |
|
||||||
|
|
||||||
|
2026-07-26 又从 T04 页面填写了可安全收集的全量资料并真实提交:姓名、人物编号、示例字典值、排序、别名、字辈、生卒日期、生卒地、安葬地、简介、备注;HTTP `200`,业务仍返回 `code:500 / 发生未知异常,请联系管理员`。首位成员不应伪填 `fatherId`、`motherId`、`relationName`;`appUserId` 缺同类型候选映射,头像 `avatarOssId` 仍受 19 位 `ossId` 类型冲突阻断。`sex`、`birthLunar`、`deathLunar`、`personStatus` 虽为可选,但 Apifox 仅称项目字典值、无枚举,页面不能把猜测的代码或农历文本作为正式交互。
|
||||||
|
|
||||||
|
已复现结果:最小请求与上述全字段页面请求均为 HTTP 层成功、业务响应 `code:500`;随后读取世系树仍为空。
|
||||||
|
|
||||||
|
请后端处理:
|
||||||
|
|
||||||
|
1. 查询该请求的异常栈,以及空世系创建首位成员所需的家谱/租户初始化条件。
|
||||||
|
2. 修复后使该最小合法请求返回业务成功,并返回可读取的成员结果(至少可取得稳定 `personId`)。
|
||||||
|
3. 以同一测试家谱验证后续读取:世系树、成员目录分页、成员详情均能读到该成员。
|
||||||
|
4. 若暂不能修复,请提供当前测试账号有访问权限的测试 `genealogyId` 与真实 `personId`,供只读和允许写入的回读验证;不要提供生产数据。
|
||||||
|
|
||||||
|
解锁范围为 T03–T08、R01–R02:成员详情、首位成员/亲属创建、编辑、排行、成员目录与成员状态等。已有读取/写入路径应在取得真实 `personId` 后按页面顺序验证,不应改成 mock 或本地假成功。
|
||||||
|
|
||||||
|
## 四、后端回传模板
|
||||||
|
|
||||||
|
请每个问题按下列项目回复,便于前端不重做已接线部分:
|
||||||
|
|
||||||
|
```text
|
||||||
|
页面/问题:
|
||||||
|
资源 owner:
|
||||||
|
APP operation(HTTP 方法 + 路径):
|
||||||
|
鉴权和 clientid 要求:
|
||||||
|
path/query/body 参数(字段、类型、必填、枚举/范围):
|
||||||
|
成功响应 DTO(字段、类型、必填):
|
||||||
|
错误响应(HTTP 状态 + 业务 code + 可展示文案):
|
||||||
|
分页/排序/权限语义(如适用):
|
||||||
|
可使用的测试 genealogyId / personId(仅测试数据):
|
||||||
|
OpenAPI YAML/JSON 更新版本:
|
||||||
|
```
|
||||||
|
|
||||||
|
前端收到更新后的合同和测试数据后,会先做最小浏览器真实请求,并以服务端成功响应后的列表/详情回读作为验收依据;样式问题另在模拟器复核。
|
||||||
|
|
||||||
|
## 五、A01 密码登录:已解除
|
||||||
|
|
||||||
|
2026-07-25 已在桌面版 Apifox 核对并用测试账号直连验证。最新已发布合同如下:
|
||||||
|
|
||||||
|
| 位置 | 字段 |
|
||||||
|
| --- | --- |
|
||||||
|
| Header | `clientid` |
|
||||||
|
| Body | `grantType=password`、`tenantId`、`phone`、32 位 MD5 `password`;`validToken` 仅在验证中心策略开启时必传 |
|
||||||
|
|
||||||
|
`clientId` 与 `sceneCode` 不再属于密码登录 body:后端根据 `POST /genealogy/app/auth/login` 绑定 `APP_PASSWORD_LOGIN` 场景。前端先查询 `GET /genealogy/app/auth/verification/password-login/require`;服务端返回 `required:false` 时不伪造滑块或票据,直接提交上述 body;返回 `required:true` 时才完成验证中心 challenge/verify 并提交真实 `validToken`。
|
||||||
|
|
||||||
|
实际结果:`password-login/require` 返回 `HTTP 200 / code:200 / required:false`;随后密码登录返回 `HTTP 200 / code:200`,响应含 `access_token`。本项不再是后端阻塞。
|
||||||
|
|
||||||
|
## 六、2026-07-25 Apifox 认证动作合同更新(已接入)
|
||||||
|
|
||||||
|
Apifox 当前“家谱”项目新增并发布了下列 APP 认证动作接口;本地旧 `APP.openapi.json/yaml` 尚未同步这次导出,不能再作为认证链的 source of truth:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/app/auth/verification/{operationCode}/require
|
||||||
|
POST /genealogy/app/auth/verification/{operationCode}/challenge
|
||||||
|
POST /genealogy/app/auth/verification/{operationCode}/verify
|
||||||
|
POST /genealogy/app/auth/sms/{operationCode}/code
|
||||||
|
```
|
||||||
|
|
||||||
|
`operationCode` 的已发布枚举为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
password-login | sms-login | register | forgot-password | phone-change | account-deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
合同要点:
|
||||||
|
|
||||||
|
| 操作 | Header | path/query/body |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 查询策略 | `clientid` | path `operationCode`;query `tenantId`、`subject` |
|
||||||
|
| 获取挑战 | `clientid` | path `operationCode`;body `tenantId`、`subject` |
|
||||||
|
| 校验挑战 | `clientid` | path 必须与挑战使用同一 `operationCode`;body `tenantId`、`subject`、`challengeId` 和验证码证据 |
|
||||||
|
| 发送短信 | `clientid` | path `operationCode`;body `tenantId`、`phone`、`grantType=sms`;仅服务端策略开启时提交 `validToken` |
|
||||||
|
|
||||||
|
所有新接口都明确禁止前端再提交 `clientId` 或 `sceneCode`;服务端按 APP 路由的当前激活绑定解析验证场景。
|
||||||
|
|
||||||
|
已做的最小真实验证(仅测试手机号、未发送短信):
|
||||||
|
|
||||||
|
| 请求 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET …/verification/APP_SMS_LOGIN/require` | `HTTP 200`,业务 `code:500`:不支持的认证业务动作 |
|
||||||
|
| `GET …/verification/sms-login/require` | `HTTP 200`,业务 `code:200`,`required:true`,`TIANAI/SLIDER` |
|
||||||
|
| `GET …/verification/password-login/require` | `HTTP 200`,业务 `code:200`,`required:false`,服务端绑定 `sceneCode: APP_LOGIN` |
|
||||||
|
|
||||||
|
前端已将 A01/A04/A05 的验证、挑战、校验和发码链接到新路径;A01 密码登录使用 `password-login`,策略为 `required:false` 时直接调用最新密码登录 body;返回 `required:true` 时携带真实 `validToken`。短信、注册、找回仍只在服务端返回 `required:true` 后完成 TAC 并携带真实 `validToken`。
|
||||||
|
|
||||||
|
已用测试账号验证 A01 密码登录的真实成功响应;本轮未发送短信、注册、改密、换绑、退出或执行其他敏感操作。
|
||||||
|
|
||||||
|
## 七、2026-07-25 桌面 Apifox 复核:仍缺 owner 与分页响应异常
|
||||||
|
|
||||||
|
以下结论直接来自桌面版 Apifox 当前 APP 目录,前端不以其他资源替代:
|
||||||
|
|
||||||
|
| 页面 | Apifox 当前 operation | 缺口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F10 家族视频 | 仅 `DELETE /genealogy/app/genealogies/{genealogyId}/videos/{videoId}` | 缺视频列表、详情、播放地址、发布/上传和编辑 owner |
|
||||||
|
| R09 人生大事 | “族务记录”目录仅有成长记录、备忘、亲友记录、功德记录四类 operation | 缺人生事件的列表、详情、创建、编辑、删除 owner |
|
||||||
|
| N02 消息详情 | 通知目录只有列表、单条标已读、未读数、全部标已读 | 缺单条消息详情读取 owner |
|
||||||
|
|
||||||
|
另发现一个后端响应异常,不影响当前 F01(F01 使用非分页 `GET …/feeds`,已真实返回数组),但会使已发布分页 operation 无法使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/app/genealogies/{genealogyId}/feeds/page?pageNum=1&pageSize=20
|
||||||
|
```
|
||||||
|
|
||||||
|
桌面 Apifox 将成功响应声明为 `PageResult`;同一测试账号实际得到 `HTTP 200 / code:200`,却完全省略 `data`。客户端的分页合同需要 `data.rows`(数组)和 `data.total`(整数),因此当前会精确拒绝为分页响应无效,不能把空 envelope 当成空列表。
|
||||||
|
|
||||||
|
请后端:
|
||||||
|
|
||||||
|
1. 为 F10、R09、N02 分别提供独立 APP operation 与完整 DTO;不要让前端复用相册、成长记录或通知列表字段。
|
||||||
|
2. 修复 `feeds/page` 成功响应,使 `data.rows` 和 `data.total` 与 Apifox `PageResult` 一致;若该 operation 已废弃,请在 Apifox 明确下线,避免保留可调用但无结果的合同。
|
||||||
|
|
||||||
|
## 八、2026-07-26 真实 H5 图片上传:`ossId` 跨接口类型冲突
|
||||||
|
|
||||||
|
本项使用测试家谱的 F09「添加照片」页面、浏览器文件选择器和本地真实图片
|
||||||
|
`static/assets/foundation/transparent/auth-login-outline.png` 验证;没有使用 mock、fixture 或手工伪造上传回执。
|
||||||
|
|
||||||
|
实际请求链路均返回 `HTTP 200 / code:200`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /genealogy/app/files/resumable/init
|
||||||
|
POST /genealogy/app/files/resumable/chunk
|
||||||
|
POST /genealogy/app/files/resumable/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
`complete` 的真实响应为(URL 已省略):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"fileName": "auth-login-outline.png",
|
||||||
|
"ossId": "2081232520259612673"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该 `ossId` 是 19 位雪花标识,必须以字符串原样传递。它大于 JavaScript `Number.MAX_SAFE_INTEGER`;转换为 number 会丢失精度。当前照片创建 DTO 却把 `ossId` 声明为 `int64` 数值,F09 无法在不篡改标识的前提下提交:页面已经明确提示并停止在关联业务数据之前。
|
||||||
|
|
||||||
|
受影响的是所有“上传回执 → 业务表单”页面,而不只是 F09:动态配图、谱文封面、相册封面/照片、家谱封面、礼仪图片、备忘附件等。前端不会把字符串转为不安全 number,也不会提交截断后的 ID。
|
||||||
|
|
||||||
|
请后端在同一 APP OpenAPI 版本统一文件标识合同:
|
||||||
|
|
||||||
|
| 位置 | 需要确定的合同 |
|
||||||
|
| --- | --- |
|
||||||
|
| 上传完成响应 | `ossId` 保持十进制字符串(现网已如此返回),并明确为稳定文件标识 |
|
||||||
|
| 所有消费该标识的 APP 请求 DTO | 对应字段统一接受同一十进制字符串;不要在 JSON 中要求前端传 `int64` 数值 |
|
||||||
|
| 响应 DTO 与字段示例 | 同步明确字符串类型、正整数词法约束和真实大于 `2^53-1` 的示例 |
|
||||||
|
| Apifox 发布内容 | 更新照片创建、封面、附件等所有相关 operation,避免上传接口和业务接口各自定义一套类型 |
|
||||||
|
|
||||||
|
后端发布统一合同后,请提供仅测试数据可写入的家谱上下文。前端会重新从 F09 页面选择真实文件,完成“上传 → 创建照片 → 相册列表/详情回读”验收;在此之前不会猜测创建照片接口是否私下兼容字符串。
|
||||||
|
|
||||||
|
## 九、2026-07-26 Apifox 全量复核:贺礼邀约缺少受邀人候选来源
|
||||||
|
|
||||||
|
桌面 Apifox 当前已发布以下 4 个“贺礼邀约” operation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PUT /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/invitees
|
||||||
|
GET /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/invitations
|
||||||
|
PUT /genealogy/app/genealogies/{genealogyId}/ceremonies/{ceremonyId}/invitations/me
|
||||||
|
GET /genealogy/app/genealogies/ceremony-invitations/mine
|
||||||
|
```
|
||||||
|
|
||||||
|
其中请求合同已明确,前端不会补猜字段:
|
||||||
|
|
||||||
|
| operation | 必填参数 | 可选参数 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 替换活动受邀人 | path `genealogyId`、`ceremonyId`;body `inviteeUserIds` | 无 | `inviteeUserIds` 是唯一业务用户 ID 的完整数组;空数组表示取消全部尚未响应的邀请 |
|
||||||
|
| 查询活动邀请名单 | path `genealogyId`、`ceremonyId` | 无 | 返回邀请记录列表 |
|
||||||
|
| 响应当前用户邀请 | path `genealogyId`、`ceremonyId`;body `inviteStatus` | 无 | 枚举仅允许 `ACCEPTED` 或 `DECLINED` |
|
||||||
|
| 查询我的活动邀请 | 无业务 path/body | 无 | 当前登录业务用户的有效邀请列表 |
|
||||||
|
|
||||||
|
当前 `CeremonyInvitationVo` 可读取字段为:`invitationId`、`genealogyId`、`ceremonyId`、`inviteeUserId`、`inviteStatus`(`PENDING` / `ACCEPTED` / `DECLINED` / `CANCELED`)、`inviteVersion`、`deliveredTime`、`readTime`、`responseTime`、`ceremonyTitle`、`ceremonyTime`、`location`、`locationAddress`、`longitude`、`latitude`。这些 ID 均为 `int64`,真实雪花 ID 必须统一允许十进制字符串传递,不能要求 H5 转为不安全的 JavaScript number。
|
||||||
|
|
||||||
|
### 阻塞点
|
||||||
|
|
||||||
|
R07 需要的是 `inviteeUserIds`(业务用户 ID),但当前 APP 家谱成员列表没有声明可供选择的业务用户 ID,也没有“可受邀用户候选列表” operation。`memberId`、`lineagePersonId` 和 `inviteeUserId` 是不同资源标识,前端不会用任一个替代另一个,也不会提供让用户手输未知业务 ID 的伪流程。
|
||||||
|
|
||||||
|
请后端在同一版 APP OpenAPI 中二选一,并提供仅测试数据:
|
||||||
|
|
||||||
|
1. 在现有“家谱成员列表/选项”成功 DTO 中明确返回稳定的 `appUserId`(或等价字段),并声明该字段就是 `inviteeUserIds` 的合法值;或
|
||||||
|
2. 新增“查询活动可邀请用户候选列表” operation,至少返回业务用户 ID、展示名、是否可邀请、不可邀请原因。
|
||||||
|
|
||||||
|
同时请确认 `int64` 业务用户 ID 在所有 APP JSON 请求/响应中使用十进制字符串合同,避免与第八节 `ossId` 相同的精度丢失问题。候选来源和字符串 ID 合同发布前,前端只接入邀请读取/响应的精确 API 方法,不会把“替换受邀人”伪装成可用页面功能。
|
||||||
|
|
||||||
|
## 十、2026-07-26 Apifox 路径修正:行政区划
|
||||||
|
|
||||||
|
桌面 Apifox 当前正式路径为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/region/children?parentCode={parentCode}
|
||||||
|
GET /genealogy/region/path/{regionCode}
|
||||||
|
GET /genealogy/region/search?keyword={keyword}&level={level?}&limit={limit?}
|
||||||
|
GET /genealogy/region/{regionCode}
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `search.keyword` 必填;`search.level`、`search.limit` 可选;其余 `regionCode` path 参数必填,`children.parentCode` 可选。G03 原先错误使用了 `/genealogy/app/region/children`,已改为正式路径并在 H5 浏览器验证省、市、区三级均返回业务 `code:200`。其余 3 个 operation 尚无当前页面交互入口,待桌面 Apifox 补齐可消费的响应 DTO 后按实际页面需求接入;不会以 `additionalProperties` 的导出占位结构猜字段。
|
||||||
|
|
||||||
|
## 十一、2026-07-26 F03 真实点赞后缺少当前用户状态
|
||||||
|
|
||||||
|
测试账号通过 F03 动态详情页面执行了一次真实“点赞”(没有执行取消点赞或其他删除操作):
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/likes
|
||||||
|
```
|
||||||
|
|
||||||
|
该请求返回 `HTTP 200`。随后页面重新读取详情,`likeCount` 从 `0` 变为 `1`,说明服务端已写入点赞;但详情响应没有给出可用的当前用户点赞状态,前端读取到的 `likedByMe` 仍为 `false`/缺失,按钮继续显示“点赞”而不是“取消点赞”。
|
||||||
|
|
||||||
|
这不是前端可以用本地布尔值补齐的缺口:页面刷新后必须以服务端状态决定下一次应该调用 `POST /likes` 还是 `DELETE /likes`。当前合同会导致重复点击语义不明确,也无法安全展示已点赞状态。
|
||||||
|
|
||||||
|
请后端在以下 APP 读取 DTO 中统一提供当前登录用户维度的必填布尔字段 `likedByMe`(或同义且明确的字段),并与点赞写入立即一致:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/app/genealogies/{genealogyId}/feeds
|
||||||
|
GET /genealogy/app/genealogies/{genealogyId}/feeds/page
|
||||||
|
GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段必须是 `boolean`,不能以点赞总数、空值或仅他人点赞记录代替。后端发布后,前端会再从 F03 页面验证“点赞 → 详情回读为已点赞 → 仅在用户主动操作时取消点赞”的完整闭环。
|
||||||
|
|
||||||
|
前端已将缺失/非布尔值保留为“状态未知”,并在 F03 禁用点赞按钮、显示明确原因;不会再把它转换为 `false` 后重复发送点赞请求。
|
||||||
|
|
||||||
|
## 十二、2026-07-26 谱文分类列表缺少可消费的条目 DTO
|
||||||
|
|
||||||
|
桌面 Apifox 的正式 operation 为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/app/genealogies/{genealogyId}/article-categories
|
||||||
|
```
|
||||||
|
|
||||||
|
请求仅要求 path `genealogyId`(以及认证 `clientid`),成功响应在当前桌面文档中标注为通用 `ListResult`,没有声明列表项字段模型、字段示例或 `categoryId` 的类型。F06 的创建谱文请求可选 `categoryId`,但前端不能在不知道“分类标识”和“分类展示名”字段的情况下把任意对象渲染成选择器。
|
||||||
|
|
||||||
|
请后端把该 operation 的 `data[]` 明确为具名 DTO,至少包含:
|
||||||
|
|
||||||
|
| 字段 | 合同要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| `categoryId` | 稳定分类标识;若为 int64,APP JSON 统一使用十进制字符串 |
|
||||||
|
| `categoryName` | 非空展示名称 |
|
||||||
|
| `status` / `enabled` | 明确是否允许在新建谱文中选择;若不需要则明确只返回可选项 |
|
||||||
|
| `sortOrder` | 可选的稳定排序字段;若服务端已排序则在文档明确 |
|
||||||
|
|
||||||
|
在条目 DTO 发布前,F06 继续允许按现有创建合同不带 `categoryId` 发布,不会把导出文件里的通用对象或本地硬编码分类当作真实分类数据。
|
||||||
|
|
||||||
|
## 十三、当前全部已确认阻塞总表
|
||||||
|
|
||||||
|
本表是当前 APP 136 operation 审计的唯一汇总入口;详情、请求字段和回传模板见本文件对应章节,以及 `APP-136请求参数字段核对表-2026-07-26.md`。这里的“阻塞”不等于前端缺少调用代码:可能是 operation 根本不存在、响应 DTO 无法消费、服务端真实失败,或 H5 不存在安全参数来源。
|
||||||
|
|
||||||
|
| 编号 | 类别 | operation / 合同 | 影响页面 | 已验证事实 | 后端解除条件 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| B01 | 缺 operation | 视频仅有 `DELETE /videos/{videoId}` | F10 | 无列表、详情、播放地址、创建、上传、编辑等 owner | 发布视频读取与发布完整 operation/DTO |
|
||||||
|
| B02 | 缺 operation | 人生大事无资源 owner | R09 | 族务目录只有成长、备忘、亲友、功德 | 明确人物专属或家谱公共归属后发布列表/详情/写入 DTO |
|
||||||
|
| B03 | 缺 operation | 通知仅有列表和标已读 | N02 | 无 `notificationId` 单条详情读取 | 发布单条详情读取及授权/错误语义 |
|
||||||
|
| B05 | 安全链路缺失 | `PUT /auth/phone` | M05 | 只有最终 PUT,缺经确认的当前账号读取、TAC/发码闭环 | 发布专用换绑验证合同与读 DTO;敏感写入仍需人工测试许可 |
|
||||||
|
| B06 | 服务端异常 | `POST /genealogies/{genealogyId}/lineage/persons` | T03–T08、R01–R02 | 2026-07-26 从 T04 页面分别以最小首位成员数据、以及包含编号/示例字典值/排序/生卒信息/简介备注的全字段资料真实提交,均为 HTTP `200` 但业务 `code:500 / 发生未知异常`,未生成本地或伪造 `personId`;同谱只读 `GET .../lineage/persons/options` 实测 `200 / data: []`,因此也没有可读取的真实人物候选 | 修复最小合法 body,返回并可读取真实 `personId` |
|
||||||
|
| B08 | ID 类型冲突 | 上传 complete 与所有消费 `ossId` 的 DTO | F02/F06/F07/F09/G03/G11/R04/R07/R08/R10/T04/M02 | 真实 complete 返回 19 位字符串,消费者声明 `int64` number | APP JSON 统一十进制字符串 `ossId`;同版更新示例与所有消费者 |
|
||||||
|
| B09 | 候选 ID 缺失 | `PUT .../ceremonies/{ceremonyId}/invitees` | R06/R07、我的邀请入口 | body 需要 `inviteeUserIds`,现有 member/person DTO 无同类型候选 | 公开 `appUserId` 对应关系或新增可邀请用户候选 operation |
|
||||||
|
| B10 | 当前用户状态缺失 | 动态列表/分页/详情 `likedByMe` | F03 | 实际点赞后数量变更,详情未给当前用户布尔状态 | 三个读取 DTO 均返回同步的 required boolean `likedByMe` |
|
||||||
|
| B11 | 条目 DTO 缺失 | `GET .../article-categories` | F06 | 只有通用 `ListResult`,无分类 ID/名称/状态/排序字段 | 发布 `categoryId`、`categoryName`、可选状态与排序 DTO |
|
||||||
|
| B12 | 页面交互待确认 | 行政区划 path/search/detail | G03/G11 后续回填、搜索 | 2026-07-26 复测已返回 `value`、`label`、`regionCode`、层级等真实条目;但当前产品页面没有搜索/路径回填入口 | 产品确认地区搜索与路径回填交互后接入页面;接口 DTO 不再阻塞 |
|
||||||
|
| B13 | 资源语义不能混用 | `members` 与 `lineage/persons` | 成员管理、R06/R07 | `memberId`、`personId`、`appUserId` 是不同资源,当前无独立成员管理页 | 提供成员管理页需求与稳定候选/映射 DTO,不得前端猜代 |
|
||||||
|
| B14 | 业务绑定 owner 缺失 | `POST /files/reference` | 所有附件业务 | 需要 `bizTable`、`bizId`、`bizField`,页面没有可信业务表/字段来源 | 后端为各业务资源声明文件绑定 owner,或明确由创建 DTO 原子绑定 |
|
||||||
|
| B15 | 枚举/候选缺失 | 多个 body 中的 `sex`、`birthLunar`、`deathLunar`、`personStatus`、`roleType`、`status`、`payType` 等 | T04/T05 及 T/M/G/R/F 写入扩展 | 2026-07-26 复核 `LineagePersonBody`:`birthLunar`、`deathLunar` 的含义是“日期是否农历”的项目字典值,不是农历日期文本;性别和人物状态也都是字典值。导出与桌面 Apifox 只给出示例 `0`,没有 value-label 枚举或字典 options operation,因此不能把输入框、农历日期文本或猜测的 `0/1` 映射留给用户 | 在 operation 详情给出完整 value-label enum,或发布字典 options operation;前端收到确定合同后统一改为选择器,并同步详情页的字典值展示 |
|
||||||
|
| B16 | 测试边界,不是接口缺失 | 短信、改密、换绑、注销、退出、删除、审核、支付 | A/M/G/F/R/N 全域 | 当前任务禁止执行这些敏感动作 | 仅在用户另行授权、测试账号与可回收数据齐备后执行真实写入 |
|
||||||
|
| B17 | 导出与实际校验冲突 | `POST /genealogy/app/files/resumable/init`(并影响 complete) | F02/F06/F07/F09/G03/G11/R04/R07/R08/R10/T04/M02 | 2026-07-26 真实 H5 上传:省略 `uploadId` 返回“上传ID不能为空”;以导出字段 `fileSize` 替代 `totalSize` 返回“文件大小不能为空”。实际可用请求为 `uploadId + totalSize`;秒传 `instant:true` 会合法返回 `uploadId:null` 与真实 `ossId` | 在桌面 Apifox 与可导出 OpenAPI 中统一请求/响应 DTO、required、示例及秒传分支;明确普通分片与秒传的 `uploadId` 可空规则 |
|
||||||
|
|
||||||
|
## 十四、真实浏览器请求与返回记录(供后端排查)
|
||||||
|
|
||||||
|
说明:以下均在测试账号、测试家谱中从 APP 页面发起;未记录账号密码、令牌或完整 `clientid`。所有请求均已携带运行时 `clientid`、测试账号登录态和 `genealogyId=2081191846772518914`。
|
||||||
|
|
||||||
|
### 1. B06:首位成员创建
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /genealogy/app/genealogies/2081191846772518914/lineage/persons
|
||||||
|
```
|
||||||
|
|
||||||
|
最小请求(2026-07-26,页面 T04):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "首位成员最小请求验证",
|
||||||
|
"generation": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实际返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 200
|
||||||
|
{"code":500,"msg":"发生未知异常,请联系管理员","data":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
页面结果:`保存失败 / 首位成员尚未保存 / 发生未知异常,请联系管理员`。
|
||||||
|
|
||||||
|
后端称已修复后的复测(2026-07-26,重新登录测试账号后从 T04 提交):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "后端修复验证首位成员",
|
||||||
|
"generation": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回仍为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 200
|
||||||
|
{"code":500,"msg":"发生未知异常,请联系管理员","data":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
因此 B06 仍未解除;本次不是会话过期导致的结论。首次请求得到 `code:401` 后已通过 A01 真实密码登录,重试才得到上述业务 `code:500`。
|
||||||
|
|
||||||
|
全字段诊断请求(同一页面、同一测试家谱;用于验证可选字段是否会改变异常,不是字段字典的最终验收):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "LineageFullBody0726",
|
||||||
|
"generation": 1,
|
||||||
|
"personNo": "P20260726001",
|
||||||
|
"sex": "0",
|
||||||
|
"generationName": "FullGen",
|
||||||
|
"aliasName": "FullBodyAlias",
|
||||||
|
"birthLunar": "1900-01-01",
|
||||||
|
"birthPlace": "TestBirthPlace",
|
||||||
|
"deathLunar": "2000-01-01",
|
||||||
|
"deathPlace": "TestDeathPlace",
|
||||||
|
"burialPlace": "TestBurialPlace",
|
||||||
|
"biography": "Full body browser submission verification",
|
||||||
|
"remark": "test only",
|
||||||
|
"personStatus": "0",
|
||||||
|
"birthDate": "2026-07-26",
|
||||||
|
"deathDate": "2026-07-26",
|
||||||
|
"sortOrder": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实际返回同样为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 200
|
||||||
|
{"code":500,"msg":"发生未知异常,请联系管理员","data":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
特别说明:该诊断请求暴露出旧页面把 `birthLunar`、`deathLunar` 当作农历日期文本;Apifox 的真实语义是“是否农历”的字典值。不能把这一请求视为正确字典值示例,也不能要求前端据此猜选项。首位成员不应伪填 `fatherId`、`motherId`、`relationName`;`appUserId` 没有同类型候选,`avatarOssId` 被 B08 阻断。
|
||||||
|
|
||||||
|
随后读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /genealogy/app/genealogies/2081191846772518914/lineage/persons/options
|
||||||
|
HTTP 200 / code:200 / data: []
|
||||||
|
```
|
||||||
|
|
||||||
|
因此 T01 没有成员数据并非前端空白占位:服务端尚未成功写入任何真实成员。
|
||||||
|
|
||||||
|
### 2. 初次验证记录(历史结果;以 2.1 的复测结论为准)
|
||||||
|
|
||||||
|
| 编号 | 接口与页面 | 实际请求值/返回值 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| B07 | `GET /genealogy/app/genealogies/{genealogyId}/feeds/page`,F01/F03 | 已按页面分页参数真实读取;HTTP `200`、业务 `code:200`,但响应没有 Apifox `PageResult` 所需的 `data.rows`、`data.total` | 无法安全分页或把空对象伪装为无动态 |
|
||||||
|
| B08 | `POST /genealogy/app/files/resumable/init`,F09 等上传页面 | 省略 `uploadId`:返回“上传ID不能为空”;将 `totalSize` 改为导出字段 `fileSize`:返回“文件大小不能为空”;实际可用字段为 `uploadId`、`fileName`、`fileMd5`、`totalSize`、`totalChunks`、`chunkSize`。秒传返回 `{"uploadId":null,"instant":true,"ossId":"2081232520259612673"}` | 文件服务返回 19 位字符串 ID,而业务 DTO 写 `int64` 数字,前端不能无损作为 JSON 数字提交 |
|
||||||
|
| B10 | 动态点赞,F03 | 已真实点赞,点赞数量变化;详情/列表未返回 `likedByMe` | 不能据数量推断当前用户是否已点赞,页面已禁用不确定状态下的再次写入 |
|
||||||
|
| B11 | `GET /genealogy/app/genealogies/{genealogyId}/article-categories`,F06 | HTTP `200`、业务 `code:200`、`data: []` | 无分类候选可供选择;同时合同没有分类条目字段定义 |
|
||||||
|
| B12 | 行政区划 children,G03/G11 | `GET /genealogy/region/children` 已可真实读取;path/search/detail 没有可消费的条目 DTO 和页面交互归属 | 不猜地区回填字段或新增搜索行为 |
|
||||||
|
| B17 | 分片上传初始化,F09 等 | 见 B08 的实际请求和返回;导出与服务端校验字段冲突 | Apifox 与导出必须先统一为唯一合同 |
|
||||||
|
|
||||||
|
### 2.1 后端称修复后的复测结果(2026-07-26)
|
||||||
|
|
||||||
|
| 编号 | 复测结果 | 当前结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B06 | 重新登录后,T04 最小 body 仍返回 HTTP `200`、业务 `code:500`、`data:null` | 未修复 |
|
||||||
|
| B07 | `GET .../feeds/page?pageNum=1&pageSize=20` 现在返回根级 `total:1` 和 `rows:[...]`,不再是空 envelope | 分页数据已出现;但 `likedByMe` 返回字符串 `"1"`,B10 仍未修复 |
|
||||||
|
| B08 | F09 从真实图片文件上传到“已上传”成功;保存照片时前端收到真实 19 位文件 ID 后仍报“超出 APP 安全整数范围”,未发出照片创建请求 | 未修复,需统一字符串 `ossId` 合同 |
|
||||||
|
| B09 | 礼仪列表可读取,`GET .../ceremonies/{ceremonyId}/invitations` 与 `GET .../ceremony-invitations/mine` 均返回 `data:[]`;没有可提交的 `inviteeUserIds` 候选 | 未修复,仍缺 `appUserId` 候选/映射 |
|
||||||
|
| B11 | 谱文分类仍返回 `HTTP 200 / code:200 / data:[]` | 测试谱没有分类;仍需分类 DTO 与可用测试候选才能验证选择/写入 |
|
||||||
|
| B12 | `GET /genealogy/region/path/110101`、`GET /genealogy/region/110101`、`GET /genealogy/region/search?keyword=北京&limit=10` 均返回包含 `value`、`label`、`regionCode`、层级等字段的真实数据 | 后端已补齐可消费条目;待产品确认搜索/回填交互后接入页面 |
|
||||||
|
|
||||||
|
### 3. 未发起写入请求的原因(不是漏测)
|
||||||
|
|
||||||
|
| 编号 | 接口/功能 | 未发起的原因 | 后端需要提供 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| B01 | 视频 F10 | 仅发布删除接口;没有列表、详情、播放、创建、编辑或上传 owner | 完整资源 operation 与 DTO |
|
||||||
|
| B02 | 人生大事 R09 | 没有资源 operation | 明确人物或家谱归属后的列表、详情、写入 DTO |
|
||||||
|
| B03 | 通知详情 N02 | 只有列表、标已读;没有按 `notificationId` 读取详情 | 单条详情接口及授权语义 |
|
||||||
|
| B05/B16 | 短信、改密、换绑、退出、注销、删除、审核、支付 | 属于本轮明确禁止的敏感动作 | 另行授权、测试账号和可回收数据后再测 |
|
||||||
|
| B09/B13 | 礼仪受邀人、成员绑定 | 请求需要 `appUserId`,现有 `memberId`、`personId`、`appUserId` 不能互相替代;没有候选映射 | 同类型候选接口或稳定映射 DTO |
|
||||||
|
| B14 | 文件业务引用 | 请求需要 `bizTable`、`bizId`、`bizField`,没有页面可信 owner | 各资源的文件绑定归属合同 |
|
||||||
|
| B15 | 性别、人物状态、出生/逝世是否农历等 | 需要选择器,但 Apifox 仅给示例 `0`,没有枚举、中文标签或字典 options 接口 | value-label 枚举或字典 options operation |
|
||||||
|
|
||||||
|
|
||||||
|
### 不应误标为阻塞的已完成项
|
||||||
|
|
||||||
|
- A01 密码登录、G03 创建家谱、家族动态/评论、谱文、相册、亲友往来、礼仪、成长记录、备忘、功德均已做过真实创建后的列表或详情回读。
|
||||||
|
- M03 账号与安全已真实读取当前 profile,并仅展示掩码手机号、账号编号和账号状态;设备与登录记录不在当前页面范围内。
|
||||||
|
- 4 条行政区划 operation 都已有 API owner;`children` 已在 G03/G11 页面真实读取,其余三条的阻塞是没有可安全消费 DTO/产品交互,不是路径不存在。
|
||||||
|
- 136 条 `method + path` 均可在桌面 Apifox 当前目录找到;数量差异不是“导出漏了 path”,而是页面数、功能入口、DTO 和安全参数来源不同。
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<link rel="icon" type="image/png" href="/static/assets/foundation/transparent/brand-seal.png" />
|
||||||
<title></title>
|
<title></title>
|
||||||
<script type="module" src="/main.js"></script>
|
<script type="module" src="/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+33
-13
@@ -234,7 +234,7 @@ import {
|
|||||||
isRequestCancelled,
|
isRequestCancelled,
|
||||||
} from "@/utils/api.js";
|
} from "@/utils/api.js";
|
||||||
import {
|
import {
|
||||||
AUTH_TAC_SCENE,
|
AUTH_VERIFICATION_OPERATION,
|
||||||
createTacRenderContext,
|
createTacRenderContext,
|
||||||
isAuthPhone,
|
isAuthPhone,
|
||||||
isSmsDeliveryOutcomeUnknown,
|
isSmsDeliveryOutcomeUnknown,
|
||||||
@@ -276,7 +276,7 @@ const authenticationNavigationFailure =
|
|||||||
"登录已完成,但暂时无法进入家谱,请再次点击进入";
|
"登录已完成,但暂时无法进入家谱,请再次点击进入";
|
||||||
const authRequestController = createRequestController();
|
const authRequestController = createRequestController();
|
||||||
const smsCooldown = createAuthSmsCooldown({
|
const smsCooldown = createAuthSmsCooldown({
|
||||||
sceneCode: AUTH_TAC_SCENE.SMS_LOGIN,
|
operationCode: AUTH_VERIFICATION_OPERATION.SMS_LOGIN,
|
||||||
onChange: (seconds) => {
|
onChange: (seconds) => {
|
||||||
cooldownSeconds.value = seconds;
|
cooldownSeconds.value = seconds;
|
||||||
},
|
},
|
||||||
@@ -407,22 +407,33 @@ const prepareGetCode = async () => {
|
|||||||
if (!validatePhone() || !requireAgreement()) return;
|
if (!validatePhone() || !requireAgreement()) return;
|
||||||
sendingCode.value = true;
|
sendingCode.value = true;
|
||||||
try {
|
try {
|
||||||
const sceneCode = AUTH_TAC_SCENE.SMS_LOGIN;
|
const operationCode = AUTH_VERIFICATION_OPERATION.SMS_LOGIN;
|
||||||
const requestedPhone = phone.value;
|
const requestedPhone = phone.value;
|
||||||
const response = await appApi.getCaptchaRequirement(
|
const response = await appApi.getCaptchaRequirement(
|
||||||
{ sceneCode, subject: requestedPhone },
|
{ operationCode, subject: requestedPhone },
|
||||||
{ requestController: authRequestController },
|
{ requestController: authRequestController },
|
||||||
);
|
);
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
|
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
|
||||||
const requirement = normalizeCaptchaRequirement(response, sceneCode);
|
const requirement = normalizeCaptchaRequirement(response);
|
||||||
|
if (!requirement.required) {
|
||||||
|
await appApi.sendSmsCode(
|
||||||
|
{ operationCode, phone: requestedPhone },
|
||||||
|
{ requestController: authRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
sentPhone.value = requestedPhone;
|
||||||
|
smsCooldown.start();
|
||||||
|
showFeedback("验证码已发送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
tacSequence += 1;
|
tacSequence += 1;
|
||||||
tacContext.value = createTacRenderContext({
|
tacContext.value = createTacRenderContext({
|
||||||
requestId: `a01-sms-${tacSequence}`,
|
requestId: `a01-sms-${tacSequence}`,
|
||||||
baseUrl: runtimeConfig.baseUrl,
|
baseUrl: runtimeConfig.baseUrl,
|
||||||
clientId: runtimeConfig.clientId,
|
clientId: runtimeConfig.clientId,
|
||||||
tenantId: runtimeConfig.tenantId,
|
tenantId: runtimeConfig.tenantId,
|
||||||
sceneCode,
|
operationCode,
|
||||||
subject: requestedPhone,
|
subject: requestedPhone,
|
||||||
requirement,
|
requirement,
|
||||||
});
|
});
|
||||||
@@ -445,25 +456,33 @@ const preparePasswordLogin = async () => {
|
|||||||
const passwordHash = calcMD5(password.value);
|
const passwordHash = calcMD5(password.value);
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
// 产品规则:密码登录也必须先完成滑动验证。当前登录接口不消费
|
const operationCode = AUTH_VERIFICATION_OPERATION.PASSWORD_LOGIN;
|
||||||
// validToken,因此票据只作为本次前端验证成功的完成信号,不写入登录体。
|
|
||||||
const sceneCode = AUTH_TAC_SCENE.SMS_LOGIN;
|
|
||||||
const response = await appApi.getCaptchaRequirement(
|
const response = await appApi.getCaptchaRequirement(
|
||||||
{ sceneCode, subject: requestedPhone },
|
{ operationCode, subject: requestedPhone },
|
||||||
{ requestController: authRequestController },
|
{ requestController: authRequestController },
|
||||||
);
|
);
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
if (phone.value !== requestedPhone) {
|
if (phone.value !== requestedPhone) {
|
||||||
throw new Error("手机号已变化,请重新登录");
|
throw new Error("手机号已变化,请重新登录");
|
||||||
}
|
}
|
||||||
const requirement = normalizeCaptchaRequirement(response, sceneCode);
|
const requirement = normalizeCaptchaRequirement(response);
|
||||||
|
if (!requirement.required) {
|
||||||
|
await appApi.loginWithPassword(
|
||||||
|
{ phone: requestedPhone, passwordHash },
|
||||||
|
{ requestController: authRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
authenticationCommitted.value = true;
|
||||||
|
await enterAuthenticatedRoot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
tacSequence += 1;
|
tacSequence += 1;
|
||||||
tacContext.value = createTacRenderContext({
|
tacContext.value = createTacRenderContext({
|
||||||
requestId: `a01-password-${tacSequence}`,
|
requestId: `a01-password-${tacSequence}`,
|
||||||
baseUrl: runtimeConfig.baseUrl,
|
baseUrl: runtimeConfig.baseUrl,
|
||||||
clientId: runtimeConfig.clientId,
|
clientId: runtimeConfig.clientId,
|
||||||
tenantId: runtimeConfig.tenantId,
|
tenantId: runtimeConfig.tenantId,
|
||||||
sceneCode,
|
operationCode,
|
||||||
subject: requestedPhone,
|
subject: requestedPhone,
|
||||||
requirement,
|
requirement,
|
||||||
});
|
});
|
||||||
@@ -504,6 +523,7 @@ const completeTac = async (result) => {
|
|||||||
{
|
{
|
||||||
phone: action.phone,
|
phone: action.phone,
|
||||||
passwordHash: action.passwordHash,
|
passwordHash: action.passwordHash,
|
||||||
|
validToken: ticket.validToken,
|
||||||
},
|
},
|
||||||
{ requestController: authRequestController },
|
{ requestController: authRequestController },
|
||||||
);
|
);
|
||||||
@@ -521,7 +541,7 @@ const completeTac = async (result) => {
|
|||||||
sendingCode.value = true;
|
sendingCode.value = true;
|
||||||
await appApi.sendSmsCode(
|
await appApi.sendSmsCode(
|
||||||
{
|
{
|
||||||
sceneCode: AUTH_TAC_SCENE.SMS_LOGIN,
|
operationCode: AUTH_VERIFICATION_OPERATION.SMS_LOGIN,
|
||||||
phone: expectedContext.subject,
|
phone: expectedContext.subject,
|
||||||
validToken: ticket.validToken,
|
validToken: ticket.validToken,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ import {
|
|||||||
isRequestCancelled,
|
isRequestCancelled,
|
||||||
} from "@/utils/api.js";
|
} from "@/utils/api.js";
|
||||||
import {
|
import {
|
||||||
AUTH_TAC_SCENE,
|
AUTH_VERIFICATION_OPERATION,
|
||||||
createTacRenderContext,
|
createTacRenderContext,
|
||||||
isAuthPhone,
|
isAuthPhone,
|
||||||
isSmsDeliveryOutcomeUnknown,
|
isSmsDeliveryOutcomeUnknown,
|
||||||
@@ -321,7 +321,7 @@ const registrationNavigationFailure =
|
|||||||
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
||||||
const authRequestController = createRequestController();
|
const authRequestController = createRequestController();
|
||||||
const smsCooldown = createAuthSmsCooldown({
|
const smsCooldown = createAuthSmsCooldown({
|
||||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||||
onChange: (seconds) => {
|
onChange: (seconds) => {
|
||||||
cooldownSeconds.value = seconds;
|
cooldownSeconds.value = seconds;
|
||||||
},
|
},
|
||||||
@@ -428,22 +428,33 @@ const prepareGetCode = async () => {
|
|||||||
}
|
}
|
||||||
sendingCode.value = true;
|
sendingCode.value = true;
|
||||||
try {
|
try {
|
||||||
const sceneCode = AUTH_TAC_SCENE.REGISTER;
|
const operationCode = AUTH_VERIFICATION_OPERATION.REGISTER;
|
||||||
const requestedPhone = phone.value;
|
const requestedPhone = phone.value;
|
||||||
const response = await appApi.getCaptchaRequirement(
|
const response = await appApi.getCaptchaRequirement(
|
||||||
{ sceneCode, subject: requestedPhone },
|
{ operationCode, subject: requestedPhone },
|
||||||
{ requestController: authRequestController },
|
{ requestController: authRequestController },
|
||||||
);
|
);
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
|
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
|
||||||
const requirement = normalizeCaptchaRequirement(response, sceneCode);
|
const requirement = normalizeCaptchaRequirement(response);
|
||||||
|
if (!requirement.required) {
|
||||||
|
await appApi.sendSmsCode(
|
||||||
|
{ operationCode, phone: requestedPhone },
|
||||||
|
{ requestController: authRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
sentPhone.value = requestedPhone;
|
||||||
|
smsCooldown.start();
|
||||||
|
showFeedback("验证码已发送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
tacSequence += 1;
|
tacSequence += 1;
|
||||||
tacContext.value = createTacRenderContext({
|
tacContext.value = createTacRenderContext({
|
||||||
requestId: `a04-register-${tacSequence}`,
|
requestId: `a04-register-${tacSequence}`,
|
||||||
baseUrl: runtimeConfig.baseUrl,
|
baseUrl: runtimeConfig.baseUrl,
|
||||||
clientId: runtimeConfig.clientId,
|
clientId: runtimeConfig.clientId,
|
||||||
tenantId: runtimeConfig.tenantId,
|
tenantId: runtimeConfig.tenantId,
|
||||||
sceneCode,
|
operationCode,
|
||||||
subject: requestedPhone,
|
subject: requestedPhone,
|
||||||
requirement,
|
requirement,
|
||||||
});
|
});
|
||||||
@@ -467,7 +478,7 @@ const completeTac = async (result) => {
|
|||||||
sendingCode.value = true;
|
sendingCode.value = true;
|
||||||
await appApi.sendSmsCode(
|
await appApi.sendSmsCode(
|
||||||
{
|
{
|
||||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||||
phone: expectedContext.subject,
|
phone: expectedContext.subject,
|
||||||
validToken: ticket.validToken,
|
validToken: ticket.validToken,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ import {
|
|||||||
isRequestCancelled,
|
isRequestCancelled,
|
||||||
} from "@/utils/api.js";
|
} from "@/utils/api.js";
|
||||||
import {
|
import {
|
||||||
AUTH_TAC_SCENE,
|
AUTH_VERIFICATION_OPERATION,
|
||||||
createTacRenderContext,
|
createTacRenderContext,
|
||||||
isAuthPhone,
|
isAuthPhone,
|
||||||
isSmsDeliveryOutcomeUnknown,
|
isSmsDeliveryOutcomeUnknown,
|
||||||
@@ -274,7 +274,7 @@ let tacSequence = 0;
|
|||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
const authRequestController = createRequestController();
|
const authRequestController = createRequestController();
|
||||||
const smsCooldown = createAuthSmsCooldown({
|
const smsCooldown = createAuthSmsCooldown({
|
||||||
sceneCode: AUTH_TAC_SCENE.FORGOT_PASSWORD,
|
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
|
||||||
onChange: (seconds) => {
|
onChange: (seconds) => {
|
||||||
cooldownSeconds.value = seconds;
|
cooldownSeconds.value = seconds;
|
||||||
},
|
},
|
||||||
@@ -356,22 +356,33 @@ const prepareGetCode = async () => {
|
|||||||
fieldErrors.value.phone = "";
|
fieldErrors.value.phone = "";
|
||||||
sendingCode.value = true;
|
sendingCode.value = true;
|
||||||
try {
|
try {
|
||||||
const sceneCode = AUTH_TAC_SCENE.FORGOT_PASSWORD;
|
const operationCode = AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD;
|
||||||
const requestedPhone = phone.value;
|
const requestedPhone = phone.value;
|
||||||
const response = await appApi.getCaptchaRequirement(
|
const response = await appApi.getCaptchaRequirement(
|
||||||
{ sceneCode, subject: requestedPhone },
|
{ operationCode, subject: requestedPhone },
|
||||||
{ requestController: authRequestController },
|
{ requestController: authRequestController },
|
||||||
);
|
);
|
||||||
if (!pageActive) return;
|
if (!pageActive) return;
|
||||||
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
|
if (phone.value !== requestedPhone) throw new Error("手机号已变化,请重新获取验证码");
|
||||||
const requirement = normalizeCaptchaRequirement(response, sceneCode);
|
const requirement = normalizeCaptchaRequirement(response);
|
||||||
|
if (!requirement.required) {
|
||||||
|
await appApi.sendSmsCode(
|
||||||
|
{ operationCode, phone: requestedPhone },
|
||||||
|
{ requestController: authRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
sentPhone.value = requestedPhone;
|
||||||
|
smsCooldown.start();
|
||||||
|
showFeedback("验证码已发送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
tacSequence += 1;
|
tacSequence += 1;
|
||||||
tacContext.value = createTacRenderContext({
|
tacContext.value = createTacRenderContext({
|
||||||
requestId: `a05-forgot-${tacSequence}`,
|
requestId: `a05-forgot-${tacSequence}`,
|
||||||
baseUrl: runtimeConfig.baseUrl,
|
baseUrl: runtimeConfig.baseUrl,
|
||||||
clientId: runtimeConfig.clientId,
|
clientId: runtimeConfig.clientId,
|
||||||
tenantId: runtimeConfig.tenantId,
|
tenantId: runtimeConfig.tenantId,
|
||||||
sceneCode,
|
operationCode,
|
||||||
subject: requestedPhone,
|
subject: requestedPhone,
|
||||||
requirement,
|
requirement,
|
||||||
});
|
});
|
||||||
@@ -395,7 +406,7 @@ const completeTac = async (result) => {
|
|||||||
sendingCode.value = true;
|
sendingCode.value = true;
|
||||||
await appApi.sendSmsCode(
|
await appApi.sendSmsCode(
|
||||||
{
|
{
|
||||||
sceneCode: AUTH_TAC_SCENE.FORGOT_PASSWORD,
|
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
|
||||||
phone: expectedContext.subject,
|
phone: expectedContext.subject,
|
||||||
validToken: ticket.validToken,
|
validToken: ticket.validToken,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="publish-field">
|
<view class="publish-field">
|
||||||
<text class="publish-field__label">动态类型</text>
|
<text class="publish-field__label">动态类型</text>
|
||||||
<input v-model="form.feedType" placeholder="留空时服务端默认为 text" placeholder-class="publish-placeholder" @input="submitError = ''" />
|
<view class="publish-field__value publish-field__value--readonly">
|
||||||
|
<text>文字动态</text>
|
||||||
|
<text>当前仅支持此类型</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="publish-field publish-field--media">
|
<view class="publish-field publish-field--media">
|
||||||
<view>
|
<view>
|
||||||
@@ -65,7 +68,7 @@ import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-imag
|
|||||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||||
|
|
||||||
const genealogyId = ref("");
|
const genealogyId = ref("");
|
||||||
const form = reactive({ feedContent: "", feedType: "", sortOrder: "" });
|
const form = reactive({ feedContent: "", feedType: "text", sortOrder: "" });
|
||||||
const mediaReceipts = ref([]);
|
const mediaReceipts = ref([]);
|
||||||
const publishState = ref("form");
|
const publishState = ref("form");
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
@@ -76,7 +79,7 @@ const discardVisible = ref(false);
|
|||||||
const requestController = createRequestController();
|
const requestController = createRequestController();
|
||||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||||
const isDirty = computed(() => Boolean(
|
const isDirty = computed(() => Boolean(
|
||||||
form.feedContent.trim() || form.feedType.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
|
form.feedContent.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
|
||||||
));
|
));
|
||||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
const resultCopy = computed(() => ({
|
const resultCopy = computed(() => ({
|
||||||
@@ -139,7 +142,7 @@ const submit = async () => {
|
|||||||
mediaOssIds: mediaOssIds.value,
|
mediaOssIds: mediaOssIds.value,
|
||||||
sortOrder: form.sortOrder,
|
sortOrder: form.sortOrder,
|
||||||
}, { requestController });
|
}, { requestController });
|
||||||
Object.assign(form, { feedContent: "", feedType: "", sortOrder: "" });
|
Object.assign(form, { feedContent: "", feedType: "text", sortOrder: "" });
|
||||||
mediaReceipts.value = [];
|
mediaReceipts.value = [];
|
||||||
publishState.value = "success";
|
publishState.value = "success";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -191,10 +194,13 @@ onUnmounted(() => {
|
|||||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||||
.publish-field input, .publish-field textarea { display: block; box-sizing: border-box; width: 100%; margin-top: 12rpx; color: $ink; font-size: 24rpx; }
|
.publish-field input, .publish-field textarea { display: block; box-sizing: border-box; width: 100%; margin-top: 12rpx; color: $ink; font-size: 24rpx; }
|
||||||
.publish-field input { min-height: 64rpx; }
|
.publish-field input { min-height: 64rpx; }
|
||||||
|
.publish-field__value--readonly { display:flex; min-height:76rpx; align-items:center; justify-content:space-between; margin-top:12rpx; padding:0 20rpx; border:1rpx solid rgba(128,89,49,.22); border-radius:10rpx; background:rgba(255,252,245,.48); }
|
||||||
|
.publish-field__value--readonly text:first-child { color:$ink; font-size:24rpx; }
|
||||||
|
.publish-field__value--readonly text:last-child { color:$ink-muted; font-size:20rpx; }
|
||||||
.publish-field--content textarea { min-height: 200rpx; line-height: 1.7; }
|
.publish-field--content textarea { min-height: 200rpx; line-height: 1.7; }
|
||||||
.publish-field--media { display: grid; gap: 12rpx; }
|
.publish-field--media { display: grid; gap: 12rpx; }
|
||||||
.publish-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
.publish-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||||
.publish-placeholder { color: #8e806e; }
|
.publish-placeholder { color: #8e806e; }
|
||||||
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; line-height: 1.5; }
|
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; line-height: 1.5; }
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
<text>发布:{{ feed.publisher }}</text>
|
<text>发布:{{ feed.publisher }}</text>
|
||||||
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
|
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
|
||||||
</view>
|
</view>
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:disabled="isTogglingLike || !hasLikeState"
|
||||||
|
:label="isTogglingLike ? '正在提交' : (hasLikeState ? (feed.likedByMe ? '取消点赞' : '点赞') : '点赞状态不可用')"
|
||||||
|
@click="toggleLike"
|
||||||
|
/>
|
||||||
|
<text v-if="!hasLikeState" class="like-note">点赞状态暂不可用,页面不会猜测下一次操作。</text>
|
||||||
|
<text v-if="likeError" class="like-error">{{ likeError }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="comment-section">
|
<view class="comment-section">
|
||||||
@@ -82,6 +91,8 @@ const comments = ref([]);
|
|||||||
const commentDraft = ref("");
|
const commentDraft = ref("");
|
||||||
const commentError = ref("");
|
const commentError = ref("");
|
||||||
const isSubmittingComment = ref(false);
|
const isSubmittingComment = ref(false);
|
||||||
|
const isTogglingLike = ref(false);
|
||||||
|
const likeError = ref("");
|
||||||
const controller = createRequestController();
|
const controller = createRequestController();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
|
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
|
||||||
@@ -89,6 +100,7 @@ const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "tex
|
|||||||
const hasValidContext = computed(() =>
|
const hasValidContext = computed(() =>
|
||||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
|
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
|
||||||
);
|
);
|
||||||
|
const hasLikeState = computed(() => typeof feed.value?.likedByMe === "boolean");
|
||||||
const stateCopy = computed(() => {
|
const stateCopy = computed(() => {
|
||||||
if (!hasValidContext.value) {
|
if (!hasValidContext.value) {
|
||||||
return {
|
return {
|
||||||
@@ -151,6 +163,23 @@ const refresh = async () => {
|
|||||||
if (feedState.value === "ready") await loadComments();
|
if (feedState.value === "ready") await loadComments();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleLike = async () => {
|
||||||
|
if (isTogglingLike.value || !hasValidContext.value || !feed.value || !hasLikeState.value) return;
|
||||||
|
const nextLiked = !feed.value.likedByMe;
|
||||||
|
isTogglingLike.value = true;
|
||||||
|
likeError.value = "";
|
||||||
|
try {
|
||||||
|
await appApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, { requestController: controller });
|
||||||
|
if (!pageActive) return;
|
||||||
|
await loadFeed();
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
likeError.value = error?.message || "点赞操作失败,请稍后重试";
|
||||||
|
} finally {
|
||||||
|
if (pageActive) isTogglingLike.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitComment = async () => {
|
const submitComment = async () => {
|
||||||
if (isSubmittingComment.value || !hasValidContext.value) return;
|
if (isSubmittingComment.value || !hasValidContext.value) return;
|
||||||
const commentContent = commentDraft.value.trim();
|
const commentContent = commentDraft.value.trim();
|
||||||
@@ -208,6 +237,10 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
|||||||
.feed-card__heading text:last-child, .comment-card__heading text:last-child { color: $ink-muted; font-size: 21rpx; text-align: right; }
|
.feed-card__heading text:last-child, .comment-card__heading text:last-child { color: $ink-muted; font-size: 21rpx; text-align: right; }
|
||||||
.feed-card__content { display: block; margin-top: 20rpx; color: $ink; font-size: 29rpx; line-height: 1.7; white-space: pre-wrap; }
|
.feed-card__content { display: block; margin-top: 20rpx; color: $ink; font-size: 29rpx; line-height: 1.7; white-space: pre-wrap; }
|
||||||
.feed-card__meta { margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; }
|
.feed-card__meta { margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; }
|
||||||
|
.feed-card .app-button { margin-top: 18rpx; }
|
||||||
|
.like-note, .like-error { display: block; margin-top: 10rpx; font-size: 22rpx; }
|
||||||
|
.like-note { color: $ink-muted; }
|
||||||
|
.like-error { color: $brand-red; }
|
||||||
.comment-section { margin-top: 18rpx; padding: 28rpx; }
|
.comment-section { margin-top: 18rpx; padding: 28rpx; }
|
||||||
.section-title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
.section-title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
||||||
.comment-list { margin-top: 18rpx; }
|
.comment-list { margin-top: 18rpx; }
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ onUnmounted(() => {
|
|||||||
.editor-field--cover { display: grid; gap: 12rpx; padding: 18rpx 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
.editor-field--cover { display: grid; gap: 12rpx; padding: 18rpx 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||||
.editor-field--cover .editor-field__label { margin: 0; }
|
.editor-field--cover .editor-field__label { margin: 0; }
|
||||||
.editor-field__hint { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
.editor-field__hint { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||||
.editor-placeholder { color: #9e8e79; }
|
.editor-placeholder { color: #9e8e79; }
|
||||||
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
|
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
|
||||||
|
|||||||
@@ -101,5 +101,5 @@ onUnmounted(() => { active = false; controller.abort(); });
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.album-list-header, .album-list-content { z-index: 1; }.album-list-content { padding: 18rpx 24rpx 72rpx; }.album-state-card, .album-card { @include adaptive.adaptive-family-content; }.album-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }.album-state-card text, .album-card text { display: block; }.album-state-card text:first-child, .album-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }.album-state-card text:nth-child(2), .album-card text:not(:first-child) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }.album-state-card .app-button { margin-top: 28rpx; }.album-list { display: flex; flex-direction: column; gap: 16rpx; }.album-card { padding: 28rpx 30rpx; }.album-dialog-field { width: 100%; margin: 20rpx 0; text-align: left; }.album-dialog-field > text:first-child, .album-dialog-field--cover > view > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; }.album-dialog-field input, .album-dialog-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }.album-dialog-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }.album-dialog-field textarea { min-height: 112rpx; line-height: 1.55; }.album-dialog-field--cover { display: grid; gap: 12rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }.album-field-hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.album-field-error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; line-height: 1.45; }
|
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.album-list-header, .album-list-content { z-index: 1; }.album-list-content { padding: 18rpx 24rpx 72rpx; }.album-state-card, .album-card { @include adaptive.adaptive-family-content; }.album-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }.album-state-card text, .album-card text { display: block; }.album-state-card text:first-child, .album-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }.album-state-card text:nth-child(2), .album-card text:not(:first-child) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }.album-state-card .app-button { margin-top: 28rpx; }.album-list { display: flex; flex-direction: column; gap: 16rpx; }.album-card { padding: 28rpx 30rpx; }.album-dialog-field { width: 100%; margin: 20rpx 0; text-align: left; }.album-dialog-field > text:first-child, .album-dialog-field--cover > view > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; }.album-dialog-field input, .album-dialog-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }.album-dialog-field input { min-height: 76rpx; padding-top: 0; padding-bottom: 0; }.album-dialog-field textarea { min-height: 112rpx; line-height: 1.55; }.album-dialog-field--cover { display: grid; gap: 12rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }.album-field-hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.album-field-error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; line-height: 1.45; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,9 +27,17 @@
|
|||||||
<text class="media-field__label">拍摄人</text>
|
<text class="media-field__label">拍摄人</text>
|
||||||
<input v-model="form.photographer" maxlength="30" placeholder="请输入拍摄人" placeholder-class="placeholder" @input="submitError = ''" />
|
<input v-model="form.photographer" maxlength="30" placeholder="请输入拍摄人" placeholder-class="placeholder" @input="submitError = ''" />
|
||||||
</view>
|
</view>
|
||||||
<view class="media-field">
|
<view class="media-field media-field--picker">
|
||||||
|
<text class="media-field__label">拍摄日期</text>
|
||||||
|
<picker mode="date" :value="form.shootDate" @change="selectShootDate">
|
||||||
|
<view :class="{ placeholder: !form.shootDate }">{{ form.shootDate || '请选择' }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="media-field media-field--picker">
|
||||||
<text class="media-field__label">拍摄时间</text>
|
<text class="media-field__label">拍摄时间</text>
|
||||||
<input v-model="form.shootTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="submitError = ''" />
|
<picker mode="time" :value="form.shootClock" @change="selectShootClock">
|
||||||
|
<view :class="{ placeholder: !form.shootClock }">{{ form.shootClock || '请选择' }}</view>
|
||||||
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
<view class="media-field">
|
<view class="media-field">
|
||||||
<text class="media-field__label">排序值</text>
|
<text class="media-field__label">排序值</text>
|
||||||
@@ -79,13 +87,14 @@ const uploadError = ref("");
|
|||||||
const submitError = ref("");
|
const submitError = ref("");
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const form = reactive({ photoTitle: "", photoDesc: "", photographer: "", shootTime: "", sortOrder: "" });
|
const form = reactive({ photoTitle: "", photoDesc: "", photographer: "", shootDate: "", shootClock: "", sortOrder: "" });
|
||||||
const controller = createRequestController();
|
const controller = createRequestController();
|
||||||
const discardVisible = ref(false);
|
const discardVisible = ref(false);
|
||||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||||
|
|
||||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
|
||||||
const isDirty = computed(() => receipt.value || Object.values(form).some((value) => value.trim()));
|
const isDirty = computed(() => Boolean(receipt.value) || Object.values(form).some((value) => value.trim()));
|
||||||
|
const shootTime = computed(() => form.shootDate ? `${form.shootDate} ${form.shootClock || "00:00"}:00` : "");
|
||||||
const stateCopy = computed(() => pageState.value === "success"
|
const stateCopy = computed(() => pageState.value === "success"
|
||||||
? { title: "照片已提交服务端", copy: "服务端已返回成功结果。", action: "返回相册" }
|
? { title: "照片已提交服务端", copy: "服务端已返回成功结果。", action: "返回相册" }
|
||||||
: { title: "照片入口无效", copy: "没有取得有效家谱或相册标识。", action: "返回上一页" });
|
: { title: "照片入口无效", copy: "没有取得有效家谱或相册标识。", action: "返回上一页" });
|
||||||
@@ -110,6 +119,8 @@ const selectPhoto = async () => {
|
|||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const selectShootDate = (event) => { form.shootDate = event.detail.value || ""; submitError.value = ""; };
|
||||||
|
const selectShootClock = (event) => { form.shootClock = event.detail.value || ""; submitError.value = ""; };
|
||||||
|
|
||||||
const submitPhoto = async () => {
|
const submitPhoto = async () => {
|
||||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||||
@@ -120,8 +131,10 @@ const submitPhoto = async () => {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
submitError.value = "";
|
submitError.value = "";
|
||||||
try {
|
try {
|
||||||
|
const { shootDate, shootClock, ...photoForm } = form;
|
||||||
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
|
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
|
||||||
...form,
|
...photoForm,
|
||||||
|
shootTime: shootTime.value,
|
||||||
ossId: toConsumerOssId(receipt.value.ossId),
|
ossId: toConsumerOssId(receipt.value.ossId),
|
||||||
}, { requestController: controller });
|
}, { requestController: controller });
|
||||||
pageState.value = "success";
|
pageState.value = "success";
|
||||||
@@ -167,11 +180,13 @@ onUnmounted(() => {
|
|||||||
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
|
||||||
.media-field input, .media-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; color: $ink; font-size: 24rpx; }
|
.media-field input, .media-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; color: $ink; font-size: 24rpx; }
|
||||||
.media-field input { min-height: 78rpx; padding: 0 22rpx; }
|
.media-field input { min-height: 78rpx; padding: 0 22rpx; }
|
||||||
|
.media-field--picker picker { display: block; min-height: 78rpx; }
|
||||||
|
.media-field--picker picker > view { display: flex; min-height: 78rpx; align-items: center; padding: 0 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); color: $ink; font-size: 24rpx; }
|
||||||
.media-field textarea { min-height: 116rpx; padding: 18rpx 22rpx; line-height: 1.55; }
|
.media-field textarea { min-height: 116rpx; padding: 18rpx 22rpx; line-height: 1.55; }
|
||||||
.placeholder { color: #9e8e79; }
|
.placeholder { color: #9e8e79; }
|
||||||
.media-field--upload { display: grid; gap: 12rpx; padding: 18rpx 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
.media-field--upload { display: grid; gap: 12rpx; padding: 18rpx 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
|
||||||
.media-field--upload .media-field__label { margin: 0; }
|
.media-field--upload .media-field__label { margin: 0; }
|
||||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
|
||||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||||
.field-error { margin-top: 10rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
|
.field-error { margin-top: 10rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
|
||||||
.media-panel .app-button { margin-top: 28rpx; }
|
.media-panel .app-button { margin-top: 28rpx; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- 页面编号:F-10;用途:家族视频。当前缺读取、发布和播放业务 owner。 -->
|
<!-- 页面编号:F-10;用途:家族视频。当前缺读取、发布和播放业务 owner。 -->
|
||||||
<template>
|
<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>Apifox 当前只有删除视频 operation,未声明视频列表、详情、播放地址、发布、编辑、评论、点赞或分享 owner。页面不以相册、参考项目或本地数据补造视频能力。</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view></view>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed,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 { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToFamily=()=>hasValidContext.value?returnTo("F01",{genealogyId:genealogyId.value}):goBack();
|
import { computed,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 { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToFamily=()=>hasValidContext.value?returnTo("F01",{genealogyId:genealogyId.value}):goBack();
|
||||||
|
|||||||
@@ -259,6 +259,7 @@
|
|||||||
<view class="add-dialog__heading">
|
<view class="add-dialog__heading">
|
||||||
<text class="dialog-title">添加家谱</text>
|
<text class="dialog-title">添加家谱</text>
|
||||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||||
|
<text v-if="quota" class="dialog-copy">{{ quota.createRemaining === -1 ? '当前可继续创建家谱' : `还可创建 ${quota.createRemaining} 部家谱` }}</text>
|
||||||
<view
|
<view
|
||||||
class="add-dialog__close"
|
class="add-dialog__close"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -285,6 +286,7 @@
|
|||||||
block
|
block
|
||||||
type="secondary"
|
type="secondary"
|
||||||
label="继续创建家谱"
|
label="继续创建家谱"
|
||||||
|
:disabled="quota?.canCreate === false"
|
||||||
@click="createGenealogy"
|
@click="createGenealogy"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -380,8 +382,14 @@ const switcherVisible = ref(false);
|
|||||||
const selectedGenealogyId = ref(null);
|
const selectedGenealogyId = ref(null);
|
||||||
const listScrollCommand = ref(0);
|
const listScrollCommand = ref(0);
|
||||||
const currentListScrollTop = ref(0);
|
const currentListScrollTop = ref(0);
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
const quota = ref(null);
|
||||||
const listRequestController = createRequestController();
|
const listRequestController = createRequestController();
|
||||||
|
const unreadRequestController = createRequestController();
|
||||||
|
const quotaRequestController = createRequestController();
|
||||||
let loadGeneration = 0;
|
let loadGeneration = 0;
|
||||||
|
let unreadLoadGeneration = 0;
|
||||||
|
let quotaLoadGeneration = 0;
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
let presentationState = "default";
|
let presentationState = "default";
|
||||||
let skipNextShowRefresh = true;
|
let skipNextShowRefresh = true;
|
||||||
@@ -454,11 +462,39 @@ const loadGenealogies = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadUnreadCount = async () => {
|
||||||
|
const generation = ++unreadLoadGeneration;
|
||||||
|
unreadRequestController.abort();
|
||||||
|
try {
|
||||||
|
const count = await appApi.getUnreadNotificationCount({
|
||||||
|
requestController: unreadRequestController,
|
||||||
|
});
|
||||||
|
if (pageActive && generation === unreadLoadGeneration) unreadCount.value = count;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRequestCancelled(error)) unreadCount.value = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadQuota = async () => {
|
||||||
|
const generation = ++quotaLoadGeneration;
|
||||||
|
quotaRequestController.abort();
|
||||||
|
try {
|
||||||
|
const result = await appApi.getGenealogyQuota({
|
||||||
|
requestController: quotaRequestController,
|
||||||
|
});
|
||||||
|
if (pageActive && generation === quotaLoadGeneration) quota.value = result;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRequestCancelled(error) && pageActive && generation === quotaLoadGeneration) quota.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
syncEmptyStateFromRoute(query);
|
syncEmptyStateFromRoute(query);
|
||||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||||
if (presentationState === "default") {
|
if (presentationState === "default") {
|
||||||
loadGenealogies();
|
loadGenealogies();
|
||||||
|
loadUnreadCount();
|
||||||
|
loadQuota();
|
||||||
} else {
|
} else {
|
||||||
reconcilePageGenealogyContext();
|
reconcilePageGenealogyContext();
|
||||||
}
|
}
|
||||||
@@ -480,15 +516,20 @@ onShow(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (presentationState === "default") loadGenealogies();
|
if (presentationState === "default") loadGenealogies();
|
||||||
|
if (presentationState === "default") loadUnreadCount();
|
||||||
|
if (presentationState === "default") loadQuota();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
pageActive = false;
|
pageActive = false;
|
||||||
loadGeneration += 1;
|
loadGeneration += 1;
|
||||||
|
unreadLoadGeneration += 1;
|
||||||
|
quotaLoadGeneration += 1;
|
||||||
listRequestController.abort();
|
listRequestController.abort();
|
||||||
|
unreadRequestController.abort();
|
||||||
|
quotaRequestController.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
const unreadCount = computed(() => 0);
|
|
||||||
const hasGenealogies = computed(
|
const hasGenealogies = computed(
|
||||||
() => !forceEmptyState.value && list.value.length > 0,
|
() => !forceEmptyState.value && list.value.length > 0,
|
||||||
);
|
);
|
||||||
@@ -725,11 +766,16 @@ const openShortcut = (key) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.current-name {
|
.current-name {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
color: $ink;
|
color: $ink;
|
||||||
font-family: "STKaiti", "KaiTi", serif;
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
font-size: 50rpx;
|
font-size: 38rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
overflow-wrap: anywhere;
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.current-switch-copy {
|
.current-switch-copy {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="create-page">
|
<view class="create-page">
|
||||||
<GenealogyPageBackground />
|
<GenealogyPageBackground />
|
||||||
<PageHeader title="创建家谱" custom-back @back="backToGenealogies" />
|
<PageHeader title="创建家谱" custom-back @back="requestBack" />
|
||||||
|
|
||||||
<view class="page-content">
|
<view class="page-content">
|
||||||
<view class="create-card">
|
<view class="create-card">
|
||||||
@@ -111,19 +111,34 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardDialogVisible"
|
||||||
|
eyebrow="尚未保存"
|
||||||
|
title="要放弃本次创建吗"
|
||||||
|
message="返回后,本次填写的家谱信息不会保留。"
|
||||||
|
cancel-text="继续填写"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@cancel="cancelDiscard"
|
||||||
|
@confirm="confirmDiscard"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { onUnload } from "@dcloudio/uni-app";
|
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||||
import AppButton from "@/components/AppButton.vue";
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||||
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
|
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
|
||||||
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
|
||||||
import { finishPage, returnTo } from "@/utils/navigation.js";
|
import { finishPage, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
surname: "",
|
surname: "",
|
||||||
@@ -150,8 +165,22 @@ const regionPickerIndexes = ref([0]);
|
|||||||
const regionPickerError = ref("");
|
const regionPickerError = ref("");
|
||||||
const regionLoading = ref(false);
|
const regionLoading = ref(false);
|
||||||
const regionPickerOpen = ref(false);
|
const regionPickerOpen = ref(false);
|
||||||
|
const discardDialogVisible = ref(false);
|
||||||
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
|
const hasDraft = computed(() =>
|
||||||
|
Object.entries(form).some(([key, value]) =>
|
||||||
|
key === "accessPreset"
|
||||||
|
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
|
||||||
|
: String(value).trim(),
|
||||||
|
) || Boolean(selectedRegion.value?.regionCode) || Boolean(coverOssId.value),
|
||||||
|
);
|
||||||
|
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardDialogVisible.value = visible;
|
||||||
|
});
|
||||||
|
const requestDiscardConfirmation = discardConfirmation.request;
|
||||||
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
|
|
||||||
const clearFieldError = (field) => {
|
const clearFieldError = (field) => {
|
||||||
fieldErrors[field] = "";
|
fieldErrors[field] = "";
|
||||||
@@ -259,9 +288,17 @@ onMounted(() => {
|
|||||||
loadRegionRoot();
|
loadRegionRoot();
|
||||||
});
|
});
|
||||||
|
|
||||||
const backToGenealogies = () => {
|
const requestBack = () =>
|
||||||
if (!isSubmitting.value && !isUploading.value) return returnTo("G01");
|
runBackGuard({
|
||||||
};
|
transientOpen: regionPickerOpen.value || discardDialogVisible.value,
|
||||||
|
dirty: hasDraft.value,
|
||||||
|
submitting: isSubmitting.value || isUploading.value,
|
||||||
|
"close-transient": () => regionPickerOpen.value ? closeRegionPicker() : cancelDiscard(),
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
|
});
|
||||||
|
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
|
||||||
const uploadCover = async () => {
|
const uploadCover = async () => {
|
||||||
if (isUploading.value || isSubmitting.value) return;
|
if (isUploading.value || isSubmitting.value) return;
|
||||||
@@ -321,6 +358,7 @@ onUnload(() => {
|
|||||||
pageActive = false;
|
pageActive = false;
|
||||||
createController.abort();
|
createController.abort();
|
||||||
regionController.abort();
|
regionController.abort();
|
||||||
|
discardConfirmation.dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -381,10 +381,10 @@ const applyToJoin = () => {
|
|||||||
}
|
}
|
||||||
.overview-ready {
|
.overview-ready {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: min(483px, calc(100vw * 1.3422));
|
min-height: min(540px, calc(100vw * 1.5));
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 34rpx;
|
gap: 34rpx;
|
||||||
padding: 58rpx 0 42rpx;
|
padding: 10rpx 0 42rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.overview-hero {
|
.overview-hero {
|
||||||
@@ -410,14 +410,21 @@ const applyToJoin = () => {
|
|||||||
margin-top: 6rpx;
|
margin-top: 6rpx;
|
||||||
color: #e9dcc1;
|
color: #e9dcc1;
|
||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
.overview-hero__stats {
|
.overview-hero__stats {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 18rpx;
|
gap: 18rpx;
|
||||||
margin-top: 12rpx;
|
margin-top: 12rpx;
|
||||||
color: #dbc990;
|
color: #dbc990;
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
|
transform: translateY(-18rpx);
|
||||||
}
|
}
|
||||||
|
.overview-hero__stats + .overview-hero__stats { margin-left: 18rpx; }
|
||||||
.overview-actions {
|
.overview-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 386rpx;
|
min-height: 386rpx;
|
||||||
@@ -543,7 +550,7 @@ const applyToJoin = () => {
|
|||||||
}
|
}
|
||||||
.overview-public {
|
.overview-public {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: min(483px, calc(100vw * 1.3422));
|
min-height: min(540px, calc(100vw * 1.5));
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 58rpx 0 28rpx;
|
padding: 58rpx 0 28rpx;
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="poem-panel" :class="`poem-state--${poemState}`">
|
<view class="poem-panel" :class="`poem-state--${poemState}`">
|
||||||
<AppLoading v-if="poemState === 'loading'" text="正在读取字辈诗" description="正在读取后端已声明的世代、字辈和状态字段。" />
|
<AppLoading v-if="poemState === 'loading'" text="正在读取字辈诗" description="正在整理家谱的传承字序。" />
|
||||||
|
|
||||||
<view v-else-if="poemState === 'list'" class="poem-list">
|
<view v-else-if="poemState === 'list'" class="poem-list">
|
||||||
<text class="poem-list__eyebrow">{{ genealogyName }} · 传承字序</text>
|
<text class="poem-list__eyebrow">{{ genealogyName }} · 传承字序</text>
|
||||||
<text class="poem-list__title">{{ generationRangeTitle }}</text>
|
<text class="poem-list__title">{{ generationRangeTitle }}</text>
|
||||||
<text class="poem-list__copy">接口未声明“当前世代”字段,页面只按后端返回的世代、文字和状态展示。</text>
|
<text class="poem-list__copy">按已记录的世代和字辈展示传承字序。</text>
|
||||||
<view class="poem-rows">
|
<view class="poem-rows">
|
||||||
<view v-for="item in visiblePoemRows" :key="item.poemId" class="poem-row" :class="{ 'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED }">
|
<view v-for="item in visiblePoemRows" :key="item.poemId" class="poem-row" :class="{ 'poem-row--disabled': item.status === GENERATION_POEM_STATUS.DISABLED }">
|
||||||
<text class="poem-row__number">第 {{ item.generationNo }} 世</text>
|
<text class="poem-row__number">第 {{ item.generationNo }} 世</text>
|
||||||
@@ -136,8 +136,8 @@ const previewSummary = computed(() => {
|
|||||||
return `服务端预览:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
|
return `服务端预览:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
|
||||||
});
|
});
|
||||||
const stateCopy = computed(() => poemState.value === "empty"
|
const stateCopy = computed(() => poemState.value === "empty"
|
||||||
? "正常字辈读取未返回条目。维护列表由后端单独鉴权,点击后才会判断是否可维护。"
|
? "当前尚未添加可用的字辈,可进入维护页面查看。"
|
||||||
: "未取得可消费的服务端响应,页面不会用本地字辈或本地权限替代。",
|
: "暂未读取到可展示的字辈,请稍后重试。",
|
||||||
);
|
);
|
||||||
const isDirty = computed(() => Boolean(
|
const isDirty = computed(() => Boolean(
|
||||||
poemState.value === "edit" && editorSnapshot.value && (
|
poemState.value === "edit" && editorSnapshot.value && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- 页面编号:N-02;用途:通知详情。没有详情读取 operation 时保持关闭。 -->
|
<!-- 页面编号:N-02;用途:通知详情。没有详情读取 operation 时保持关闭。 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="message-detail-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view><view class="page-content"><view class="state-card"><text>消息详情暂未开放</text><text>Apifox 当前只有通知列表、单条标已读和全部标已读 operation,没有消息详情读取 owner;页面不再从 fixture 展示正文、来源或业务跳转。</text><AppButton block label="返回消息中心" @click="backToMessages" /></view></view></view>
|
<view class="message-detail-page"><ModulePageBackground module="notification" /><view class="page-header"><PageHeader title="消息详情" custom-back @back="backToMessages" /></view><view class="page-content"><view class="state-card"><text>暂无法查看消息详情</text><text>请返回消息中心查看已收到的消息。</text><AppButton block label="返回消息中心" @click="backToMessages" /></view></view></view>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToMessages=()=>returnTo("N01");
|
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToMessages=()=>returnTo("N01");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<view class="profile-summary">
|
<view class="profile-summary">
|
||||||
<view class="profile-summary__avatar"><text>{{ profile.avatar ? "已设置" : "未设置" }}</text></view>
|
<view class="profile-summary__avatar"><image src="/static/assets/foundation/transparent/auth-login-outline.png" mode="aspectFit" /></view>
|
||||||
<view class="profile-summary__identity">
|
<view class="profile-summary__identity">
|
||||||
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
<text>{{ profile.nickName || "未设置昵称" }}</text>
|
||||||
<text>{{ profile.realName || "未设置真实姓名" }}</text>
|
<text>{{ profile.realName || "未设置真实姓名" }}</text>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="profile-fields">
|
<view class="profile-fields">
|
||||||
<view class="profile-field"><text>性别</text><text>{{ profile.sex || "未设置" }}</text></view>
|
<view class="profile-field"><text>性别</text><text>{{ sexText }}</text></view>
|
||||||
<view class="profile-field"><text>生日</text><text>{{ birthdayText }}</text></view>
|
<view class="profile-field"><text>生日</text><text>{{ birthdayText }}</text></view>
|
||||||
<view class="profile-field"><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
|
<view class="profile-field"><text>邮箱</text><text>{{ profile.email || "未设置" }}</text></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -58,6 +58,8 @@ const loadError = ref("");
|
|||||||
const requestController = createRequestController();
|
const requestController = createRequestController();
|
||||||
let pageActive = true;
|
let pageActive = true;
|
||||||
|
|
||||||
|
const sexText = computed(() => (profile.sex ? "待确认" : "未设置"));
|
||||||
|
|
||||||
const birthdayText = computed(() => /^\d{4}-\d{2}-\d{2}/.test(profile.birthday) ? profile.birthday.slice(0, 10) : "未设置");
|
const birthdayText = computed(() => /^\d{4}-\d{2}-\d{2}/.test(profile.birthday) ? profile.birthday.slice(0, 10) : "未设置");
|
||||||
|
|
||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
@@ -99,7 +101,8 @@ onUnload(() => {
|
|||||||
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
.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; }
|
.state-card .app-button { margin-top: 28rpx; }
|
||||||
.profile-summary { display: grid; grid-template-columns: 96rpx minmax(0, 1fr); align-items: center; gap: 18rpx; padding: 30rpx; }
|
.profile-summary { display: grid; grid-template-columns: 96rpx minmax(0, 1fr); align-items: center; gap: 18rpx; padding: 30rpx; }
|
||||||
.profile-summary__avatar { display: flex; width: 96rpx; height: 96rpx; align-items: center; justify-content: center; border: 1rpx solid rgba(159, 23, 15, .32); border-radius: 50%; background: rgba(159, 23, 15, .06); color: $brand-red; font-size: 19rpx; text-align: center; }
|
.profile-summary__avatar { display: flex; width: 96rpx; height: 96rpx; align-items: center; justify-content: center; border: 1rpx solid rgba(159, 23, 15, .32); border-radius: 50%; background: rgba(159, 23, 15, .06); }
|
||||||
|
.profile-summary__avatar image { width: 64rpx; height: 64rpx; opacity: .8; }
|
||||||
.profile-summary__identity { min-width: 0; }
|
.profile-summary__identity { min-width: 0; }
|
||||||
.profile-summary__identity text { display: block; overflow-wrap: anywhere; }
|
.profile-summary__identity text { display: block; overflow-wrap: anywhere; }
|
||||||
.profile-summary__identity text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
.profile-summary__identity text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<view class="form-row"><text>昵称</text><input v-model.trim="form.nickName" maxlength="30" placeholder="请输入昵称" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
<view class="form-row"><text>昵称</text><input v-model.trim="form.nickName" maxlength="30" placeholder="请输入昵称" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
||||||
<view class="form-row"><text>真实姓名</text><input v-model.trim="form.realName" maxlength="30" placeholder="请输入真实姓名" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
<view class="form-row"><text>真实姓名</text><input v-model.trim="form.realName" maxlength="30" placeholder="请输入真实姓名" placeholder-class="placeholder" :disabled="saving || uploading" /></view>
|
||||||
<view class="form-row"><text>性别</text><input v-model.trim="form.sex" placeholder="请输入性别" 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">
|
<view class="form-row">
|
||||||
<text>生日</text>
|
<text>生日</text>
|
||||||
<picker mode="date" :value="form.birthday" :disabled="saving || uploading" @change="changeBirthday"><view class="picker-value" :class="{ 'picker-value--placeholder': !form.birthday }">{{ form.birthday || "请选择生日" }}</view></picker>
|
<picker mode="date" :value="form.birthday" :disabled="saving || uploading" @change="changeBirthday"><view class="picker-value" :class="{ 'picker-value--placeholder': !form.birthday }">{{ form.birthday || "请选择生日" }}</view></picker>
|
||||||
@@ -185,9 +185,12 @@ onUnload(() => {
|
|||||||
.avatar-row > view text { display: block; }
|
.avatar-row > view text { display: block; }
|
||||||
.avatar-row > view text:first-child, .form-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
|
.avatar-row > view text:first-child, .form-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
|
||||||
.avatar-row > view text:last-child { margin-top: 4rpx; color: $ink-muted; font-size: 19rpx; overflow-wrap: anywhere; }
|
.avatar-row > view text:last-child { margin-top: 4rpx; color: $ink-muted; font-size: 19rpx; overflow-wrap: anywhere; }
|
||||||
.upload-button { display: flex; min-height: 64rpx; align-items: center; justify-content: center; border: 0; background: transparent; color: $brand-red; font-size: 22rpx; text-align: right; }
|
.upload-button { display: flex; min-height: 88rpx; align-items: center; justify-content: center; border: 0; background: transparent; color: $brand-red; font-size: 22rpx; text-align: right; }
|
||||||
.form-row input, .picker-value { width: auto; min-width: 0; min-height: 68rpx; color: $ink; font-size: 23rpx; text-align: right; }
|
.form-row input, .picker-value { width: auto; min-width: 0; min-height: 68rpx; color: $ink; font-size: 23rpx; text-align: right; }
|
||||||
.picker-value { display: flex; align-items: center; justify-content: flex-end; }
|
.picker-value { display: flex; 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; }
|
.picker-value--placeholder, .placeholder { color: #ab9a86; }
|
||||||
.field-error, .save-error, .form-note { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
.field-error, .save-error, .form-note { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
||||||
.field-error, .save-error { color: #b42318; }
|
.field-error, .save-error { color: #b42318; }
|
||||||
|
|||||||
@@ -1,11 +1,75 @@
|
|||||||
<!-- 页面编号:M-03;用途:账号与安全入口。安全概览 DTO 未声明。 -->
|
<!-- 页面编号:M-03;用途:展示当前账号可读取的安全概览,不执行安全写入。 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="security-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="账号与安全" custom-back @back="backToProfile" /></view><view class="page-content"><view class="state-card"><text>安全资料待后端字段合同</text><text>没有独立安全概览、设备或登录记录读取 owner;页面不再显示 mock 账号摘要。可进入已独立核对的修改密码页面,换绑手机号仍需人工 TAC/短信窗口。</text><AppButton block label="修改密码" @click="openPassword" /><AppButton type="secondary" block label="换绑手机号" @click="openPhone" /></view></view></view>
|
<view class="security-page">
|
||||||
|
<ModulePageBackground module="profile" />
|
||||||
|
<view class="page-header"><PageHeader title="账号与安全" custom-back @back="backToProfile" /></view>
|
||||||
|
<view class="page-content">
|
||||||
|
<view v-if="loading" class="state-card"><AppLoading text="正在读取账号安全资料" /></view>
|
||||||
|
<view v-else-if="loadError" class="state-card">
|
||||||
|
<text>账号安全资料暂时无法读取</text><text>{{ loadError }}</text>
|
||||||
|
<AppButton block label="重新读取" @click="loadProfile" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="security-card">
|
||||||
|
<text class="security-card__title">当前账号安全概览</text>
|
||||||
|
<view class="security-field"><text>绑定手机号</text><text>{{ maskedPhone }}</text></view>
|
||||||
|
<view class="security-field"><text>账号编号</text><text>{{ profile.userNo || "未提供" }}</text></view>
|
||||||
|
<view class="security-field"><text>账号状态</text><text>{{ profile.status || "未提供" }}</text></view>
|
||||||
|
<text class="security-note">当前仅展示可确认的账户信息,其他安全记录暂不可查看。</text>
|
||||||
|
<AppButton block label="修改密码" @click="openPassword" />
|
||||||
|
<AppButton type="secondary" block label="换绑手机号" @click="openPhone" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { openPage,returnTo } from "@/utils/navigation.js"; const backToProfile=()=>returnTo("M01");const openPassword=()=>openPage("M04",{},"M03");const openPhone=()=>openPage("M05",{},"M03");
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||||
|
import { openPage, returnTo } from "@/utils/navigation.js";
|
||||||
|
|
||||||
|
const profile = reactive({ phone: "", userNo: "", status: "" });
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
|
const controller = createRequestController();
|
||||||
|
let pageActive = true;
|
||||||
|
const maskedPhone = computed(() => /^\d{7,}$/.test(profile.phone)
|
||||||
|
? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}`
|
||||||
|
: "未提供");
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
if (loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
|
try {
|
||||||
|
const data = await appApi.getProfile({ requestController: controller });
|
||||||
|
if (pageActive) Object.assign(profile, data);
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isRequestCancelled(error)) loadError.value = error?.message || "请稍后重试";
|
||||||
|
} finally {
|
||||||
|
if (pageActive) loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const backToProfile = () => returnTo("M01");
|
||||||
|
const openPassword = () => openPage("M04", {}, "M03");
|
||||||
|
const openPhone = () => openPage("M05", {}, "M03");
|
||||||
|
onShow(loadProfile);
|
||||||
|
onUnload(() => { pageActive = false; controller.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
.security-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:20rpx}
|
.security-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,.security-card { box-sizing:border-box; min-height:340rpx; padding:54rpx 42rpx 44rpx; @include adaptive.adaptive-family-content; }
|
||||||
|
.state-card { text-align:center; }.state-card text,.security-card text { display:block; }
|
||||||
|
.state-card text:first-child,.security-card__title { color:$ink; font-family:STKaiti,KaiTi,serif; font-size:35rpx; font-weight:700; }
|
||||||
|
.state-card text:nth-child(2) { margin-top:18rpx; color:$ink-muted; font-size:24rpx; line-height:1.65; }.state-card .app-button { margin-top:28rpx; }
|
||||||
|
.security-field { display:flex; min-height:82rpx; align-items:center; justify-content:space-between; gap:20rpx; border-bottom:1rpx solid rgba(181,137,63,.3); }
|
||||||
|
.security-field:first-of-type { margin-top:24rpx; }.security-field text:first-child { color:$ink; font-size:24rpx; font-weight:700; }.security-field text:last-child { color:$ink-muted; font-size:22rpx; text-align:right; overflow-wrap:anywhere; }
|
||||||
|
.security-note { margin-top:22rpx; color:$ink-muted; font-size:22rpx; line-height:1.6; }.security-card .app-button { margin-top:20rpx; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- 页面编号:M-05;用途:换绑手机号。缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭。 -->
|
<!-- 页面编号:M-05;用途:换绑手机号。缺专用发码/资料 DTO 且需人工 TAC/短信时保持关闭。 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="phone-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="换绑手机号" custom-back @back="backToSecurity" /></view><view class="page-content"><view class="state-card"><text>换绑手机号待人工验证</text><text>换绑接口要求新手机号、四位短信码和 `clientId`,但没有可安全消费的当前手机号 DTO,也没有已核实的专用受保护发码链。页面不显示 mock 手机号、不发送验证码、不在无人值守时换绑。</text><AppButton block label="返回账号与安全" @click="backToSecurity" /></view></view></view>
|
<view class="phone-page"><ModulePageBackground module="profile" /><view class="page-header"><PageHeader title="换绑手机号" custom-back @back="backToSecurity" /></view><view class="page-content"><view class="state-card"><text>暂不能在线换绑手机号</text><text>为保护账户安全,请联系管理员协助办理手机号换绑。</text><AppButton block label="返回账号与安全" @click="backToSecurity" /></view></view></view>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToSecurity=()=>returnTo("M03");
|
import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { returnTo } from "@/utils/navigation.js"; const backToSecurity=()=>returnTo("M03");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<view class="invite-hero">
|
<view class="invite-hero">
|
||||||
<image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
|
<image src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" />
|
||||||
<text>家谱服务推荐</text>
|
<text>家谱服务推荐</text>
|
||||||
<text>以下内容由服务端配置;不生成邀请码,也不会伪造推广信息。</text>
|
<text>以下推荐内容由服务端配置。</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取推广内容" /></view>
|
<view v-if="state === 'loading'" class="state-card"><AppLoading text="正在读取推广内容" /></view>
|
||||||
<view v-else-if="state === 'error'" class="state-card">
|
<view v-else-if="state === 'error'" class="state-card">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="person-related-actions">
|
<view class="person-related-actions">
|
||||||
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
|
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
|
||||||
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
|
<view class="person-related-unavailable"><text>人生大事</text><text>暂未开放</text></view>
|
||||||
</view>
|
</view>
|
||||||
<view class="person-edit-action" @click="toMemberProfile"><AppButton block label="查看成员档案" /></view>
|
<view class="person-edit-action" @click="toMemberProfile"><AppButton block label="查看成员档案" /></view>
|
||||||
</template>
|
</template>
|
||||||
@@ -118,14 +118,6 @@ const toGrowthJournal = () =>
|
|||||||
"R02",
|
"R02",
|
||||||
)
|
)
|
||||||
: Promise.resolve(false);
|
: Promise.resolve(false);
|
||||||
const toLifeEvents = () =>
|
|
||||||
person.id
|
|
||||||
? openPage(
|
|
||||||
"R09",
|
|
||||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
|
||||||
"R02",
|
|
||||||
)
|
|
||||||
: Promise.resolve(false);
|
|
||||||
const toMemberProfile = () =>
|
const toMemberProfile = () =>
|
||||||
person.id
|
person.id
|
||||||
? openPage(
|
? openPage(
|
||||||
@@ -188,6 +180,9 @@ onUnload(() => {
|
|||||||
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
|
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
|
||||||
.person-edit-action { margin-top: 20rpx; }
|
.person-edit-action { margin-top: 20rpx; }
|
||||||
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
|
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
|
||||||
|
.person-related-unavailable { display:flex; min-height:88rpx; flex-direction:column; align-items:center; justify-content:center; box-sizing:border-box; border:1rpx solid rgba(128,89,49,.24); border-radius:8rpx; background:rgba(255,252,245,.38); color:$ink-muted; }
|
||||||
|
.person-related-unavailable text:first-child { font-size:25rpx; font-weight:700; }
|
||||||
|
.person-related-unavailable text:last-child { margin-top:4rpx; font-size:20rpx; }
|
||||||
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
|
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
|
||||||
.person-state-card text { display: block; }
|
.person-state-card text { display: block; }
|
||||||
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
|
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<view class="field-row"><text><text class="required-mark">*</text>亲友姓名</text><input v-model="form.relativeName" placeholder="请输入亲友姓名" @input="submitError = ''" /></view>
|
<view class="field-row"><text><text class="required-mark">*</text>亲友姓名</text><input v-model="form.relativeName" placeholder="请输入亲友姓名" @input="submitError = ''" /></view>
|
||||||
<view class="field-row"><text>关系称谓</text><input v-model="form.relationName" placeholder="请输入关系称谓" @input="submitError = ''" /></view>
|
<view class="field-row"><text>关系称谓</text><input v-model="form.relationName" placeholder="请输入关系称谓" @input="submitError = ''" /></view>
|
||||||
<view class="field-row"><text>礼仪事项</text><input v-model="form.eventName" placeholder="请输入礼仪事项" @input="submitError = ''" /></view>
|
<view class="field-row"><text>礼仪事项</text><input v-model="form.eventName" placeholder="请输入礼仪事项" @input="submitError = ''" /></view>
|
||||||
<view class="field-row"><text>事项时间</text><input v-model="form.eventTime" placeholder="请输入事项时间" @input="submitError = ''" /></view>
|
<view class="field-row field-row--picker"><text>事项日期</text><picker mode="date" :value="form.eventDate" @change="selectEventDate"><view>{{ form.eventDate || '请选择' }}</view></picker></view>
|
||||||
|
<view class="field-row field-row--picker"><text>事项时间</text><picker mode="time" :value="form.eventClock" @change="selectEventClock"><view>{{ form.eventClock || '请选择' }}</view></picker></view>
|
||||||
<view class="field-row"><text>礼金金额</text><input v-model="form.giftAmount" type="digit" placeholder="请输入礼金金额" @input="submitError = ''" /></view>
|
<view class="field-row"><text>礼金金额</text><input v-model="form.giftAmount" type="digit" placeholder="请输入礼金金额" @input="submitError = ''" /></view>
|
||||||
<view class="field-row field-row--textarea"><text>往来备注</text><textarea v-model="form.recordContent" auto-height placeholder="记录往来内容" @input="submitError = ''" /></view>
|
<view class="field-row field-row--textarea"><text>往来备注</text><textarea v-model="form.recordContent" auto-height placeholder="记录往来内容" @input="submitError = ''" /></view>
|
||||||
<view class="upload-field">
|
<view class="upload-field">
|
||||||
@@ -48,10 +49,11 @@ const isUploading = ref(false);
|
|||||||
const discardVisible = ref(false);
|
const discardVisible = ref(false);
|
||||||
const submitError = ref("");
|
const submitError = ref("");
|
||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "", sortOrder: "" });
|
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventDate: "", eventClock: "", giftAmount: "", recordContent: "", sortOrder: "" });
|
||||||
const mediaReceipts = ref([]);
|
const mediaReceipts = ref([]);
|
||||||
const requestController = createRequestController();
|
const requestController = createRequestController();
|
||||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||||
|
const eventTime = computed(() => form.eventDate ? `${form.eventDate} ${form.eventClock || "00:00"}:00` : "");
|
||||||
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
||||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
const resultCopy = computed(() => editorState.value === "success"
|
const resultCopy = computed(() => editorState.value === "success"
|
||||||
@@ -67,6 +69,8 @@ onLoad((query) => {
|
|||||||
genealogyId.value = String(query?.genealogyId || "");
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
|
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
|
||||||
});
|
});
|
||||||
|
const selectEventDate = (event) => { form.eventDate = event.detail.value || ""; submitError.value = ""; };
|
||||||
|
const selectEventClock = (event) => { form.eventClock = event.detail.value || ""; submitError.value = ""; };
|
||||||
const uploadImage = async () => {
|
const uploadImage = async () => {
|
||||||
if (isUploading.value || isSubmitting.value) return;
|
if (isUploading.value || isSubmitting.value) return;
|
||||||
isUploading.value = true;
|
isUploading.value = true;
|
||||||
@@ -85,8 +89,9 @@ const saveRelative = async () => {
|
|||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
submitError.value = "";
|
submitError.value = "";
|
||||||
try {
|
try {
|
||||||
await appApi.createRelativeRecord(genealogyId.value, { ...form, mediaOssIds: mediaOssIds.value }, { requestController });
|
const { eventDate, eventClock, ...recordForm } = form;
|
||||||
Object.assign(form, { relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "", sortOrder: "" });
|
await appApi.createRelativeRecord(genealogyId.value, { ...recordForm, eventTime: eventTime.value, mediaOssIds: mediaOssIds.value }, { requestController });
|
||||||
|
Object.assign(form, { relativeName: "", relationName: "", eventName: "", eventDate: "", eventClock: "", giftAmount: "", recordContent: "", sortOrder: "" });
|
||||||
mediaReceipts.value = [];
|
mediaReceipts.value = [];
|
||||||
editorState.value = "success";
|
editorState.value = "success";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -110,6 +115,7 @@ onUnmounted(() => { requestController.abort(); discardConfirmation.dispose(); })
|
|||||||
.field-row { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }
|
.field-row { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }
|
||||||
.field-row > text, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }
|
.field-row > text, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }
|
||||||
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field-row--textarea { align-items: start; }.field-row textarea { min-height: 72rpx; text-align: left; }
|
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field-row--textarea { align-items: start; }.field-row textarea { min-height: 72rpx; text-align: left; }
|
||||||
|
.field-row--picker picker { min-width: 0; }.field-row--picker picker > view { min-height: 48rpx; display: flex; align-items: center; justify-content: flex-end; color: $ink; font-size: 23rpx; }
|
||||||
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
|
||||||
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
|
||||||
.save-error { display: block; margin-top: 14rpx; color: $brand-red; font-size: 22rpx; }.form-card .app-button { margin-top: 18rpx; }.state-card { min-height: 340rpx; padding-top: 80rpx; text-align: center; }.state-card > text:nth-child(2) { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }.state-card .app-button { margin-top: 28rpx; }
|
.save-error { display: block; margin-top: 14rpx; color: $brand-red; font-size: 22rpx; }.form-card .app-button { margin-top: 18rpx; }.state-card { min-height: 340rpx; padding-top: 80rpx; text-align: center; }.state-card > text:nth-child(2) { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }.state-card .app-button { margin-top: 28rpx; }
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
<text class="textarea-field__label">活动说明</text>
|
<text class="textarea-field__label">活动说明</text>
|
||||||
<textarea v-model="form.ceremonyDesc" maxlength="500" auto-height placeholder="补充活动安排或说明" placeholder-class="placeholder" @input="clearError" />
|
<textarea v-model="form.ceremonyDesc" maxlength="500" auto-height placeholder="补充活动安排或说明" placeholder-class="placeholder" @input="clearError" />
|
||||||
</view>
|
</view>
|
||||||
<view class="field-row">
|
<view class="field-row field-row--picker">
|
||||||
|
<text class="field-row__label">活动日期</text>
|
||||||
|
<picker mode="date" :value="form.ceremonyDate" @change="selectCeremonyDate"><view :class="{ placeholder: !form.ceremonyDate }">{{ form.ceremonyDate || '请选择' }}</view></picker>
|
||||||
|
</view>
|
||||||
|
<view class="field-row field-row--picker">
|
||||||
<text class="field-row__label">活动时间</text>
|
<text class="field-row__label">活动时间</text>
|
||||||
<input v-model="form.ceremonyTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="clearError" />
|
<picker mode="time" :value="form.ceremonyClock" @change="selectCeremonyClock"><view :class="{ placeholder: !form.ceremonyClock }">{{ form.ceremonyClock || '请选择' }}</view></picker>
|
||||||
</view>
|
</view>
|
||||||
<view class="field-row">
|
<view class="field-row">
|
||||||
<text class="field-row__label">地点</text>
|
<text class="field-row__label">地点</text>
|
||||||
@@ -68,7 +72,7 @@ import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigat
|
|||||||
|
|
||||||
const genealogyId = ref("");
|
const genealogyId = ref("");
|
||||||
const pageState = ref("form");
|
const pageState = ref("form");
|
||||||
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyTime: "", location: "", sortOrder: "" });
|
const form = reactive({ ceremonyType: "", ceremonyTitle: "", ceremonyDesc: "", ceremonyDate: "", ceremonyClock: "", location: "", sortOrder: "" });
|
||||||
const coverOssId = ref(null);
|
const coverOssId = ref(null);
|
||||||
const coverFileName = ref("");
|
const coverFileName = ref("");
|
||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
@@ -80,7 +84,8 @@ const discardVisible = ref(false);
|
|||||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||||
|
|
||||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
const isDirty = computed(() => coverOssId.value || Object.values(form).some((value) => value.trim()));
|
const isDirty = computed(() => Boolean(coverOssId.value) || Object.values(form).some((value) => value.trim()));
|
||||||
|
const ceremonyTime = computed(() => form.ceremonyDate ? `${form.ceremonyDate} ${form.ceremonyClock || "00:00"}:00` : "");
|
||||||
const stateCopy = computed(() => pageState.value === "success"
|
const stateCopy = computed(() => pageState.value === "success"
|
||||||
? { title: "礼仪活动已提交服务端", copy: "服务端已返回成功结果。", action: "返回礼仪活动" }
|
? { title: "礼仪活动已提交服务端", copy: "服务端已返回成功结果。", action: "返回礼仪活动" }
|
||||||
: { title: "礼仪活动入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
|
: { title: "礼仪活动入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
|
||||||
@@ -90,6 +95,8 @@ onLoad((query) => {
|
|||||||
if (!hasValidContext.value || query?.mode !== "create") pageState.value = "invalid";
|
if (!hasValidContext.value || query?.mode !== "create") pageState.value = "invalid";
|
||||||
});
|
});
|
||||||
const clearError = () => { submitError.value = ""; };
|
const clearError = () => { submitError.value = ""; };
|
||||||
|
const selectCeremonyDate = (event) => { form.ceremonyDate = event.detail.value || ""; clearError(); };
|
||||||
|
const selectCeremonyClock = (event) => { form.ceremonyClock = event.detail.value || ""; clearError(); };
|
||||||
const uploadCover = async () => {
|
const uploadCover = async () => {
|
||||||
if (uploading.value || submitting.value) return;
|
if (uploading.value || submitting.value) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
@@ -113,7 +120,8 @@ const submit = async () => {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
submitError.value = "";
|
submitError.value = "";
|
||||||
try {
|
try {
|
||||||
await appApi.createCeremony(genealogyId.value, { ...form, ...(coverOssId.value ? { coverOssId: coverOssId.value } : {}) }, { requestController: controller });
|
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
|
||||||
|
await appApi.createCeremony(genealogyId.value, { ...ceremonyForm, ceremonyTime: ceremonyTime.value, ...(coverOssId.value ? { coverOssId: coverOssId.value } : {}) }, { requestController: controller });
|
||||||
pageState.value = "success";
|
pageState.value = "success";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
|
if (!isRequestCancelled(error)) submitError.value = error?.message || "礼仪活动保存失败,请稍后重试";
|
||||||
@@ -135,8 +143,8 @@ onUnmounted(() => { controller.abort(); confirmation.dispose(); });
|
|||||||
.editor-card,.state-card { @include adaptive.adaptive-records-content; box-sizing:border-box; }
|
.editor-card,.state-card { @include adaptive.adaptive-records-content; box-sizing:border-box; }
|
||||||
.editor-card { padding:38rpx 32rpx 42rpx; }.editor-card__title,.editor-card__note,.field-row__label,.textarea-field__label,.form-error { display:block; }
|
.editor-card { padding:38rpx 32rpx 42rpx; }.editor-card__title,.editor-card__note,.field-row__label,.textarea-field__label,.form-error { display:block; }
|
||||||
.editor-card__title { color:$ink; font-family:STKaiti,KaiTi,serif; font-size:38rpx; font-weight:700; }.editor-card__note { margin-top:10rpx; color:$ink-muted; font-size:23rpx; line-height:1.55; }
|
.editor-card__title { color:$ink; font-family:STKaiti,KaiTi,serif; font-size:38rpx; font-weight:700; }.editor-card__note { margin-top:10rpx; color:$ink-muted; font-size:23rpx; line-height:1.55; }
|
||||||
.field-row { display:flex; min-height:92rpx; align-items:center; margin-top:20rpx; padding:0 18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.field-row__label { width:150rpx; flex:0 0 auto; color:$ink; font-size:26rpx; font-weight:700; }.field-row input { min-width:0; flex:1; color:$ink; font-size:25rpx; }.required-mark { display:inline-block; margin-right:4rpx; color:$brand-red; font-size:26rpx; transform:translateY(-3rpx); }.placeholder { color:#9e8e79; }
|
.field-row { display:flex; min-height:92rpx; align-items:center; margin-top:20rpx; padding:0 18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.field-row__label { width:150rpx; flex:0 0 auto; color:$ink; font-size:26rpx; font-weight:700; }.field-row input { min-width:0; flex:1; color:$ink; font-size:25rpx; }.field-row--picker picker { min-width:0; flex:1; }.field-row--picker picker > view { min-height:92rpx; display:flex; align-items:center; color:$ink; font-size:25rpx; }.required-mark { display:inline-block; margin-right:4rpx; color:$brand-red; font-size:26rpx; transform:translateY(-3rpx); }.placeholder { color:#9e8e79; }
|
||||||
.textarea-field,.cover-field { margin-top:20rpx; padding:18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.textarea-field__label,.cover-field__label { color:$ink; font-size:26rpx; font-weight:700; }.textarea-field textarea { display:block; width:100%; min-height:122rpx; margin-top:12rpx; color:$ink; font-size:25rpx; line-height:1.55; }
|
.textarea-field,.cover-field { margin-top:20rpx; padding:18rpx; border:1rpx solid rgba(128,89,49,.34); border-radius:12rpx; background:rgba(255,252,245,.7); }.textarea-field__label,.cover-field__label { color:$ink; font-size:26rpx; font-weight:700; }.textarea-field textarea { display:block; width:100%; min-height:122rpx; margin-top:12rpx; color:$ink; font-size:25rpx; line-height:1.55; }
|
||||||
.cover-field { display:grid; gap:12rpx; }.cover-field__hint { display:block; margin-top:5rpx; color:$ink-muted; font-size:20rpx; line-height:1.45; }.upload-button { justify-self:start; min-height:60rpx; margin:0; padding:0 20rpx; border:1rpx solid rgba(159,23,15,.42); border-radius:8rpx; background:transparent; color:$brand-red; font-size:22rpx; }.upload-receipt { color:$ink-muted; font-size:21rpx; overflow-wrap:anywhere; }.form-error { margin-top:10rpx; color:$brand-red; font-size:22rpx; line-height:1.45; }.editor-card .app-button { margin-top:28rpx; }
|
.cover-field { display:grid; gap:12rpx; }.cover-field__hint { display:block; margin-top:5rpx; color:$ink-muted; font-size:20rpx; line-height:1.45; }.upload-button { justify-self:start; min-height:88rpx; margin:0; padding:0 20rpx; border:1rpx solid rgba(159,23,15,.42); border-radius:8rpx; background:transparent; color:$brand-red; font-size:22rpx; }.upload-receipt { color:$ink-muted; font-size:21rpx; overflow-wrap:anywhere; }.form-error { margin-top:10rpx; color:$brand-red; font-size:22rpx; line-height:1.45; }.editor-card .app-button { margin-top:28rpx; }
|
||||||
.state-card { min-height:340rpx; padding:78rpx 52rpx 50rpx; text-align:center; }.state-card text { display:block; }.state-card text:first-child { color:$ink; 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; }
|
.state-card { min-height:340rpx; padding:78rpx 52rpx 50rpx; text-align:center; }.state-card text { display:block; }.state-card text:first-child { color:$ink; 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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
<view class="field"><text>记录类型</text><input v-model="form.recordType" placeholder="请输入记录类型" @input="error = ''" /></view>
|
<view class="field"><text>记录类型</text><input v-model="form.recordType" placeholder="请输入记录类型" @input="error = ''" /></view>
|
||||||
<view class="field"><text><text class="required-mark">*</text>记录标题</text><input v-model="form.recordTitle" placeholder="请输入记录标题" @input="error = ''" /></view>
|
<view class="field"><text><text class="required-mark">*</text>记录标题</text><input v-model="form.recordTitle" placeholder="请输入记录标题" @input="error = ''" /></view>
|
||||||
<view class="field field--textarea"><text>记录内容</text><textarea v-model="form.recordContent" auto-height placeholder="记录成长片段" @input="error = ''" /></view>
|
<view class="field field--textarea"><text>记录内容</text><textarea v-model="form.recordContent" auto-height placeholder="记录成长片段" @input="error = ''" /></view>
|
||||||
<view class="field"><text>记录时间</text><input v-model="form.recordDate" placeholder="请输入记录时间" @input="error = ''" /></view>
|
<view class="field field--picker"><text>记录日期</text><picker mode="date" :value="form.recordDate" @change="selectRecordDate"><view :class="{ placeholder: !form.recordDate }">{{ form.recordDate || '请选择' }}</view></picker></view>
|
||||||
<view class="field"><text>提醒时间</text><input v-model="form.remindTime" placeholder="请输入提醒时间" @input="error = ''" /></view>
|
<view class="field field--picker"><text>记录时间</text><picker mode="time" :value="form.recordClock" @change="selectRecordClock"><view :class="{ placeholder: !form.recordClock }">{{ form.recordClock || '请选择' }}</view></picker></view>
|
||||||
|
<view class="field field--picker"><text>提醒日期</text><picker mode="date" :value="form.remindDate" @change="selectRemindDate"><view :class="{ placeholder: !form.remindDate }">{{ form.remindDate || '请选择' }}</view></picker></view>
|
||||||
|
<view class="field field--picker"><text>提醒时间</text><picker mode="time" :value="form.remindClock" @change="selectRemindClock"><view :class="{ placeholder: !form.remindClock }">{{ form.remindClock || '请选择' }}</view></picker></view>
|
||||||
<view class="upload-field">
|
<view class="upload-field">
|
||||||
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执,并在提交记录时关联。</text></view>
|
<view><text>相关图片</text><text>图片选定后会先取得真实上传回执,并在提交记录时关联。</text></view>
|
||||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">{{ uploading ? '上传中…' : '添加图片' }}</button>
|
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">{{ uploading ? '上传中…' : '添加图片' }}</button>
|
||||||
@@ -65,19 +67,21 @@ const uploading = ref(false);
|
|||||||
const error = ref("");
|
const error = ref("");
|
||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
const discardVisible = ref(false);
|
const discardVisible = ref(false);
|
||||||
const form = reactive({ lineagePersonId: "", recordType: "", recordTitle: "", recordContent: "", recordDate: "", remindTime: "", sortOrder: "" });
|
const form = reactive({ lineagePersonId: "", recordType: "", recordTitle: "", recordContent: "", recordDate: "", recordClock: "", remindDate: "", remindClock: "", sortOrder: "" });
|
||||||
const mediaReceipts = ref([]);
|
const mediaReceipts = ref([]);
|
||||||
const controller = createRequestController();
|
const controller = createRequestController();
|
||||||
let active = true;
|
let active = true;
|
||||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||||
|
const recordTime = computed(() => form.recordDate ? `${form.recordDate} ${form.recordClock || "00:00"}:00` : "");
|
||||||
|
const remindTime = computed(() => form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "");
|
||||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0);
|
||||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||||
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
||||||
const cancelDiscard = confirmation.cancel;
|
const cancelDiscard = confirmation.cancel;
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
Object.assign(form, { lineagePersonId: personId.value, recordType: "", recordTitle: "", recordContent: "", recordDate: "", remindTime: "", sortOrder: "" });
|
Object.assign(form, { lineagePersonId: personId.value, recordType: "", recordTitle: "", recordContent: "", recordDate: "", recordClock: "", remindDate: "", remindClock: "", sortOrder: "" });
|
||||||
mediaReceipts.value = [];
|
mediaReceipts.value = [];
|
||||||
error.value = "";
|
error.value = "";
|
||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
@@ -105,6 +109,10 @@ const loadRecords = async () => {
|
|||||||
};
|
};
|
||||||
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
||||||
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
||||||
|
const selectRecordDate = (event) => { form.recordDate = event.detail.value || ""; error.value = ""; };
|
||||||
|
const selectRecordClock = (event) => { form.recordClock = event.detail.value || ""; error.value = ""; };
|
||||||
|
const selectRemindDate = (event) => { form.remindDate = event.detail.value || ""; error.value = ""; };
|
||||||
|
const selectRemindClock = (event) => { form.remindClock = event.detail.value || ""; error.value = ""; };
|
||||||
const uploadImage = async () => {
|
const uploadImage = async () => {
|
||||||
if (uploading.value || submitting.value) return;
|
if (uploading.value || submitting.value) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
@@ -119,7 +127,8 @@ const submit = async () => {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
await appApi.createGrowthRecord(genealogyId.value, { ...form, mediaOssIds: mediaOssIds.value }, { requestController: controller });
|
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||||
|
await appApi.createGrowthRecord(genealogyId.value, { ...recordForm, recordDate: recordTime.value, remindTime: remindTime.value, mediaOssIds: mediaOssIds.value }, { requestController: controller });
|
||||||
resetForm();
|
resetForm();
|
||||||
view.value = "list";
|
view.value = "list";
|
||||||
saveNotice.value = true;
|
saveNotice.value = true;
|
||||||
@@ -149,8 +158,8 @@ onUnmounted(() => { active = false; controller.abort(); confirmation.dispose();
|
|||||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
.record-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
.record-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||||
.form-card, .state-card, .record-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
|
.form-card, .state-card, .record-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
|
||||||
.field { margin-top: 16rpx; }.field > text { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field textarea { @include adaptive.adaptive-records-field; box-sizing: border-box; width: 100%; min-height: 76rpx; padding: 16rpx 22rpx; color: $ink; font-size: 23rpx; }.field textarea { min-height: 150rpx; }
|
.field { margin-top: 16rpx; }.field > text { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field textarea, .field--picker picker > view { @include adaptive.adaptive-records-field; box-sizing: border-box; width: 100%; min-height: 76rpx; padding: 16rpx 22rpx; color: $ink; font-size: 23rpx; }.field textarea { min-height: 150rpx; }.field--picker picker { display: block; }.field--picker .placeholder { color: $ink-muted; }
|
||||||
.field--context > view { @include adaptive.adaptive-records-field; display: flex; min-height: 76rpx; align-items: center; justify-content: space-between; padding: 16rpx 22rpx; box-sizing: border-box; }.field--context > view > text:first-child { color: $ink; font-size: 23rpx; }.field--context > view > text:last-child { color: $ink-muted; font-size: 21rpx; }
|
.field--context > view { @include adaptive.adaptive-records-field; display: flex; min-height: 76rpx; align-items: center; justify-content: space-between; padding: 16rpx 22rpx; box-sizing: border-box; }.field--context > view > text:first-child { color: $ink; font-size: 23rpx; }.field--context > view > text:last-child { color: $ink-muted; font-size: 21rpx; }
|
||||||
.upload-field { display: grid; gap: 12rpx; margin-top: 16rpx; padding: 18rpx 22rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text, .error { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
.upload-field { display: grid; gap: 12rpx; margin-top: 16rpx; padding: 18rpx 22rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text, .error { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
||||||
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.record-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.record-card { min-height: 128rpx; padding: 28rpx 32rpx; }.record-card text { display: block; }.record-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.record-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
|
.form-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr); gap: 16rpx; margin-top: 20rpx; }.form-actions .app-button { width: 100%; min-width: 0; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.record-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.record-card { min-height: 128rpx; padding: 28rpx 32rpx; }.record-card text { display: block; }.record-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.record-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- 页面编号:R-09;用途:人生大事。当前没有独立业务 operation。 -->
|
<!-- 页面编号:R-09;用途:人生大事。当前没有独立业务 operation。 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="life-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view><view class="page-content"><view class="state-card"><text>人生大事暂未开放</text><text>已在 Apifox APP/PC 目录检索“人生”“life”,没有独立人生事件读取或写入 operation;页面不借成长、备忘、人物资料或参考项目伪造保存。</text><AppButton block :label="hasValidContext ? '返回记录首页' : '返回上一页'" @click="returnToRecords" /></view></view></view>
|
<view class="life-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view><view class="page-content"><view class="state-card"><text>人生大事暂未开放</text><text>该功能正在准备中。开放后,重要人生经历会在这里沉淀为家族记忆。</text><AppButton block :label="hasValidContext ? '返回记录首页' : '返回上一页'" @click="returnToRecords" /></view></view></view>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed,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 { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToRecords=()=>hasValidContext.value?returnTo("R01",{genealogyId:genealogyId.value}):goBack();
|
import { computed,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 { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToRecords=()=>hasValidContext.value?returnTo("R01",{genealogyId:genealogyId.value}):goBack();
|
||||||
|
|||||||
@@ -126,6 +126,6 @@ onUnmounted(() => { active = false; controller.abort(); confirmation.dispose();
|
|||||||
.memo-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
.memo-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||||
.form-card, .state-card, .memo-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
|
.form-card, .state-card, .memo-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
|
||||||
.field { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }.field > text, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field > text:last-child { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field--textarea { align-items: start; }.field textarea { min-height: 116rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
|
.field { display: grid; grid-template-columns: 142rpx minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }.field > text, .upload-field > view > text:first-child { color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field > text:last-child { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }.field--textarea { align-items: start; }.field textarea { min-height: 116rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
|
||||||
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
.upload-field { display: grid; gap: 12rpx; margin-top: 14rpx; padding: 18rpx 24rpx; @include adaptive.adaptive-records-field; }.upload-field > view > text { display: block; }.upload-field > view > text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-field > text { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
||||||
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.memo-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.memo-card { min-height: 128rpx; padding: 28rpx 32rpx; }.memo-card text { display: block; }.memo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.memo-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
|
.form-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr); gap: 16rpx; margin-top: 20rpx; }.form-actions .app-button { width: 100%; min-width: 0; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.memo-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.memo-card { min-height: 128rpx; padding: 28rpx 32rpx; }.memo-card text { display: block; }.memo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.memo-card text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<text>新建功德记录</text>
|
<text>新建功德记录</text>
|
||||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||||
<view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view>
|
<view v-for="field in fields" :key="field.key" class="field"><text><text v-if="field.required" class="required-mark">*</text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.inputType || 'text'" :placeholder="field.placeholder || `请输入${field.label}`" @input="error = ''" /></view>
|
||||||
|
<view class="field field--picker"><text>记录日期</text><picker mode="date" :value="form.meritDate" @change="selectMeritDate"><view :class="{ placeholder: !form.meritDate }">{{ form.meritDate || '请选择' }}</view></picker></view>
|
||||||
|
<view class="field field--picker"><text>记录时间</text><picker mode="time" :value="form.meritClock" @change="selectMeritClock"><view :class="{ placeholder: !form.meritClock }">{{ form.meritClock || '请选择' }}</view></picker></view>
|
||||||
<text v-if="error" class="error">{{ error }}</text>
|
<text v-if="error" class="error">{{ error }}</text>
|
||||||
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view>
|
<view class="form-actions"><AppButton type="secondary" label="取消" @click="cancelCreate" /><AppButton :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -45,13 +47,12 @@ const saveNotice = ref(false);
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const discardVisible = ref(false);
|
const discardVisible = ref(false);
|
||||||
const form = reactive({ donor: "", title: "", type: "", amount: "", time: "", content: "", sortOrder: "" });
|
const form = reactive({ donor: "", title: "", type: "", amount: "", meritDate: "", meritClock: "", content: "", sortOrder: "" });
|
||||||
const fields = [
|
const fields = [
|
||||||
{ key: "donor", label: "捐赠人", required: true },
|
{ key: "donor", label: "捐赠人", required: true },
|
||||||
{ key: "title", label: "功德标题", required: true },
|
{ key: "title", label: "功德标题", required: true },
|
||||||
{ key: "type", label: "功德类型" },
|
{ key: "type", label: "功德类型" },
|
||||||
{ key: "amount", inputType: "digit", label: "金额" },
|
{ key: "amount", inputType: "digit", label: "金额" },
|
||||||
{ key: "time", label: "记录时间" },
|
|
||||||
{ key: "content", label: "记录内容" },
|
{ key: "content", label: "记录内容" },
|
||||||
{ key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" },
|
{ key: "sortOrder", label: "排序值", inputType: "number", placeholder: "数值越小越靠前" },
|
||||||
];
|
];
|
||||||
@@ -59,12 +60,13 @@ const controller = createRequestController();
|
|||||||
let active = true;
|
let active = true;
|
||||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
|
const dirty = computed(() => Object.values(form).some((value) => String(value).trim()));
|
||||||
|
const meritTime = computed(() => form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "");
|
||||||
const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2));
|
const totalAmount = computed(() => merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2));
|
||||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||||
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
const confirmDiscard = () => { confirmation.confirm(); resetForm(); view.value = "list"; };
|
||||||
const cancelDiscard = confirmation.cancel;
|
const cancelDiscard = confirmation.cancel;
|
||||||
|
|
||||||
const resetForm = () => { Object.assign(form, { donor: "", title: "", type: "", amount: "", time: "", content: "", sortOrder: "" }); error.value = ""; };
|
const resetForm = () => { Object.assign(form, { donor: "", title: "", type: "", amount: "", meritDate: "", meritClock: "", content: "", sortOrder: "" }); error.value = ""; };
|
||||||
const loadMerits = async () => {
|
const loadMerits = async () => {
|
||||||
if (!valid.value) return;
|
if (!valid.value) return;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
@@ -89,6 +91,8 @@ const loadMerits = async () => {
|
|||||||
};
|
};
|
||||||
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
const openCreate = () => { if (!valid.value) return; saveNotice.value = false; resetForm(); view.value = "form"; };
|
||||||
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
const cancelCreate = () => { resetForm(); view.value = "list"; };
|
||||||
|
const selectMeritDate = (event) => { form.meritDate = event.detail.value || ""; error.value = ""; };
|
||||||
|
const selectMeritClock = (event) => { form.meritClock = event.detail.value || ""; error.value = ""; };
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (submitting.value || !valid.value) return;
|
if (submitting.value || !valid.value) return;
|
||||||
const donorName = form.donor.trim();
|
const donorName = form.donor.trim();
|
||||||
@@ -99,7 +103,7 @@ const submit = async () => {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
await appApi.createMeritRecord(genealogyId.value, { donorName, meritTitle, meritType: form.type, meritContent: form.content, meritTime: form.time, sortOrder: form.sortOrder, ...(amountText ? { amount: Number(amountText) } : {}) }, { requestController: controller });
|
await appApi.createMeritRecord(genealogyId.value, { donorName, meritTitle, meritType: form.type, meritContent: form.content, meritTime: meritTime.value, sortOrder: form.sortOrder, ...(amountText ? { amount: Number(amountText) } : {}) }, { requestController: controller });
|
||||||
resetForm();
|
resetForm();
|
||||||
view.value = "list";
|
view.value = "list";
|
||||||
saveNotice.value = true;
|
saveNotice.value = true;
|
||||||
@@ -122,6 +126,6 @@ onUnmounted(() => { active = false; controller.abort(); confirmation.dispose();
|
|||||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
.merit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
.merit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header, .page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||||
.form-card, .state-card, .merit-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
|
.form-card, .state-card, .merit-card { @include adaptive.adaptive-records-content; }.form-card { box-sizing: border-box; padding: 46rpx; }.form-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
|
||||||
.field { margin-top: 16rpx; }.field > text { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field textarea { @include adaptive.adaptive-records-field; box-sizing: border-box; width: 100%; min-height: 76rpx; padding: 16rpx 22rpx; color: $ink; font-size: 23rpx; }.field textarea { min-height: 150rpx; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
.field { margin-top: 16rpx; }.field > text { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; transform: translateY(-3rpx); }.field input, .field textarea, .field--picker picker > view { @include adaptive.adaptive-records-field; box-sizing: border-box; width: 100%; min-height: 76rpx; padding: 16rpx 22rpx; color: $ink; font-size: 23rpx; }.field textarea { min-height: 150rpx; }.field--picker picker { display: block; }.field--picker .placeholder { color: $ink-muted; }.error { display: block; margin-top: 12rpx; color: $brand-red; font-size: 22rpx; }
|
||||||
.form-actions { display: grid; grid-template-columns: 1fr 1.45fr; gap: 16rpx; margin-top: 20rpx; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.merit-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.merit-summary { display: block; color: $ink-muted; font-size: 22rpx; }.merit-card { display: flex; min-height: 138rpx; align-items: center; justify-content: space-between; gap: 18rpx; padding: 28rpx 32rpx; }.merit-card > view { min-width: 0; flex: 1; }.merit-card text { display: block; }.merit-card > view text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; overflow-wrap: anywhere; }.merit-card > view text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }.merit-card > text { flex: 0 0 auto; color: $brand-red; font-size: 22rpx; }
|
.form-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr); gap: 16rpx; margin-top: 20rpx; }.form-actions .app-button { width: 100%; min-width: 0; }.state-card { display: flex; min-height: 320rpx; flex-direction: column; align-items: center; justify-content: center; padding: 42rpx; color: $ink; font-size: 28rpx; text-align: center; }.state-card .app-button { width: 100%; margin-top: 24rpx; }.merit-list { display: flex; flex-direction: column; gap: 16rpx; }.save-notice { display: block; color: $brand-red; font-size: 22rpx; }.merit-summary { display: block; color: $ink-muted; font-size: 22rpx; }.merit-card { display: flex; min-height: 138rpx; align-items: center; justify-content: space-between; gap: 18rpx; padding: 28rpx 32rpx; }.merit-card > view { min-width: 0; flex: 1; }.merit-card text { display: block; }.merit-card > view text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; overflow-wrap: anywhere; }.merit-card > view text:not(:first-child) { margin-top: 7rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; overflow-wrap: anywhere; }.merit-card > text { flex: 0 0 auto; color: $brand-red; font-size: 22rpx; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
if (!context || typeof context !== "object") fail("TAC 请求上下文无效");
|
if (!context || typeof context !== "object") fail("TAC 请求上下文无效");
|
||||||
return {
|
return {
|
||||||
tenantId: text(context.tenantId, "租户标识"),
|
tenantId: text(context.tenantId, "租户标识"),
|
||||||
clientId: text(context.clientId, "客户端标识"),
|
operationCode: text(context.operationCode, "认证动作"),
|
||||||
sceneCode: text(context.sceneCode, "验证场景"),
|
|
||||||
subject: text(context.subject, "验证主体"),
|
subject: text(context.subject, "验证主体"),
|
||||||
providerCode: text(context.providerCode, "验证服务商").toUpperCase(),
|
providerCode: text(context.providerCode, "验证服务商").toUpperCase(),
|
||||||
captchaType: text(context.captchaType, "验证码类型").toUpperCase(),
|
captchaType: text(context.captchaType, "验证码类型").toUpperCase(),
|
||||||
@@ -120,8 +119,6 @@
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
tenantId: context.tenantId,
|
tenantId: context.tenantId,
|
||||||
clientId: context.clientId,
|
|
||||||
sceneCode: context.sceneCode,
|
|
||||||
subject: context.subject,
|
subject: context.subject,
|
||||||
challengeId: challengeId,
|
challengeId: challengeId,
|
||||||
providerCode: challenge.providerCode,
|
providerCode: challenge.providerCode,
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ foreach ($contract in @(
|
|||||||
'const togglePasswordVisibility = () =>',
|
'const togglePasswordVisibility = () =>',
|
||||||
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
|
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
|
||||||
'<TacVerification',
|
'<TacVerification',
|
||||||
'AUTH_TAC_SCENE.SMS_LOGIN',
|
'AUTH_VERIFICATION_OPERATION.SMS_LOGIN',
|
||||||
'normalizeTacSuccess',
|
'normalizeTacSuccess',
|
||||||
'appApi.loginWithPassword',
|
'appApi.loginWithPassword',
|
||||||
'appApi.loginWithSms',
|
'appApi.loginWithSms',
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const createHarnessFactory = (
|
|||||||
goRootResult = true,
|
goRootResult = true,
|
||||||
goRootError = null,
|
goRootError = null,
|
||||||
initialSessionToken = "",
|
initialSessionToken = "",
|
||||||
|
captchaRequired = true,
|
||||||
) => new Function(
|
) => new Function(
|
||||||
"goRootResult",
|
"goRootResult",
|
||||||
"goRootError",
|
"goRootError",
|
||||||
"initialSessionToken",
|
"initialSessionToken",
|
||||||
|
"captchaRequired",
|
||||||
`
|
`
|
||||||
"use strict";
|
"use strict";
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -56,10 +58,10 @@ const createHarnessFactory = (
|
|||||||
async getCaptchaRequirement(payload) {
|
async getCaptchaRequirement(payload) {
|
||||||
calls.push({ type: "require", payload: { ...payload } });
|
calls.push({ type: "require", payload: { ...payload } });
|
||||||
return {
|
return {
|
||||||
required: true,
|
required: captchaRequired,
|
||||||
providerCode: "TIANAI",
|
providerCode: "TIANAI",
|
||||||
captchaType: "SLIDER",
|
captchaType: "SLIDER",
|
||||||
sceneCode: payload.sceneCode,
|
sceneCode: "APP_PASSWORD_LOGIN",
|
||||||
ttlSeconds: 300,
|
ttlSeconds: 300,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -76,11 +78,14 @@ const createHarnessFactory = (
|
|||||||
return { access_token: "token-sms" };
|
return { access_token: "token-sms" };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const AUTH_TAC_SCENE = Object.freeze({ SMS_LOGIN: "APP_SMS_LOGIN" });
|
const AUTH_VERIFICATION_OPERATION = Object.freeze({
|
||||||
|
PASSWORD_LOGIN: "password-login",
|
||||||
|
SMS_LOGIN: "sms-login",
|
||||||
|
});
|
||||||
const createTacRenderContext = (value) => ({ ...value });
|
const createTacRenderContext = (value) => ({ ...value });
|
||||||
const isAuthPhone = (value) => /^1[3-9]\\d{9}$/.test(value);
|
const isAuthPhone = (value) => /^1[3-9]\\d{9}$/.test(value);
|
||||||
const normalizeCaptchaRequirement = (value, expectedScene) => {
|
const normalizeCaptchaRequirement = (value) => {
|
||||||
if (value?.sceneCode !== expectedScene || value?.required !== true) {
|
if (typeof value?.required !== "boolean") {
|
||||||
throw new Error("requirement invalid");
|
throw new Error("requirement invalid");
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
@@ -141,13 +146,14 @@ const createHarnessFactory = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
`,
|
`,
|
||||||
)(goRootResult, goRootError, initialSessionToken);
|
)(goRootResult, goRootError, initialSessionToken, captchaRequired);
|
||||||
|
|
||||||
const createHarness = (options = {}) =>
|
const createHarness = (options = {}) =>
|
||||||
createHarnessFactory(
|
createHarnessFactory(
|
||||||
options.goRootResult ?? true,
|
options.goRootResult ?? true,
|
||||||
options.goRootError ?? null,
|
options.goRootError ?? null,
|
||||||
options.initialSessionToken ?? "",
|
options.initialSessionToken ?? "",
|
||||||
|
options.captchaRequired ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const countCalls = (harness, type) =>
|
const countCalls = (harness, type) =>
|
||||||
@@ -193,6 +199,7 @@ const run = async () => {
|
|||||||
assert.deepStrictEqual(success.calls[1].payload, {
|
assert.deepStrictEqual(success.calls[1].payload, {
|
||||||
phone: "13800138000",
|
phone: "13800138000",
|
||||||
passwordHash: "md5:secret-1",
|
passwordHash: "md5:secret-1",
|
||||||
|
validToken: "ticket-1",
|
||||||
});
|
});
|
||||||
assert.strictEqual(success.calls[2].pageId, "G01");
|
assert.strictEqual(success.calls[2].pageId, "G01");
|
||||||
assert.strictEqual(countCalls(success, "require"), 1);
|
assert.strictEqual(countCalls(success, "require"), 1);
|
||||||
@@ -200,6 +207,18 @@ const run = async () => {
|
|||||||
assert.strictEqual(success.tacVisible.value, false);
|
assert.strictEqual(success.tacVisible.value, false);
|
||||||
assert.strictEqual(success.tacContext.value, null);
|
assert.strictEqual(success.tacContext.value, null);
|
||||||
|
|
||||||
|
const noCaptcha = createHarness({ captchaRequired: false });
|
||||||
|
noCaptcha.phone.value = "13800138000";
|
||||||
|
noCaptcha.password.value = "secret-1";
|
||||||
|
noCaptcha.agreed.value = true;
|
||||||
|
await noCaptcha.submitLogin();
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
noCaptcha.calls.map((call) => call.type),
|
||||||
|
["require", "password-login", "go-root"],
|
||||||
|
"服务端关闭 password-login TAC 时必须直接走已发布密码登录请求",
|
||||||
|
);
|
||||||
|
assert.strictEqual(noCaptcha.tacVisible.value, false);
|
||||||
|
|
||||||
const navigationRejected = createHarness({ goRootResult: false });
|
const navigationRejected = createHarness({ goRootResult: false });
|
||||||
await preparePassword(navigationRejected);
|
await preparePassword(navigationRejected);
|
||||||
await completePasswordTac(navigationRejected);
|
await completePasswordTac(navigationRejected);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ foreach ($required in @(
|
|||||||
'const submitRegister = async () =>',
|
'const submitRegister = async () =>',
|
||||||
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
|
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
|
||||||
'<TacVerification',
|
'<TacVerification',
|
||||||
'AUTH_TAC_SCENE.REGISTER',
|
'AUTH_VERIFICATION_OPERATION.REGISTER',
|
||||||
'appApi.registerWithPassword',
|
'appApi.registerWithPassword',
|
||||||
'if (!/^\d{4}$/.test(verificationCode.value))',
|
'if (!/^\d{4}$/.test(verificationCode.value))',
|
||||||
'if (!isAuthPhone(phone.value))',
|
'if (!isAuthPhone(phone.value))',
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ foreach ($required in @(
|
|||||||
'if (!isAuthPhone(phone.value))',
|
'if (!isAuthPhone(phone.value))',
|
||||||
'if (!/^\d{4}$/.test(verificationCode.value))',
|
'if (!/^\d{4}$/.test(verificationCode.value))',
|
||||||
'<TacVerification',
|
'<TacVerification',
|
||||||
'AUTH_TAC_SCENE.FORGOT_PASSWORD',
|
'AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD',
|
||||||
'await appApi.resetPassword',
|
'await appApi.resetPassword',
|
||||||
'validatePassword(password.value)',
|
'validatePassword(password.value)',
|
||||||
'PASSWORD_POLICY_MESSAGE',
|
'PASSWORD_POLICY_MESSAGE',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const routeEnd = Number(process.env.PAGE_AUDIT_END || 0);
|
|||||||
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
|
||||||
const pageExpectations = {
|
const pageExpectations = {
|
||||||
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true },
|
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true, allowAuthenticatedRedirect: true },
|
||||||
"pages/auth/a04-register": { title: "注册账号" },
|
"pages/auth/a04-register": { title: "注册账号" },
|
||||||
"pages/auth/a05-reset-password": { title: "重设密码" },
|
"pages/auth/a05-reset-password": { title: "重设密码" },
|
||||||
"pages/genealogy/g01-my-genealogies": { title: "我的家谱", capture: "01-genealogies.png" },
|
"pages/genealogy/g01-my-genealogies": { title: "我的家谱", capture: "01-genealogies.png" },
|
||||||
@@ -47,7 +47,7 @@ const pageExpectations = {
|
|||||||
"pages/family/f10-video-list": { title: "家族视频", query: `genealogyId=${genealogyId}` },
|
"pages/family/f10-video-list": { title: "家族视频", query: `genealogyId=${genealogyId}` },
|
||||||
"pages/records/r01-people-list": { title: "人物录", query: `genealogyId=${genealogyId}` },
|
"pages/records/r01-people-list": { title: "人物录", query: `genealogyId=${genealogyId}` },
|
||||||
"pages/records/r02-person-detail": { title: "人物详情", query: `genealogyId=${genealogyId}&state=error` },
|
"pages/records/r02-person-detail": { title: "人物详情", query: `genealogyId=${genealogyId}&state=error` },
|
||||||
"pages/records/r03-gift-list": { title: "亲友往来", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
|
"pages/records/r03-gift-list": { title: "贺礼簿", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
|
||||||
"pages/records/r04-gift-editor": { title: "新建往来记录", query: `genealogyId=${genealogyId}&mode=create` },
|
"pages/records/r04-gift-editor": { title: "新建往来记录", query: `genealogyId=${genealogyId}&mode=create` },
|
||||||
"pages/records/r05-ritual-list": { title: "礼仪活动", query: `genealogyId=${genealogyId}` },
|
"pages/records/r05-ritual-list": { title: "礼仪活动", query: `genealogyId=${genealogyId}` },
|
||||||
"pages/records/r06-ritual-detail": { title: "礼仪详情", query: `genealogyId=${genealogyId}&ceremonyId=0` },
|
"pages/records/r06-ritual-detail": { title: "礼仪详情", query: `genealogyId=${genealogyId}&ceremonyId=0` },
|
||||||
@@ -111,13 +111,24 @@ const waitFor = async (send, expression, message) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const open = async (send, route, noReload) => {
|
const open = async (send, route, noReload) => {
|
||||||
|
const expectation = pageExpectations[route];
|
||||||
const url = `${origin}/#/${route}${pageExpectations[route].query ? `?${pageExpectations[route].query}` : ""}`;
|
const url = `${origin}/#/${route}${pageExpectations[route].query ? `?${pageExpectations[route].query}` : ""}`;
|
||||||
await send("Page.navigate", { url });
|
await send("Page.navigate", { url });
|
||||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
const authenticatedRedirect = `${origin}/#/pages/genealogy/g01-my-genealogies`;
|
||||||
|
const expectedLocation = pageExpectations[route].allowAuthenticatedRedirect
|
||||||
|
? `location.href === ${JSON.stringify(url)} || location.href === ${JSON.stringify(authenticatedRedirect)}`
|
||||||
|
: `location.href === ${JSON.stringify(url)}`;
|
||||||
|
await waitFor(send, expectedLocation, `navigation failed: ${route}`);
|
||||||
|
if (!expectation.skipTitle) {
|
||||||
|
await waitFor(send, `document.body?.innerText.includes(${JSON.stringify(expectation.title)})`, `title did not render: ${route}`);
|
||||||
|
}
|
||||||
if (!noReload) {
|
if (!noReload) {
|
||||||
const previousTimeOrigin = await evaluate(send, "performance.timeOrigin");
|
const previousTimeOrigin = await evaluate(send, "performance.timeOrigin");
|
||||||
await send("Page.reload");
|
send("Page.reload").catch(() => {});
|
||||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
|
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
|
||||||
|
if (!expectation.skipTitle) {
|
||||||
|
await waitFor(send, `document.body?.innerText.includes(${JSON.stringify(expectation.title)})`, `title did not render after reload: ${route}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await waitFor(send, "document.body && document.body.innerText.length > 0", `page did not render: ${route}`);
|
await waitFor(send, "document.body && document.body.innerText.length > 0", `page did not render: ${route}`);
|
||||||
await wait(700);
|
await wait(700);
|
||||||
@@ -131,7 +142,7 @@ const capture = async (send, filename) => {
|
|||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const allRoutes = require("../pages.json").pages.map((page) => page.path);
|
const allRoutes = require("../pages.json").pages.map((page) => page.path);
|
||||||
assert.deepStrictEqual(allRoutes.sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
|
assert.deepStrictEqual([...allRoutes].sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
|
||||||
const routes = routeEnd > routeStart ? allRoutes.slice(routeStart, routeEnd) : allRoutes;
|
const routes = routeEnd > routeStart ? allRoutes.slice(routeStart, routeEnd) : allRoutes;
|
||||||
const { socket, send } = await connect();
|
const { socket, send } = await connect();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ const run = async () => {
|
|||||||
};
|
};
|
||||||
const hasRemoteConfig = () => true;
|
const hasRemoteConfig = () => true;
|
||||||
const resolveRuntimeMode = () => "remote";
|
const resolveRuntimeMode = () => "remote";
|
||||||
const AUTH_TAC_SCENE = Object.freeze({
|
const AUTH_VERIFICATION_OPERATION = Object.freeze({
|
||||||
SMS_LOGIN: "APP_SMS_LOGIN",
|
PASSWORD_LOGIN: "password-login",
|
||||||
REGISTER: "APP_REGISTER",
|
SMS_LOGIN: "sms-login",
|
||||||
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
|
REGISTER: "register",
|
||||||
|
FORGOT_PASSWORD: "forgot-password",
|
||||||
});
|
});
|
||||||
const assertSmsCode = (value) => {
|
const assertSmsCode = (value) => {
|
||||||
if (typeof value !== "string" || !/^\\d{4}$/.test(value)) throw new Error("请输入 4 位短信验证码");
|
if (typeof value !== "string" || !/^\\d{4}$/.test(value)) throw new Error("请输入 4 位短信验证码");
|
||||||
@@ -84,7 +85,7 @@ const run = async () => {
|
|||||||
nextResponse = response;
|
nextResponse = response;
|
||||||
};
|
};
|
||||||
const sendSms = () => appApi.sendSmsCode({
|
const sendSms = () => appApi.sendSmsCode({
|
||||||
sceneCode: "APP_REGISTER",
|
operationCode: "register",
|
||||||
phone: "13800138000",
|
phone: "13800138000",
|
||||||
validToken: "ticket-1",
|
validToken: "ticket-1",
|
||||||
});
|
});
|
||||||
@@ -114,6 +115,26 @@ const run = async () => {
|
|||||||
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
|
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
|
||||||
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
|
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
|
||||||
assert.strictEqual(requests.at(-1).header.tenantId, "000000");
|
assert.strictEqual(requests.at(-1).header.tenantId, "000000");
|
||||||
|
assert.strictEqual(
|
||||||
|
requests.at(-1).url,
|
||||||
|
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/register/code",
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(requests.at(-1).data, {
|
||||||
|
tenantId: "000000",
|
||||||
|
grantType: "sms",
|
||||||
|
phone: "13800138000",
|
||||||
|
validToken: "ticket-1",
|
||||||
|
});
|
||||||
|
respond({ statusCode: 200, data: { code: 200 } });
|
||||||
|
await appApi.sendSmsCode({
|
||||||
|
operationCode: "register",
|
||||||
|
phone: "13800138000",
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(requests.at(-1).data, {
|
||||||
|
tenantId: "000000",
|
||||||
|
grantType: "sms",
|
||||||
|
phone: "13800138000",
|
||||||
|
}, "策略关闭时发码请求不得伪造 validToken");
|
||||||
|
|
||||||
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
|
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
|
||||||
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
|
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
|
||||||
@@ -144,6 +165,26 @@ const run = async () => {
|
|||||||
"找回密码的 RVoid 省略 data 时仍必须解析为 null",
|
"找回密码的 RVoid 省略 data 时仍必须解析为 null",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
respond({
|
||||||
|
statusCode: 200,
|
||||||
|
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
|
||||||
|
});
|
||||||
|
respond({ statusCode: 200, data: { code: 200 } });
|
||||||
|
await appApi.sendLegacySmsCode({
|
||||||
|
phone: "13800138000",
|
||||||
|
validToken: "ticket-legacy",
|
||||||
|
});
|
||||||
|
assert.strictEqual(
|
||||||
|
requests.at(-1).url,
|
||||||
|
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/code",
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(requests.at(-1).data, {
|
||||||
|
tenantId: "000000",
|
||||||
|
grantType: "sms",
|
||||||
|
phone: "13800138000",
|
||||||
|
validToken: "ticket-legacy",
|
||||||
|
});
|
||||||
|
|
||||||
respond({
|
respond({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
|
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
|
||||||
@@ -159,6 +200,7 @@ const run = async () => {
|
|||||||
const passwordLogin = await appApi.loginWithPassword({
|
const passwordLogin = await appApi.loginWithPassword({
|
||||||
phone: "13800138000",
|
phone: "13800138000",
|
||||||
passwordHash: "a".repeat(32),
|
passwordHash: "a".repeat(32),
|
||||||
|
validToken: "ticket-password",
|
||||||
});
|
});
|
||||||
assert.strictEqual(passwordLogin.access_token, "token-password");
|
assert.strictEqual(passwordLogin.access_token, "token-password");
|
||||||
const passwordRequest = requests.at(-1);
|
const passwordRequest = requests.at(-1);
|
||||||
@@ -167,11 +209,11 @@ const run = async () => {
|
|||||||
assert.strictEqual(passwordRequest.header.clientid, "client-1");
|
assert.strictEqual(passwordRequest.header.clientid, "client-1");
|
||||||
assert.strictEqual(passwordRequest.header.Authorization, undefined);
|
assert.strictEqual(passwordRequest.header.Authorization, undefined);
|
||||||
assert.deepStrictEqual(passwordRequest.data, {
|
assert.deepStrictEqual(passwordRequest.data, {
|
||||||
clientId: "client-1",
|
|
||||||
tenantId: "000000",
|
tenantId: "000000",
|
||||||
grantType: "password",
|
grantType: "password",
|
||||||
phone: "13800138000",
|
phone: "13800138000",
|
||||||
password: "a".repeat(32),
|
password: "a".repeat(32),
|
||||||
|
validToken: "ticket-password",
|
||||||
});
|
});
|
||||||
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
|
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
|
||||||
|
|
||||||
@@ -224,7 +266,7 @@ const run = async () => {
|
|||||||
const requestController = createRequestController();
|
const requestController = createRequestController();
|
||||||
const cancelled = appApi.sendSmsCode(
|
const cancelled = appApi.sendSmsCode(
|
||||||
{
|
{
|
||||||
sceneCode: "APP_REGISTER",
|
operationCode: "register",
|
||||||
phone: "13800138000",
|
phone: "13800138000",
|
||||||
validToken: "ticket-1",
|
validToken: "ticket-1",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const run = async () => {
|
|||||||
|
|
||||||
const firstValues = [];
|
const firstValues = [];
|
||||||
const first = createAuthSmsCooldown({
|
const first = createAuthSmsCooldown({
|
||||||
sceneCode: "APP_SMS_LOGIN",
|
operationCode: "sms-login",
|
||||||
onChange: (value) => firstValues.push(value),
|
onChange: (value) => firstValues.push(value),
|
||||||
now: () => now,
|
now: () => now,
|
||||||
setIntervalFn,
|
setIntervalFn,
|
||||||
@@ -43,7 +43,7 @@ const run = async () => {
|
|||||||
|
|
||||||
const restoredValues = [];
|
const restoredValues = [];
|
||||||
const restored = createAuthSmsCooldown({
|
const restored = createAuthSmsCooldown({
|
||||||
sceneCode: "APP_SMS_LOGIN",
|
operationCode: "sms-login",
|
||||||
onChange: (value) => restoredValues.push(value),
|
onChange: (value) => restoredValues.push(value),
|
||||||
now: () => now,
|
now: () => now,
|
||||||
setIntervalFn,
|
setIntervalFn,
|
||||||
@@ -56,7 +56,7 @@ const run = async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const other = createAuthSmsCooldown({
|
const other = createAuthSmsCooldown({
|
||||||
sceneCode: "APP_REGISTER",
|
operationCode: "register",
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
now: () => now,
|
now: () => now,
|
||||||
setIntervalFn,
|
setIntervalFn,
|
||||||
@@ -72,10 +72,10 @@ const run = async () => {
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createAuthSmsCooldown({
|
createAuthSmsCooldown({
|
||||||
sceneCode: "",
|
operationCode: "",
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
}),
|
}),
|
||||||
/sceneCode/,
|
/operationCode/,
|
||||||
);
|
);
|
||||||
|
|
||||||
process.stdout.write("AUTH-SMS-COOLDOWN-RUNTIME-SMOKE PASS\n");
|
process.stdout.write("AUTH-SMS-COOLDOWN-RUNTIME-SMOKE PASS\n");
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ $authApiMatch = [regex]::Match($api, '(?s)async getCaptchaRequirement.*?(?=\s+as
|
|||||||
if (-not $authApiMatch.Success) { throw '无法定位唯一认证 API 区段' }
|
if (-not $authApiMatch.Success) { throw '无法定位唯一认证 API 区段' }
|
||||||
Reject-Text -Content $authApiMatch.Value -Text "return { success: true }" -Label '认证短信 mock'
|
Reject-Text -Content $authApiMatch.Value -Text "return { success: true }" -Label '认证短信 mock'
|
||||||
Reject-Text -Content $authApiMatch.Value -Text "mock-session-token" -Label '认证会话 mock'
|
Reject-Text -Content $authApiMatch.Value -Text "mock-session-token" -Label '认证会话 mock'
|
||||||
Require-Text -Content $api -Text 'sceneCode, phone, validToken' -Label '短信票据请求'
|
Require-Text -Content $api -Text 'operationCode, phone, validToken' -Label '短信票据请求'
|
||||||
foreach ($token in @('const requestAuth =', 'strictEnvelope: true', 'expectedStatus: 200', "hasOwnProperty.call(data, 'data')")) {
|
foreach ($token in @('const requestAuth =', 'strictEnvelope: true', 'expectedStatus: 200', "hasOwnProperty.call(data, 'data')")) {
|
||||||
Require-Text -Content $api -Text $token -Label '认证严格响应 owner'
|
Require-Text -Content $api -Text $token -Label '认证严格响应 owner'
|
||||||
}
|
}
|
||||||
@@ -115,17 +115,17 @@ foreach ($token in @('const blockBusyAction = () =>', 'if (blockBusyAction()) re
|
|||||||
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
|
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
|
foreach ($token in @('AUTH_VERIFICATION_OPERATION.PASSWORD_LOGIN', 'AUTH_VERIFICATION_OPERATION.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
|
||||||
Require-Text -Content $a01 -Text $token -Label 'A01'
|
Require-Text -Content $a01 -Text $token -Label 'A01'
|
||||||
}
|
}
|
||||||
Reject-Text -Content $a01 -Text 'PASSWORD_TAC_BLOCKED_MESSAGE' -Label 'A01 旧密码登录硬关闭'
|
Reject-Text -Content $a01 -Text 'PASSWORD_TAC_BLOCKED_MESSAGE' -Label 'A01 旧密码登录硬关闭'
|
||||||
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
|
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
|
||||||
|
|
||||||
foreach ($token in @('AUTH_TAC_SCENE.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
|
foreach ($token in @('AUTH_VERIFICATION_OPERATION.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
|
||||||
Require-Text -Content $a04 -Text $token -Label 'A04'
|
Require-Text -Content $a04 -Text $token -Label 'A04'
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($token in @('AUTH_TAC_SCENE.FORGOT_PASSWORD', 'appApi.resetPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'await appApi.resetPassword')) {
|
foreach ($token in @('AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD', 'appApi.resetPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'await appApi.resetPassword')) {
|
||||||
Require-Text -Content $a05 -Text $token -Label 'A05'
|
Require-Text -Content $a05 -Text $token -Label 'A05'
|
||||||
}
|
}
|
||||||
Reject-Text -Content $a05 -Text '/^\d{6}$/' -Label 'A05 六位短信码'
|
Reject-Text -Content $a05 -Text '/^\d{6}$/' -Label 'A05 六位短信码'
|
||||||
|
|||||||
@@ -15,23 +15,23 @@ foreach ($path in @($apiPath, $a01Path, $runtimePath)) {
|
|||||||
if ($issues.Count -eq 0) {
|
if ($issues.Count -eq 0) {
|
||||||
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
|
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
|
||||||
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
|
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
|
||||||
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash \}.*?(?=\s+async loginWithSms)')
|
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash, validToken \}.*?(?=\s+async loginWithSms)')
|
||||||
if (-not $passwordOwner.Success) {
|
if (-not $passwordOwner.Success) {
|
||||||
$issues.Add('missing bounded password login owner')
|
$issues.Add('missing bounded password login owner')
|
||||||
} else {
|
} else {
|
||||||
foreach ($required in @("url: '/genealogy/app/auth/login'", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)')) {
|
foreach ($required in @("url: '/genealogy/app/auth/login'", "tenantId: runtimeConfig.tenantId", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)', 'const normalizedValidToken = normalizeOptionalValidToken(validToken)', '...(normalizedValidToken ? { validToken: normalizedValidToken } : {})')) {
|
||||||
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
|
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
|
||||||
}
|
}
|
||||||
if ($passwordOwner.Value.Contains('validToken')) {
|
if ($passwordOwner.Value.Contains('authPayload(')) {
|
||||||
$issues.Add('password login must not upload validToken')
|
$issues.Add('password login must not put clientId in the body')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
|
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
|
||||||
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
|
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
|
||||||
}
|
}
|
||||||
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ sceneCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
|
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ operationCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
|
||||||
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('validToken: assertValidToken(validToken)')) {
|
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('const normalizedValidToken = normalizeOptionalValidToken(validToken)') -or -not $smsOwner.Value.Contains('...(normalizedValidToken ? { validToken: normalizedValidToken } : {})')) {
|
||||||
$issues.Add('SMS owner must remain the sole validToken consumer')
|
$issues.Add('SMS owner must remain the sole validToken consumer and omit it only when the server policy closes TAC')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ if ($issues.Count -eq 0) {
|
|||||||
if ($issues.Count -gt 0) {
|
if ($issues.Count -gt 0) {
|
||||||
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
|
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
|
||||||
foreach ($issue in $issues) { Write-Output "- $issue" }
|
foreach ($issue in $issues) { Write-Output "- $issue" }
|
||||||
Write-Output '- Password login is gated by native TAC on the client and must not send validToken. SMS operations consume their own validToken only.'
|
Write-Output '- Password login and SMS operations send a validToken only after the server marks that operation as verification-required.'
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const run = async () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
AUTH_TAC_SCENE,
|
AUTH_VERIFICATION_OPERATION,
|
||||||
isAuthPhone,
|
isAuthPhone,
|
||||||
assertSmsCode,
|
assertSmsCode,
|
||||||
normalizeCaptchaRequirement,
|
normalizeCaptchaRequirement,
|
||||||
@@ -23,13 +23,14 @@ const run = async () => {
|
|||||||
} = await import(toDataModuleUrl(source));
|
} = await import(toDataModuleUrl(source));
|
||||||
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
{ ...AUTH_TAC_SCENE },
|
{ ...AUTH_VERIFICATION_OPERATION },
|
||||||
{
|
{
|
||||||
SMS_LOGIN: "APP_SMS_LOGIN",
|
PASSWORD_LOGIN: "password-login",
|
||||||
REGISTER: "APP_REGISTER",
|
SMS_LOGIN: "sms-login",
|
||||||
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
|
REGISTER: "register",
|
||||||
|
FORGOT_PASSWORD: "forgot-password",
|
||||||
},
|
},
|
||||||
"认证 TAC 场景必须由受保护 OpenAPI 的唯一枚举拥有",
|
"认证动作必须由受保护 OpenAPI 的唯一枚举拥有",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(isAuthPhone("13800138000"), true);
|
assert.strictEqual(isAuthPhone("13800138000"), true);
|
||||||
@@ -47,32 +48,28 @@ const run = async () => {
|
|||||||
required: true,
|
required: true,
|
||||||
providerCode: "TIANAI",
|
providerCode: "TIANAI",
|
||||||
captchaType: "SLIDER",
|
captchaType: "SLIDER",
|
||||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
sceneCode: "APP_REGISTER",
|
||||||
ttlSeconds: 300,
|
ttlSeconds: 300,
|
||||||
},
|
},
|
||||||
AUTH_TAC_SCENE.REGISTER,
|
|
||||||
);
|
);
|
||||||
assert.deepStrictEqual(requirement, {
|
assert.deepStrictEqual(requirement, {
|
||||||
required: true,
|
required: true,
|
||||||
providerCode: "TIANAI",
|
providerCode: "TIANAI",
|
||||||
captchaType: "SLIDER",
|
captchaType: "SLIDER",
|
||||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
sceneCode: "APP_REGISTER",
|
||||||
ttlSeconds: 300,
|
ttlSeconds: 300,
|
||||||
});
|
});
|
||||||
assert.throws(
|
assert.deepStrictEqual(
|
||||||
() => normalizeCaptchaRequirement({ ...requirement, required: false }, AUTH_TAC_SCENE.REGISTER),
|
normalizeCaptchaRequirement({ ...requirement, required: false }),
|
||||||
/未要求行为验证|票据/,
|
{ required: false, sceneCode: "APP_REGISTER" },
|
||||||
|
"策略关闭时必须保留服务端绑定场景并跳过 TAC 渲染",
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => normalizeCaptchaRequirement({ ...requirement, sceneCode: AUTH_TAC_SCENE.SMS_LOGIN }, AUTH_TAC_SCENE.REGISTER),
|
() => normalizeCaptchaRequirement({ ...requirement, providerCode: "OTHER" }),
|
||||||
/场景/,
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => normalizeCaptchaRequirement({ ...requirement, providerCode: "OTHER" }, AUTH_TAC_SCENE.REGISTER),
|
|
||||||
/TIANAI/,
|
/TIANAI/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => normalizeCaptchaRequirement({ ...requirement, captchaType: "math" }, AUTH_TAC_SCENE.REGISTER),
|
() => normalizeCaptchaRequirement({ ...requirement, captchaType: "math" }),
|
||||||
/验证码类型/,
|
/验证码类型/,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,18 +78,18 @@ const run = async () => {
|
|||||||
baseUrl: "https://backend-api.ddxcjp.cn/",
|
baseUrl: "https://backend-api.ddxcjp.cn/",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
tenantId: "000000",
|
tenantId: "000000",
|
||||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||||
subject: "13800138000",
|
subject: "13800138000",
|
||||||
requirement,
|
requirement,
|
||||||
});
|
});
|
||||||
assert.deepStrictEqual(context, {
|
assert.deepStrictEqual(context, {
|
||||||
requestId: "register-1",
|
requestId: "register-1",
|
||||||
baseUrl: "https://backend-api.ddxcjp.cn",
|
baseUrl: "https://backend-api.ddxcjp.cn",
|
||||||
challengeUrl: "https://backend-api.ddxcjp.cn/captcha/challenge",
|
challengeUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/challenge",
|
||||||
verifyUrl: "https://backend-api.ddxcjp.cn/captcha/verify",
|
verifyUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
tenantId: "000000",
|
tenantId: "000000",
|
||||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||||
subject: "13800138000",
|
subject: "13800138000",
|
||||||
providerCode: "TIANAI",
|
providerCode: "TIANAI",
|
||||||
captchaType: "SLIDER",
|
captchaType: "SLIDER",
|
||||||
@@ -105,6 +102,14 @@ const run = async () => {
|
|||||||
() => createTacRenderContext({ ...context, subject: "1380013800", requirement }),
|
() => createTacRenderContext({ ...context, subject: "1380013800", requirement }),
|
||||||
/手机号/,
|
/手机号/,
|
||||||
);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createTacRenderContext({ ...context, operationCode: "APP_REGISTER", requirement }),
|
||||||
|
/认证动作/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createTacRenderContext({ ...context, requirement: { ...requirement, required: false } }),
|
||||||
|
/无需行为验证/,
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
normalizeTacSuccess(
|
normalizeTacSuccess(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const toDataModuleUrl = (source) =>
|
||||||
|
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||||
|
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||||
|
const prelude = `
|
||||||
|
const currentUser = {};
|
||||||
|
const genealogies = [];
|
||||||
|
const publicGenealogies = [];
|
||||||
|
const treeMembers = [];
|
||||||
|
const notifications = [];
|
||||||
|
const joinApplications = [];
|
||||||
|
const listFamilyFeedFixtures = () => [];
|
||||||
|
const listFamilyArticleFixtures = () => [];
|
||||||
|
const listFamilyAlbumFixtures = () => [];
|
||||||
|
const listCeremonyFixtures = () => [];
|
||||||
|
const listGrowthRecordFixtures = () => [];
|
||||||
|
const listNotificationFixtures = () => [];
|
||||||
|
const runtimeConfig = { mode: "remote", 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 = [];
|
||||||
|
let nextResponse;
|
||||||
|
globalThis.uni = {
|
||||||
|
request(options) {
|
||||||
|
requests.push(options);
|
||||||
|
queueMicrotask(() => options.success(nextResponse));
|
||||||
|
return { abort() {} };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||||
|
|
||||||
|
nextResponse = { statusCode: 200, data: { code: 200, data: [{ invitationId: "9001", inviteStatus: "PENDING" }] } };
|
||||||
|
assert.deepStrictEqual(await appApi.getCeremonyInvitations("1001", "2001"), [{ invitationId: "9001", inviteStatus: "PENDING" }]);
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitations");
|
||||||
|
assert.strictEqual(requests.at(-1).method, "GET");
|
||||||
|
|
||||||
|
nextResponse = { statusCode: 200, data: { code: 200, data: { invitationId: "9001", inviteStatus: "ACCEPTED" } } };
|
||||||
|
await appApi.respondToCeremonyInvitation("1001", "2001", { inviteStatus: "ACCEPTED" });
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitations/me");
|
||||||
|
assert.strictEqual(requests.at(-1).method, "PUT");
|
||||||
|
assert.deepStrictEqual(requests.at(-1).data, { inviteStatus: "ACCEPTED" });
|
||||||
|
|
||||||
|
nextResponse = { statusCode: 200, data: { code: 200, data: [] } };
|
||||||
|
await appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: [] });
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/ceremonies/2001/invitees");
|
||||||
|
assert.deepStrictEqual(requests.at(-1).data, { inviteeUserIds: [] });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: [7, 7] }),
|
||||||
|
/重复标识/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
appApi.replaceCeremonyInvitees("1001", "2001", { inviteeUserIds: ["2081232520259612673"] }),
|
||||||
|
(error) => error?.code === "CEREMONY_INVITEE_ID_UNSAFE",
|
||||||
|
);
|
||||||
|
|
||||||
|
nextResponse = { statusCode: 200, data: { code: 200, data: [] } };
|
||||||
|
await appApi.getMyCeremonyInvitations();
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/ceremony-invitations/mine");
|
||||||
|
assert.strictEqual(requests.at(-1).method, "GET");
|
||||||
|
delete globalThis.uni;
|
||||||
|
process.stdout.write("CEREMONY-INVITATIONS-API-RUNTIME-SMOKE PASS\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
process.stderr.write(`${error.stack || error.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ const run = async () => {
|
|||||||
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
|
||||||
const hasRemoteConfig = () => true;
|
const hasRemoteConfig = () => true;
|
||||||
const resolveRuntimeMode = () => "remote";
|
const resolveRuntimeMode = () => "remote";
|
||||||
const AUTH_TAC_SCENE = Object.freeze({});
|
const AUTH_VERIFICATION_OPERATION = Object.freeze({});
|
||||||
const assertSmsCode = (value) => value;
|
const assertSmsCode = (value) => value;
|
||||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
||||||
const fromApiGenealogyAccess = () => null;
|
const fromApiGenealogyAccess = () => null;
|
||||||
@@ -79,16 +79,16 @@ const run = async () => {
|
|||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
};
|
};
|
||||||
const completePayload = {
|
const completePayload = {
|
||||||
uploadId: basePayload.uploadId,
|
uploadId: "upload-1",
|
||||||
fileName: basePayload.fileName,
|
fileName: basePayload.fileName,
|
||||||
fileMd5: basePayload.fileMd5,
|
fileMd5: basePayload.fileMd5,
|
||||||
totalSize: basePayload.totalSize,
|
totalSize: basePayload.totalSize,
|
||||||
totalChunks: basePayload.totalChunks,
|
totalChunks: basePayload.totalChunks,
|
||||||
};
|
};
|
||||||
|
|
||||||
response = { statusCode: 200, data: { code: 200, data: { uploadId: "upload-1", instant: true, ossId: 900001 } } };
|
response = { statusCode: 200, data: { code: 200, data: { uploadId: null, instant: true, ossId: 900001 } } };
|
||||||
assert.deepStrictEqual(await appApi.initializeResumableUpload(basePayload), {
|
assert.deepStrictEqual(await appApi.initializeResumableUpload(basePayload), {
|
||||||
uploadId: "upload-1",
|
uploadId: null,
|
||||||
instant: true,
|
instant: true,
|
||||||
ossId: "900001",
|
ossId: "900001",
|
||||||
url: "",
|
url: "",
|
||||||
@@ -97,6 +97,12 @@ const run = async () => {
|
|||||||
assert.deepStrictEqual(requests.at(-1).data, basePayload);
|
assert.deepStrictEqual(requests.at(-1).data, basePayload);
|
||||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/init");
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/init");
|
||||||
|
|
||||||
|
response = { statusCode: 200, data: { code: 200, data: { uploadId: null, instant: false, ossId: null } } };
|
||||||
|
await assert.rejects(
|
||||||
|
appApi.initializeResumableUpload(basePayload),
|
||||||
|
/uploadId/,
|
||||||
|
);
|
||||||
|
|
||||||
await appApi.uploadResumableChunk({
|
await appApi.uploadResumableChunk({
|
||||||
uploadId: "upload-1",
|
uploadId: "upload-1",
|
||||||
chunkIndex: 0,
|
chunkIndex: 0,
|
||||||
|
|||||||
@@ -197,6 +197,54 @@ const run = async () => {
|
|||||||
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
|
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
nextResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
data: { code: 200, data: 7 },
|
||||||
|
};
|
||||||
|
assert.strictEqual(await appApi.getUnreadNotificationCount(), 7);
|
||||||
|
assert.strictEqual(
|
||||||
|
requests.at(-1).url,
|
||||||
|
"https://backend-api.ddxcjp.cn/genealogy/app/notifications/unread-count",
|
||||||
|
);
|
||||||
|
|
||||||
|
nextResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
data: { code: 200, data: -1 },
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
appApi.getUnreadNotificationCount(),
|
||||||
|
(error) => error?.code === "NOTIFICATION_COUNT_RESPONSE_INVALID",
|
||||||
|
);
|
||||||
|
|
||||||
|
const quota = {
|
||||||
|
createUsed: 1,
|
||||||
|
createLimit: 3,
|
||||||
|
createRemaining: 2,
|
||||||
|
canCreate: true,
|
||||||
|
joinUsed: 2,
|
||||||
|
joinLimit: 10,
|
||||||
|
joinRemaining: 8,
|
||||||
|
canJoin: true,
|
||||||
|
};
|
||||||
|
nextResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
data: { code: 200, data: quota },
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(await appApi.getGenealogyQuota(), quota);
|
||||||
|
assert.strictEqual(
|
||||||
|
requests.at(-1).url,
|
||||||
|
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/quota",
|
||||||
|
);
|
||||||
|
|
||||||
|
nextResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
data: { code: 200, data: { ...quota, canJoin: undefined } },
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
appApi.getGenealogyQuota(),
|
||||||
|
(error) => error?.code === "GENEALOGY_QUOTA_RESPONSE_INVALID",
|
||||||
|
);
|
||||||
|
|
||||||
process.stdout.write("G01-MY-GENEALOGIES-RUNTIME-SMOKE PASS\n");
|
process.stdout.write("G01-MY-GENEALOGIES-RUNTIME-SMOKE PASS\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
const cdpPort = process.env.CDP_PORT || "9222";
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
|
const pages = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
|
||||||
const page = pages.find((item) => item.type === "page" && item.url.startsWith("http://localhost:5173"));
|
const page = pages.find((item) => item.type === "page" && item.url.startsWith("http://localhost:5173"));
|
||||||
if (!page) throw new Error("Chrome debugging has no localhost:5173 page");
|
if (!page) throw new Error("Chrome debugging has no localhost:5173 page");
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ const waitFor = async (send, expression, message) => {
|
|||||||
const origin = process.argv[2] || "http://localhost:5173";
|
const origin = process.argv[2] || "http://localhost:5173";
|
||||||
|
|
||||||
const openG03 = async (send, suffix = "") => {
|
const openG03 = async (send, suffix = "") => {
|
||||||
const url = `${origin}/?g03CreateAudit=${Date.now()}#${"/pages/genealogy/g03-create-genealogy"}${suffix}`;
|
const url = `${origin}/?g03CreateAudit=${Date.now()}${suffix}#${"/pages/genealogy/g03-create-genealogy"}`;
|
||||||
await send("Page.navigate", { url });
|
await send("Page.navigate", { url });
|
||||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, "G03 navigation failed");
|
await waitFor(send, `location.href === ${JSON.stringify(url)}`, "G03 navigation failed");
|
||||||
await waitFor(send, "Boolean(document.querySelector('.create-card'))", "G03 create form did not render");
|
await waitFor(send, "Boolean(document.querySelector('.create-card'))", "G03 create form did not render");
|
||||||
@@ -77,6 +78,21 @@ const run = async () => {
|
|||||||
const fakeControl = await valueOf(send, "Boolean(document.querySelector('.flow-success-dialog'))");
|
const fakeControl = await valueOf(send, "Boolean(document.querySelector('.flow-success-dialog'))");
|
||||||
if (fakeControl) throw new Error("G03 must not expose the retired local bootstrap flow");
|
if (fakeControl) throw new Error("G03 must not expose the retired local bootstrap flow");
|
||||||
|
|
||||||
|
await valueOf(send, `(() => {
|
||||||
|
const input = document.querySelector('.create-card input');
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
||||||
|
setter.call(input, 'discard-check');
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
document.querySelector('.header-back')?.click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "G03 dirty back did not require discard confirmation");
|
||||||
|
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:first-child')?.click()");
|
||||||
|
await waitFor(send, "!document.querySelector('.app-dialog-layer')", "G03 discard cancellation did not close the dialog");
|
||||||
|
if ((await valueOf(send, "document.querySelector('.create-card input')?.value")) !== "discard-check") {
|
||||||
|
throw new Error("G03 discard cancellation lost the draft");
|
||||||
|
}
|
||||||
|
|
||||||
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
|
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
|
||||||
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true });
|
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true });
|
||||||
await openG03(send, `&width=${size.width}`);
|
await openG03(send, `&width=${size.width}`);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Captures the current native MuMu rendering of every app route. It uses the
|
||||||
|
* installed app's WebView and ADB; no browser emulation, fixture, or mock data.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $env:ADB_PATH='...\\adb.exe'; node tests/mumu-page-visual-audit.js
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const { mkdirSync } = require("node:fs");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
|
||||||
|
const adb = process.env.ADB_PATH || "adb";
|
||||||
|
const device = process.env.MUMU_DEVICE || "emulator-5554";
|
||||||
|
const outputDir = process.env.MUMU_AUDIT_DIR || join(process.cwd(), "tmp", "mumu-visual-audit");
|
||||||
|
const genealogyId = process.env.MUMU_GENEALOGY_ID || "2081191846772518914";
|
||||||
|
const start = Number(process.env.MUMU_AUDIT_START || 0);
|
||||||
|
const end = Number(process.env.MUMU_AUDIT_END || 52);
|
||||||
|
const routes = [
|
||||||
|
["a01", "/pages/auth/a01-entry"], ["a04", "/pages/auth/a04-register"], ["a05", "/pages/auth/a05-reset-password"],
|
||||||
|
["g01", "/pages/genealogy/g01-my-genealogies"], ["g03", "/pages/genealogy/g03-create-genealogy"], ["g05", `/pages/genealogy/g05-genealogy-overview?genealogyId=${genealogyId}`], ["g06", "/pages/genealogy/g06-search-genealogies"], ["g08", `/pages/genealogy/g08-join-application?genealogyId=${genealogyId}`], ["g09", "/pages/genealogy/g09-my-applications"], ["g10", `/pages/genealogy/g10-application-review?genealogyId=${genealogyId}`], ["g11", `/pages/genealogy/g11-genealogy-settings?genealogyId=${genealogyId}`], ["g12", `/pages/genealogy/g12-generation-poems?genealogyId=${genealogyId}`],
|
||||||
|
["t01", `/pages/tree/t01-tree-overview?genealogyId=${genealogyId}`], ["t03", `/pages/tree/t03-member-profile?genealogyId=${genealogyId}`], ["t04", `/pages/tree/t04-add-relative?genealogyId=${genealogyId}`], ["t05", `/pages/tree/t05-edit-member?genealogyId=${genealogyId}`], ["t06", `/pages/tree/t06-edit-relationship?genealogyId=${genealogyId}`], ["t07", `/pages/tree/t07-member-directory?genealogyId=${genealogyId}`], ["t08", `/pages/tree/t08-member-states?genealogyId=${genealogyId}`],
|
||||||
|
["f01", `/pages/family/f01-family-feed?genealogyId=${genealogyId}`], ["f02", `/pages/family/f02-publish-feed?genealogyId=${genealogyId}`], ["f03", `/pages/family/f03-feed-detail?genealogyId=${genealogyId}&feedId=2081197770778128385`], ["f04", `/pages/family/f04-article-list?genealogyId=${genealogyId}`], ["f05", `/pages/family/f05-article-detail?genealogyId=${genealogyId}&articleId=2081200381052891138`], ["f06", `/pages/family/f06-article-editor?genealogyId=${genealogyId}`], ["f07", `/pages/family/f07-album-list?genealogyId=${genealogyId}`], ["f08", `/pages/family/f08-album-detail?genealogyId=${genealogyId}&albumId=2081202236751392769`], ["f09", `/pages/family/f09-media-upload?genealogyId=${genealogyId}&albumId=2081202236751392769`], ["f10", `/pages/family/f10-video-list?genealogyId=${genealogyId}`],
|
||||||
|
["r01", `/pages/records/r01-people-list?genealogyId=${genealogyId}`], ["r02", `/pages/records/r02-person-detail?genealogyId=${genealogyId}`], ["r03", `/pages/records/r03-gift-list?genealogyId=${genealogyId}`], ["r04", `/pages/records/r04-gift-editor?genealogyId=${genealogyId}`], ["r05", `/pages/records/r05-ritual-list?genealogyId=${genealogyId}`], ["r06", `/pages/records/r06-ritual-detail?genealogyId=${genealogyId}`], ["r07", `/pages/records/r07-ritual-editor?genealogyId=${genealogyId}`], ["r08", `/pages/records/r08-growth-journal?genealogyId=${genealogyId}`], ["r09", `/pages/records/r09-life-events?genealogyId=${genealogyId}`], ["r10", `/pages/records/r10-memo-list?genealogyId=${genealogyId}`], ["r11", `/pages/records/r11-merit-records?genealogyId=${genealogyId}`],
|
||||||
|
["n01", "/pages/notification/n01-message-center"], ["n02", "/pages/notification/n02-message-detail"],
|
||||||
|
["m01", "/pages/profile/m01-profile-home"], ["m02", "/pages/profile/m02-edit-profile"], ["m03", "/pages/profile/m03-security-settings"], ["m04", "/pages/profile/m04-change-password"], ["m05", "/pages/profile/m05-change-phone"], ["m06", "/pages/profile/m06-help-center"], ["m07", "/pages/profile/m07-feedback"], ["m08", "/pages/profile/m08-promotion"], ["m09", "/pages/profile/m09-vip-orders"], ["m10", "/pages/profile/m10-about-settings"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const run = (...args) => execFileSync(adb, ["-s", device, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function currentTarget() {
|
||||||
|
const targets = await (await fetch("http://127.0.0.1:9223/json/list")).json();
|
||||||
|
const pages = targets.filter((item) => item.type === "page" && item.title !== "View");
|
||||||
|
if (!pages.length) throw new Error("MuMu 中没有可操作的应用页面 WebView");
|
||||||
|
return pages.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(expression) {
|
||||||
|
const target = await currentTarget();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||||
|
socket.onopen = () => socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } }));
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const result = JSON.parse(event.data);
|
||||||
|
socket.close();
|
||||||
|
if (result.error || result.result?.exceptionDetails) reject(new Error(JSON.stringify(result.error || result.result.exceptionDetails)));
|
||||||
|
else resolve(result.result?.result?.value);
|
||||||
|
};
|
||||||
|
socket.onerror = () => reject(new Error("无法连接 MuMu WebView 调试端口"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
run("forward", "tcp:9223", "localabstract:webview_devtools_remote_2728");
|
||||||
|
try {
|
||||||
|
for (const [name, route] of routes.slice(start, end)) {
|
||||||
|
await evaluate(`uni.reLaunch({ url: ${JSON.stringify(route)} }); true`);
|
||||||
|
await delay(1800);
|
||||||
|
run("shell", "screencap", "-p", "/sdcard/jiapu-visual-audit.png");
|
||||||
|
run("pull", "/sdcard/jiapu-visual-audit.png", join(outputDir, `${name}.png`));
|
||||||
|
process.stdout.write(`captured ${name}\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { run("forward", "--remove", "tcp:9223"); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; });
|
||||||
@@ -547,7 +547,7 @@ foreach ($contract in @(
|
|||||||
@{ Key = 'M04'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
|
@{ Key = 'M04'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
|
||||||
@{ Key = 'M05'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
|
@{ Key = 'M05'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
|
||||||
@{ Key = 'M06'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
|
@{ Key = 'M06'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
|
||||||
@{ Key = 'M07'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M06"]' },
|
@{ Key = 'M07'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M01", "M06"]' },
|
||||||
@{ Key = 'M08'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
|
@{ Key = 'M08'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
|
||||||
@{ Key = 'M09'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
|
@{ Key = 'M09'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
|
||||||
@{ Key = 'M10'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' }
|
@{ Key = 'M10'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' }
|
||||||
|
|||||||
@@ -1411,6 +1411,39 @@ process.exitCode = 1;
|
|||||||
assert.strictEqual(harness.calls[0].payload.delta, 2, "必须返回最近的既有目标实例");
|
assert.strictEqual(harness.calls[0].payload.delta, 2, "必须返回最近的既有目标实例");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const harness = await createHarness({
|
||||||
|
routes: ROUTES,
|
||||||
|
stack: [
|
||||||
|
createPage(ROUTES, "F01"),
|
||||||
|
createPage(ROUTES, "F03", { genealogyId: "g1", feedId: "feed-1" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.strictEqual(
|
||||||
|
await harness.navigation.returnTo("F01", { genealogyId: "g1" }),
|
||||||
|
true,
|
||||||
|
"根页未在 URL 保留可选上下文时,子页可按自身同一上下文返回",
|
||||||
|
);
|
||||||
|
assert.strictEqual(harness.calls[0].method, "navigateBack");
|
||||||
|
assert.strictEqual(harness.calls[0].payload.delta, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const harness = await createHarness({
|
||||||
|
routes: ROUTES,
|
||||||
|
stack: [
|
||||||
|
createPage(ROUTES, "F01"),
|
||||||
|
createPage(ROUTES, "F03", { genealogyId: "g1", feedId: "feed-1" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
harness.navigation.returnTo("F01", { genealogyId: "g2" }),
|
||||||
|
/genealogyId.*不一致|最近实例/,
|
||||||
|
"根页隐式上下文仍不得接受与当前子页不同的家谱",
|
||||||
|
);
|
||||||
|
assert.strictEqual(harness.calls.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const harness = await createHarness({
|
const harness = await createHarness({
|
||||||
routes: ROUTES,
|
routes: ROUTES,
|
||||||
|
|||||||
@@ -17,13 +17,31 @@ const run = async () => {
|
|||||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const fromApiGenealogyAccess = () => null; const session = { getToken: () => "session-1", saveToken() {} };
|
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const fromApiGenealogyAccess = () => null; const session = { getToken: () => "session-1", saveToken() {} };
|
||||||
`;
|
`;
|
||||||
const requests = [];
|
const requests = [];
|
||||||
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }] } })); return { abort() {} }; } };
|
let responseData = [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }];
|
||||||
|
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } })); return { abort() {} }; } };
|
||||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||||
assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]);
|
assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]);
|
||||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/children");
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/children");
|
||||||
assert.strictEqual(requests.at(-1).method, "GET");
|
assert.strictEqual(requests.at(-1).method, "GET");
|
||||||
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
|
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
|
||||||
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
|
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
|
||||||
|
responseData = [{ regionCode: "110000", label: "Beijing" }];
|
||||||
|
assert.deepStrictEqual(await appApi.getRegionPath("110000"), responseData);
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/path/110000");
|
||||||
|
await assert.rejects(appApi.getRegionPath(" "), /行政区划编码/);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
await appApi.searchRegions({ keyword: " Beijing ", level: 2, limit: 20 }),
|
||||||
|
responseData,
|
||||||
|
);
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/search");
|
||||||
|
assert.deepStrictEqual(requests.at(-1).data, { keyword: "Beijing", level: 2, limit: 20 });
|
||||||
|
await assert.rejects(appApi.searchRegions({ keyword: "" }), /行政区划搜索关键词/);
|
||||||
|
await assert.rejects(appApi.searchRegions({ keyword: "Beijing", level: 6 }), /行政区划级别/);
|
||||||
|
|
||||||
|
responseData = { regionCode: "110000", label: "Beijing" };
|
||||||
|
assert.deepStrictEqual(await appApi.getRegion("110000"), responseData);
|
||||||
|
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/110000");
|
||||||
delete globalThis.uni;
|
delete globalThis.uni;
|
||||||
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
|
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ assert(adapter, "TAC 供应商适配器必须暴露唯一全局 owner");
|
|||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
tenantId: "000000",
|
tenantId: "000000",
|
||||||
clientId: "client-1",
|
operationCode: "register",
|
||||||
sceneCode: "APP_REGISTER",
|
|
||||||
subject: "13800138000",
|
subject: "13800138000",
|
||||||
providerCode: "TIANAI",
|
providerCode: "TIANAI",
|
||||||
captchaType: "SLIDER",
|
captchaType: "SLIDER",
|
||||||
@@ -72,8 +71,6 @@ const verifyBody = adapter.buildVerifyBody(
|
|||||||
);
|
);
|
||||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(verifyBody)), {
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(verifyBody)), {
|
||||||
tenantId: "000000",
|
tenantId: "000000",
|
||||||
clientId: "client-1",
|
|
||||||
sceneCode: "APP_REGISTER",
|
|
||||||
subject: "13800138000",
|
subject: "13800138000",
|
||||||
challengeId: "challenge-1",
|
challengeId: "challenge-1",
|
||||||
providerCode: "TIANAI",
|
providerCode: "TIANAI",
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ for (const [name, method] of Object.entries(definition.methods)) {
|
|||||||
|
|
||||||
const strictRequest = () => instance.sendStrictRequest({
|
const strictRequest = () => instance.sendStrictRequest({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "https://backend-api.ddxcjp.cn/captcha/verify",
|
url: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
|
||||||
headers: { clientid: "client-1" },
|
headers: { clientid: "client-1" },
|
||||||
data: { challengeId: "challenge-1" },
|
data: { challengeId: "challenge-1" },
|
||||||
});
|
});
|
||||||
@@ -180,11 +180,11 @@ const run = async () => {
|
|||||||
const context = {
|
const context = {
|
||||||
visible: true,
|
visible: true,
|
||||||
requestId: "request-1",
|
requestId: "request-1",
|
||||||
challengeUrl: "https://backend-api.ddxcjp.cn/captcha/challenge",
|
challengeUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/challenge",
|
||||||
verifyUrl: "https://backend-api.ddxcjp.cn/captcha/verify",
|
verifyUrl: "https://backend-api.ddxcjp.cn/genealogy/app/auth/verification/register/verify",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
tenantId: "000000",
|
tenantId: "000000",
|
||||||
sceneCode: "APP_REGISTER",
|
operationCode: "register",
|
||||||
subject: "13800138000",
|
subject: "13800138000",
|
||||||
};
|
};
|
||||||
instance.requestContext = context;
|
instance.requestContext = context;
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
继续维护 C:\Users\Administrator\Desktop\job\jiapuapp。
|
|
||||||
|
|
||||||
先完整阅读:
|
|
||||||
1. AGENTS.md
|
|
||||||
2. docs/全量页面接口联调交接说明-2026-07-24.md
|
|
||||||
3. git diff / 当前未提交改动
|
|
||||||
|
|
||||||
不要重做已经完成的接口接线,不要用 mock、fixture 或本地假成功补接口缺口。
|
|
||||||
|
|
||||||
当前事实:
|
|
||||||
- pages.json 共 52 个页面,已逐页在浏览器复核并稳定打开。
|
|
||||||
- 页面 71 个 appApi 调用都已有 utils/api.js 实现。
|
|
||||||
- 家谱、动态、谱文、相册、亲友往来、礼仪、成长记录、备忘、功德已有真实创建后列表/详情回读验证。
|
|
||||||
- 已新增 tests/all-page-route-runtime-smoke.js 供全页复测。
|
|
||||||
- F01/F03 的 feedType=text 已改为展示“文字动态”。
|
|
||||||
- 严重阻塞:创建世系首位成员 POST /genealogy/app/genealogies/{genealogyId}/lineage/persons,合法最小 body 仍返回业务 code:500;因此 T03–T08、R01–R02 的真实成员读写还不能闭环。不要猜额外字段,不要伪造成员。
|
|
||||||
|
|
||||||
测试边界:
|
|
||||||
- 仅测试账号和测试数据。
|
|
||||||
- 不做短信、改密、换绑、退出、删除、审核、支付等敏感操作。
|
|
||||||
- 功能/接口用浏览器验证;样式问题必须在模拟器验证。
|
|
||||||
- 每次改动后运行最小相关测试,不能只说“应该可以”。
|
|
||||||
|
|
||||||
下一步优先级:
|
|
||||||
1. 等后端修复首位成员创建 code:500,或提供可读取的测试成员。
|
|
||||||
2. 取得真实 personId 后,完成 T03–T08、R01–R02 的真实读取/写入回读。
|
|
||||||
3. 继续按页面顺序补齐接口验证,不要跳页。
|
|
||||||
4. 若发现后端接口缺 DTO/operation,明确标为阻塞,不要在前端猜字段。
|
|
||||||
|
|
||||||
先向我汇报:你读到的当前状态、准备执行的第一步、需要我在公司电脑上打开或提供什么。
|
|
||||||
+332
-20
@@ -12,7 +12,7 @@ import {
|
|||||||
listNotificationFixtures
|
listNotificationFixtures
|
||||||
} from '@/data/mock.js'
|
} from '@/data/mock.js'
|
||||||
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
|
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
|
||||||
import { AUTH_TAC_SCENE, assertSmsCode } from '@/utils/auth-verification.js'
|
import { AUTH_VERIFICATION_OPERATION, assertSmsCode } from '@/utils/auth-verification.js'
|
||||||
import { GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess } from '@/utils/genealogy-contracts.js'
|
import { GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess } from '@/utils/genealogy-contracts.js'
|
||||||
import { session } from '@/utils/session.js'
|
import { session } from '@/utils/session.js'
|
||||||
|
|
||||||
@@ -302,6 +302,64 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { requestController = null } = {}) => new Promise((resolve, reject) => {
|
||||||
|
if (typeof globalThis.fetch !== 'function' || typeof globalThis.FormData !== 'function') {
|
||||||
|
reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestController !== null && (
|
||||||
|
typeof requestController.bind !== 'function' ||
|
||||||
|
typeof requestController.release !== 'function'
|
||||||
|
)) {
|
||||||
|
reject(new TypeError('文件上传控制器格式无效'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let settled = false
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const release = () => requestController?.release(abort)
|
||||||
|
const resolveOnce = (value) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
release()
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const rejectOnce = (error) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
release()
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
const abort = () => {
|
||||||
|
if (settled) return
|
||||||
|
abortController.abort()
|
||||||
|
rejectOnce(createRequestCancelledError())
|
||||||
|
}
|
||||||
|
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}`
|
||||||
|
const token = session.getToken()
|
||||||
|
const formData = new globalThis.FormData()
|
||||||
|
formData.append('file', file, file.name || 'image')
|
||||||
|
if (requestController) requestController.bind(abort)
|
||||||
|
globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk?${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
clientid: runtimeConfig.clientId,
|
||||||
|
tenantId: runtimeConfig.tenantId,
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
signal: abortController.signal
|
||||||
|
}).then(async (response) => {
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw createRequestError(`文件分片上传失败(HTTP ${response.status})`, 'HTTP_ERROR', { httpStatus: response.status })
|
||||||
|
}
|
||||||
|
parseStrictUploadResponse(await response.text(), false)
|
||||||
|
resolveOnce(null)
|
||||||
|
}).catch((error) => {
|
||||||
|
if (settled) return
|
||||||
|
rejectOnce(error?.name === 'AbortError' ? createRequestCancelledError() : error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const requestNativeSingleFileUpload = ({ filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
|
const requestNativeSingleFileUpload = ({ filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
|
||||||
if (requestController !== null && (
|
if (requestController !== null && (
|
||||||
typeof requestController.bind !== 'function' ||
|
typeof requestController.bind !== 'function' ||
|
||||||
@@ -371,11 +429,11 @@ const requireRemoteAuth = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assertAuthScene = (sceneCode) => {
|
const assertAuthVerificationOperation = (operationCode) => {
|
||||||
if (!Object.values(AUTH_TAC_SCENE).includes(sceneCode)) {
|
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
|
||||||
throw new TypeError('短信场景不属于当前认证合同')
|
throw new TypeError('认证动作不属于当前认证合同')
|
||||||
}
|
}
|
||||||
return sceneCode
|
return operationCode
|
||||||
}
|
}
|
||||||
|
|
||||||
const assertValidToken = (validToken) => {
|
const assertValidToken = (validToken) => {
|
||||||
@@ -385,6 +443,9 @@ const assertValidToken = (validToken) => {
|
|||||||
return validToken.trim()
|
return validToken.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeOptionalValidToken = (validToken) =>
|
||||||
|
validToken === undefined ? undefined : assertValidToken(validToken)
|
||||||
|
|
||||||
const assertPasswordHash = (passwordHash) => {
|
const assertPasswordHash = (passwordHash) => {
|
||||||
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
|
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
|
||||||
throw new TypeError('密码摘要必须是 32 位小写 MD5')
|
throw new TypeError('密码摘要必须是 32 位小写 MD5')
|
||||||
@@ -579,11 +640,13 @@ const normalizeResumableInitResult = (payload) => {
|
|||||||
if (typeof payload.instant !== 'boolean') {
|
if (typeof payload.instant !== 'boolean') {
|
||||||
throw createRequestError('文件初始化响应缺少 instant', 'FILE_RESPONSE_INVALID')
|
throw createRequestError('文件初始化响应缺少 instant', 'FILE_RESPONSE_INVALID')
|
||||||
}
|
}
|
||||||
const uploadId = normalizeUploadId(payload.uploadId, '响应 uploadId')
|
|
||||||
const ossId = payload.ossId === undefined || payload.ossId === null ? null : normalizeUploadOssId(payload.ossId, 'ossId')
|
const ossId = payload.ossId === undefined || payload.ossId === null ? null : normalizeUploadOssId(payload.ossId, 'ossId')
|
||||||
if (payload.instant && !ossId) {
|
if (payload.instant && !ossId) {
|
||||||
throw createRequestError('秒传响应缺少 ossId', 'FILE_RESPONSE_INVALID')
|
throw createRequestError('秒传响应缺少 ossId', 'FILE_RESPONSE_INVALID')
|
||||||
}
|
}
|
||||||
|
const uploadId = payload.instant && (payload.uploadId === undefined || payload.uploadId === null)
|
||||||
|
? null
|
||||||
|
: normalizeUploadId(payload.uploadId, '响应 uploadId')
|
||||||
return {
|
return {
|
||||||
uploadId,
|
uploadId,
|
||||||
instant: payload.instant,
|
instant: payload.instant,
|
||||||
@@ -844,6 +907,40 @@ const normalizeOptionalNonnegativeInteger = (value, label, code) => {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeGenealogyQuota = (value) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw createRequestError('家谱配额响应无效', 'GENEALOGY_QUOTA_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
const normalizeUsage = (field) => {
|
||||||
|
if (!Number.isSafeInteger(value[field]) || value[field] < 0) {
|
||||||
|
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return value[field]
|
||||||
|
}
|
||||||
|
const normalizeLimit = (field) => {
|
||||||
|
if (!Number.isSafeInteger(value[field]) || value[field] < -1) {
|
||||||
|
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return value[field]
|
||||||
|
}
|
||||||
|
const normalizePermission = (field) => {
|
||||||
|
if (typeof value[field] !== 'boolean') {
|
||||||
|
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return value[field]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
createUsed: normalizeUsage('createUsed'),
|
||||||
|
createLimit: normalizeLimit('createLimit'),
|
||||||
|
createRemaining: normalizeLimit('createRemaining'),
|
||||||
|
canCreate: normalizePermission('canCreate'),
|
||||||
|
joinUsed: normalizeUsage('joinUsed'),
|
||||||
|
joinLimit: normalizeLimit('joinLimit'),
|
||||||
|
joinRemaining: normalizeLimit('joinRemaining'),
|
||||||
|
canJoin: normalizePermission('canJoin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeOptionalNumericId = (value, label, code) => {
|
const normalizeOptionalNumericId = (value, label, code) => {
|
||||||
if (value === undefined || value === null || value === '') return null
|
if (value === undefined || value === null || value === '') return null
|
||||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
||||||
@@ -896,6 +993,31 @@ const normalizeRegionParentCode = (value) => {
|
|||||||
return value.trim()
|
return value.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeRegionCode = (value) => {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) throw new TypeError('行政区划编码无效')
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeRegionSearchPayload = (payload) => {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
throw new TypeError('行政区划搜索参数无效')
|
||||||
|
}
|
||||||
|
const keyword = typeof payload.keyword === 'string' ? payload.keyword.trim() : ''
|
||||||
|
if (!keyword) throw new TypeError('行政区划搜索关键词无效')
|
||||||
|
const data = { keyword }
|
||||||
|
if (payload.level !== undefined && payload.level !== null && payload.level !== '') {
|
||||||
|
if (!Number.isInteger(payload.level) || payload.level < 1 || payload.level > 5) {
|
||||||
|
throw new TypeError('行政区划级别无效')
|
||||||
|
}
|
||||||
|
data.level = payload.level
|
||||||
|
}
|
||||||
|
if (payload.limit !== undefined && payload.limit !== null && payload.limit !== '') {
|
||||||
|
if (!Number.isInteger(payload.limit)) throw new TypeError('行政区划搜索数量上限无效')
|
||||||
|
data.limit = payload.limit
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeRegionSelectorItems = (value) => {
|
const normalizeRegionSelectorItems = (value) => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
throw createRequestError('地区选择响应不是列表', 'REGION_RESPONSE_INVALID')
|
throw createRequestError('地区选择响应不是列表', 'REGION_RESPONSE_INVALID')
|
||||||
@@ -1269,7 +1391,7 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
|
|||||||
mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'),
|
mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'),
|
||||||
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
|
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
|
||||||
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
|
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
|
||||||
likedByMe: item.likedByMe === true,
|
likedByMe: typeof item.likedByMe === 'boolean' ? item.likedByMe : null,
|
||||||
pinned: item.pinned === true
|
pinned: item.pinned === true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1666,6 +1788,33 @@ const normalizeJoinAuditPayload = (payload) => {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeCeremonyInviteePayload = (payload) => {
|
||||||
|
assertPlainPayload(payload, new Set(['inviteeUserIds']), '活动受邀人请求')
|
||||||
|
if (!Array.isArray(payload.inviteeUserIds)) {
|
||||||
|
throw new TypeError('inviteeUserIds必须是数组')
|
||||||
|
}
|
||||||
|
const inviteeUserIds = payload.inviteeUserIds.map((value) => {
|
||||||
|
const id = normalizeResourcePathId(value, '受邀业务用户标识')
|
||||||
|
const numericId = Number(id)
|
||||||
|
if (!Number.isSafeInteger(numericId)) {
|
||||||
|
throw createRequestError('受邀业务用户标识超出 APP 安全整数范围,需后端统一字符串 ID 合同', 'CEREMONY_INVITEE_ID_UNSAFE')
|
||||||
|
}
|
||||||
|
return numericId
|
||||||
|
})
|
||||||
|
if (new Set(inviteeUserIds).size !== inviteeUserIds.length) {
|
||||||
|
throw new TypeError('inviteeUserIds不能包含重复标识')
|
||||||
|
}
|
||||||
|
return { inviteeUserIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCeremonyInvitationResponsePayload = (payload) => {
|
||||||
|
assertPlainPayload(payload, new Set(['inviteStatus']), '活动邀请响应请求')
|
||||||
|
if (!['ACCEPTED', 'DECLINED'].includes(payload.inviteStatus)) {
|
||||||
|
throw new TypeError('inviteStatus仅允许ACCEPTED或DECLINED')
|
||||||
|
}
|
||||||
|
return { inviteStatus: payload.inviteStatus }
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeGenerationPoemWritePayload = (payload) => {
|
const normalizeGenerationPoemWritePayload = (payload) => {
|
||||||
assertPlainPayload(payload, new Set(['generationNo', 'generationText', 'description', 'sortOrder', 'status']), '字辈请求')
|
assertPlainPayload(payload, new Set(['generationNo', 'generationText', 'description', 'sortOrder', 'status']), '字辈请求')
|
||||||
const generationNo = Number(payload.generationNo)
|
const generationNo = Number(payload.generationNo)
|
||||||
@@ -1798,9 +1947,15 @@ const normalizeLineageWritePayload = (payload) => {
|
|||||||
throw new TypeError('人物写入请求必须是普通对象')
|
throw new TypeError('人物写入请求必须是普通对象')
|
||||||
}
|
}
|
||||||
const allowedFields = new Set([
|
const allowedFields = new Set([
|
||||||
|
'appUserId',
|
||||||
|
'personNo',
|
||||||
'name',
|
'name',
|
||||||
'aliasName',
|
'aliasName',
|
||||||
|
'sex',
|
||||||
'generationName',
|
'generationName',
|
||||||
|
'fatherId',
|
||||||
|
'motherId',
|
||||||
|
'avatarOssId',
|
||||||
'birthDate',
|
'birthDate',
|
||||||
'birthLunar',
|
'birthLunar',
|
||||||
'birthPlace',
|
'birthPlace',
|
||||||
@@ -1811,7 +1966,9 @@ const normalizeLineageWritePayload = (payload) => {
|
|||||||
'biography',
|
'biography',
|
||||||
'remark',
|
'remark',
|
||||||
'relationName',
|
'relationName',
|
||||||
'generation'
|
'generation',
|
||||||
|
'personStatus',
|
||||||
|
'sortOrder'
|
||||||
])
|
])
|
||||||
for (const field of Object.keys(payload)) {
|
for (const field of Object.keys(payload)) {
|
||||||
if (!allowedFields.has(field)) throw new TypeError(`人物写入包含未声明字段:${field}`)
|
if (!allowedFields.has(field)) throw new TypeError(`人物写入包含未声明字段:${field}`)
|
||||||
@@ -1819,6 +1976,15 @@ const normalizeLineageWritePayload = (payload) => {
|
|||||||
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
|
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
|
||||||
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
|
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
|
||||||
const data = { name }
|
const data = { name }
|
||||||
|
for (const [field, label] of [
|
||||||
|
['appUserId', '绑定用户标识'],
|
||||||
|
['fatherId', '父亲标识'],
|
||||||
|
['motherId', '母亲标识'],
|
||||||
|
['avatarOssId', '头像文件标识']
|
||||||
|
]) {
|
||||||
|
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
|
||||||
|
data[field] = normalizeLineagePersonIdentity(payload[field], label)
|
||||||
|
}
|
||||||
if (payload.generation !== undefined) {
|
if (payload.generation !== undefined) {
|
||||||
if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
|
if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
|
||||||
throw new TypeError('人物世代必须是正安全整数')
|
throw new TypeError('人物世代必须是正安全整数')
|
||||||
@@ -1826,6 +1992,8 @@ const normalizeLineageWritePayload = (payload) => {
|
|||||||
data.generation = payload.generation
|
data.generation = payload.generation
|
||||||
}
|
}
|
||||||
for (const [field, label, maxLength] of [
|
for (const [field, label, maxLength] of [
|
||||||
|
['personNo', '人物编号', null],
|
||||||
|
['sex', '性别', null],
|
||||||
['generationName', '字辈', 12],
|
['generationName', '字辈', 12],
|
||||||
['aliasName', '别名或曾用名', null],
|
['aliasName', '别名或曾用名', null],
|
||||||
['birthLunar', '出生农历', null],
|
['birthLunar', '出生农历', null],
|
||||||
@@ -1835,7 +2003,8 @@ const normalizeLineageWritePayload = (payload) => {
|
|||||||
['burialPlace', '安葬地', null],
|
['burialPlace', '安葬地', null],
|
||||||
['biography', '人物简介', 500],
|
['biography', '人物简介', 500],
|
||||||
['remark', '备注', null],
|
['remark', '备注', null],
|
||||||
['relationName', '关系显示名称', null]
|
['relationName', '关系显示名称', null],
|
||||||
|
['personStatus', '人物状态', null]
|
||||||
]) {
|
]) {
|
||||||
if (payload[field] === undefined) continue
|
if (payload[field] === undefined) continue
|
||||||
const value = normalizeLineagePersonText(payload[field], label)
|
const value = normalizeLineagePersonText(payload[field], label)
|
||||||
@@ -1853,6 +2022,11 @@ const normalizeLineageWritePayload = (payload) => {
|
|||||||
if (data.birthDate && data.deathDate && data.deathDate < data.birthDate) {
|
if (data.birthDate && data.deathDate && data.deathDate < data.birthDate) {
|
||||||
throw new TypeError('人物离世日期不能早于出生日期')
|
throw new TypeError('人物离世日期不能早于出生日期')
|
||||||
}
|
}
|
||||||
|
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
|
||||||
|
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
|
||||||
|
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('人物排序值必须是安全整数')
|
||||||
|
data.sortOrder = sortOrder
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1877,33 +2051,55 @@ const requireLineageWriteRequestController = (requestOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const appApi = {
|
export const appApi = {
|
||||||
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
|
async getCaptchaRequirement({ operationCode, subject }, requestOptions = {}) {
|
||||||
requireRemoteAuth()
|
requireRemoteAuth()
|
||||||
return requestAuth({
|
return requestAuth({
|
||||||
url: '/captcha/require',
|
url: `/genealogy/app/auth/verification/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/require`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: authPayload({ sceneCode: assertAuthScene(sceneCode), subject })
|
data: { tenantId: runtimeConfig.tenantId, subject }
|
||||||
}, requestOptions)
|
}, requestOptions)
|
||||||
},
|
},
|
||||||
async sendSmsCode({ sceneCode, phone, validToken }, requestOptions = {}) {
|
async sendSmsCode({ operationCode, phone, validToken }, requestOptions = {}) {
|
||||||
requireRemoteAuth()
|
requireRemoteAuth()
|
||||||
|
const normalizedValidToken = normalizeOptionalValidToken(validToken)
|
||||||
|
return requestAuthVoid({
|
||||||
|
url: `/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code`,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
tenantId: runtimeConfig.tenantId,
|
||||||
|
grantType: 'sms',
|
||||||
|
phone,
|
||||||
|
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
|
||||||
|
}
|
||||||
|
}, requestOptions)
|
||||||
|
},
|
||||||
|
async sendLegacySmsCode({ phone, validToken }, requestOptions = {}) {
|
||||||
|
requireRemoteAuth()
|
||||||
|
const normalizedValidToken = normalizeOptionalValidToken(validToken)
|
||||||
return requestAuthVoid({
|
return requestAuthVoid({
|
||||||
url: '/genealogy/app/auth/sms/code',
|
url: '/genealogy/app/auth/sms/code',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: authPayload({
|
data: {
|
||||||
|
tenantId: runtimeConfig.tenantId,
|
||||||
grantType: 'sms',
|
grantType: 'sms',
|
||||||
sceneCode: assertAuthScene(sceneCode),
|
|
||||||
phone,
|
phone,
|
||||||
validToken: assertValidToken(validToken)
|
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
|
||||||
})
|
}
|
||||||
}, requestOptions)
|
}, requestOptions)
|
||||||
},
|
},
|
||||||
async loginWithPassword({ phone, passwordHash }, requestOptions = {}) {
|
async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) {
|
||||||
requireRemoteAuth()
|
requireRemoteAuth()
|
||||||
|
const normalizedValidToken = normalizeOptionalValidToken(validToken)
|
||||||
const result = await requestAuth({
|
const result = await requestAuth({
|
||||||
url: '/genealogy/app/auth/login',
|
url: '/genealogy/app/auth/login',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: authPayload({ grantType: 'password', phone, password: assertPasswordHash(passwordHash) })
|
data: {
|
||||||
|
tenantId: runtimeConfig.tenantId,
|
||||||
|
grantType: 'password',
|
||||||
|
phone,
|
||||||
|
password: assertPasswordHash(passwordHash),
|
||||||
|
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
|
||||||
|
}
|
||||||
}, requestOptions)
|
}, requestOptions)
|
||||||
return saveLogin(result)
|
return saveLogin(result)
|
||||||
},
|
},
|
||||||
@@ -2069,6 +2265,25 @@ export const appApi = {
|
|||||||
filePath: payload.filePath.trim()
|
filePath: payload.filePath.trim()
|
||||||
}, requestOptions)
|
}, requestOptions)
|
||||||
},
|
},
|
||||||
|
async uploadBrowserResumableChunk(payload, file, requestOptions = {}) {
|
||||||
|
const allowedFields = new Set(['uploadId', 'chunkIndex', 'chunkMd5'])
|
||||||
|
assertPlainPayload(payload, allowedFields, '浏览器文件分片请求')
|
||||||
|
if (!Number.isInteger(payload.chunkIndex) || payload.chunkIndex < 0 || payload.chunkIndex > 2147483647) {
|
||||||
|
throw new TypeError('chunkIndex必须是非负 int32')
|
||||||
|
}
|
||||||
|
if (!file || typeof file !== 'object') {
|
||||||
|
throw new TypeError('浏览器文件分片必须提供文件对象')
|
||||||
|
}
|
||||||
|
if (!hasRemoteConfig()) {
|
||||||
|
throw createRequestError('文件上传需要真实服务,当前本地预览不伪造回执', 'REMOTE_WRITE_REQUIRED')
|
||||||
|
}
|
||||||
|
return requestBrowserFileChunk({
|
||||||
|
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
|
||||||
|
chunkIndex: payload.chunkIndex,
|
||||||
|
chunkMd5: normalizeUploadMd5(payload.chunkMd5, 'chunkMd5'),
|
||||||
|
file
|
||||||
|
}, requestOptions)
|
||||||
|
},
|
||||||
async completeResumableUpload(payload, requestOptions = {}) {
|
async completeResumableUpload(payload, requestOptions = {}) {
|
||||||
const data = normalizeResumableCompletePayload(payload)
|
const data = normalizeResumableCompletePayload(payload)
|
||||||
if (!hasRemoteConfig()) {
|
if (!hasRemoteConfig()) {
|
||||||
@@ -2111,7 +2326,7 @@ export const appApi = {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
const result = await requestStrict({
|
const result = await requestStrict({
|
||||||
url: '/genealogy/app/region/children',
|
url: '/genealogy/region/children',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { parentCode: normalizeRegionParentCode(parentCode) }
|
data: { parentCode: normalizeRegionParentCode(parentCode) }
|
||||||
}, {
|
}, {
|
||||||
@@ -2119,6 +2334,46 @@ export const appApi = {
|
|||||||
})
|
})
|
||||||
return normalizeRegionSelectorItems(result)
|
return normalizeRegionSelectorItems(result)
|
||||||
},
|
},
|
||||||
|
async getRegionPath(regionCode, requestOptions = {}) {
|
||||||
|
requireRemoteResource('行政区划路径', '读取')
|
||||||
|
const result = await requestStrict({
|
||||||
|
url: `/genealogy/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
|
||||||
|
method: 'GET'
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw createRequestError('行政区划路径响应不是列表', 'REGION_PATH_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
async searchRegions(payload, requestOptions = {}) {
|
||||||
|
requireRemoteResource('行政区划搜索', '读取')
|
||||||
|
const result = await requestStrict({
|
||||||
|
url: '/genealogy/region/search',
|
||||||
|
method: 'GET',
|
||||||
|
data: normalizeRegionSearchPayload(payload)
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw createRequestError('行政区划搜索响应不是列表', 'REGION_SEARCH_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
async getRegion(regionCode, requestOptions = {}) {
|
||||||
|
requireRemoteResource('行政区划详情', '读取')
|
||||||
|
const result = await requestStrict({
|
||||||
|
url: `/genealogy/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
|
||||||
|
method: 'GET'
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||||||
|
throw createRequestError('行政区划详情响应不是对象', 'REGION_DETAIL_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
async getMyGenealogies(requestOptions = {}) {
|
async getMyGenealogies(requestOptions = {}) {
|
||||||
if (!hasRemoteConfig()) {
|
if (!hasRemoteConfig()) {
|
||||||
const error = new Error('我的家谱需要真实读取服务,当前本地预览不会伪造家谱列表')
|
const error = new Error('我的家谱需要真实读取服务,当前本地预览不会伪造家谱列表')
|
||||||
@@ -2133,6 +2388,16 @@ export const appApi = {
|
|||||||
})
|
})
|
||||||
return normalizeMyGenealogies(result)
|
return normalizeMyGenealogies(result)
|
||||||
},
|
},
|
||||||
|
async getGenealogyQuota(requestOptions = {}) {
|
||||||
|
requireRemoteResource('家谱配额', '读取')
|
||||||
|
const result = await requestStrict({
|
||||||
|
url: '/genealogy/app/genealogies/quota',
|
||||||
|
method: 'GET'
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
return normalizeGenealogyQuota(result)
|
||||||
|
},
|
||||||
async createGenealogy(payload, requestOptions = {}) {
|
async createGenealogy(payload, requestOptions = {}) {
|
||||||
const data = normalizeGenealogyCreatePayload(payload)
|
const data = normalizeGenealogyCreatePayload(payload)
|
||||||
if (!hasRemoteConfig()) {
|
if (!hasRemoteConfig()) {
|
||||||
@@ -2820,6 +3085,40 @@ export const appApi = {
|
|||||||
if (!hasRemoteConfig()) return listCeremonyFixtures(normalizedGenealogyId)
|
if (!hasRemoteConfig()) return listCeremonyFixtures(normalizedGenealogyId)
|
||||||
return readRemoteList(`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies`, '礼仪活动', requestOptions)
|
return readRemoteList(`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies`, '礼仪活动', requestOptions)
|
||||||
},
|
},
|
||||||
|
async replaceCeremonyInvitees(genealogyId, ceremonyId, payload, requestOptions = {}) {
|
||||||
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
|
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||||
|
return writeRemoteObject(
|
||||||
|
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitees`,
|
||||||
|
'PUT',
|
||||||
|
normalizeCeremonyInviteePayload(payload),
|
||||||
|
'活动受邀人',
|
||||||
|
requestOptions
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async getCeremonyInvitations(genealogyId, ceremonyId, requestOptions = {}) {
|
||||||
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
|
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||||
|
return readRemoteList(
|
||||||
|
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations`,
|
||||||
|
'活动邀请名单',
|
||||||
|
requestOptions
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async respondToCeremonyInvitation(genealogyId, ceremonyId, payload, requestOptions = {}) {
|
||||||
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
|
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||||
|
return writeRemoteObject(
|
||||||
|
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations/me`,
|
||||||
|
'PUT',
|
||||||
|
normalizeCeremonyInvitationResponsePayload(payload),
|
||||||
|
'活动邀请响应',
|
||||||
|
requestOptions
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async getMyCeremonyInvitations(requestOptions = {}) {
|
||||||
|
return readRemoteList('/genealogy/app/genealogies/ceremony-invitations/mine', '我的活动邀请', requestOptions)
|
||||||
|
},
|
||||||
async getCeremonyDetail(genealogyId, ceremonyId, requestOptions = {}) {
|
async getCeremonyDetail(genealogyId, ceremonyId, requestOptions = {}) {
|
||||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||||
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||||
@@ -3014,6 +3313,19 @@ export const appApi = {
|
|||||||
if (!hasRemoteConfig()) return listNotificationFixtures()
|
if (!hasRemoteConfig()) return listNotificationFixtures()
|
||||||
return readRemoteList('/genealogy/app/notifications', '消息通知', requestOptions)
|
return readRemoteList('/genealogy/app/notifications', '消息通知', requestOptions)
|
||||||
},
|
},
|
||||||
|
async getUnreadNotificationCount(requestOptions = {}) {
|
||||||
|
requireRemoteResource('未读通知数量', '读取')
|
||||||
|
const value = await requestStrict({
|
||||||
|
url: '/genealogy/app/notifications/unread-count',
|
||||||
|
method: 'GET'
|
||||||
|
}, {
|
||||||
|
requestController: requestOptions.requestController ?? null
|
||||||
|
})
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw createRequestError('未读通知数量响应无效', 'NOTIFICATION_COUNT_RESPONSE_INVALID')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
async markNotificationRead(notificationId, requestOptions = {}) {
|
async markNotificationRead(notificationId, requestOptions = {}) {
|
||||||
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
|
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
|
||||||
if (hasRemoteConfig()) {
|
if (hasRemoteConfig()) {
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ const requireFunction = (value, name) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createAuthSmsCooldown = ({
|
export const createAuthSmsCooldown = ({
|
||||||
sceneCode,
|
operationCode,
|
||||||
onChange,
|
onChange,
|
||||||
now = Date.now,
|
now = Date.now,
|
||||||
setIntervalFn = setInterval,
|
setIntervalFn = setInterval,
|
||||||
clearIntervalFn = clearInterval,
|
clearIntervalFn = clearInterval,
|
||||||
}) => {
|
}) => {
|
||||||
if (typeof sceneCode !== "string" || !sceneCode.trim()) {
|
if (typeof operationCode !== "string" || !operationCode.trim()) {
|
||||||
throw new TypeError("sceneCode must be a non-empty string");
|
throw new TypeError("operationCode must be a non-empty string");
|
||||||
}
|
}
|
||||||
const normalizedSceneCode = sceneCode.trim();
|
const normalizedOperationCode = operationCode.trim();
|
||||||
const publish = requireFunction(onChange, "onChange");
|
const publish = requireFunction(onChange, "onChange");
|
||||||
const readNow = requireFunction(now, "now");
|
const readNow = requireFunction(now, "now");
|
||||||
const scheduleInterval = requireFunction(setIntervalFn, "setIntervalFn");
|
const scheduleInterval = requireFunction(setIntervalFn, "setIntervalFn");
|
||||||
@@ -32,13 +32,13 @@ export const createAuthSmsCooldown = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
const expiresAt = sceneExpiresAt.get(normalizedSceneCode) || 0;
|
const expiresAt = sceneExpiresAt.get(normalizedOperationCode) || 0;
|
||||||
const remaining = Math.max(
|
const remaining = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.ceil((expiresAt - Number(readNow())) / 1000),
|
Math.ceil((expiresAt - Number(readNow())) / 1000),
|
||||||
);
|
);
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
sceneExpiresAt.delete(normalizedSceneCode);
|
sceneExpiresAt.delete(normalizedOperationCode);
|
||||||
stopTimer();
|
stopTimer();
|
||||||
} else if (timer === null) {
|
} else if (timer === null) {
|
||||||
timer = scheduleInterval(sync, 1000);
|
timer = scheduleInterval(sync, 1000);
|
||||||
@@ -52,7 +52,7 @@ export const createAuthSmsCooldown = ({
|
|||||||
throw new TypeError("cooldown seconds must be an integer from 1 to 300");
|
throw new TypeError("cooldown seconds must be an integer from 1 to 300");
|
||||||
}
|
}
|
||||||
sceneExpiresAt.set(
|
sceneExpiresAt.set(
|
||||||
normalizedSceneCode,
|
normalizedOperationCode,
|
||||||
Number(readNow()) + seconds * 1000,
|
Number(readNow()) + seconds * 1000,
|
||||||
);
|
);
|
||||||
return sync();
|
return sync();
|
||||||
|
|||||||
+24
-15
@@ -1,7 +1,8 @@
|
|||||||
export const AUTH_TAC_SCENE = Object.freeze({
|
export const AUTH_VERIFICATION_OPERATION = Object.freeze({
|
||||||
SMS_LOGIN: "APP_SMS_LOGIN",
|
PASSWORD_LOGIN: "password-login",
|
||||||
REGISTER: "APP_REGISTER",
|
SMS_LOGIN: "sms-login",
|
||||||
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
|
REGISTER: "register",
|
||||||
|
FORGOT_PASSWORD: "forgot-password",
|
||||||
});
|
});
|
||||||
|
|
||||||
const SUPPORTED_TAC_TYPES = new Set([
|
const SUPPORTED_TAC_TYPES = new Set([
|
||||||
@@ -35,19 +36,24 @@ export const assertSmsCode = (value) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeCaptchaRequirement = (value, expectedSceneCode) => {
|
const assertAuthVerificationOperation = (operationCode) => {
|
||||||
|
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
|
||||||
|
throw contractError("认证动作不属于当前认证合同", "AUTH_OPERATION_INVALID");
|
||||||
|
}
|
||||||
|
return operationCode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCaptchaRequirement = (value) => {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
throw contractError("行为验证策略响应无效", "AUTH_TAC_REQUIREMENT_INVALID");
|
throw contractError("行为验证策略响应无效", "AUTH_TAC_REQUIREMENT_INVALID");
|
||||||
}
|
}
|
||||||
const sceneCode = requireText(value.sceneCode, "行为验证场景");
|
const sceneCode = requireText(value.sceneCode, "行为验证场景");
|
||||||
if (sceneCode !== expectedSceneCode) {
|
if (value.required === false) {
|
||||||
throw contractError("行为验证场景与当前操作不匹配", "AUTH_TAC_SCENE_MISMATCH");
|
return { required: false, sceneCode };
|
||||||
}
|
}
|
||||||
// 短信接口把 validToken 定义为必填,因此 required=false 不能在客户端被解释成
|
|
||||||
// “跳过验证”。后端必须为短信场景启用 TAC,或另行签发可消费的免验证票据。
|
|
||||||
if (value.required !== true) {
|
if (value.required !== true) {
|
||||||
throw contractError(
|
throw contractError(
|
||||||
"服务端未要求行为验证,无法取得发送短信所需票据",
|
"行为验证策略缺少 required 布尔值",
|
||||||
"AUTH_TAC_POLICY_INCOMPLETE",
|
"AUTH_TAC_POLICY_INCOMPLETE",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,11 +77,14 @@ export const createTacRenderContext = ({
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
clientId,
|
clientId,
|
||||||
tenantId,
|
tenantId,
|
||||||
sceneCode,
|
operationCode,
|
||||||
subject,
|
subject,
|
||||||
requirement,
|
requirement,
|
||||||
}) => {
|
}) => {
|
||||||
const normalizedRequirement = normalizeCaptchaRequirement(requirement, sceneCode);
|
const normalizedRequirement = normalizeCaptchaRequirement(requirement);
|
||||||
|
if (!normalizedRequirement.required) {
|
||||||
|
throw contractError("当前认证动作无需行为验证", "AUTH_TAC_NOT_REQUIRED");
|
||||||
|
}
|
||||||
const normalizedBaseUrl = requireText(baseUrl, "后端地址").replace(/\/+$/, "");
|
const normalizedBaseUrl = requireText(baseUrl, "后端地址").replace(/\/+$/, "");
|
||||||
if (!/^https:\/\/[^/]+/i.test(normalizedBaseUrl)) {
|
if (!/^https:\/\/[^/]+/i.test(normalizedBaseUrl)) {
|
||||||
throw contractError("行为验证只允许使用 HTTPS 后端地址", "AUTH_TAC_HTTPS_REQUIRED");
|
throw contractError("行为验证只允许使用 HTTPS 后端地址", "AUTH_TAC_HTTPS_REQUIRED");
|
||||||
@@ -87,11 +96,11 @@ export const createTacRenderContext = ({
|
|||||||
return {
|
return {
|
||||||
requestId: requireText(requestId, "验证请求标识"),
|
requestId: requireText(requestId, "验证请求标识"),
|
||||||
baseUrl: normalizedBaseUrl,
|
baseUrl: normalizedBaseUrl,
|
||||||
challengeUrl: `${normalizedBaseUrl}/captcha/challenge`,
|
challengeUrl: `${normalizedBaseUrl}/genealogy/app/auth/verification/${assertAuthVerificationOperation(operationCode)}/challenge`,
|
||||||
verifyUrl: `${normalizedBaseUrl}/captcha/verify`,
|
verifyUrl: `${normalizedBaseUrl}/genealogy/app/auth/verification/${assertAuthVerificationOperation(operationCode)}/verify`,
|
||||||
clientId: requireText(clientId, "客户端标识"),
|
clientId: requireText(clientId, "客户端标识"),
|
||||||
tenantId: requireText(tenantId, "租户标识"),
|
tenantId: requireText(tenantId, "租户标识"),
|
||||||
sceneCode: normalizedRequirement.sceneCode,
|
operationCode,
|
||||||
subject: normalizedSubject,
|
subject: normalizedSubject,
|
||||||
providerCode: normalizedRequirement.providerCode,
|
providerCode: normalizedRequirement.providerCode,
|
||||||
captchaType: normalizedRequirement.captchaType,
|
captchaType: normalizedRequirement.captchaType,
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ export const ROUTES = Object.freeze({
|
|||||||
path: "/pages/profile/m07-feedback",
|
path: "/pages/profile/m07-feedback",
|
||||||
kind: "flow",
|
kind: "flow",
|
||||||
parent: "M06",
|
parent: "M06",
|
||||||
allowedSources: ["M06"],
|
allowedSources: ["M01", "M06"],
|
||||||
}),
|
}),
|
||||||
M08: defineRoute({
|
M08: defineRoute({
|
||||||
path: "/pages/profile/m08-promotion",
|
path: "/pages/profile/m08-promotion",
|
||||||
|
|||||||
+38
-2
@@ -633,6 +633,34 @@ const assertResultSourceContext = (targetRoute, targetParams, sourcePage) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isImplicitRootOptionalContext = (
|
||||||
|
routeKey,
|
||||||
|
route,
|
||||||
|
existingParams,
|
||||||
|
targetParams,
|
||||||
|
name,
|
||||||
|
sourcePage,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!ROOT_ROUTE_KEYS.includes(routeKey) ||
|
||||||
|
!route.optionalParams.includes(name) ||
|
||||||
|
hasOwn(existingParams, name) ||
|
||||||
|
!hasOwn(targetParams, name)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRouteKey = getPageRouteKey(sourcePage);
|
||||||
|
const sourceRoute = getRoute(sourceRouteKey);
|
||||||
|
if (!sourceRoute) return false;
|
||||||
|
const sourceParams = validateRouteParams(
|
||||||
|
sourceRouteKey,
|
||||||
|
getPageParams(sourcePage, sourceRoute),
|
||||||
|
true,
|
||||||
|
).params;
|
||||||
|
return hasOwn(sourceParams, name) && sourceParams[name] === targetParams[name];
|
||||||
|
};
|
||||||
|
|
||||||
const returnToValidated = (routeKey, targetParams, result) => {
|
const returnToValidated = (routeKey, targetParams, result) => {
|
||||||
const validatedTarget = validateRouteParams(
|
const validatedTarget = validateRouteParams(
|
||||||
routeKey,
|
routeKey,
|
||||||
@@ -656,8 +684,16 @@ const returnToValidated = (routeKey, targetParams, result) => {
|
|||||||
targetIdentity = validatedExisting.params;
|
targetIdentity = validatedExisting.params;
|
||||||
for (const [name, value] of Object.entries(normalizedTargetParams)) {
|
for (const [name, value] of Object.entries(normalizedTargetParams)) {
|
||||||
if (
|
if (
|
||||||
!hasOwn(validatedExisting.params, name) ||
|
(!hasOwn(validatedExisting.params, name) ||
|
||||||
validatedExisting.params[name] !== value
|
validatedExisting.params[name] !== value) &&
|
||||||
|
!isImplicitRootOptionalContext(
|
||||||
|
routeKey,
|
||||||
|
route,
|
||||||
|
validatedExisting.params,
|
||||||
|
normalizedTargetParams,
|
||||||
|
name,
|
||||||
|
sourcePage,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(`${routeKey} 显式目标参数 ${name} 与最近实例不一致`);
|
throw new Error(`${routeKey} 显式目标参数 ${name} 与最近实例不一致`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const createUploadError = (message, code) => {
|
|||||||
|
|
||||||
export const isImagePickCancelled = (error) => error?.code === "IMAGE_PICK_CANCELLED";
|
export const isImagePickCancelled = (error) => error?.code === "IMAGE_PICK_CANCELLED";
|
||||||
|
|
||||||
|
const createUploadId = () => {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||||
|
return `app-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const pickNativeImage = () => new Promise((resolve, reject) => {
|
const pickNativeImage = () => new Promise((resolve, reject) => {
|
||||||
if (!globalThis.plus?.gallery?.pick) {
|
if (!globalThis.plus?.gallery?.pick) {
|
||||||
reject(createUploadError("当前运行环境不支持从相册选择图片", "IMAGE_PICK_UNAVAILABLE"));
|
reject(createUploadError("当前运行环境不支持从相册选择图片", "IMAGE_PICK_UNAVAILABLE"));
|
||||||
@@ -21,6 +26,53 @@ const pickNativeImage = () => new Promise((resolve, reject) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pickBrowserImage = () => new Promise((resolve, reject) => {
|
||||||
|
if (typeof globalThis.document?.createElement !== "function" || typeof globalThis.FileReader !== "function") {
|
||||||
|
reject(createUploadError("当前运行环境不支持选择图片", "IMAGE_PICK_UNAVAILABLE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = globalThis.document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.style.display = "none";
|
||||||
|
const cleanup = () => input.remove();
|
||||||
|
input.addEventListener("cancel", () => {
|
||||||
|
cleanup();
|
||||||
|
reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED"));
|
||||||
|
}, { once: true });
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
const browserFile = input.files?.[0];
|
||||||
|
if (!browserFile) {
|
||||||
|
cleanup();
|
||||||
|
reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new globalThis.FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const data = reader.result;
|
||||||
|
cleanup();
|
||||||
|
if (!(data instanceof ArrayBuffer)) {
|
||||||
|
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
browserFile,
|
||||||
|
fileName: browserFile.name || "image",
|
||||||
|
size: browserFile.size,
|
||||||
|
contentType: browserFile.type || "image/*",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(browserFile);
|
||||||
|
}, { once: true });
|
||||||
|
globalThis.document.body?.append(input);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
|
||||||
const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
||||||
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
|
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
|
||||||
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
|
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
|
||||||
@@ -54,11 +106,6 @@ const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUploadId = () => {
|
|
||||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
||||||
return `app-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toConsumerOssId = (ossId) => {
|
export const toConsumerOssId = (ossId) => {
|
||||||
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
||||||
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
||||||
@@ -89,15 +136,15 @@ const toUploadReceipt = ({ ossId, url = "", thumbnailUrl = "", fileName = "" })
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||||
const filePath = await pickNativeImage();
|
const file = globalThis.plus?.gallery?.pick
|
||||||
const file = await readNativeImage(filePath);
|
? await readNativeImage(await pickNativeImage())
|
||||||
|
: await pickBrowserImage();
|
||||||
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
|
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
|
||||||
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
|
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
|
||||||
}
|
}
|
||||||
const fileMd5 = calcMD5Bytes(file.data);
|
const fileMd5 = calcMD5Bytes(file.data);
|
||||||
const uploadId = createUploadId();
|
|
||||||
const initPayload = {
|
const initPayload = {
|
||||||
uploadId,
|
uploadId: createUploadId(),
|
||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
fileMd5,
|
fileMd5,
|
||||||
totalSize: file.size,
|
totalSize: file.size,
|
||||||
@@ -107,12 +154,16 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
|||||||
};
|
};
|
||||||
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
||||||
if (initialized.instant) return toUploadReceipt(initialized);
|
if (initialized.instant) return toUploadReceipt(initialized);
|
||||||
await appApi.uploadResumableChunk({
|
const chunkPayload = {
|
||||||
uploadId: initialized.uploadId,
|
uploadId: initialized.uploadId,
|
||||||
chunkIndex: 0,
|
chunkIndex: 0,
|
||||||
chunkMd5: fileMd5,
|
chunkMd5: fileMd5,
|
||||||
filePath: file.filePath,
|
};
|
||||||
}, { requestController });
|
if (file.browserFile) {
|
||||||
|
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||||
|
} else {
|
||||||
|
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||||
|
}
|
||||||
const completed = await appApi.completeResumableUpload({
|
const completed = await appApi.completeResumableUpload({
|
||||||
uploadId: initialized.uploadId,
|
uploadId: initialized.uploadId,
|
||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
|
|||||||
+11571
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user