test(api): 更新API客户端契约测试以符合YAML规范
- 更新登录响应模拟数据以匹配真实的AppLoginVo结构 - 添加认证客户端租户和授权字段验证 - 增加操作码枚举验证测试用例 - 移除对旧token别名的兼容性测试 - 修复测试用例中的短信验证码长度一致性问题 - 更新区域接口路径为PC专用路径 - 调整分片上传接口参数以符合新契约定义 refactor(api): 重构API客户端实现以严格遵循YAML契约 - 添加认证操作码和短信操作码枚举验证 - 实现严格的token响应解析只接受access_token字段 - 使用pickDefined函数过滤请求体中未定义的字段 - 重构认证接口参数映射以符合契约定义 - 更新区域接口路径为PC专用路径/genealogy/pc/region/* - 优化分片上传接口参数结构与契约保持一致 - 添加操作码枚举验证函数toRequiredOperationCode - 实现请求体字段选择性提取功能 feat(auth): 优化认证页面的验证码处理流程 - 添加takeCaptchaToken函数用于一次性获取验证码票据 - 更新短信验证码长度验证从4-6位改为精确4位 - 在登录和密码重置流程中集成验证码票据处理 - 修复验证码发送后票据清理逻辑 - 更新HTML模板中的验证码输入字段属性 chore(config): 提取常量配置并扩展配置对象结构 - 将客户端ID、租户ID和令牌键提取为常量 - 扩展配置对象返回客户端配置信息 - 更新配置测试用例以验证新增配置项 docs(planning): 更新PC接口对接规划文档 - 更新契约源说明以反映YAML冻结契约 - 添加YAML与在线Apifox复核对比内容 - 更新阻断项状态表格 - 修订登录响应token字段处理规范 - 更新文件上传和行政区划接口规范说明 style(profile): 优化相册管理页面的文件上传交互 - 将封面和照片OSS ID输入改为隐藏字段 - 添加文件选择标签以改善用户体验 - 移除手动输入OSS ID的选项保持界面简洁
This commit is contained in:
@@ -9,6 +9,10 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
||||
'use strict';
|
||||
|
||||
var CLIENT_ID = 'ced7e5f0498645c6ec642dcf450b036f';
|
||||
var TENANT_ID = '000000';
|
||||
var TOKEN_KEY = 'genealogy_auth_token';
|
||||
|
||||
// 这里只维护运行环境和后端基础地址,具体接口路径统一放在 ApiClient 中。
|
||||
var ENVIRONMENTS = {
|
||||
development: {
|
||||
@@ -37,7 +41,10 @@
|
||||
function getConfig() {
|
||||
return {
|
||||
environment: getEnvironment(),
|
||||
apiBaseUrl: getApiBaseUrl()
|
||||
apiBaseUrl: getApiBaseUrl(),
|
||||
clientId: CLIENT_ID,
|
||||
tenantId: TENANT_ID,
|
||||
tokenKey: TOKEN_KEY
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+53
-7
@@ -74,7 +74,9 @@
|
||||
4. 当前仓库代码;
|
||||
5. 旧规划和历史交接记录。
|
||||
|
||||
Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一业务契约 owner。`utils/ApiClient.js` 是前端路径和请求方法的唯一 owner。页面脚本只能调用 `ApiClient` 业务方法,不能直接拼 URL 或调用 Axios。
|
||||
通常以 Apifox 的 PC 目录作为 PC 前端接口的正式契约源。2026-07-28 用户明确指定本轮直接以 `D:/WorkSpace/Java/Genealogy/doc/apifox/genealogy-pc-openapi.yaml` 对接,因此该 YAML 是本轮冻结契约;在线 Apifox 的旧重复记录不再覆盖它。`utils/ApiClient.js` 是前端路径和请求方法的唯一 owner。页面脚本只能调用 `ApiClient` 业务方法,不能直接拼 URL 或调用 Axios。
|
||||
|
||||
旧 OpenAPI 文件不再作为新增功能依据,只用于结构化差异比较和追踪导出遗漏。
|
||||
|
||||
如果 Apifox 在线内容发生变化,先更新本地 OpenAPI、本规划的受影响章节和契约测试,再修改运行时代码。不得让文档、测试和运行时同时保留两套契约。
|
||||
|
||||
@@ -117,6 +119,24 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
4. 当前 96 个操作全部被声明为需要 `Authorization`,连注册、登录、短信发送和验证中心也不例外。这与未登录流程冲突,必须在 Apifox 逐项修正 security。
|
||||
5. 当前有 21 个成功响应使用无业务属性的 `ObjectResult`,12 个使用无元素 Schema 的 `ListResult`;另有 `LineagePersonTreeView` 名义上是专用 DTO,但没有任何属性。
|
||||
|
||||
### 4.3 2026-07-28 补齐版 YAML 与在线 Apifox 复核
|
||||
|
||||
补齐文件 `genealogy-pc-openapi.yaml` 含 98 个 path、137 个 HTTP operation、17 个 tag、319 个 schema;旧 `PC.openapi.json` 含 71 个 path、96 个 operation、68 个 schema。补齐文件新增了家谱、成员、相册、文章、祭祀、通知、反馈、VIP 和官网内容等路径及 DTO,但它还不能替代在线 Apifox:
|
||||
|
||||
1. 已实际登录并打开 Apifox“家谱”项目的 PC 模块。在线概览显示 **142 个接口、87 个数据模型**,与补齐 YAML 的 137 个 HTTP operation 不一致。
|
||||
2. 在线“认证登录”目录仍显示 **12 个接口**,同时存在:
|
||||
- 错误旧路径:`genealogy/pc/auth/sms/{operationCode}/code`
|
||||
- 正确路径:`/genealogy/pc/auth/sms/{operationCode}/code`
|
||||
|
||||
补齐 YAML 只包含正确路径,因此 C02 在线尚未完成。
|
||||
3. 补齐 YAML 给注册、密码登录、短信登录和短信发送标了 `security: []`;在线密码登录详情仍展示必需的 `Authorization` API Key,并显示 `Auth 1`。因此 C03 的导出定义和在线详情冲突。
|
||||
4. 在线 `GET /genealogy/pc/auth/profile` 成功响应仍为泛型 `ObjectResult`;展开后的 `data` 只有任意附加属性,没有 `ProfileView` 字段。补齐 YAML 同样引用泛型 `ObjectResult`,C04/C06 未完成。
|
||||
5. `ProfileUpdateBody.avatar` 仍为 `integer<int64>`,而 `FileUploadVo.ossId`、`LineagePersonView.avatarOssId` 等 OSS ID 为 `string`;部分头像字段也仍为 `integer<int64>`。C10 未完成。
|
||||
6. 已用真实登录响应和后端 `AppLoginVo` 复核:token 的唯一 JSON 字段是 `access_token`;原 YAML 的 `token`、`accessToken`、`tokenValue`、`userId`、`tenantId`、`clientId` 均不是该响应的直接字段,现已删除。
|
||||
7. `ProfileUpdateBody` 仍只有 `nickName`、`realName`、`avatar`、`sex`、`birthday`、`email`,没有地区字段,也没有说明缺省、`null`、空串的清空语义。C07/C18 未完成。
|
||||
|
||||
用户已明确要求本轮直接按补齐 YAML 施工,所以在线旧短信路径和公开接口 security 残留不再阻断本轮实现。YAML 自身仍存在的 profile 泛型响应、头像 ID 类型冲突和更新清空语义缺失继续保留为阻断项,前端不得自行补造。
|
||||
|
||||
## 5. 实施前必须解决的契约阻断项
|
||||
|
||||
以下事项不是前端自由选择,必须先在 Apifox 明确:
|
||||
@@ -144,6 +164,20 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
| C19 | 多数 View 没有 `canEdit` / `canDelete` 等权限字段 | 页面无法可靠决定按钮可见性 | 增加能力字段,或提供明确且可实现的角色规则 |
|
||||
| C20 | 文章、相册、祭祀只有删除操作 | 没有真实 ID 来源和完整资源流程 | 补全 PC 列表/详情/新增/修改后才开放页面 |
|
||||
|
||||
当前复核状态:
|
||||
|
||||
| 阻断项 | 状态 | 在线/补齐文件证据 |
|
||||
| --- | --- | --- |
|
||||
| C02 | 本轮按 YAML 解决 | 只实现带 `/` 的正确路径,不保留旧路径 fallback |
|
||||
| C03 | 本轮按 YAML 解决 | 验证、注册、登录、短信、找回使用 `security: []`;其他认证接口携带 Bearer |
|
||||
| C04/C06 | 阻断 | profile GET 在线及 YAML 都只有泛型 `ObjectResult` |
|
||||
| C07 | 阻断 | `ProfileUpdateBody` 没有地区字段 |
|
||||
| C01/C08 | 前端按后端 PC 实链解决 | 只调用 `/genealogy/pc/region/*`;列表按 `RegionSelectVo` 的 `regionCode/regionName/regionLevel` 读取,不保留公共路径 fallback |
|
||||
| C09 | 前端按后端 PC 实链解决 | `SysOssResumableInitVo` 明确提供 `uploadId/instant/ossId/url/fileName/uploadedChunks`,完成响应使用 `SysOssUploadVo` |
|
||||
| C10 | 前端边界解决 | 后端初始化 `ossId` 为 Long、完成响应为 string;浏览器统一转十进制字符串,隐藏回填且不转 `Number` |
|
||||
| C13 | 后续日期模块仍阻断 | 本阶段上传与区划响应不消费日期;其他模块仍需逐字段核验 Java 日期类型 |
|
||||
| C18 | 阻断 | 未说明可选更新字段的省略、`null`、空串语义 |
|
||||
|
||||
阶段 0 验收标准:
|
||||
|
||||
- Apifox 中不存在旧短信路径和双区划路径;
|
||||
@@ -246,8 +280,18 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
| `passed` | R/A | 决定校验成功或失败 |
|
||||
| `validToken` | I | 一次性票据,不显示、不落长期存储 |
|
||||
| `message` | R | 安全地显示验证结果 |
|
||||
| 登录响应的 `token` / `accessToken` / `tokenValue` | I | Apifox 收口为一个正式字段后保存 |
|
||||
| `userId`、`tenantId`、`clientId`、`clientKey` | I/R | 会话上下文或诊断信息,不作为用户编辑字段 |
|
||||
| `access_token` | string / I | 登录接口 `data`;密码或短信登录成功时立即保存,后续仅用于 Bearer 鉴权 |
|
||||
| `expire_in` | integer<int64> / I/R | 登录接口 `data`;登录成功时取得,可用于会话到期提示,不由用户填写 |
|
||||
| `client_id`、`clientKey`、`deviceType`、`userType` | string / I/R | 登录接口 `data`;登录成功时取得,仅作会话上下文或诊断,不回传为用户输入 |
|
||||
| `profile` | object / R | 登录接口 `data.profile`;登录成功后取得,可用于页面只读展示或资料页初始回填 |
|
||||
| `profile.userId`、`profile.tenantId`、`profile.userNo` | integer/string、string、string / I/R | 后端自动产生;不得让用户手工填写,业务 ID 超出 JS 安全整数时保持字符串 |
|
||||
| `profile.phone`、`profile.nickName`、`profile.realName`、`profile.email` | string/null / R | 后端用户资料;登录成功后只读展示或作为资料页回填,编辑时仍只提交 `ProfileUpdateBody` 允许字段 |
|
||||
| `profile.avatar` | integer/string/null / R | 后端头像 OSS ID;只读回填,用户换头像必须经文件选择和上传组件产生,禁止手填 OSS ID |
|
||||
| `profile.sex` | enum `0/1/2` / R | 后端用户资料;页面映射男/女/未知 |
|
||||
| `profile.birthday` | date/null / R | 后端用户资料;页面按日期展示 |
|
||||
| `profile.registerSource`、`profile.loginIp`、`profile.loginDate` | string/null / R/I | 后端自动产生;只读或内部诊断,不作为表单输入 |
|
||||
| `profile.status` | string / R/I | 后端账号状态;只读或内部诊断,不作为表单输入 |
|
||||
| `profile.clientKey`、`profile.deviceType` | string / I/R | 后端登录上下文;只读或内部诊断 |
|
||||
|
||||
#### 认证业务流程
|
||||
|
||||
@@ -258,7 +302,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
3. 若 `required=false`,直接提交密码登录。
|
||||
4. 若需要验证,调用 challenge,展示服务端指定控件,再调用 verify。
|
||||
5. 得到 `validToken` 后与 MD5 密码一起登录。
|
||||
6. 成功后仅保存一个正式 token 字段;Apifox 必须选定 `token`、`accessToken` 或 `tokenValue` 的唯一字段,不能长期兼容三个别名。
|
||||
6. 成功后只读取并保存 `data.access_token`;`token`、`accessToken`、`tokenValue` 均视为非法旧响应,不做兼容读取。
|
||||
|
||||
短信类动作:
|
||||
|
||||
@@ -284,6 +328,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
现有页面整改点:
|
||||
|
||||
- 当前 `avatarOssId` 字段名与请求契约 `avatar` 不一致;
|
||||
- 补齐 YAML 中 `avatar` 仍是 `integer<int64>`,但示例值已超过 JavaScript 安全整数范围;浏览器边界必须按十进制字符串保留精度,且只能由上传响应自动回填,不能转 `Number` 或让用户手填;
|
||||
- 缺少 `realName`、`email` 和性别“未知”;
|
||||
- 页面现居地区、父亲、微信/QQ、学历/职业不在 `ProfileUpdateBody`,不得混入保存请求;
|
||||
- 在后端增加地区字段前,“保存地区”按钮必须关闭或改成纯查询演示;
|
||||
@@ -312,7 +357,8 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
- `FileUploadVo.ossId`:I,隐藏写入业务表单;
|
||||
- `url`、`thumbnailUrl`:R,用于预览;
|
||||
- `fileName`、`originalName`:R,用于文件列表;
|
||||
- 初始化的 `instant`、已上传分片和秒传 OSS 信息尚未定义,C09 完成前不得猜测;
|
||||
- 初始化 `instant=true` 时直接读取 `ossId/url/fileName` 完成秒传;`instant=false` 时使用服务端 `uploadId` 与 `uploadedChunks` 跳过已上传分片;
|
||||
- 初始化 Long `ossId` 和完成响应 string `ossId` 均在浏览器边界转为十进制字符串;
|
||||
- 上传成功但业务保存失败时,当前契约没有释放引用接口,必须让后端补充生命周期规则。
|
||||
|
||||
统一由 `public/js/upload-pages.js` 管理分片、进度、重试、取消、MD5 和隐藏 ID。各业务页面不得复制上传算法,也不得出现“请输入 OSS ID”的可见输入框。
|
||||
@@ -339,7 +385,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
|
||||
### 7.5 行政区划
|
||||
|
||||
先完成 C01,只实现选中的唯一一套路径。
|
||||
本 PC 前端只实现 `/genealogy/pc/region/*`,不调用重复的 `/genealogy/region/*`,也不保留 fallback。
|
||||
|
||||
| 字段 | 必填性 | 类型 | 页面处理 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -350,7 +396,7 @@ Apifox 是 method、path、参数、DTO、枚举、权限和错误码的唯一
|
||||
| `limit` | 可选 query | A | 搜索组件固定合理上限,不让用户自由输入 |
|
||||
| `clientid` | 可选 header | A | 统一请求层注入 |
|
||||
|
||||
在 `RegionView` 补齐前不能假设响应一定含 `regionCode`、`regionName`、`regionLevel`。个人资料当前也没有可保存的地区字段,因此区划组件只能在有真实消费字段的页面启用提交。
|
||||
真实 Java `RegionSelectVo` 已明确 `regionCode`、`regionName`、`regionLevel` 等字段。个人资料当前仍没有可保存的地区字段,因此区划组件只提供级联、搜索和路径回显,不提交到资料更新接口。
|
||||
|
||||
### 7.6 家族圈
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
class="input"
|
||||
name="smsCode"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
pattern="\d{4}"
|
||||
placeholder="验证码"
|
||||
/>
|
||||
<button class="btn ghost" type="button" data-api-send-code>获取验证码</button>
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
class="input"
|
||||
name="smsCode"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
pattern="\d{4}"
|
||||
placeholder="短信验证码"
|
||||
/>
|
||||
<button class="btn ghost" type="button" data-api-send-code>
|
||||
|
||||
+6
-6
@@ -69,9 +69,8 @@
|
||||
</div>
|
||||
<div class="editor-two">
|
||||
<div class="editor-field">
|
||||
<label for="coverOssId">封面 OSS ID</label>
|
||||
<input id="coverOssId" name="coverOssId" type="text" placeholder="上传后填写 OSS ID" />
|
||||
<!-- 公共上传脚本会调用 /files/upload 并回填封面 OSS ID -->
|
||||
<label for="albumCoverFile">封面文件</label>
|
||||
<input id="coverOssId" name="coverOssId" type="hidden" />
|
||||
<label class="upload-control" for="albumCoverFile">选择封面图片</label>
|
||||
<input id="albumCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#coverOssId" data-upload-status="#albumCoverStatus" />
|
||||
<p id="albumCoverStatus" class="upload-status">未选择文件</p>
|
||||
@@ -99,9 +98,8 @@
|
||||
<form class="editor-form album-photo-form" data-album-photo-form>
|
||||
<div class="editor-two">
|
||||
<div class="editor-field">
|
||||
<label for="photoOssId">照片 OSS ID</label>
|
||||
<input id="photoOssId" name="ossId" type="text" placeholder="上传后填写 OSS ID" required />
|
||||
<!-- 公共上传脚本会调用 /files/upload 并回填照片 OSS ID -->
|
||||
<label for="albumPhotoFile">照片文件</label>
|
||||
<input id="photoOssId" name="ossId" type="hidden" required />
|
||||
<label class="upload-control" for="albumPhotoFile">选择照片</label>
|
||||
<input id="albumPhotoFile" class="upload-input" type="file" accept="image/*" data-upload-target="#photoOssId" data-upload-status="#albumPhotoStatus" />
|
||||
<p id="albumPhotoStatus" class="upload-status">未选择文件</p>
|
||||
@@ -134,6 +132,8 @@
|
||||
<script src="utils/axios.js"></script>
|
||||
<script src="utils/AxiosRequestUtil.js"></script>
|
||||
<script src="utils/ApiClient.js"></script>
|
||||
<script src="public/js/md5.js"></script>
|
||||
<script src="public/js/upload-pages.js"></script>
|
||||
<script src="public/js/pending-pages.js"></script>
|
||||
<script src="public/js/page-effects.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -93,9 +93,8 @@
|
||||
<textarea id="articleSummary" name="articleSummary" placeholder="请输入谱文摘要"></textarea>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label for="articleCoverOssId">封面 OSS ID</label>
|
||||
<input id="articleCoverOssId" name="coverOssId" type="text" placeholder="上传封面后自动回填" />
|
||||
<!-- 公共上传脚本会调用 /files/upload 并把 OSS ID 写入上方输入框 -->
|
||||
<label for="articleCoverFile">封面文件</label>
|
||||
<input id="articleCoverOssId" name="coverOssId" type="hidden" />
|
||||
<label class="upload-control" for="articleCoverFile">选择封面图片</label>
|
||||
<input id="articleCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#articleCoverOssId" data-upload-status="#articleCoverStatus" />
|
||||
<p id="articleCoverStatus" class="upload-status">未选择文件</p>
|
||||
@@ -124,6 +123,8 @@
|
||||
<script src="utils/axios.js"></script>
|
||||
<script src="utils/AxiosRequestUtil.js"></script>
|
||||
<script src="utils/ApiClient.js"></script>
|
||||
<script src="public/js/md5.js"></script>
|
||||
<script src="public/js/upload-pages.js"></script>
|
||||
<script src="public/js/pending-pages.js"></script>
|
||||
<script src="public/js/page-effects.js"></script>
|
||||
</body>
|
||||
|
||||
+34
-9
@@ -68,15 +68,29 @@
|
||||
id="profileNickName"
|
||||
name="nickName"
|
||||
type="text"
|
||||
maxlength="30"
|
||||
autocomplete="nickname"
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label for="profileRealName">真实姓名</label>
|
||||
<input
|
||||
id="profileRealName"
|
||||
name="realName"
|
||||
type="text"
|
||||
maxlength="30"
|
||||
autocomplete="name"
|
||||
placeholder="请输入真实姓名"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-two">
|
||||
<div class="editor-field">
|
||||
<label for="profileAvatarFile">头像</label>
|
||||
<input
|
||||
id="profileAvatarOssId"
|
||||
name="avatarOssId"
|
||||
id="profileAvatar"
|
||||
name="avatar"
|
||||
type="hidden"
|
||||
/>
|
||||
<label class="upload-control" for="profileAvatarFile">选择头像文件</label>
|
||||
@@ -85,13 +99,13 @@
|
||||
class="upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
data-upload-target="#profileAvatarOssId"
|
||||
data-upload-target="#profileAvatar"
|
||||
data-upload-status="#profileAvatarStatus"
|
||||
data-upload-mode="resumable"
|
||||
data-upload-biz-type="avatar"
|
||||
data-upload-usage-scene="profile_avatar"
|
||||
/>
|
||||
<p id="profileAvatarStatus" class="upload-status">当前 PC 文件上传响应未定义头像回填字段,暂不能上传头像</p>
|
||||
<p id="profileAvatarStatus" class="upload-status">请选择头像文件</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-two">
|
||||
@@ -101,6 +115,7 @@
|
||||
<option value="">请选择</option>
|
||||
<option value="0">男</option>
|
||||
<option value="1">女</option>
|
||||
<option value="2">未知</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
@@ -108,6 +123,17 @@
|
||||
<input id="profileBirthday" name="birthday" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label for="profileEmail">邮箱</label>
|
||||
<input
|
||||
id="profileEmail"
|
||||
name="email"
|
||||
type="email"
|
||||
maxlength="100"
|
||||
autocomplete="email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div class="bottom-actions profile-form-actions">
|
||||
<button class="btn primary magnetic" type="submit">保存资料</button>
|
||||
<span class="form-status" data-profile-status></span>
|
||||
@@ -164,8 +190,7 @@
|
||||
</section>
|
||||
<section class="module-panel">
|
||||
<h2>现居地区</h2>
|
||||
<!-- 地区资料表单:字段对应 ProfileUpdateBody 的省市区编码。 -->
|
||||
<form class="editor-form region-profile-form" data-region-profile-form>
|
||||
<div class="editor-form region-profile-form">
|
||||
<div class="region-picker profile-region-picker" data-region-picker>
|
||||
<select class="input" name="provinceCode" data-region-level="province">
|
||||
<option value="">请选择省</option>
|
||||
@@ -178,10 +203,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="bottom-actions">
|
||||
<button class="btn primary magnetic" type="submit">保存地区</button>
|
||||
<span class="form-status" data-region-profile-status></span>
|
||||
<button class="btn primary magnetic" type="button" disabled>暂不支持保存地区</button>
|
||||
<span class="form-status">当前 YAML 的 ProfileUpdateBody 没有地区字段</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form class="editor-form" data-region-search-form data-region-search-panel>
|
||||
<div class="editor-two">
|
||||
<div class="editor-field">
|
||||
|
||||
@@ -93,9 +93,8 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label for="giftCoverOssId">封面 OSS ID</label>
|
||||
<input id="giftCoverOssId" name="coverOssId" type="text" placeholder="上传封面后自动回填" />
|
||||
<!-- 当前 PC 文件初始化响应未展开,封面上传保持阻止。 -->
|
||||
<label for="giftCoverFile">封面文件</label>
|
||||
<input id="giftCoverOssId" name="coverOssId" type="hidden" />
|
||||
<label class="upload-control" for="giftCoverFile">选择封面图片</label>
|
||||
<input id="giftCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#giftCoverOssId" data-upload-status="#giftCoverStatus" />
|
||||
<p id="giftCoverStatus" class="upload-status">未选择文件</p>
|
||||
@@ -124,6 +123,8 @@
|
||||
<script src="utils/axios.js"></script>
|
||||
<script src="utils/AxiosRequestUtil.js"></script>
|
||||
<script src="utils/ApiClient.js"></script>
|
||||
<script src="public/js/md5.js"></script>
|
||||
<script src="public/js/upload-pages.js"></script>
|
||||
<script src="public/js/pending-pages.js"></script>
|
||||
<script src="public/js/page-effects.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -94,8 +94,11 @@
|
||||
</div>
|
||||
<div class="editor-two">
|
||||
<div class="editor-field">
|
||||
<label for="growthMediaOssIds">附件 OSS ID</label>
|
||||
<input id="growthMediaOssIds" name="mediaOssIds" type="text" placeholder="多个附件 ID 用英文逗号分隔" />
|
||||
<label for="growthMediaFiles">附件文件</label>
|
||||
<input id="growthMediaOssIds" name="mediaOssIds" type="hidden" />
|
||||
<label class="upload-control" for="growthMediaFiles">选择附件</label>
|
||||
<input id="growthMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#growthMediaOssIds" data-upload-status="#growthMediaStatus" />
|
||||
<p id="growthMediaStatus" class="upload-status">未选择文件</p>
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label for="growthSortOrder">排序值</label>
|
||||
@@ -131,6 +134,8 @@
|
||||
<script src="utils/axios.js"></script>
|
||||
<script src="utils/AxiosRequestUtil.js"></script>
|
||||
<script src="utils/ApiClient.js"></script>
|
||||
<script src="public/js/md5.js"></script>
|
||||
<script src="public/js/upload-pages.js"></script>
|
||||
<script src="public/js/growth-pages.js"></script>
|
||||
<script src="public/js/page-effects.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="editor-field"><label for="memoCompleted">是否已完成</label><input id="memoCompleted" name="completed" type="text" placeholder="可选;按后端定义填写" /></div>
|
||||
</div>
|
||||
<div class="editor-two">
|
||||
<div class="editor-field"><label for="memoMediaOssIds">附件 OSS ID</label><input id="memoMediaOssIds" name="mediaOssIds" type="text" placeholder="多个附件 ID 用英文逗号分隔" /></div>
|
||||
<div class="editor-field"><label for="memoMediaFiles">附件文件</label><input id="memoMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="memoMediaFiles">选择附件</label><input id="memoMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#memoMediaOssIds" data-upload-status="#memoMediaStatus" /><p id="memoMediaStatus" class="upload-status">未选择文件</p></div>
|
||||
<div class="editor-field"><label for="memoSortOrder">排序值</label><input id="memoSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" /></div>
|
||||
</div>
|
||||
<div class="editor-field"><label for="memoStatus">状态</label><input id="memoStatus" name="status" type="text" placeholder="可选;按后端定义填写" /></div>
|
||||
@@ -54,6 +54,8 @@
|
||||
<script src="utils/axios.js"></script>
|
||||
<script src="utils/AxiosRequestUtil.js"></script>
|
||||
<script src="utils/ApiClient.js"></script>
|
||||
<script src="public/js/md5.js"></script>
|
||||
<script src="public/js/upload-pages.js"></script>
|
||||
<script src="public/js/memo-pages.js"></script>
|
||||
<script src="public/js/page-effects.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<div class="editor-two"><div class="editor-field"><label for="eventName">事件名称</label><input id="eventName" name="eventName" type="text" placeholder="可选" /></div><div class="editor-field"><label for="eventTime">事件时间</label><input id="eventTime" name="eventTime" type="text" placeholder="可选;使用带时区的 ISO 8601 时间" /></div></div>
|
||||
<div class="editor-two"><div class="editor-field"><label for="giftAmount">礼金金额</label><input id="giftAmount" name="giftAmount" type="number" step="any" placeholder="可选" /></div><div class="editor-field"><label for="relativeSortOrder">排序值</label><input id="relativeSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" /></div></div>
|
||||
<div class="editor-field"><label for="relativeContent">记录内容</label><textarea id="relativeContent" name="recordContent" placeholder="可选"></textarea></div>
|
||||
<div class="editor-two"><div class="editor-field"><label for="relativeMediaOssIds">附件 OSS ID</label><input id="relativeMediaOssIds" name="mediaOssIds" type="text" placeholder="多个附件 ID 用英文逗号分隔" /></div><div class="editor-field"><label for="relativeStatus">状态</label><input id="relativeStatus" name="status" type="text" placeholder="可选;按后端定义填写" /></div></div>
|
||||
<div class="editor-two"><div class="editor-field"><label for="relativeMediaFiles">附件文件</label><input id="relativeMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="relativeMediaFiles">选择附件</label><input id="relativeMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#relativeMediaOssIds" data-upload-status="#relativeMediaStatus" /><p id="relativeMediaStatus" class="upload-status">未选择文件</p></div><div class="editor-field"><label for="relativeStatus">状态</label><input id="relativeStatus" name="status" type="text" placeholder="可选;按后端定义填写" /></div></div>
|
||||
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存</button><a class="btn ghost magnetic" href="profile-relative.html" data-genealogy-context-link>取消</a><span class="form-status" data-relative-form-status></span></div>
|
||||
</form>
|
||||
</section></div>
|
||||
</div></section>
|
||||
</main>
|
||||
<script src="public/js/jquery360.js"></script><script src="public/layui/layui.js"></script><script src="public/js/lay-config.js"></script><script src="public/js/profile-common.js?v=20260725-entry"></script><script src="config.js"></script><script src="utils/StorageUtil.js"></script><script src="utils/axios.js"></script><script src="utils/AxiosRequestUtil.js"></script><script src="utils/ApiClient.js"></script><script src="public/js/relative-pages.js"></script><script src="public/js/page-effects.js"></script>
|
||||
<script src="public/js/jquery360.js"></script><script src="public/layui/layui.js"></script><script src="public/js/lay-config.js"></script><script src="public/js/profile-common.js?v=20260725-entry"></script><script src="config.js"></script><script src="utils/StorageUtil.js"></script><script src="utils/axios.js"></script><script src="utils/AxiosRequestUtil.js"></script><script src="utils/ApiClient.js"></script><script src="public/js/md5.js"></script><script src="public/js/upload-pages.js"></script><script src="public/js/relative-pages.js"></script><script src="public/js/page-effects.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-2
@@ -88,7 +88,16 @@
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label for="phoneSmsCode">短信验证码</label>
|
||||
<input id="phoneSmsCode" name="smsCode" type="text" required />
|
||||
<input
|
||||
id="phoneSmsCode"
|
||||
name="smsCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
pattern="\d{4}"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<button class="btn ghost magnetic" type="button" data-security-send-code>获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +119,8 @@
|
||||
name="smsCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
maxlength="4"
|
||||
pattern="\d{4}"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
|
||||
+12
-3
@@ -151,6 +151,14 @@
|
||||
if (field) field.value = '';
|
||||
}
|
||||
|
||||
function takeCaptchaToken(form) {
|
||||
var field = query('input[name="validToken"]', form);
|
||||
var token = field && field.value || '';
|
||||
|
||||
if (field) field.value = '';
|
||||
return token;
|
||||
}
|
||||
|
||||
function startSmsCooldown(button) {
|
||||
var originalText;
|
||||
var remaining = SMS_CODE_COOLDOWN_SECONDS;
|
||||
@@ -202,7 +210,7 @@
|
||||
}
|
||||
|
||||
function isSmsCode(value) {
|
||||
return /^\d{4,6}$/.test(value || '');
|
||||
return /^\d{4}$/.test(value || '');
|
||||
}
|
||||
|
||||
function validatePassword(value, message) {
|
||||
@@ -315,6 +323,7 @@
|
||||
if (!validateBeforeSubmit('login', values)) return;
|
||||
if (!await ensureCaptcha(form, getFormCaptchaOperation(form, 'login'), values.phone)) return;
|
||||
values = readForm(form);
|
||||
values.validToken = takeCaptchaToken(form);
|
||||
setBusy(button, true);
|
||||
try {
|
||||
await api.login(buildLoginBody(values));
|
||||
@@ -384,6 +393,7 @@
|
||||
if (!await ensureCaptcha(form, operationCode, values.phone)) return;
|
||||
|
||||
values = readForm(form);
|
||||
values.validToken = takeCaptchaToken(form);
|
||||
|
||||
setBusy(button, true);
|
||||
try {
|
||||
@@ -391,12 +401,10 @@
|
||||
phone: values.phone,
|
||||
validToken: values.validToken || ''
|
||||
});
|
||||
clearCaptchaToken(form);
|
||||
showMessage('验证码已发送');
|
||||
setBusy(button, false);
|
||||
startSmsCooldown(button);
|
||||
} catch (error) {
|
||||
clearCaptchaToken(form);
|
||||
showMessage(error.message || '验证码发送失败');
|
||||
setBusy(button, false);
|
||||
}
|
||||
@@ -476,6 +484,7 @@
|
||||
buildRegisterBody: buildRegisterBody,
|
||||
buildPasswordResetBody: buildPasswordResetBody,
|
||||
clearCaptchaToken: clearCaptchaToken,
|
||||
takeCaptchaToken: takeCaptchaToken,
|
||||
startSmsCooldown: startSmsCooldown,
|
||||
getCaptchaOperation: getCaptchaOperation,
|
||||
getFormCaptchaOperation: getFormCaptchaOperation,
|
||||
|
||||
+21
-10
@@ -58,17 +58,28 @@
|
||||
function buildProfileUpdateBody(values) {
|
||||
// 字段名称严格对应 PC YAML 里的 ProfileUpdateBody。
|
||||
var source = values || {};
|
||||
var body = {};
|
||||
var avatar = trimOrUndefined(source.avatar);
|
||||
|
||||
return {
|
||||
nickName: trimOrUndefined(source.nickName),
|
||||
// int64 在浏览器中必须保留字符串,避免 Number 转换丢失精度。
|
||||
avatarOssId: trimOrUndefined(source.avatarOssId),
|
||||
sex: trimOrUndefined(source.sex),
|
||||
birthday: trimOrUndefined(source.birthday),
|
||||
provinceCode: trimOrUndefined(source.provinceCode),
|
||||
cityCode: trimOrUndefined(source.cityCode),
|
||||
districtCode: trimOrUndefined(source.districtCode)
|
||||
};
|
||||
[
|
||||
'nickName',
|
||||
'realName',
|
||||
'sex',
|
||||
'birthday',
|
||||
'email'
|
||||
].forEach(function (field) {
|
||||
var value = trimOrUndefined(source[field]);
|
||||
|
||||
if (value !== undefined) body[field] = value;
|
||||
});
|
||||
|
||||
if (avatar !== undefined) {
|
||||
if (!/^\d+$/.test(avatar)) throw new Error('头像 OSS ID 必须是整数');
|
||||
// YAML 将 avatar 标成 int64,但其示例已超过 JS 安全整数;浏览器按十进制字符串保留精度。
|
||||
body.avatar = avatar;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function getApi() {
|
||||
|
||||
@@ -81,22 +81,6 @@
|
||||
return source.districtCode || source.cityCode || source.provinceCode || '';
|
||||
}
|
||||
|
||||
function trimOrUndefined(value) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function buildProfileRegionBody(values) {
|
||||
var source = values || {};
|
||||
|
||||
return {
|
||||
provinceCode: trimOrUndefined(source.provinceCode),
|
||||
cityCode: trimOrUndefined(source.cityCode),
|
||||
districtCode: trimOrUndefined(source.districtCode)
|
||||
};
|
||||
}
|
||||
|
||||
function getApi() {
|
||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||
}
|
||||
@@ -354,49 +338,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfileRegion(form) {
|
||||
var api = getApi();
|
||||
var values = getFormValues(form);
|
||||
var body = buildProfileRegionBody(values);
|
||||
var status = query('[data-region-profile-status]', form);
|
||||
|
||||
if (redirectUnauthorized(api)) return;
|
||||
if (!body.provinceCode) {
|
||||
showMessage('请选择省份');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(form, true);
|
||||
if (status) status.textContent = '保存中...';
|
||||
await api.updateProfile(body);
|
||||
if (status) status.textContent = '已保存';
|
||||
showMessage('地区资料已保存');
|
||||
if (root.ProfilePages && root.ProfilePages.reloadProfile) await root.ProfilePages.reloadProfile();
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
if (status) status.textContent = '保存失败';
|
||||
showMessage(error.message || '地区资料保存失败');
|
||||
} finally {
|
||||
setSubmitting(form, false);
|
||||
}
|
||||
}
|
||||
|
||||
function setSubmitting(form, isSubmitting) {
|
||||
queryAll('button[type="submit"]', form).forEach(function (button) {
|
||||
button.disabled = isSubmitting;
|
||||
});
|
||||
}
|
||||
|
||||
function bindProfileForms() {
|
||||
queryAll('[data-region-profile-form]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
submitProfileRegion(form);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindSearchForms() {
|
||||
queryAll('[data-region-search-form]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
@@ -425,7 +366,6 @@
|
||||
queryAll('[data-region-picker]').forEach(function (picker) {
|
||||
initPicker(picker).catch(handlePickerError);
|
||||
});
|
||||
bindProfileForms();
|
||||
bindSearchForms();
|
||||
}
|
||||
|
||||
@@ -438,7 +378,6 @@
|
||||
buildPathText: buildPathText,
|
||||
buildRegionSelection: buildRegionSelection,
|
||||
getFinalRegionCode: getFinalRegionCode,
|
||||
buildProfileRegionBody: buildProfileRegionBody,
|
||||
applyRegionSelection: applyRegionSelection,
|
||||
applyProfileRegionSelection: applyProfileRegionSelection,
|
||||
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
}
|
||||
|
||||
function isSmsCode(value) {
|
||||
return /^\d{4,6}$/.test(String(value || '').trim());
|
||||
return /^\d{4}$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function getApi() {
|
||||
@@ -161,6 +161,14 @@
|
||||
if (field) field.value = '';
|
||||
}
|
||||
|
||||
function takeCaptchaToken(form) {
|
||||
var field = query('input[name="validToken"]', form);
|
||||
var token = field && field.value || '';
|
||||
|
||||
if (field) field.value = '';
|
||||
return token;
|
||||
}
|
||||
|
||||
function startSmsCooldown(button) {
|
||||
var originalText;
|
||||
var remaining = SMS_CODE_COOLDOWN_SECONDS;
|
||||
@@ -308,6 +316,7 @@
|
||||
}
|
||||
if (!await ensureCaptcha(form, 'phone', values.phone)) return;
|
||||
values = getFormValues(form);
|
||||
values.validToken = takeCaptchaToken(form);
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
@@ -315,12 +324,10 @@
|
||||
phone: values.phone,
|
||||
validToken: values.validToken || ''
|
||||
});
|
||||
clearCaptchaToken(form);
|
||||
showMessage('验证码已发送');
|
||||
startSmsCooldown(button);
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
clearCaptchaToken(form);
|
||||
showMessage(error.message || '验证码发送失败');
|
||||
button.disabled = false;
|
||||
}
|
||||
@@ -338,6 +345,7 @@
|
||||
}
|
||||
if (!await ensureCaptcha(form, 'deactivate', boundPhone)) return;
|
||||
values = getFormValues(form);
|
||||
values.validToken = takeCaptchaToken(form);
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
@@ -345,12 +353,10 @@
|
||||
phone: boundPhone,
|
||||
validToken: values.validToken || ''
|
||||
});
|
||||
clearCaptchaToken(form);
|
||||
showMessage('验证码已发送');
|
||||
startSmsCooldown(button);
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
clearCaptchaToken(form);
|
||||
showMessage(error.message || '验证码发送失败');
|
||||
button.disabled = false;
|
||||
}
|
||||
@@ -462,6 +468,7 @@
|
||||
isDangerConfirmed: isDangerConfirmed,
|
||||
isPhone: isPhone,
|
||||
isSmsCode: isSmsCode,
|
||||
takeCaptchaToken: takeCaptchaToken,
|
||||
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||
init: init
|
||||
};
|
||||
|
||||
+147
-11
@@ -14,6 +14,8 @@
|
||||
'use strict';
|
||||
|
||||
var documentRef = root.document;
|
||||
var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
var activeUploads = {};
|
||||
|
||||
function normalizeUploadResult(data) {
|
||||
var source = data || {};
|
||||
@@ -28,7 +30,7 @@
|
||||
}
|
||||
|
||||
function buildUploadStatus(fileName, ossId) {
|
||||
return (fileName || '文件') + ' 上传完成,OSS ID:' + ossId;
|
||||
return (fileName || '文件') + (ossId ? ' 上传完成' : ' 上传失败');
|
||||
}
|
||||
|
||||
function getUploadMode() {
|
||||
@@ -71,27 +73,159 @@
|
||||
if (root.alert) root.alert(message);
|
||||
}
|
||||
|
||||
function uploadFileForPage(api, file) {
|
||||
// 当前初始化响应未展开 uploadId、instant 与 OSS 字段,不能猜测分片闭环。
|
||||
if (!api || !file) return Promise.reject(new Error('请选择文件'));
|
||||
return Promise.reject(new Error('当前 PC 文件上传响应未定义头像回填字段,暂不能上传头像'));
|
||||
function createUploadId() {
|
||||
if (root.crypto && typeof root.crypto.randomUUID === 'function') {
|
||||
return 'pc-' + root.crypto.randomUUID();
|
||||
}
|
||||
return 'pc-' + Date.now() + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function arrayBufferToBinary(buffer) {
|
||||
var bytes = new Uint8Array(buffer);
|
||||
var parts = [];
|
||||
var offset;
|
||||
|
||||
for (offset = 0; offset < bytes.length; offset += 8192) {
|
||||
parts.push(String.fromCharCode.apply(null, bytes.subarray(offset, offset + 8192)));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
async function hashBlob(blob) {
|
||||
var buffer;
|
||||
|
||||
if (!blob || typeof blob.arrayBuffer !== 'function') throw new Error('当前浏览器无法读取文件');
|
||||
if (typeof root.hex_md5 !== 'function') throw new Error('缺少文件 MD5 组件');
|
||||
buffer = await blob.arrayBuffer();
|
||||
return root.hex_md5(arrayBufferToBinary(buffer));
|
||||
}
|
||||
|
||||
async function retry(operation, maxRetries) {
|
||||
var attempt = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= maxRetries) throw error;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function performResumableUpload(api, file, options) {
|
||||
var settings = options || {};
|
||||
var chunkSize = settings.chunkSize || DEFAULT_CHUNK_SIZE;
|
||||
var totalChunks = Math.ceil(file.size / chunkSize);
|
||||
var calculateHash = settings.hashBlob || hashBlob;
|
||||
var fileMd5 = await calculateHash(file);
|
||||
var initBody = {
|
||||
uploadId: settings.uploadId || createUploadId(),
|
||||
fileName: file.name,
|
||||
fileMd5: fileMd5,
|
||||
totalSize: file.size,
|
||||
totalChunks: totalChunks,
|
||||
chunkSize: chunkSize,
|
||||
contentType: file.type || 'application/octet-stream'
|
||||
};
|
||||
var initResult = await api.initResumableUpload(initBody);
|
||||
var uploadedChunks = Array.isArray(initResult && initResult.uploadedChunks)
|
||||
? initResult.uploadedChunks.slice()
|
||||
: [];
|
||||
var uploadedLookup = {};
|
||||
var serverUploadId;
|
||||
var chunkIndex;
|
||||
|
||||
if (initResult && initResult.instant) return normalizeUploadResult(initResult);
|
||||
serverUploadId = initResult && initResult.uploadId || initBody.uploadId;
|
||||
uploadedChunks.forEach(function (index) {
|
||||
uploadedLookup[Number(index)] = true;
|
||||
});
|
||||
if (settings.onProgress) {
|
||||
settings.onProgress({ uploadedChunks: uploadedChunks.length, totalChunks: totalChunks });
|
||||
}
|
||||
|
||||
for (chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
|
||||
if (uploadedLookup[chunkIndex]) continue;
|
||||
|
||||
await (async function (index) {
|
||||
var start = index * chunkSize;
|
||||
var chunk = file.slice(start, Math.min(start + chunkSize, file.size));
|
||||
var chunkMd5 = await calculateHash(chunk);
|
||||
|
||||
await retry(function () {
|
||||
return api.uploadResumableChunk({
|
||||
uploadId: serverUploadId,
|
||||
chunkIndex: index,
|
||||
chunkMd5: chunkMd5,
|
||||
file: chunk
|
||||
});
|
||||
}, settings.maxRetries === undefined ? 2 : settings.maxRetries);
|
||||
uploadedChunks.push(index);
|
||||
if (settings.onProgress) {
|
||||
settings.onProgress({ uploadedChunks: uploadedChunks.length, totalChunks: totalChunks });
|
||||
}
|
||||
})(chunkIndex);
|
||||
}
|
||||
|
||||
return normalizeUploadResult(await api.completeResumableUpload({
|
||||
uploadId: serverUploadId,
|
||||
fileName: file.name,
|
||||
fileMd5: fileMd5,
|
||||
totalSize: file.size,
|
||||
totalChunks: totalChunks
|
||||
}));
|
||||
}
|
||||
|
||||
function uploadFileForPage(api, file, options) {
|
||||
var key;
|
||||
var promise;
|
||||
|
||||
if (!api || !file || !file.name || !Number(file.size)) return Promise.reject(new Error('请选择文件'));
|
||||
key = [file.name, file.size, file.lastModified || 0].join(':');
|
||||
if (activeUploads[key]) return activeUploads[key];
|
||||
|
||||
promise = performResumableUpload(api, file, options).finally(function () {
|
||||
delete activeUploads[key];
|
||||
});
|
||||
activeUploads[key] = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
function mergeUploadTargetValue(currentValue, ossId, multiple) {
|
||||
var current = String(currentValue || '').trim();
|
||||
var next = String(ossId || '').trim();
|
||||
|
||||
if (!multiple || !current) return next;
|
||||
return current.split(',').concat(next).filter(Boolean).join(',');
|
||||
}
|
||||
|
||||
async function uploadFromInput(input) {
|
||||
var api = getApi();
|
||||
var file = input.files && input.files[0];
|
||||
var files = Array.prototype.slice.call(input.files || []);
|
||||
var target = query(input.getAttribute('data-upload-target'));
|
||||
var status = query(input.getAttribute('data-upload-status'));
|
||||
var multiple = input.getAttribute('data-upload-multiple') === 'true';
|
||||
var result;
|
||||
var index;
|
||||
|
||||
if (!file || !target || redirectUnauthorized(api)) return;
|
||||
if (!files.length || !target || redirectUnauthorized(api)) return;
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
result = normalizeUploadResult(await uploadFileForPage(api, file));
|
||||
if (!result.ossId) throw new Error('上传响应缺少 ossId');
|
||||
target.value = result.ossId;
|
||||
if (status) status.textContent = buildUploadStatus(result.fileName || file.name, result.ossId);
|
||||
for (index = 0; index < files.length; index += 1) {
|
||||
if (status) status.textContent = files[index].name + ' 正在上传...';
|
||||
result = normalizeUploadResult(await uploadFileForPage(api, files[index], {
|
||||
onProgress: function (progress) {
|
||||
if (status) {
|
||||
status.textContent = '正在上传 ' + progress.uploadedChunks + '/' + progress.totalChunks + ' 分片';
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (!result.ossId) throw new Error('上传响应缺少 ossId');
|
||||
target.value = mergeUploadTargetValue(target.value, result.ossId, multiple);
|
||||
if (status) status.textContent = buildUploadStatus(result.fileName || files[index].name, result.ossId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
if (status) status.textContent = error.message || '上传失败';
|
||||
@@ -121,7 +255,9 @@
|
||||
normalizeUploadResult: normalizeUploadResult,
|
||||
buildUploadStatus: buildUploadStatus,
|
||||
getUploadMode: getUploadMode,
|
||||
hashBlob: hashBlob,
|
||||
uploadFileForPage: uploadFileForPage,
|
||||
mergeUploadTargetValue: mergeUploadTargetValue,
|
||||
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||
init: init
|
||||
};
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@
|
||||
name="smsCode"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
maxlength="4"
|
||||
pattern="\d{4}"
|
||||
placeholder="短信验证码"
|
||||
/>
|
||||
<button class="btn ghost" type="button" data-api-send-code>
|
||||
|
||||
@@ -49,7 +49,7 @@ test('API client exposes latest document-defined PC operations', () => {
|
||||
assert.deepEqual(Object.keys(client).sort(), allowed);
|
||||
});
|
||||
|
||||
test('password login stores only the returned token', async () => {
|
||||
test('password login stores access_token from the real AppLoginVo response', async () => {
|
||||
let stored = '';
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
@@ -63,7 +63,38 @@ test('password login stores only the returned token', async () => {
|
||||
assert.equal(config.method, 'post');
|
||||
assert.equal(config.url, '/genealogy/pc/auth/login');
|
||||
assert.equal(config.headers.Authorization, undefined);
|
||||
return Promise.resolve({ data: { code: 200, data: { accessToken: 'access-token' } } });
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
data: {
|
||||
clientKey: 'web_pc',
|
||||
deviceType: 'pc',
|
||||
userType: 'app_user',
|
||||
profile: {
|
||||
userId: '2062179707935264769',
|
||||
tenantId: '000000',
|
||||
userNo: 'U2062179707910225920',
|
||||
phone: '19181970173',
|
||||
nickName: '叶子',
|
||||
realName: '',
|
||||
avatar: null,
|
||||
sex: '2',
|
||||
birthday: null,
|
||||
email: '',
|
||||
registerSource: 'h5',
|
||||
loginIp: '112.45.165.24',
|
||||
loginDate: '2026-07-28 11:06:09',
|
||||
status: '0',
|
||||
clientKey: 'web_pc',
|
||||
deviceType: 'pc'
|
||||
},
|
||||
access_token: 'access-token',
|
||||
expire_in: 604800,
|
||||
client_id: 'ced7e5f0498645c6ec642dcf450b036f'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -73,6 +104,95 @@ test('password login stores only the returned token', async () => {
|
||||
assert.equal(client.getToken(), 'access-token');
|
||||
});
|
||||
|
||||
test('authentication client owns tenant and grant fields and drops fields outside YAML DTOs', async () => {
|
||||
const calls = [];
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tenantId: 'tenant-from-config',
|
||||
tokenStore: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||
axiosInstance: {
|
||||
request(config) {
|
||||
calls.push(config);
|
||||
if (config.url.endsWith('/login')) {
|
||||
return Promise.resolve({ data: { code: 200, data: { access_token: 'login-token' } } });
|
||||
}
|
||||
return Promise.resolve({ data: { code: 200, data: {} } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await client.register({
|
||||
grantType: 'legacy',
|
||||
tenantId: 'caller-tenant',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
smsCode: '1234',
|
||||
nickName: '小李',
|
||||
registerSource: 'APP',
|
||||
clientId: 'legacy-client',
|
||||
sceneCode: 'legacy-scene',
|
||||
validToken: 'legacy-ticket'
|
||||
});
|
||||
await client.login({
|
||||
grantType: 'legacy',
|
||||
tenantId: 'caller-tenant',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
validToken: 'captcha-ticket',
|
||||
clientId: 'legacy-client',
|
||||
sceneCode: 'legacy-scene'
|
||||
});
|
||||
|
||||
assert.deepEqual(calls[0].data, {
|
||||
grantType: 'password',
|
||||
tenantId: 'tenant-from-config',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
nickName: '小李',
|
||||
registerSource: 'PC',
|
||||
smsCode: '1234'
|
||||
});
|
||||
assert.deepEqual(calls[1].data, {
|
||||
grantType: 'password',
|
||||
tenantId: 'tenant-from-config',
|
||||
phone: '13800000000',
|
||||
password: 'password-md5',
|
||||
validToken: 'captcha-ticket'
|
||||
});
|
||||
});
|
||||
|
||||
test('authentication operationCode rejects values outside the YAML enum', async () => {
|
||||
const client = createClient();
|
||||
|
||||
await assert.rejects(
|
||||
async () => client.captchaRequirement('legacy-login', { subject: '13800000000' }),
|
||||
/不支持的PC 认证动作/
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => client.sendSmsCode('password-login', { phone: '13800000000' }),
|
||||
/不支持的PC 短信认证动作/
|
||||
);
|
||||
});
|
||||
|
||||
test('login rejects legacy token aliases outside AppLoginVo', async () => {
|
||||
for (const legacyField of ['token', 'accessToken', 'tokenValue']) {
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tokenStore: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.resolve({ data: { code: 200, data: { [legacyField]: 'legacy-token' } } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.login({ phone: '13800000000', password: 'password-md5' }),
|
||||
/登录响应缺少 token/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('sending an SMS code uses the PC operation path and documented request body', async () => {
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
@@ -192,11 +312,11 @@ test('registration and SMS login use the PC auth endpoints with SMS fields', asy
|
||||
|
||||
await client.register({
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
smsCode: '1234',
|
||||
nickName: '小李',
|
||||
password: 'password-md5'
|
||||
});
|
||||
await client.loginBySms({ phone: '13800000000', smsCode: '123456' });
|
||||
await client.loginBySms({ phone: '13800000000', smsCode: '1234' });
|
||||
|
||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||
['post', '/genealogy/pc/auth/register', {
|
||||
@@ -204,7 +324,7 @@ test('registration and SMS login use the PC auth endpoints with SMS fields', asy
|
||||
registerSource: 'PC',
|
||||
tenantId: '000000',
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
smsCode: '1234',
|
||||
nickName: '小李',
|
||||
password: 'password-md5'
|
||||
}],
|
||||
@@ -212,7 +332,7 @@ test('registration and SMS login use the PC auth endpoints with SMS fields', asy
|
||||
grantType: 'sms',
|
||||
tenantId: '000000',
|
||||
phone: '13800000000',
|
||||
smsCode: '123456'
|
||||
smsCode: '1234'
|
||||
}]
|
||||
]);
|
||||
calls.forEach((config) => {
|
||||
@@ -282,10 +402,10 @@ test('region methods use PC paths and retain the logged-in authorization header'
|
||||
config.params,
|
||||
config.headers.Authorization
|
||||
]), [
|
||||
['get', '/genealogy/region/children', { parentCode: '0' }, 'Bearer access-token'],
|
||||
['get', '/genealogy/region/path/11', undefined, 'Bearer access-token'],
|
||||
['get', '/genealogy/region/search', { keyword: '北京', limit: 20 }, 'Bearer access-token'],
|
||||
['get', '/genealogy/region/110101', undefined, 'Bearer access-token']
|
||||
['get', '/genealogy/pc/region/children', { parentCode: '0' }, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/region/path/11', undefined, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/region/search', { keyword: '北京', limit: 20 }, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/region/110101', undefined, 'Bearer access-token']
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -304,14 +424,13 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
const chunk = new Blob(['part'], { type: 'application/octet-stream' });
|
||||
|
||||
await client.initResumableUpload({
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileName: 'video.mp4',
|
||||
fileSize: 8388608,
|
||||
fileMd5: 'file-md5',
|
||||
chunkSize: 4194304,
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2,
|
||||
contentType: 'video/mp4',
|
||||
bizType: 'video',
|
||||
usageScene: 'family_video'
|
||||
chunkSize: 4194304,
|
||||
contentType: 'video/mp4'
|
||||
});
|
||||
await client.uploadResumableChunk({
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
@@ -321,8 +440,10 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
});
|
||||
await client.completeResumableUpload({
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileMd5: 'file-md5',
|
||||
fileSize: 8388608
|
||||
fileName: 'video.mp4',
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2
|
||||
});
|
||||
assert.deepEqual(calls.map((config) => [
|
||||
config.method,
|
||||
@@ -335,14 +456,13 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
['post', '/genealogy/pc/files/resumable/complete', undefined, 'Bearer access-token']
|
||||
]);
|
||||
assert.deepEqual(calls[0].data, {
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileName: 'video.mp4',
|
||||
fileSize: 8388608,
|
||||
fileMd5: 'file-md5',
|
||||
chunkSize: 4194304,
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2,
|
||||
contentType: 'video/mp4',
|
||||
bizType: 'video',
|
||||
usageScene: 'family_video'
|
||||
chunkSize: 4194304,
|
||||
contentType: 'video/mp4'
|
||||
});
|
||||
assert.equal(calls[1].data.get('uploadId'), 'UPLOAD202607240001');
|
||||
assert.equal(calls[1].data.get('chunkIndex'), '0');
|
||||
@@ -350,8 +470,10 @@ test('current PC file methods use the three documented resumable paths and reque
|
||||
assert.equal(calls[1].data.get('file').name, 'blob');
|
||||
assert.deepEqual(calls[2].data, {
|
||||
uploadId: 'UPLOAD202607240001',
|
||||
fileMd5: 'file-md5',
|
||||
fileSize: 8388608
|
||||
fileName: 'video.mp4',
|
||||
fileMd5: '0123456789abcdef0123456789abcdef',
|
||||
totalSize: 8388608,
|
||||
totalChunks: 2
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test('authentication forms use the PC verification operation codes defined by Ap
|
||||
test('registration validates SMS code and password confirmation before submission', () => {
|
||||
const base = {
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
smsCode: '1234',
|
||||
password: 'password',
|
||||
confirmPassword: 'password'
|
||||
};
|
||||
@@ -79,6 +79,19 @@ test('registration validates SMS code and password confirmation before submissio
|
||||
assert.equal(AuthPages.validateAuthValues('register', { ...base, confirmPassword: 'different' }), '两次输入的密码不一致');
|
||||
});
|
||||
|
||||
test('authentication forms reject SMS codes that are not exactly four digits', () => {
|
||||
const smsLogin = { phone: '13800000000', smsCode: '123456' };
|
||||
const register = {
|
||||
phone: '13800000000',
|
||||
smsCode: '123456',
|
||||
password: 'password',
|
||||
confirmPassword: 'password'
|
||||
};
|
||||
|
||||
assert.equal(AuthPages.validateAuthValues('sms-login', smsLogin), '请输入正确的短信验证码');
|
||||
assert.equal(AuthPages.validateAuthValues('register', register), '请输入正确的短信验证码');
|
||||
});
|
||||
|
||||
test('registration page exposes the documented SMS and confirmation inputs', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'register.html'), 'utf8');
|
||||
|
||||
@@ -92,3 +105,16 @@ test('password login page retains a verification token field for the PC captcha
|
||||
|
||||
assert.match(source, /id="login-password-form"[\s\S]*name="validToken"/);
|
||||
});
|
||||
|
||||
test('authentication captcha ticket is removed from the form when consumed', () => {
|
||||
const field = { value: 'one-time-ticket' };
|
||||
const form = {
|
||||
querySelector(selector) {
|
||||
return selector === 'input[name="validToken"]' ? field : null;
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(AuthPages.takeCaptchaToken(form), 'one-time-ticket');
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(AuthPages.takeCaptchaToken(form), '');
|
||||
});
|
||||
|
||||
@@ -18,12 +18,19 @@ test('配置始终使用后端提供的 PC 接口地址', () => {
|
||||
assert.equal(production.getEnvironment(), 'production');
|
||||
assert.equal(development.getApiBaseUrl(), 'https://backend-api.ddxcjp.cn/');
|
||||
assert.equal(production.getApiBaseUrl(), 'https://backend-api.ddxcjp.cn/');
|
||||
assert.deepEqual(production.getConfig(), {
|
||||
environment: 'production',
|
||||
apiBaseUrl: 'https://backend-api.ddxcjp.cn/',
|
||||
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
||||
tenantId: '000000',
|
||||
tokenKey: 'genealogy_auth_token'
|
||||
});
|
||||
});
|
||||
|
||||
test('PC 对接规划明确以 Apifox 目录而非导出快照为契约源', () => {
|
||||
test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
|
||||
const plan = read('docs/PC接口对接规划.md');
|
||||
|
||||
assert.match(plan, /Apifox 的 PC 目录是 PC 前端接口的唯一正式契约源/);
|
||||
assert.match(plan, /genealogy-pc-openapi\.yaml` 对接,因此该 YAML 是本轮冻结契约/);
|
||||
assert.match(plan, /旧 OpenAPI 文件不再作为新增功能依据/);
|
||||
});
|
||||
|
||||
|
||||
+25
-14
@@ -38,25 +38,28 @@ test('profile view reads only the documented profile fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile update preserves int64 OSS IDs as text', () => {
|
||||
test('profile update emits only the six fields defined by ProfileUpdateBody', () => {
|
||||
assert.deepEqual(ProfilePages.buildProfileUpdateBody({
|
||||
nickName: '小林',
|
||||
avatarOssId: '2060000000000000000',
|
||||
realName: '林某',
|
||||
avatar: '',
|
||||
sex: '0',
|
||||
birthday: '1990-01-01',
|
||||
provinceCode: '11',
|
||||
cityCode: '1101',
|
||||
districtCode: '110101'
|
||||
email: 'lin@example.com',
|
||||
avatarOssId: 'legacy-avatar',
|
||||
provinceCode: '11'
|
||||
}), {
|
||||
nickName: '小林',
|
||||
avatarOssId: '2060000000000000000',
|
||||
realName: '林某',
|
||||
sex: '0',
|
||||
birthday: '1990-01-01',
|
||||
provinceCode: '11',
|
||||
cityCode: '1101',
|
||||
districtCode: '110101'
|
||||
email: 'lin@example.com'
|
||||
});
|
||||
assert.equal(typeof ProfilePages.buildProfileUpdateBody({ avatarOssId: '2060000000000000000' }).avatarOssId, 'string');
|
||||
assert.deepEqual(ProfilePages.buildProfileUpdateBody({ avatar: '123' }), { avatar: '123' });
|
||||
assert.deepEqual(
|
||||
ProfilePages.buildProfileUpdateBody({ avatar: '2060000000000000001' }),
|
||||
{ avatar: '2060000000000000001' }
|
||||
);
|
||||
assert.equal(UploadPages.normalizeUploadResult({ ossId: '2060000000000000000' }).ossId, '2060000000000000000');
|
||||
});
|
||||
|
||||
@@ -77,13 +80,21 @@ test('region selector accepts only documented region fields and levels', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile data page contains PC profile, upload and region flow markers', () => {
|
||||
test('profile data page exposes every editable ProfileUpdateBody field without a manual OSS ID input', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-data.html'), 'utf8');
|
||||
|
||||
assert.match(source, /data-profile-form/);
|
||||
assert.match(source, /name="avatarOssId"/);
|
||||
assert.match(source, /data-region-profile-form/);
|
||||
assert.match(source, /data-region-profile-status/);
|
||||
assert.match(source, /name="nickName"/);
|
||||
assert.match(source, /name="realName"/);
|
||||
assert.match(source, /name="avatar"/);
|
||||
assert.match(source, /name="sex"[\s\S]*<option value="2">未知<\/option>/);
|
||||
assert.match(source, /name="birthday"/);
|
||||
assert.match(source, /name="email"/);
|
||||
assert.doesNotMatch(source, /name="avatarOssId"/);
|
||||
assert.match(source, /name="avatar"[\s\S]*type="hidden"|type="hidden"[\s\S]*name="avatar"/);
|
||||
assert.doesNotMatch(source, /data-region-profile-form/);
|
||||
assert.doesNotMatch(source, /data-region-profile-status/);
|
||||
assert.match(source, /data-region-search-form/);
|
||||
assert.match(source, /src="public\/js\/upload-pages\.js"/);
|
||||
assert.match(source, /src="public\/js\/region-pages\.js"/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const GenealogyApi = require('../utils/ApiClient.js');
|
||||
|
||||
function createFailingClient(status) {
|
||||
let storedToken = 'access-token';
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tokenStore: {
|
||||
getItem() {
|
||||
return storedToken;
|
||||
},
|
||||
setItem(_, value) {
|
||||
storedToken = value;
|
||||
},
|
||||
removeItem() {
|
||||
storedToken = '';
|
||||
}
|
||||
},
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.reject({
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
status,
|
||||
data: {
|
||||
code: status,
|
||||
msg: status === 401 ? '登录已失效' : '无权访问'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
test('HTTP 401 clears the stored login token', async () => {
|
||||
const client = createFailingClient(401);
|
||||
|
||||
await assert.rejects(client.currentProfile(), (error) => {
|
||||
assert.equal(error.status, 401);
|
||||
assert.equal(error.message, '登录已失效');
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(client.getToken(), '');
|
||||
});
|
||||
|
||||
test('HTTP 403 preserves the stored login token', async () => {
|
||||
const client = createFailingClient(403);
|
||||
|
||||
await assert.rejects(client.currentProfile(), (error) => {
|
||||
assert.equal(error.status, 403);
|
||||
assert.equal(error.message, '无权访问');
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(client.getToken(), 'access-token');
|
||||
});
|
||||
|
||||
test('business response code 401 clears the stored login token', async () => {
|
||||
let storedToken = 'access-token';
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
tokenStore: {
|
||||
getItem() {
|
||||
return storedToken;
|
||||
},
|
||||
setItem(_, value) {
|
||||
storedToken = value;
|
||||
},
|
||||
removeItem() {
|
||||
storedToken = '';
|
||||
}
|
||||
},
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
code: 401,
|
||||
msg: '登录已失效'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(client.currentProfile(), (error) => {
|
||||
assert.equal(error.code, 401);
|
||||
assert.equal(error.message, '登录已失效');
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.equal(client.getToken(), '');
|
||||
});
|
||||
@@ -34,14 +34,151 @@ test('security forms use PC verification operation codes and submit only documen
|
||||
assert.deepEqual(SecurityPages.buildDeactivateBody({ smsCode: '654321' }), { smsCode: '654321' });
|
||||
});
|
||||
|
||||
test('upload helper keeps the avatar flow blocked until the current PC resumable response is defined', async () => {
|
||||
assert.equal(UploadPages.getUploadMode(5 * 1024 * 1024), 'resumable');
|
||||
await assert.rejects(
|
||||
UploadPages.uploadFileForPage({ initResumableUpload() {} }, { name: 'avatar.png' }),
|
||||
/当前 PC 文件上传响应未定义头像回填字段/
|
||||
test('security forms accept only the four-digit SMS code defined by YAML', () => {
|
||||
assert.equal(SecurityPages.isSmsCode('1234'), true);
|
||||
assert.equal(SecurityPages.isSmsCode('123456'), false);
|
||||
});
|
||||
|
||||
test('security captcha ticket is removed from the form when consumed', () => {
|
||||
const field = { value: 'one-time-ticket' };
|
||||
const form = {
|
||||
querySelector(selector) {
|
||||
return selector === 'input[name="validToken"]' ? field : null;
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(SecurityPages.takeCaptchaToken(form), 'one-time-ticket');
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(SecurityPages.takeCaptchaToken(form), '');
|
||||
});
|
||||
|
||||
test('resumable upload returns the instant-upload OSS result without sending chunks', async () => {
|
||||
let chunkCalls = 0;
|
||||
const file = { name: 'avatar.png', size: 8, type: 'image/png', lastModified: 1 };
|
||||
const api = {
|
||||
async initResumableUpload(body) {
|
||||
assert.deepEqual(body, {
|
||||
uploadId: 'upload-fixed',
|
||||
fileName: 'avatar.png',
|
||||
fileMd5: 'file-md5',
|
||||
totalSize: 8,
|
||||
totalChunks: 2,
|
||||
chunkSize: 4,
|
||||
contentType: 'image/png'
|
||||
});
|
||||
return { instant: true, ossId: '2062179707935264769', url: '/avatar.png', fileName: 'avatar.png', uploadedChunks: [] };
|
||||
},
|
||||
async uploadResumableChunk() { chunkCalls += 1; },
|
||||
async completeResumableUpload() { throw new Error('秒传不应调用完成接口'); }
|
||||
};
|
||||
|
||||
const result = await UploadPages.uploadFileForPage(api, file, {
|
||||
uploadId: 'upload-fixed',
|
||||
chunkSize: 4,
|
||||
hashBlob: async () => 'file-md5'
|
||||
});
|
||||
|
||||
assert.equal(result.ossId, '2062179707935264769');
|
||||
assert.equal(chunkCalls, 0);
|
||||
});
|
||||
|
||||
test('resumable upload skips uploaded chunks, retries failures and completes with the init contract', async () => {
|
||||
const uploadedIndexes = [];
|
||||
const progress = [];
|
||||
let secondChunkAttempts = 0;
|
||||
const file = {
|
||||
name: 'video.mp4',
|
||||
size: 10,
|
||||
type: 'video/mp4',
|
||||
lastModified: 2,
|
||||
slice(start, end) {
|
||||
return { size: end - start, start: start };
|
||||
}
|
||||
};
|
||||
const api = {
|
||||
async initResumableUpload() {
|
||||
return { uploadId: 'server-upload', instant: false, uploadedChunks: [0] };
|
||||
},
|
||||
async uploadResumableChunk(body) {
|
||||
uploadedIndexes.push(body.chunkIndex);
|
||||
if (body.chunkIndex === 1 && secondChunkAttempts++ === 0) throw new Error('temporary');
|
||||
},
|
||||
async completeResumableUpload(body) {
|
||||
assert.deepEqual(body, {
|
||||
uploadId: 'server-upload',
|
||||
fileName: 'video.mp4',
|
||||
fileMd5: 'hash-full',
|
||||
totalSize: 10,
|
||||
totalChunks: 3
|
||||
});
|
||||
return { ossId: '2062179707935264770', fileName: 'video.mp4', url: '/video.mp4' };
|
||||
}
|
||||
};
|
||||
|
||||
const result = await UploadPages.uploadFileForPage(api, file, {
|
||||
uploadId: 'client-upload',
|
||||
chunkSize: 4,
|
||||
maxRetries: 1,
|
||||
hashBlob: async (blob) => blob === file ? 'hash-full' : 'hash-' + blob.start,
|
||||
onProgress: (value) => progress.push(value.uploadedChunks)
|
||||
});
|
||||
|
||||
assert.deepEqual(uploadedIndexes, [1, 1, 2]);
|
||||
assert.deepEqual(progress, [1, 2, 3]);
|
||||
assert.equal(result.ossId, '2062179707935264770');
|
||||
});
|
||||
|
||||
test('upload target values preserve string IDs and append multiple attachments', () => {
|
||||
assert.equal(UploadPages.mergeUploadTargetValue('', '2062179707935264769', false), '2062179707935264769');
|
||||
assert.equal(
|
||||
UploadPages.mergeUploadTargetValue('2062179707935264769', '2062179707935264770', true),
|
||||
'2062179707935264769,2062179707935264770'
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate file submissions share one active upload', async () => {
|
||||
let initCalls = 0;
|
||||
let releaseInit;
|
||||
const pendingInit = new Promise((resolve) => { releaseInit = resolve; });
|
||||
const file = { name: 'same.png', size: 4, type: 'image/png', lastModified: 3 };
|
||||
const api = {
|
||||
async initResumableUpload() {
|
||||
initCalls += 1;
|
||||
await pendingInit;
|
||||
return { instant: true, ossId: '2062179707935264771', uploadedChunks: [] };
|
||||
}
|
||||
};
|
||||
const options = { uploadId: 'same-upload', chunkSize: 4, hashBlob: async () => 'same-hash' };
|
||||
const first = UploadPages.uploadFileForPage(api, file, options);
|
||||
const second = UploadPages.uploadFileForPage(api, file, options);
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
releaseInit();
|
||||
await first;
|
||||
assert.equal(initCalls, 1);
|
||||
});
|
||||
|
||||
test('business upload pages never expose editable OSS ID fields', () => {
|
||||
const pages = [
|
||||
'profile-data.html',
|
||||
'profile-article-edit.html',
|
||||
'profile-album.html',
|
||||
'profile-gift-edit.html',
|
||||
'profile-growth-edit.html',
|
||||
'profile-memo-edit.html',
|
||||
'profile-relative-edit.html'
|
||||
];
|
||||
|
||||
pages.forEach((file) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /name="(?:avatar|coverOssId|ossId|mediaOssIds)"[^>]*type="text"/);
|
||||
assert.match(source, /data-upload-target=/);
|
||||
assert.match(source, /public\/js\/md5\.js/);
|
||||
assert.match(source, /public\/js\/upload-pages\.js/);
|
||||
});
|
||||
});
|
||||
|
||||
test('security page field names match phone-change and account-deactivation DTOs', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-security.html'), 'utf8');
|
||||
|
||||
|
||||
+90
-19
@@ -11,6 +11,21 @@
|
||||
var DEFAULT_CLIENT_ID = 'ced7e5f0498645c6ec642dcf450b036f';
|
||||
var DEFAULT_TENANT_ID = '000000';
|
||||
var DEFAULT_TOKEN_KEY = 'genealogy_auth_token';
|
||||
var VERIFICATION_OPERATION_CODES = [
|
||||
'password-login',
|
||||
'sms-login',
|
||||
'register',
|
||||
'forgot-password',
|
||||
'phone-change',
|
||||
'account-deactivate'
|
||||
];
|
||||
var SMS_OPERATION_CODES = [
|
||||
'sms-login',
|
||||
'register',
|
||||
'forgot-password',
|
||||
'phone-change',
|
||||
'account-deactivate'
|
||||
];
|
||||
|
||||
function loadNodeModule(path) {
|
||||
if (typeof require !== 'function') return null;
|
||||
@@ -49,7 +64,17 @@
|
||||
}
|
||||
|
||||
function getTokenFromResponse(data) {
|
||||
return data && (data.token || data.accessToken || data.access_token || data.tokenValue) || '';
|
||||
return data && data.access_token || '';
|
||||
}
|
||||
|
||||
function pickDefined(source, allowedFields) {
|
||||
var input = source || {};
|
||||
var result = {};
|
||||
|
||||
allowedFields.forEach(function (field) {
|
||||
if (input[field] !== undefined) result[field] = input[field];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function createChunkFormData(bodyOrFormData) {
|
||||
@@ -112,6 +137,7 @@
|
||||
baseUrl: baseUrl,
|
||||
clientId: clientId,
|
||||
getToken: getToken,
|
||||
onUnauthorized: clearToken,
|
||||
axiosInstance: settings.axiosInstance || root.axios
|
||||
});
|
||||
|
||||
@@ -121,15 +147,19 @@
|
||||
}
|
||||
|
||||
function withTenant(body) {
|
||||
return Object.assign({ tenantId: tenantId }, body || {});
|
||||
return Object.assign({}, body || {}, { tenantId: tenantId });
|
||||
}
|
||||
|
||||
function buildVerificationPath(operationCode, suffix) {
|
||||
return '/genealogy/pc/auth/verification/' + toRequiredPathId(operationCode, 'PC 认证动作') + (suffix || '');
|
||||
return '/genealogy/pc/auth/verification/' +
|
||||
toRequiredOperationCode(operationCode, 'PC 认证动作', VERIFICATION_OPERATION_CODES) +
|
||||
(suffix || '');
|
||||
}
|
||||
|
||||
function buildSmsCodePath(operationCode) {
|
||||
return '/genealogy/pc/auth/sms/' + toRequiredPathId(operationCode, 'PC 短信认证动作') + '/code';
|
||||
return '/genealogy/pc/auth/sms/' +
|
||||
toRequiredOperationCode(operationCode, 'PC 短信认证动作', SMS_OPERATION_CODES) +
|
||||
'/code';
|
||||
}
|
||||
|
||||
function toRequiredPathId(value, label) {
|
||||
@@ -139,6 +169,14 @@
|
||||
return encodeURIComponent(text);
|
||||
}
|
||||
|
||||
function toRequiredOperationCode(value, label, allowedValues) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||
|
||||
if (!text) throw new Error('缺少' + label);
|
||||
if (allowedValues.indexOf(text) === -1) throw new Error('不支持的' + label + ':' + text);
|
||||
return encodeURIComponent(text);
|
||||
}
|
||||
|
||||
function buildFeedPath(genealogyId, suffix) {
|
||||
// 家族圈接口统一由家谱编号定位,避免页面拼接出未定义的请求路径。
|
||||
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/feeds' + (suffix || '');
|
||||
@@ -235,7 +273,10 @@
|
||||
async function login(body) {
|
||||
var data = await request('POST', '/genealogy/pc/auth/login', {
|
||||
auth: false,
|
||||
body: Object.assign({ grantType: 'password' }, withTenant(body))
|
||||
body: Object.assign(
|
||||
pickDefined(body, ['phone', 'password', 'validToken']),
|
||||
{ grantType: 'password', tenantId: tenantId }
|
||||
)
|
||||
});
|
||||
var token = getTokenFromResponse(data);
|
||||
|
||||
@@ -247,7 +288,10 @@
|
||||
async function loginBySms(body) {
|
||||
var data = await request('POST', '/genealogy/pc/auth/login/sms', {
|
||||
auth: false,
|
||||
body: Object.assign({ grantType: 'sms' }, withTenant(body))
|
||||
body: Object.assign(
|
||||
pickDefined(body, ['phone', 'smsCode']),
|
||||
{ grantType: 'sms', tenantId: tenantId }
|
||||
)
|
||||
});
|
||||
var token = getTokenFromResponse(data);
|
||||
|
||||
@@ -258,7 +302,7 @@
|
||||
|
||||
async function deactivateAccount(body) {
|
||||
var data = await request('POST', '/genealogy/pc/auth/account/deactivate', {
|
||||
body: body
|
||||
body: pickDefined(body, ['smsCode'])
|
||||
});
|
||||
clearToken();
|
||||
return data;
|
||||
@@ -285,7 +329,10 @@
|
||||
register: function (body) {
|
||||
return request('POST', '/genealogy/pc/auth/register', {
|
||||
auth: false,
|
||||
body: Object.assign({ grantType: 'password', registerSource: 'PC' }, withTenant(body))
|
||||
body: Object.assign(
|
||||
pickDefined(body, ['phone', 'password', 'nickName', 'smsCode']),
|
||||
{ grantType: 'password', tenantId: tenantId, registerSource: 'PC' }
|
||||
)
|
||||
});
|
||||
},
|
||||
login: login,
|
||||
@@ -294,26 +341,38 @@
|
||||
return request('POST', buildSmsCodePath(operationCode), {
|
||||
auth: false,
|
||||
// PC 认证目录以路径 operationCode 绑定动作;clientid 只由请求头提供。
|
||||
body: Object.assign({ grantType: 'sms', tenantId: tenantId }, body || {})
|
||||
body: Object.assign(
|
||||
pickDefined(body, ['phone', 'validToken']),
|
||||
{ grantType: 'sms', tenantId: tenantId }
|
||||
)
|
||||
});
|
||||
},
|
||||
currentProfile: function () {
|
||||
return request('GET', '/genealogy/pc/auth/profile');
|
||||
},
|
||||
updateProfile: function (body) {
|
||||
return request('PUT', '/genealogy/pc/auth/profile', { body: body });
|
||||
return request('PUT', '/genealogy/pc/auth/profile', {
|
||||
body: pickDefined(body, ['nickName', 'realName', 'avatar', 'sex', 'birthday', 'email'])
|
||||
});
|
||||
},
|
||||
changePassword: function (body) {
|
||||
return request('PUT', '/genealogy/pc/auth/password', { body: body });
|
||||
return request('PUT', '/genealogy/pc/auth/password', {
|
||||
body: pickDefined(body, ['oldPassword', 'newPassword'])
|
||||
});
|
||||
},
|
||||
resetPassword: function (body) {
|
||||
return request('PUT', '/genealogy/pc/auth/password/reset', {
|
||||
auth: false,
|
||||
body: Object.assign({ grantType: 'password' }, withTenant(body))
|
||||
body: Object.assign(
|
||||
pickDefined(body, ['phone', 'smsCode', 'newPassword']),
|
||||
{ grantType: 'password', tenantId: tenantId }
|
||||
)
|
||||
});
|
||||
},
|
||||
changePhone: function (body) {
|
||||
return request('PUT', '/genealogy/pc/auth/phone', { body: body });
|
||||
return request('PUT', '/genealogy/pc/auth/phone', {
|
||||
body: pickDefined(body, ['phone', 'smsCode'])
|
||||
});
|
||||
},
|
||||
deactivateAccount: deactivateAccount,
|
||||
logout: logout,
|
||||
@@ -340,10 +399,22 @@
|
||||
});
|
||||
},
|
||||
captchaChallenge: function (operationCode, body) {
|
||||
return request('POST', buildVerificationPath(operationCode, '/challenge'), { auth: false, body: body });
|
||||
return request('POST', buildVerificationPath(operationCode, '/challenge'), {
|
||||
auth: false,
|
||||
body: withTenant(pickDefined(body, ['subject']))
|
||||
});
|
||||
},
|
||||
captchaVerify: function (operationCode, body) {
|
||||
return request('POST', buildVerificationPath(operationCode, '/verify'), { auth: false, body: body });
|
||||
return request('POST', buildVerificationPath(operationCode, '/verify'), {
|
||||
auth: false,
|
||||
body: withTenant(pickDefined(body, [
|
||||
'subject',
|
||||
'challengeId',
|
||||
'providerCode',
|
||||
'captchaType',
|
||||
'payload'
|
||||
]))
|
||||
});
|
||||
},
|
||||
captchaChallengeUrl: function (operationCode) {
|
||||
return buildApiUrl(baseUrl, buildVerificationPath(operationCode, '/challenge'));
|
||||
@@ -352,16 +423,16 @@
|
||||
return buildApiUrl(baseUrl, buildVerificationPath(operationCode, '/verify'));
|
||||
},
|
||||
regionChildren: function (parentCode) {
|
||||
return request('GET', '/genealogy/region/children', { query: { parentCode: parentCode } });
|
||||
return request('GET', '/genealogy/pc/region/children', { query: { parentCode: parentCode } });
|
||||
},
|
||||
regionPath: function (regionCode) {
|
||||
return request('GET', '/genealogy/region/path/' + encodeURIComponent(regionCode));
|
||||
return request('GET', '/genealogy/pc/region/path/' + encodeURIComponent(regionCode));
|
||||
},
|
||||
regionSearch: function (query) {
|
||||
return request('GET', '/genealogy/region/search', { query: query });
|
||||
return request('GET', '/genealogy/pc/region/search', { query: query });
|
||||
},
|
||||
regionDetail: function (regionCode) {
|
||||
return request('GET', '/genealogy/region/' + encodeURIComponent(regionCode));
|
||||
return request('GET', '/genealogy/pc/region/' + encodeURIComponent(regionCode));
|
||||
},
|
||||
genealogyQuota: function () {
|
||||
return request('GET', '/genealogy/pc/genealogies/quota');
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
var settings = options || {};
|
||||
var axiosInstance = getAxiosInstance(settings);
|
||||
|
||||
function handleUnauthorized(status) {
|
||||
if (Number(status) === 401 && settings.onUnauthorized) settings.onUnauthorized();
|
||||
}
|
||||
|
||||
return async function request(method, path, requestOptions) {
|
||||
var req = requestOptions || {};
|
||||
var headers = Object.assign({}, req.headers || {});
|
||||
@@ -90,15 +94,23 @@
|
||||
});
|
||||
} catch (error) {
|
||||
var responseData = error.response && error.response.data;
|
||||
var status = error.response && error.response.status;
|
||||
|
||||
handleUnauthorized(status);
|
||||
|
||||
throw createHttpError((responseData && responseData.msg) || error.message || '接口请求失败', {
|
||||
status: error.response && error.response.status,
|
||||
status: status,
|
||||
response: responseData,
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
|
||||
return unwrapResponse(response.data);
|
||||
try {
|
||||
return unwrapResponse(response.data);
|
||||
} catch (error) {
|
||||
handleUnauthorized(error.code);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user