完成10%
This commit is contained in:
@@ -0,0 +1,647 @@
|
|||||||
|
# PC 接口对接规划(Apifox 79 接口版)
|
||||||
|
|
||||||
|
> 状态:当前范围已确认
|
||||||
|
> 日期:2026-07-24
|
||||||
|
> 当前阶段:已直接在 Apifox 核验验证码中心、认证登录、文件上传、行政区划、家族圈、字辈谱和世系人物共 57 条接口;施工只以这些 PC 详情为准,其余目录继续逐组核验后再接入。
|
||||||
|
> 交接入口:[交接文档.md](交接文档.md)
|
||||||
|
|
||||||
|
## 1. 已确认的基线
|
||||||
|
|
||||||
|
1. **Apifox 的 PC 目录是 PC 前端接口的唯一正式契约源。**
|
||||||
|
2. 当前 Apifox PC 共 79 个操作:
|
||||||
|
|
||||||
|
| 分组 | 数量 |
|
||||||
|
| --- | ---: |
|
||||||
|
| 认证登录 | 11 |
|
||||||
|
| 验证中心 | 4 |
|
||||||
|
| 文件上传 | 6 |
|
||||||
|
| 行政区划 | 4 |
|
||||||
|
| 家族圈 | 14 |
|
||||||
|
| 字辈谱 | 6 |
|
||||||
|
| 世系人物 | 12 |
|
||||||
|
| 内容文章 | 1 |
|
||||||
|
| 相册 | 2 |
|
||||||
|
| 视频 | 1 |
|
||||||
|
| 祭祀 | 2 |
|
||||||
|
| 族务记录 | 16 |
|
||||||
|
| **合计** | **79** |
|
||||||
|
|
||||||
|
3. 仓库中的旧 OpenAPI 文件不再作为新增功能依据。Apifox 客户端中实时的 PC 目录是最终验收源;导出文件最多作为本地自动测试快照,不能替代在 Apifox 中逐项核对。
|
||||||
|
4. APP 接口不允许在 PC 页面中直接复用。PC 缺少的能力应先补入 Apifox 的 PC 目录,再开发页面。
|
||||||
|
5. 当前不根据 APP 或后端公开 OpenAPI 扩大范围;没有进入 Apifox PC 目录的接口,不视为 PC 已确认接口。
|
||||||
|
6. 后端以后新增 PC 接口时,再按新增后的 Apifox PC 契约启动下一轮规划。
|
||||||
|
|
||||||
|
## 2. 当前最重要的结论
|
||||||
|
|
||||||
|
现有 79 个接口不能覆盖整个 PC 管理端闭环,主要问题不是前端页面,而是接口目录不完整:
|
||||||
|
|
||||||
|
- 没有“我的家谱、家谱详情、创建/加入家谱、家谱成员”接口,PC 无法自行取得真实 `genealogyId`。
|
||||||
|
- 内容文章只有删除接口。
|
||||||
|
- 相册只有删除相册、删除照片接口。
|
||||||
|
- 视频只有删除接口。
|
||||||
|
- 祭祀只有删除祭祀、删除献礼接口。
|
||||||
|
- 功德记录只有删除接口。
|
||||||
|
- 世系关系只有新增父母、配偶、兄弟姐妹、子女,没有解除关系接口。
|
||||||
|
- 字辈谱没有删除接口。
|
||||||
|
|
||||||
|
因此对接分成三类:
|
||||||
|
|
||||||
|
1. **当前可以形成可用流程:**认证、验证、文件上传、区划、家族圈、字辈谱、世系人物、成长记录、亲友往来、备忘录;其中家谱内接口必须先由 PC 运行入口提供真实 `genealogyId`。
|
||||||
|
2. **当前只做契约映射、不开放页面操作:**文章、相册、视频、祭祀和功德录目前只有删除接口,不能在没有真实列表和详情数据时单独启用删除。
|
||||||
|
3. **后端补充 PC 接口后再规划:**家谱上下文、成员,以及上述资源缺少的读写操作、世系关系解除和字辈删除/停用。
|
||||||
|
|
||||||
|
### 2.1 当前范围规则
|
||||||
|
|
||||||
|
- 当前只分析和对接 Apifox PC 中已经存在的 79 个操作。
|
||||||
|
- 不读取 APP 接口补 PC 缺口,不把 `/genealogy/app` 改前缀后用于 PC。
|
||||||
|
- 后端公开文档中存在、但 Apifox PC 当前没有的接口,不进入本轮对接;也不以导出文件缺失为由跳过 Apifox 中已有的接口。
|
||||||
|
- PC 当前缺少的业务接口只登记为后端待办,相关页面继续显示待开发状态。
|
||||||
|
- 后端新增接口后,必须先进入 Apifox PC 并补齐 DTO,再进入前端对接规划。
|
||||||
|
|
||||||
|
## 3. 公共契约规则
|
||||||
|
|
||||||
|
### 3.1 接口所有权
|
||||||
|
|
||||||
|
- Apifox PC 目录拥有 method、path、请求 DTO、响应 DTO、枚举和权限说明。
|
||||||
|
- `utils/ApiClient.js` 只负责实现 Apifox 已定义的业务方法,不允许页面自行拼接接口路径。
|
||||||
|
- 页面脚本只消费 `ApiClient` 方法,不直接调用 Axios。
|
||||||
|
- 新契约确认后,旧字段兼容读取、旧路径和临时 fallback 必须一起删除,不能长期维护两套契约。
|
||||||
|
|
||||||
|
### 3.2 家谱上下文
|
||||||
|
|
||||||
|
所有家谱内业务必须使用真实 `genealogyId`:
|
||||||
|
|
||||||
|
1. 当前 79 个接口没有“我的家谱”入口,本轮不从 APP 获取 `genealogyId`。
|
||||||
|
2. 当前家谱业务页面只接受 PC 运行入口通过 URL 传入的真实 `genealogyId`。
|
||||||
|
3. `profile-common.js` 作为 PC 页面家谱上下文的唯一 owner,负责读取、校验和传播 `genealogyId`。
|
||||||
|
4. `ApiClient` 负责把 `genealogyId` 放入路径。
|
||||||
|
5. 页面缺少 `genealogyId` 时阻止请求并显示“等待家谱入口接口”,不使用示例编号。
|
||||||
|
|
||||||
|
后端补充 PC 家谱入口接口后,再规划“我的家谱 → 选择家谱 → 进入业务页面”的完整导航闭环。
|
||||||
|
|
||||||
|
### 3.3 请求头与登录状态
|
||||||
|
|
||||||
|
- 所有 PC 请求统一携带 `clientid`。
|
||||||
|
- 登录后接口统一携带 `Authorization: Bearer <token>`。
|
||||||
|
- `tenantId`、`clientId` 只在 Apifox DTO 明确要求时进入请求体,页面不得自行添加。
|
||||||
|
- 401:清除本地登录态并跳转登录页。
|
||||||
|
- 403:保留登录态,展示无权限页或无权限状态。
|
||||||
|
|
||||||
|
Apifox 必须为每个操作明确标记“公开”或“需要登录”,不能只依赖接口描述文字。
|
||||||
|
|
||||||
|
Apifox 还必须正式声明 Bearer security scheme、`Authorization` 和 `clientid`,否则自动生成文档、Mock 和测试时无法还原真实请求。
|
||||||
|
|
||||||
|
### 3.4 响应与分页
|
||||||
|
|
||||||
|
普通响应统一为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "操作成功",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
分页响应统一为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "操作成功",
|
||||||
|
"rows": [],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
分页参数应明确展开为 `pageNum`、`pageSize`、`orderByColumn`、`isAsc`,不把整个 `pageQuery` 声明成一个含义不清的 query object。
|
||||||
|
|
||||||
|
除 200 外,每组接口至少定义适用的 400、401、403、404、409、422 和 429 响应,不能把所有失败都写成 401。
|
||||||
|
|
||||||
|
### 3.5 ID、日期和枚举
|
||||||
|
|
||||||
|
- `genealogyId`、`personId`、`feedId`、`commentId`、`ossId` 等 ID 在浏览器端统一按字符串处理,避免 JavaScript `int64` 精度丢失。
|
||||||
|
- Apifox 当前需要统一文件上传响应和业务写入 DTO 中 `ossId` 的类型;不能一处为 string、另一处为 integer。
|
||||||
|
- 纯日期使用 `YYYY-MM-DD`。
|
||||||
|
- 具体时刻使用带时区的 ISO 8601。
|
||||||
|
- 性别、状态、角色、可见性、祭祀类型等必须在 Apifox 中给出固定枚举及中文含义。
|
||||||
|
|
||||||
|
### 3.6 权限
|
||||||
|
|
||||||
|
至少区分:
|
||||||
|
|
||||||
|
- 访客
|
||||||
|
- 普通家谱成员
|
||||||
|
- 内容编辑者
|
||||||
|
- 家谱管理员
|
||||||
|
- 家谱创建者/所有者
|
||||||
|
- 平台管理员
|
||||||
|
|
||||||
|
列表和详情 DTO 应返回 `canEdit`、`canDelete`、`canManage` 等能力字段。前端据此显示操作,后端仍必须独立鉴权。
|
||||||
|
|
||||||
|
同一个 PC DTO 不等于所有角色都能看到所有字段。手机号、联系方式、通知目标等敏感字段要按权限裁剪;需要时拆成摘要 DTO 和管理员详情 DTO。
|
||||||
|
|
||||||
|
前端只使用 Apifox PC 当前声明的 Schema,不从 APP Schema 推导字段。
|
||||||
|
|
||||||
|
### 3.7 文件生命周期
|
||||||
|
|
||||||
|
图片、小文件:
|
||||||
|
|
||||||
|
1. 单文件上传。
|
||||||
|
2. 获得 `ossId`。
|
||||||
|
3. 保存业务对象。
|
||||||
|
4. 绑定文件业务引用。
|
||||||
|
|
||||||
|
视频、大文件:
|
||||||
|
|
||||||
|
1. 初始化分片任务。
|
||||||
|
2. 上传所有分片。
|
||||||
|
3. 服务端合并。
|
||||||
|
4. 保存业务对象。
|
||||||
|
5. 绑定文件业务引用。
|
||||||
|
|
||||||
|
替换或删除业务对象后解除旧文件引用。上传成功但业务保存失败时,也要提供清理策略。
|
||||||
|
|
||||||
|
## 4. 79 个接口的页面用途
|
||||||
|
|
||||||
|
### 4.1 认证登录(11)
|
||||||
|
|
||||||
|
| 接口 | 使用页面 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /genealogy/pc/auth/register` | `register.html` | 用户注册 |
|
||||||
|
| `POST /genealogy/pc/auth/login` | `login.html` | 密码登录 |
|
||||||
|
| `POST /genealogy/pc/auth/login/sms` | `login.html` | 短信登录 |
|
||||||
|
| `POST /genealogy/pc/auth/sms/code` | 短信登录、注册、找回密码、换绑手机号、注销账号 | 发送短信验证码 |
|
||||||
|
| `GET /genealogy/pc/auth/profile` | `profile.html`、`profile-data.html`、`profile-security.html` | 获取当前用户资料/已绑定手机号 |
|
||||||
|
| `PUT /genealogy/pc/auth/profile` | `profile-data.html` | 修改个人资料 |
|
||||||
|
| `PUT /genealogy/pc/auth/password` | `profile-security.html` | 修改密码 |
|
||||||
|
| `PUT /genealogy/pc/auth/password/reset` | `forgot-password.html` | 找回密码 |
|
||||||
|
| `PUT /genealogy/pc/auth/phone` | `profile-security.html` | 换绑手机号 |
|
||||||
|
| `POST /genealogy/pc/auth/account/deactivate` | `profile-security.html` | 注销账号 |
|
||||||
|
| `DELETE /genealogy/pc/auth/logout` | 所有登录后页面 | 退出登录 |
|
||||||
|
|
||||||
|
状态:第一轮施工已接入密码登录、短信登录、注册、找回密码、换绑手机号、注销和退出。注册必须先取得短信码;找回密码由 `ApiClient` 统一补齐 `grantType`、`tenantId`、`clientId`。Apifox 的 `LoginVo` 明确允许 `token`、`accessToken`、`tokenValue` 三种响应字段,客户端按该顺序读取第一个非空值;资料响应未给出字段 Schema,页面只读取 PC 页面已使用的明确字段,不增加 APP 字段猜测。
|
||||||
|
|
||||||
|
已在 Apifox 直接核验的认证约束:
|
||||||
|
|
||||||
|
- 发送短信验证码只允许 `PC_SMS_LOGIN`、`PC_REGISTER`、`PC_FORGOT_PASSWORD`、`PC_PHONE_CHANGE`、`PC_ACCOUNT_DEACTIVATE` 五个场景。
|
||||||
|
- 发送短信码请求体为 `clientId`、`grantType`、`tenantId`、`sceneCode`、`phone`、`validToken`;`validToken` 来自验证码中心且只能单次消费。
|
||||||
|
- 换绑手机号请求体为 `clientId`、`phone`、`smsCode`;注销账号请求体为 `clientId`、`smsCode`。二者不附带 `tenantId`。
|
||||||
|
- 密码登录与注册使用 `grantType: "password"`;短信登录与发送短信码使用 `grantType: "sms"`;所有密码字段均为 32 位 MD5。
|
||||||
|
|
||||||
|
第一批页面与接口顺序:
|
||||||
|
|
||||||
|
| 页面 | 页面流程 | 对接接口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `login.html` | 密码登录 | `POST /genealogy/pc/auth/login` |
|
||||||
|
| `login.html` | 获取短信码 → 短信登录 | `POST /genealogy/pc/auth/sms/code` → `POST /genealogy/pc/auth/login/sms` |
|
||||||
|
| `register.html` | 获取短信码 → 注册 | `POST /genealogy/pc/auth/sms/code` → `POST /genealogy/pc/auth/register` |
|
||||||
|
| `forgot-password.html` | 获取短信码 → 重设密码 | `POST /genealogy/pc/auth/sms/code` → `PUT /genealogy/pc/auth/password/reset` |
|
||||||
|
| `profile-security.html` | 修改密码 | `PUT /genealogy/pc/auth/password` |
|
||||||
|
| `profile-security.html` | 获取短信码 → 换绑手机号 | `POST /genealogy/pc/auth/sms/code` → `PUT /genealogy/pc/auth/phone` |
|
||||||
|
| `profile-security.html` | 读取绑定手机号 → 获取短信码 → 注销 | `GET /genealogy/pc/auth/profile` → `POST /genealogy/pc/auth/sms/code` → `POST /genealogy/pc/auth/account/deactivate` |
|
||||||
|
| 所有登录后页面 | 退出登录 | `DELETE /genealogy/pc/auth/logout` |
|
||||||
|
|
||||||
|
### 4.2 验证中心(4)
|
||||||
|
|
||||||
|
| 接口 | 使用位置 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /captcha/require` | 发送登录、注册、找回、换绑、注销短信前 | 判断当前场景是否需要验证 |
|
||||||
|
| `POST /captcha/challenge` | 同上 | 获取滑块或图形验证挑战 |
|
||||||
|
| `POST /captcha/verify` | 同上 | 校验挑战并换取 `validToken` |
|
||||||
|
| `GET /auth/code` | 仅兼容旧图形验证码 | 旧验证方案 |
|
||||||
|
|
||||||
|
已在 Apifox 直接核验的验证码约束:
|
||||||
|
|
||||||
|
- `GET /captcha/require` 使用 `tenantId`、`clientId`、`sceneCode`、`subject` 查询场景策略。
|
||||||
|
- `POST /captcha/challenge` 必须携带同一组 `tenantId`、`clientId`、`sceneCode`、`subject`;天爱策略返回行为验证数据,系统图形策略返回 `uuid` 和 `img`。
|
||||||
|
- `POST /captcha/verify` 除上述字段外还必须携带 `challengeId`,并以 `providerCode`、`captchaType`、`payload` 提交验证结果;成功后取得 `validToken`。
|
||||||
|
- `validToken` 与租户、PC 客户端、场景、手机号主体、挑战和请求 IP 绑定,只能随发送短信码接口单次提交。
|
||||||
|
|
||||||
|
规划:
|
||||||
|
|
||||||
|
- 新流程统一使用 `/captcha/*` 三个接口。
|
||||||
|
- `/auth/code` 标记为兼容接口,确定所有场景迁移完成后从 Apifox 和代码一起删除。
|
||||||
|
- `validToken` 只用于发送短信验证码的请求;发送完成后立即从表单清除,不写日志、不长期存储,也不混入登录、注册、重置或换绑的业务 DTO。
|
||||||
|
|
||||||
|
### 4.3 文件上传(6)
|
||||||
|
|
||||||
|
| 接口 | 使用位置 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /genealogy/pc/files/upload` | 头像、文章封面、相册照片、祭祀封面、记录附件 | 单文件上传 |
|
||||||
|
| `POST /genealogy/pc/files/resumable/init` | 视频、大文件 | 初始化分片任务 |
|
||||||
|
| `POST /genealogy/pc/files/resumable/chunk` | 视频、大文件 | 上传单个分片 |
|
||||||
|
| `POST /genealogy/pc/files/resumable/complete` | 视频、大文件 | 合并分片并返回文件 |
|
||||||
|
| `POST /genealogy/pc/files/reference` | 所有业务保存成功后 | 绑定业务引用 |
|
||||||
|
| `DELETE /genealogy/pc/files/reference` | 替换或删除业务文件后 | 解除业务引用 |
|
||||||
|
|
||||||
|
状态:6 个文件接口已直接在 Apifox 核验,`ApiClient` 的 PC 路径、认证头和请求形状一致。单文件上传只提交 multipart `file`,返回 `data.ossId`、`url`、`thumbnailUrl`、`fileName`、`originalName`;`profile-data.html` 使用它回填 `avatarOssId`,再随 `PUT /genealogy/pc/auth/profile` 保存。
|
||||||
|
|
||||||
|
已核验的分片/引用约束:
|
||||||
|
|
||||||
|
- 初始化必填 `fileName`、`fileSize`、`fileMd5`、`chunkSize`、`totalChunks`,可选 `contentType`、`bizType`、`usageScene`;接口说明规定 `instant=true` 时直接使用返回的 OSS 信息。当前响应仍是未展开字段的 `ObjectResult`,因此不开放续传页面流程。
|
||||||
|
- 分片上传 multipart 必填 `uploadId`、`chunkIndex`、`chunkMd5`、`file`;完成上传必填 `uploadId`、`fileMd5`、`fileSize`。
|
||||||
|
- 绑定引用必填 `bizType`、`bizTable`、`bizId`、`bizField`,可选 `ossId` 或逗号分隔的 `ossIds`、`bizName`、`usageScene`、`usageName`;解除引用只必填 `bizTable`、`bizId`、`bizField`。
|
||||||
|
- 具体业务的 `bizType`、`bizTable`、`bizField` 目前没有由对应业务接口声明,不在页面中猜测创建或解除引用规则。
|
||||||
|
|
||||||
|
### 4.4 行政区划(4)
|
||||||
|
|
||||||
|
| 接口 | 使用页面 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /genealogy/region/children` | 个人资料、创建/编辑家谱 | 获取下级区划 |
|
||||||
|
| `GET /genealogy/region/path/{regionCode}` | 同上 | 区划编码回显 |
|
||||||
|
| `GET /genealogy/region/search` | 同上 | 关键词搜索地区 |
|
||||||
|
| `GET /genealogy/region/{regionCode}` | 同上 | 获取地区详情 |
|
||||||
|
|
||||||
|
状态:四条路径和请求参数已直接核验;个人资料已使用,创建家谱页面待家谱接口补齐后复用同一个区划组件。`children` 的 `parentCode` 可选(不传查省级),`search` 必填 `keyword`、可选 `level` 和 `limit`,路径/详情使用必填 `regionCode`。当前 Apifox 的地区响应仍是未展开属性的通用对象/数组,页面已有的 `regionCode`、`regionName`、`regionLevel` 消费需在联调时以真实响应再确认,不能把导出快照当 Schema。
|
||||||
|
|
||||||
|
个人资料页当前流程:
|
||||||
|
|
||||||
|
| 页面 | 页面流程 | 对接接口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `profile.html` | 读取当前资料、显示昵称/手机/生日/地区 | `GET /genealogy/pc/auth/profile` → `GET /genealogy/region/path/{regionCode}` |
|
||||||
|
| `profile-data.html` | 回填并保存昵称、性别、生日和头像 OSS ID | `GET /genealogy/pc/auth/profile` → `POST /genealogy/pc/files/upload` → `PUT /genealogy/pc/auth/profile` |
|
||||||
|
| `profile-data.html` | 回填、搜索、选择并保存现居地区 | `GET /genealogy/region/children` / `path` / `search` / `{regionCode}` → `PUT /genealogy/pc/auth/profile` |
|
||||||
|
|
||||||
|
注意:当前严格使用 Apifox PC 声明的 `/genealogy/region/*`,页面和 `ApiClient` 不再维护其他区划路径。
|
||||||
|
|
||||||
|
### 4.5 家族圈(14)
|
||||||
|
|
||||||
|
| 接口 | 使用页面 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /genealogy/pc/genealogies/{genealogyId}/feeds` | `profile-feed.html` | 非分页动态列表 |
|
||||||
|
| `GET .../feeds/page` | `profile-feed.html` | 动态分页 |
|
||||||
|
| `POST .../feeds` | `profile-feed-edit.html` | 发布动态 |
|
||||||
|
| `GET .../feeds/{feedId}` | 编辑页、动态详情页 | 动态详情 |
|
||||||
|
| `PUT .../feeds/{feedId}` | `profile-feed-edit.html` | 修改动态 |
|
||||||
|
| `DELETE .../feeds/{feedId}` | 列表、详情 | 删除动态 |
|
||||||
|
| `POST .../feeds/{feedId}/likes` | 列表、详情 | 点赞 |
|
||||||
|
| `DELETE .../feeds/{feedId}/likes` | 列表、详情 | 取消点赞 |
|
||||||
|
| `GET .../feeds/{feedId}/comments` | 动态详情 | 评论列表 |
|
||||||
|
| `GET .../feeds/{feedId}/comments/page` | 动态详情 | 评论分页 |
|
||||||
|
| `POST .../feeds/{feedId}/comments` | 动态详情 | 评论或回复 |
|
||||||
|
| `DELETE .../feeds/{feedId}/comments/{commentId}` | 动态详情 | 删除评论 |
|
||||||
|
| `GET .../comments/{commentId}/replies` | 动态详情 | 直接回复列表 |
|
||||||
|
| `GET .../comments/{commentId}/replies/page` | 动态详情 | 直接回复分页 |
|
||||||
|
|
||||||
|
状态:14 个操作均已在 Apifox PC 详情中核验并完成客户端路径映射。`profile-feed.html` 已接入动态分页、点赞、一级评论、发表回复和按需展开直接回复;评论及回复 ID 在路径中按字符串传递。
|
||||||
|
|
||||||
|
已核验的评论约束:
|
||||||
|
|
||||||
|
- 发表评论请求体只有必填 `commentContent`(最多 1000 字)和可选 `parentCommentId`;不传或传 `null` 表示一级评论,不能发送旧字段 `content` 或未声明的 `replyUserId`。
|
||||||
|
- 一级评论接口只返回正常展示的一级评论;直接回复使用 `GET .../comments/{commentId}/replies`,分页使用 `GET .../comments/{commentId}/replies/page`。两类分页均为 `pageNum`、`pageSize`。
|
||||||
|
- 评论响应字段为 `commentContent`、`commentId`、`parentCommentId`、`appUserNickName`、`replyCount`、`commentLevel`、`userDeleted` 等;作者删除后 `commentContent` 为 `null` 且 `userDeleted` 为 `1`,页面显示占位而非把响应视为异常。
|
||||||
|
|
||||||
|
页面规划:
|
||||||
|
|
||||||
|
- `profile-feed.html` 只负责动态分页和轻量操作。
|
||||||
|
- 新增动态详情页,负责完整正文、评论、回复、通知跳转和分享深链。
|
||||||
|
- 评论 DTO 必须返回稳定的 `commentId`、`parentCommentId`、`replyCount`、发布人和权限字段。
|
||||||
|
|
||||||
|
### 4.6 字辈谱(6)
|
||||||
|
|
||||||
|
| 接口 | 使用页面 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET .../generation-poems` | 家谱主页、世系展示 | 查询正常字辈谱 |
|
||||||
|
| `GET .../generation-poems/management` | `profile-generation.html` | 查询字辈维护列表 |
|
||||||
|
| `POST .../generation-poems` | `profile-generation.html` | 新增字辈 |
|
||||||
|
| `PUT .../generation-poems/{poemId}` | `profile-generation.html` | 修改字辈 |
|
||||||
|
| `POST .../generation-poems/batch/preview` | `profile-generation.html` | 批量文本预览 |
|
||||||
|
| `POST .../generation-poems/batch/save` | `profile-generation.html` | 批量保存 |
|
||||||
|
|
||||||
|
状态:6 个操作均已在 Apifox PC 详情中核验并完成客户端路径映射;`profile-generation.html` 已接入管理列表、新增、修改、停用/恢复、批量预览和批量保存。管理列表仅内容编辑者可访问,403 时页面明确显示无权限并禁用写操作;页面缺少真实 `genealogyId` 时不发请求,也不使用示例编号。
|
||||||
|
|
||||||
|
规划:
|
||||||
|
|
||||||
|
- 页面只提供新增、修改和批量导入。
|
||||||
|
- 如果产品要求物理删除,先在 Apifox 增加删除接口;当前修改接口的 `status` 仅按其已声明的正常/停用语义使用,不作为删除替代。
|
||||||
|
- 批量预览必须展示解析错误、重复代次、原/新状态和将被覆盖的记录,用户确认后才能保存;前端按 Apifox 限制校验总文本不超过 26000 字符、单个字辈不超过 50 字符、一次最多 500 代。
|
||||||
|
- 字辈新增、修改、状态切换、批量预览和批量保存执行期间锁定写入口,避免重复提交;批量输入示例必须使用 Apifox 已声明的分隔符,连续文本视为一个字辈。
|
||||||
|
|
||||||
|
### 4.7 世系人物(12)
|
||||||
|
|
||||||
|
| 接口 | 使用页面 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET .../lineage/persons` | `profile-tree.html` | 成员总览 |
|
||||||
|
| `GET .../lineage/persons/page` | `profile-tree.html` | 人物分页与关键词搜索 |
|
||||||
|
| `GET .../lineage/persons/options` | 关系选择、成长记录 | 人物下拉选项 |
|
||||||
|
| `GET .../lineage/tree` | `profile-tree.html` | 世系树 |
|
||||||
|
| `POST .../lineage/persons` | `profile-tree.html` | 新增人物 |
|
||||||
|
| `GET .../lineage/persons/{personId}` | 树内详情、人物详情 | 人物详情 |
|
||||||
|
| `PUT .../lineage/persons/{personId}` | 人物编辑 | 修改人物 |
|
||||||
|
| `DELETE .../lineage/persons/{personId}` | 人物详情 | 逻辑停用人物 |
|
||||||
|
| `POST .../persons/{personId}/children` | 关系维护 | 添加子女 |
|
||||||
|
| `POST .../persons/{personId}/parents` | 关系维护 | 添加父母 |
|
||||||
|
| `POST .../persons/{personId}/siblings` | 关系维护 | 添加兄弟姐妹 |
|
||||||
|
| `POST .../persons/{personId}/spouses` | 关系维护 | 添加配偶 |
|
||||||
|
|
||||||
|
状态:12 个操作均已在 Apifox PC 详情中核验并完成客户端路径映射;`profile-tree.html` 已接入成员总览、树、人物分页搜索与翻页、人物选项、详情、新增、修改、逻辑停用,以及新增父母、配偶、兄弟姐妹、子女。写入和停用请求执行期间会锁定操作入口,页面缺少真实 `genealogyId` 时不发请求。
|
||||||
|
|
||||||
|
已核验的关键约束:
|
||||||
|
|
||||||
|
- 人物 DTO 使用 `name`、`generation`、`biography`,不是旧页面字段 `personName`、`generationNo`、`introduction`。
|
||||||
|
- `DELETE .../persons/{personId}` 的语义是逻辑停用,不物理删除;人物存在正常子女时后端会拒绝停用。
|
||||||
|
- 四个关系接口都接收完整的 `LineagePersonBody` 来新建关系人物,并非把两个已有 `personId` 绑定在一起;当前没有解除关系接口。
|
||||||
|
- `LineagePersonTreeView` 以 `spouses`、`children` 递归返回关系树;页面避免将不安全的数值型 int64 ID 用于后续写请求。
|
||||||
|
|
||||||
|
规划:
|
||||||
|
|
||||||
|
- `profile-tree.html` 负责树、搜索、人物详情、快捷新增和关系人物新增。
|
||||||
|
- 人物详情可先使用抽屉;消息和分享需要深链时再补独立详情页。
|
||||||
|
- Apifox 需补充稳定的关系 ID 与解除关系接口。
|
||||||
|
- 删除人物前必须返回影响范围,禁止前端猜测是否级联删除子女或关系。
|
||||||
|
- `GenealogyMember` 是账号和权限成员,`LineagePerson` 是谱系人物,两者不能合并。
|
||||||
|
|
||||||
|
### 4.8 内容文章(1)
|
||||||
|
|
||||||
|
现有接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE /genealogy/pc/genealogies/{genealogyId}/articles/{articleId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用位置:`profile-article.html` 的删除操作。
|
||||||
|
|
||||||
|
当前不能启用真实文章管理,因为缺少:
|
||||||
|
|
||||||
|
- 文章列表/分页
|
||||||
|
- 文章详情
|
||||||
|
- 新增文章
|
||||||
|
- 修改文章
|
||||||
|
- 文章分类
|
||||||
|
- 发布、下线和公开可见性
|
||||||
|
- 官网公开文章详情
|
||||||
|
|
||||||
|
规划:接口补齐前,`profile-article.html`、`profile-article-edit.html` 和 `article-detail.html` 保持设计预览,不单独接入删除接口。
|
||||||
|
|
||||||
|
### 4.9 相册(2)
|
||||||
|
|
||||||
|
现有接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE /genealogy/pc/genealogies/{genealogyId}/albums/{albumId}
|
||||||
|
DELETE /genealogy/pc/genealogies/{genealogyId}/albums/{albumId}/photos/{photoId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用位置:`profile-album.html` 的删除相册、删除照片操作。
|
||||||
|
|
||||||
|
当前缺少相册列表、详情、新增、修改,以及照片列表、新增、修改和文件引用。接口补齐前不启用删除按钮。
|
||||||
|
|
||||||
|
### 4.10 视频(1)
|
||||||
|
|
||||||
|
现有接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE /genealogy/pc/genealogies/{genealogyId}/videos/{videoId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用位置:`profile-video.html` 的删除操作。
|
||||||
|
|
||||||
|
当前缺少视频列表、详情、新增、修改、发布状态、公开播放详情以及文件引用。本轮只映射删除契约;后端补齐 PC 读写接口后,再结合分片上传实施完整视频流程。
|
||||||
|
|
||||||
|
### 4.11 祭祀/典礼(2)
|
||||||
|
|
||||||
|
现有接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE /genealogy/pc/genealogies/{genealogyId}/ceremonies/{ceremonyId}
|
||||||
|
DELETE /genealogy/pc/genealogies/{genealogyId}/ceremonies/{ceremonyId}/gifts/{giftId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用位置:`profile-gift.html` 的删除活动、删除献礼操作。
|
||||||
|
|
||||||
|
当前缺少活动列表、详情、新增、修改,以及献礼列表和新增。接口补齐前不启用删除。
|
||||||
|
|
||||||
|
命名需先拍板:
|
||||||
|
|
||||||
|
- 如果只处理祖先祭祀,DTO 和页面只保留祭祀类型。
|
||||||
|
- 如果还处理婚礼、生日、升学等活动,Apifox 目录应改为“典礼/贺礼”,避免接口名称和产品含义不一致。
|
||||||
|
|
||||||
|
### 4.12 族务记录(16)
|
||||||
|
|
||||||
|
#### 成长记录(5)
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET .../growth-records
|
||||||
|
POST .../growth-records
|
||||||
|
GET .../growth-records/{recordId}
|
||||||
|
PUT .../growth-records/{recordId}
|
||||||
|
DELETE .../growth-records/{recordId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用页面:`profile-growth.html`、`profile-growth-edit.html`。
|
||||||
|
|
||||||
|
用途:列表、新增、详情、编辑、删除,可关联世系人物和附件。
|
||||||
|
|
||||||
|
#### 亲友往来(5)
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET .../relative-records
|
||||||
|
POST .../relative-records
|
||||||
|
GET .../relative-records/{relativeId}
|
||||||
|
PUT .../relative-records/{relativeId}
|
||||||
|
DELETE .../relative-records/{relativeId}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前没有独立页面。现有 `profile-memo.html` 同时写了“人情往来”和“备忘提醒”,会导致两个资源边界混乱。
|
||||||
|
|
||||||
|
规划:将“人亲簿/亲友往来”和“备忘录”拆成两个 Tab 或两组页面:
|
||||||
|
|
||||||
|
- 亲友往来:亲友、关系、事项、时间、礼金、说明。
|
||||||
|
- 备忘录:待办、提醒时间、完成状态、附件。
|
||||||
|
|
||||||
|
#### 备忘录(5)
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET .../memos
|
||||||
|
POST .../memos
|
||||||
|
GET .../memos/{memoId}
|
||||||
|
PUT .../memos/{memoId}
|
||||||
|
DELETE .../memos/{memoId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用页面:`profile-memo.html`、`profile-memo-edit.html`。
|
||||||
|
|
||||||
|
用途:家族事务、纪念事项、待办提醒和完成状态。
|
||||||
|
|
||||||
|
#### 功德记录(1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE .../merit-records/{meritId}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用位置:`profile-merit.html` 的删除操作。
|
||||||
|
|
||||||
|
当前缺少列表、新增、详情和修改。接口补齐前,`profile-merit.html` 与 `profile-merit-edit.html` 保持设计预览。
|
||||||
|
|
||||||
|
## 5. 后端后续补充清单(当前不对接)
|
||||||
|
|
||||||
|
本节只登记当前 PC 79 个接口之外的业务缺口,不借用 APP 路径、参数或 DTO。后端把新接口正式加入 Apifox PC 后,再更新接口数量和页面对接计划。
|
||||||
|
|
||||||
|
### 后续优先级 1:家谱上下文与成员
|
||||||
|
|
||||||
|
这是形成完整 PC 导航闭环的前置能力:
|
||||||
|
|
||||||
|
- 我的家谱列表
|
||||||
|
- 家谱下拉选项
|
||||||
|
- 公开家谱搜索
|
||||||
|
- 家谱详情/概览
|
||||||
|
- 创建、修改家谱
|
||||||
|
- 申请加入、我的申请、撤销申请
|
||||||
|
- 待审核申请、审核申请
|
||||||
|
- 家谱成员列表/选项
|
||||||
|
- 修改成员角色
|
||||||
|
- 移除成员
|
||||||
|
- 退出家谱
|
||||||
|
- 转让家谱
|
||||||
|
- 邀请码/邀请链接的生成、校验和失效
|
||||||
|
|
||||||
|
### 后续优先级 2:当前只有删除操作的模块
|
||||||
|
|
||||||
|
- 文章:列表、详情、新增、修改、分类、发布状态。
|
||||||
|
- 相册:相册 CRUD、照片列表/新增/修改、文件引用。
|
||||||
|
- 视频:列表、详情、新增、修改、发布状态、文件引用。
|
||||||
|
- 祭祀/典礼:活动 CRUD、献礼列表/新增。
|
||||||
|
- 功德录:列表、详情、新增、修改。
|
||||||
|
|
||||||
|
### 后续优先级 3:已有主体但缺少的操作
|
||||||
|
|
||||||
|
- 世系关系解除。
|
||||||
|
- 字辈删除或停用。
|
||||||
|
- 家族圈评论回复 DTO 和权限字段。
|
||||||
|
- 删除人物、文章、相册、视频、祭祀等操作的引用清理规则。
|
||||||
|
- 官网公开内容的无登录只读接口。
|
||||||
|
|
||||||
|
## 6. 页面与接口实施顺序
|
||||||
|
|
||||||
|
### 阶段 0:锁定当前 PC 契约
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 直接在 Apifox 客户端中核对当前 PC 目录。
|
||||||
|
- 锁定 12 个目录、79 个操作及各目录数量,作为本轮唯一对接清单。
|
||||||
|
- 为每个操作在 Apifox 中确认 method、path、请求 DTO、响应 DTO、权限和错误码;可选导出仅用于生成本地契约测试快照。
|
||||||
|
- 排除所有未进入 Apifox PC 目录的接口,不引用 APP 或其他公开文档补充本轮范围。
|
||||||
|
- 统一 token、ID、`ossId`、分页、日期和枚举。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Apifox PC 操作数等于 79,目录数量与第 1 节一致。
|
||||||
|
- 第 4 节中的每个接口都能在 Apifox 客户端中找到,且没有 APP 或其他目录的接口混入当前清单。
|
||||||
|
- 同一接口只有一套 path 和 DTO。
|
||||||
|
- 当前计划不再引用 APP 路径或 APP DTO。
|
||||||
|
|
||||||
|
### 阶段 1:已接模块回归与补齐
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
1. 认证登录 11 个。
|
||||||
|
2. 验证中心 4 个。
|
||||||
|
3. 行政区划 4 个。
|
||||||
|
4. 文件上传 6 个。
|
||||||
|
5. 家族圈 14 个。
|
||||||
|
|
||||||
|
执行重点:
|
||||||
|
|
||||||
|
- 先核对现有 `ApiClient` 与当前 PC 契约,保留已正确对接的部分。
|
||||||
|
- 补齐文件分片、文件引用,以及家族圈评论回复列表和分页等当前 PC 已有但页面尚未完整使用的能力。
|
||||||
|
- 统一 401、403、业务错误、空状态和重复提交处理。
|
||||||
|
|
||||||
|
当前施工进度:
|
||||||
|
|
||||||
|
1. 已完成认证、验证码与账号安全流程,包含登录、短信登录、注册、找回密码、换绑、注销和退出。
|
||||||
|
2. 已完成个人资料读取与保存、头像单文件上传回填、行政区划三级联动/搜索/回显;资料和区划请求统一携带登录态,`ossId` 在浏览器端保持字符串。
|
||||||
|
3. 已完成文件分片初始化、分片、完成、绑定和解绑的客户端契约映射;待后端补齐初始化响应和各业务引用值后,再开通页面上传闭环。
|
||||||
|
4. 已完成家族圈回复、字辈谱和世系人物的 Apifox 核验与首轮页面施工;下一步直接核验族务记录的完整路径、DTO 与响应字段,再决定成长记录、亲友往来、备忘录页面能否开放。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 登录、验证码、区划和上传流程只请求当前 PC 路径。
|
||||||
|
- 家族圈列表、详情、点赞、评论和回复使用同一套 DTO 与计数字段。
|
||||||
|
- 文件上传完成后按业务保存结果创建引用,删除业务数据时按后端规则解除引用。
|
||||||
|
|
||||||
|
### 阶段 2:家谱内完整资源
|
||||||
|
|
||||||
|
范围与顺序:
|
||||||
|
|
||||||
|
1. 字辈谱 6 个。
|
||||||
|
2. 世系人物 12 个。
|
||||||
|
3. 成长记录 5 个。
|
||||||
|
4. 亲友往来 5 个。
|
||||||
|
5. 备忘录 5 个。
|
||||||
|
|
||||||
|
执行前提:
|
||||||
|
|
||||||
|
- 页面必须从 PC 的真实入口参数或运行时上下文取得 `genealogyId`。
|
||||||
|
- 当前 PC 尚无家谱入口接口;没有真实 `genealogyId` 时只展示缺少上下文状态,不发请求、不写死 ID,也不调用 APP。
|
||||||
|
- 只实现当前接口支持的操作;世系关系解除、字辈删除等缺口等待后端补充 PC 接口。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 所有请求均携带同一个真实 `genealogyId`,切换上下文后不串数据。
|
||||||
|
- 列表、新增、详情、修改和删除严格按当前各资源的实际接口能力开放。
|
||||||
|
- 世系树、人物详情和记录关联统一使用当前 PC 世系人物 DTO。
|
||||||
|
- 无权限用户看不到写入口,后端 403 能正确呈现。
|
||||||
|
|
||||||
|
### 阶段 3:当前只有删除能力的模块
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
1. 内容文章 1 个。
|
||||||
|
2. 相册与照片 2 个。
|
||||||
|
3. 视频 1 个。
|
||||||
|
4. 祭祀/典礼与献礼 2 个。
|
||||||
|
5. 功德记录 1 个。
|
||||||
|
|
||||||
|
处理方式:
|
||||||
|
|
||||||
|
- 先完成删除接口的 `ApiClient` 契约映射和接口测试。
|
||||||
|
- 因当前 PC 缺少列表、详情、新增或修改接口,页面继续保持预览状态,不开放孤立的删除按钮。
|
||||||
|
- 后端补齐同模块 PC 接口后,再按完整资源流程启用页面。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 7 个删除操作的 method、path、路径参数和权限定义均与 Apifox PC 一致。
|
||||||
|
- 当前页面不会因静态演示数据触发真实删除。
|
||||||
|
- 未补齐的能力不会通过 APP 接口、猜测路径或模拟成功结果实现。
|
||||||
|
|
||||||
|
### 阶段 4:接收后端新增 PC 接口
|
||||||
|
|
||||||
|
本阶段不属于当前 79 个接口的对接范围。后端每次新增 PC 接口后:
|
||||||
|
|
||||||
|
1. 先确认接口已正式进入 Apifox PC 目录。
|
||||||
|
2. 更新基线数量、第 4 节用途映射和第 5 节缺口。
|
||||||
|
3. 再安排相应页面、`ApiClient` 方法、契约测试和联调。
|
||||||
|
4. 不因为 APP 已存在同类能力而提前实现。
|
||||||
|
|
||||||
|
## 7. 联调与测试要求
|
||||||
|
|
||||||
|
每个接口组按相同顺序验收:
|
||||||
|
|
||||||
|
1. 直接在 Apifox PC 目录确认 method、path、DTO、枚举和权限。
|
||||||
|
2. 给 `ApiClient` 增加业务方法。
|
||||||
|
3. 增加契约测试,验证 method、path、query 和 body。
|
||||||
|
4. 接页面加载、空状态、错误状态和成功状态。
|
||||||
|
5. 验证 401、403、404、业务校验失败和重复提交。
|
||||||
|
6. 验证写入后重新读取的数据与页面一致。
|
||||||
|
7. 删除操作验证关联数据和文件引用处理。
|
||||||
|
|
||||||
|
禁止以下做法:
|
||||||
|
|
||||||
|
- 页面直接写 Axios 请求。
|
||||||
|
- 猜测字段名或同时兼容多个字段。
|
||||||
|
- 使用 APP 路径补 PC 缺口。
|
||||||
|
- 写死 `genealogyId`。
|
||||||
|
- 把静态示例数据当成接口成功结果。
|
||||||
|
- 只有删除接口时先开放删除按钮。
|
||||||
|
|
||||||
|
## 8. 后续业务待确认项(不阻塞当前 79)
|
||||||
|
|
||||||
|
以下问题只影响后端后续新增 PC 接口,不改变本轮范围:
|
||||||
|
|
||||||
|
1. “祭祀”究竟只指祖先祭祀,还是通用典礼/贺礼。
|
||||||
|
2. 亲友往来是否从现有备忘录页面拆成独立 Tab,建议拆分。
|
||||||
|
3. 字辈是否允许物理删除;建议优先停用。
|
||||||
|
4. 删除世系人物是否允许级联删除关系;建议默认禁止并由接口返回影响范围。
|
||||||
|
5. `feedback` 只用于意见反馈,还是要承担客服工单;若需要状态、回复和详情,应建立独立 Ticket 契约。
|
||||||
|
6. 资料提醒是否只是通知的一种类型;若是,`NotificationDTO` 应提供稳定的通知类型和目标深链。
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
# PC 接口对接交接文档
|
||||||
|
|
||||||
|
> 更新:2026-07-24
|
||||||
|
> 状态:已完成已核验 PC 接口的首轮页面施工;其余 PC 目录尚未核验、尚未接入。
|
||||||
|
|
||||||
|
## 一、必须遵守的范围
|
||||||
|
|
||||||
|
1. Apifox 的 **PC 目录**是唯一正式契约源。
|
||||||
|
2. 本地 OpenAPI 导出文件可能少接口,只能辅助查找,不能据此决定接口存在、路径或字段。
|
||||||
|
3. 不使用 APP 接口,也不能把 `/app/` 改为 `/pc/` 后猜测使用。
|
||||||
|
4. 继续任何新接口前,必须直接在 Apifox PC 详情核对 method、path、query、body、响应和权限。
|
||||||
|
5. 家谱业务只能使用真实 `?genealogyId=...`;不写死编号、不从 APP 获取。
|
||||||
|
6. 页面不直接调用 Axios、不自行拼请求路径,统一通过 `utils/ApiClient.js`。
|
||||||
|
|
||||||
|
## 二、已直接在 Apifox PC 核验并施工的接口
|
||||||
|
|
||||||
|
| 分组 | 条数 | 状态 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| 验证中心 | 4 | 已核验、已接入 |
|
||||||
|
| 认证登录 | 11 | 已核验、已接入 |
|
||||||
|
| 文件上传 | 6 | 已核验;仅单文件上传页面闭环开放 |
|
||||||
|
| 行政区划 | 4 | 已核验、已接入 |
|
||||||
|
| 家族圈 | 14 | 已核验、已接入 |
|
||||||
|
| 字辈谱 | 6 | 已核验、已接入 |
|
||||||
|
| 世系人物 | 12 | 已核验、已接入 |
|
||||||
|
| 合计 | 57 | |
|
||||||
|
|
||||||
|
完整映射见 [PC接口对接规划.md](PC接口对接规划.md)。
|
||||||
|
|
||||||
|
### 已核验关键规则
|
||||||
|
|
||||||
|
- 短信接口为 `POST /genealogy/pc/auth/sms/code`;场景只允许 `PC_SMS_LOGIN`、`PC_REGISTER`、`PC_FORGOT_PASSWORD`、`PC_PHONE_CHANGE`、`PC_ACCOUNT_DEACTIVATE`。`validToken` 来自 `/captcha/*`,只能单次消费。
|
||||||
|
- 认证相关均使用 PC 路径;密码为 32 位 MD5。改绑/注销只使用 PC DTO 所需字段,不擅自加 `tenantId`。
|
||||||
|
- 单文件上传为 `POST /genealogy/pc/files/upload`,multipart 仅提交 `file`;返回 `ossId`、`url`、`thumbnailUrl`、`fileName`、`originalName`。
|
||||||
|
- 家族圈新评论只提交 `commentContent` 和可选 `parentCommentId`,不能发旧字段 `content`、`replyUserId`。
|
||||||
|
- 字辈管理列表使用 `GET .../generation-poems/management`;批量操作使用 `poemText` 和可选 `disableMissing`。单项最多 50 字符、一次最多 500 代、总长最多 26000。
|
||||||
|
- 世系分页 query:`pageNum`、`pageSize`、`keyword`、`generation`、`personStatus`;`keyword` 只查姓名、别名、人物编号。
|
||||||
|
- 世系写入字段是 `name`、`generation`、`biography`,不是旧字段 `personName`、`generationNo`、`introduction`;`sortOrder`、`relationName` 是可选字段。
|
||||||
|
- `DELETE .../lineage/persons/{personId}` 是逻辑停用,不是物理删除;有正常子女时后端会拒绝。
|
||||||
|
- 新增父母/配偶/兄弟姐妹/子女四个关系接口均接收完整 `LineagePersonBody` 来新建关系人物,并非绑定两个已有 ID。
|
||||||
|
- 已末次回到 Apifox 复核:世系分页 query、创建人物 body、`POST .../lineage/persons/{personId}/spouses` 和 `relationName`。
|
||||||
|
|
||||||
|
## 三、已经落地的页面
|
||||||
|
|
||||||
|
### 账号与资料
|
||||||
|
|
||||||
|
- `login.html`:密码登录、短信登录。
|
||||||
|
- `register.html`:短信注册。
|
||||||
|
- `forgot-password.html`:短信重置密码。
|
||||||
|
- `profile-security.html`:改密码、改绑手机、注销账号。
|
||||||
|
- `profile-data.html` / `profile.html`:资料读取保存、头像单文件上传、行政区划。
|
||||||
|
|
||||||
|
### 家谱业务
|
||||||
|
|
||||||
|
- `profile-feed.html` / `profile-feed-edit.html`:家族圈列表、详情、发布/修改、点赞、评论、回复。
|
||||||
|
- `profile-generation.html` + `public/js/generation-pages.js`:字辈管理列表、新增、编辑、停用/恢复、批量预览/保存。所有写操作有防重复提交锁;批量示例要求用空格或支持的标点分隔。
|
||||||
|
- `profile-tree.html` + `public/js/lineage-pages.js`:成员总览、世系树、分页搜索/翻页、下拉选择、详情、新增/编辑、逻辑停用、父母/配偶/兄弟姐妹/子女新增。所有写操作有防重复提交锁;响应 int64 ID 只以安全字符串用于后续请求。
|
||||||
|
|
||||||
|
### 关键代码文件
|
||||||
|
|
||||||
|
| 文件 | 责任 |
|
||||||
|
| --- | --- |
|
||||||
|
| `utils/ApiClient.js` | 已核验 PC 路径、请求头、DTO |
|
||||||
|
| `public/js/profile-common.js` | `genealogyId` 读取和链接传播的唯一 owner |
|
||||||
|
| `public/js/auth-pages.js`、`captcha-pages.js`、`security-pages.js` | 登录、注册、验证码、账号安全 |
|
||||||
|
| `public/js/profile-pages.js`、`region-pages.js`、`upload-pages.js` | 资料、区划、头像 |
|
||||||
|
| `public/js/feed-pages.js` | 家族圈 |
|
||||||
|
| `public/js/generation-pages.js` | 字辈谱 |
|
||||||
|
| `public/js/lineage-pages.js` | 世系人物 |
|
||||||
|
|
||||||
|
## 四、当前阻塞和未完成范围
|
||||||
|
|
||||||
|
### PC 家谱入口缺失
|
||||||
|
|
||||||
|
PC 目录没有“我的家谱、家谱详情、创建/加入/选择家谱”接口。前端无法自行得到真实 `genealogyId`,因此当前正确行为是:缺少 ID 时提示用户从具体家谱进入并且不发请求。不要伪造编号,也不要用 APP 补这个缺口。
|
||||||
|
|
||||||
|
### 尚未核验、尚未施工的 22 条 PC 接口
|
||||||
|
|
||||||
|
| 分组 | 条数 | 当前原则 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| 内容文章 | 1 | 目前只见删除能力;没有真实列表/详情来源时不开放删除 |
|
||||||
|
| 相册 | 2 | 目前只见删除能力;不猜 DTO |
|
||||||
|
| 视频 | 1 | 目前只见删除能力;不猜 DTO |
|
||||||
|
| 祭祀 | 2 | 目前只见删除能力;不猜实体和献礼字段 |
|
||||||
|
| 族务记录 | 16 | 尚未逐条核验;先在 Apifox 看完再映射成长记录、亲友往来、备忘录等页面 |
|
||||||
|
| 合计 | 22 | |
|
||||||
|
|
||||||
|
## 五、通用行为和安全约束
|
||||||
|
|
||||||
|
- `ApiClient` 统一加 `clientid`;登录后统一加 `Authorization: Bearer <token>`。
|
||||||
|
- 401 清 token 跳转 `login.html`;403 保留登录态并展示无权限状态。
|
||||||
|
- 浏览器中的 int64 ID 均按字符串保存,避免精度丢失。
|
||||||
|
- 当前接口没有定义的删除、解绑、关系解除和业务文件引用值,保持禁用/待开发,不能猜测实现。
|
||||||
|
- 这是脏工作树;不要使用 `git reset --hard`、`git checkout --`,也不要删除未跟踪文件。
|
||||||
|
|
||||||
|
## 六、验证结果
|
||||||
|
|
||||||
|
最近一次聚焦验证已通过:52/52 测试通过,`git diff --check` 通过。
|
||||||
|
|
||||||
|
已运行的检查包括:
|
||||||
|
|
||||||
|
- `node --check utils/ApiClient.js`
|
||||||
|
- `node --check public/js/profile-common.js`
|
||||||
|
- `node --check public/js/generation-pages.js`
|
||||||
|
- `node --check public/js/lineage-pages.js`
|
||||||
|
- `node --test tests/auth-pages.test.js tests/api-client-contract.test.js tests/profile-pages.test.js tests/security-upload-scope.test.js tests/feed-pages.test.js tests/generation-pages.test.js tests/lineage-pages.test.js tests/pending-pages.test.js tests/pc-scope.test.js`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
本轮未运行无关的完整历史测试集。
|
||||||
|
|
||||||
|
## 七、下一位 GPT 的执行顺序
|
||||||
|
|
||||||
|
1. 读本文和 `docs/PC接口对接规划.md`。
|
||||||
|
2. 打开 Apifox,**只看 PC 目录**,从“族务记录”16 条开始逐条核验。
|
||||||
|
3. 每次只处理一个完整小分组:Apifox 核验 → `ApiClient` → 对应页面 → 聚焦测试。
|
||||||
|
4. 只有删除能力、没有真实列表/详情来源的模块,不开放危险操作,只登记后端缺口。
|
||||||
|
5. 每完成一组,更新本文、`PC接口对接规划.md` 和相关测试,并报告改动、验证和剩余阻塞。
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="密码"
|
placeholder="密码"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="validToken" />
|
|
||||||
<button class="btn primary" type="submit">登录</button>
|
<button class="btn primary" type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -179,6 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bottom-actions">
|
<div class="bottom-actions">
|
||||||
<button class="btn primary magnetic" type="submit">保存地区</button>
|
<button class="btn primary magnetic" type="submit">保存地区</button>
|
||||||
|
<span class="form-status" data-region-profile-status></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form class="editor-form" data-region-search-form data-region-search-panel>
|
<form class="editor-form" data-region-search-form data-region-search-panel>
|
||||||
|
|||||||
@@ -63,17 +63,17 @@
|
|||||||
<h2>管理入口</h2>
|
<h2>管理入口</h2>
|
||||||
<p data-member-overview>家谱概览加载中...</p>
|
<p data-member-overview>家谱概览加载中...</p>
|
||||||
<div class="module-grid">
|
<div class="module-grid">
|
||||||
<a class="module-card tilt-card" href="profile-tree.html"
|
<a class="module-card tilt-card" href="profile-tree.html" data-feature-link="available" data-genealogy-context-link
|
||||||
><span class="icon">世</span>
|
><span class="icon">世</span>
|
||||||
<h3>世系图</h3>
|
<h3>世系图</h3>
|
||||||
<p>查看树谱模式、搜索成员、进入个人资料。</p></a
|
<p>查看树谱模式、搜索成员、进入个人资料。</p></a
|
||||||
>
|
>
|
||||||
<a class="module-card tilt-card" href="profile-generation.html"
|
<a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link
|
||||||
><span class="icon">辈</span>
|
><span class="icon">辈</span>
|
||||||
<h3>字辈谱</h3>
|
<h3>字辈谱</h3>
|
||||||
<p>维护字辈和各世代人数。</p></a
|
<p>维护字辈和各世代人数。</p></a
|
||||||
>
|
>
|
||||||
<a class="module-card tilt-card" href="profile-generation.html"
|
<a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link
|
||||||
><span class="icon">代</span>
|
><span class="icon">代</span>
|
||||||
<h3>设置世代</h3>
|
<h3>设置世代</h3>
|
||||||
<p>新增、编辑、调整始祖世代。</p></a
|
<p>新增、编辑、调整始祖世代。</p></a
|
||||||
|
|||||||
+15
-59
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="public/layui/css/layui.css" />
|
<link rel="stylesheet" href="public/layui/css/layui.css" />
|
||||||
<link rel="stylesheet" href="public/css/profile-module.css" />
|
<link rel="stylesheet" href="public/css/profile-module.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="page-profile-module" data-feature-status="pending" data-feature-message="字辈维护服务正在开发中,当前页面仅保留设计预览。">
|
<body class="page-profile-module" data-generation-page>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container nav">
|
<div class="container nav">
|
||||||
<a class="brand magnetic" href="profile.html"
|
<a class="brand magnetic" href="profile.html"
|
||||||
@@ -16,17 +16,11 @@
|
|||||||
>
|
>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="profile-families.html">我的家谱</a
|
<a href="profile-families.html">我的家谱</a
|
||||||
><a class="active" href="profile-family-admin.html">家族管理</a>
|
><a class="active" href="profile-family-admin.html" data-genealogy-context-link>家族管理</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
<a href="profile-family-admin.html">返回管理</a
|
<a href="profile-family-admin.html" data-genealogy-context-link>返回管理</a
|
||||||
><a
|
><a class="btn primary magnetic" href="#generation-content">管理</a>
|
||||||
class="btn primary magnetic"
|
|
||||||
href="#"
|
|
||||||
data-layer-confirm="进入字辈管理模式?"
|
|
||||||
data-confirm-title="管理字辈"
|
|
||||||
>管理</a
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,31 +44,31 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container module-layout">
|
<div class="container module-layout">
|
||||||
<aside class="module-nav">
|
<aside class="module-nav">
|
||||||
<a href="profile-tree.html">世系图</a
|
<a href="profile-tree.html" data-genealogy-context-link>世系图</a
|
||||||
><a class="active" href="profile-generation.html">字辈谱</a
|
><a class="active" href="profile-generation.html" data-genealogy-context-link>字辈谱</a
|
||||||
><a href="profile-family-admin.html">家族管理</a>
|
><a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="module-main">
|
<div id="generation-content" class="module-main">
|
||||||
<section class="module-panel generation-batch-panel">
|
<section class="module-panel generation-batch-panel">
|
||||||
<h2>批量字辈</h2>
|
<h2>批量字辈</h2>
|
||||||
<!-- 批量字辈表单:字段对应 GenerationPoemBatchBody。 -->
|
<!-- 批量字辈表单:字段对应 Apifox 的 GenerationPoemBatchBody。 -->
|
||||||
<form class="editor-form generation-batch-form" data-generation-batch-form>
|
<form class="editor-form generation-batch-form" data-generation-batch-form>
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="generationBatchContent">字辈内容</label>
|
<label for="generationBatchContent">字辈内容</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="generationBatchContent"
|
id="generationBatchContent"
|
||||||
name="content"
|
name="poemText"
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder="例如:德承家亦"
|
placeholder="例如:德 承 家 亦(用空格或标点分隔)"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<label class="check-row generation-batch-check" for="generationStopMissing">
|
<label class="check-row generation-batch-check" for="generationStopMissing">
|
||||||
<input
|
<input
|
||||||
id="generationStopMissing"
|
id="generationStopMissing"
|
||||||
name="stopMissingOldGeneration"
|
name="disableMissing"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<span>遇到缺失旧世代时停止导入</span>
|
<span>保存时停用未出现在文本中的后续世代(不删除历史记录)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="bottom-actions generation-batch-actions">
|
<div class="bottom-actions generation-batch-actions">
|
||||||
<button
|
<button
|
||||||
@@ -97,45 +91,7 @@
|
|||||||
<section class="module-panel">
|
<section class="module-panel">
|
||||||
<h2>字辈列表</h2>
|
<h2>字辈列表</h2>
|
||||||
<div class="module-list" data-generation-list>
|
<div class="module-list" data-generation-list>
|
||||||
<div class="module-row">
|
<div class="api-empty">正在加载字辈记录…</div>
|
||||||
<div>
|
|
||||||
<h3>第 1 代:代</h3>
|
|
||||||
<p>1 人</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
class="pill"
|
|
||||||
href="#"
|
|
||||||
data-layer-prompt="请输入新的字辈"
|
|
||||||
data-prompt-title="编辑字辈"
|
|
||||||
>编辑</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="module-row">
|
|
||||||
<div>
|
|
||||||
<h3>第 2 代:顺</h3>
|
|
||||||
<p>2 人</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
class="pill"
|
|
||||||
href="#"
|
|
||||||
data-layer-prompt="请输入新的字辈"
|
|
||||||
data-prompt-title="编辑字辈"
|
|
||||||
>编辑</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="module-row">
|
|
||||||
<div>
|
|
||||||
<h3>第 3 代:万</h3>
|
|
||||||
<p>3 人</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
class="pill"
|
|
||||||
href="#"
|
|
||||||
data-layer-prompt="请输入新的字辈"
|
|
||||||
data-prompt-title="编辑字辈"
|
|
||||||
>编辑</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +107,7 @@
|
|||||||
<script src="utils/axios.js"></script>
|
<script src="utils/axios.js"></script>
|
||||||
<script src="utils/AxiosRequestUtil.js"></script>
|
<script src="utils/AxiosRequestUtil.js"></script>
|
||||||
<script src="utils/ApiClient.js"></script>
|
<script src="utils/ApiClient.js"></script>
|
||||||
<script src="public/js/pending-pages.js"></script>
|
<script src="public/js/generation-pages.js"></script>
|
||||||
<script src="public/js/page-effects.js"></script>
|
<script src="public/js/page-effects.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+26
-9
@@ -63,7 +63,6 @@
|
|||||||
<label for="confirmPassword">确认新密码</label>
|
<label for="confirmPassword">确认新密码</label>
|
||||||
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" required />
|
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" required />
|
||||||
</div>
|
</div>
|
||||||
<input name="validToken" type="hidden" />
|
|
||||||
<div class="bottom-actions">
|
<div class="bottom-actions">
|
||||||
<button class="btn primary magnetic" type="submit">修改密码</button>
|
<button class="btn primary magnetic" type="submit">修改密码</button>
|
||||||
<span class="api-status" data-security-status>等待提交</span>
|
<span class="api-status" data-security-status>等待提交</span>
|
||||||
@@ -77,7 +76,15 @@
|
|||||||
<div class="editor-two">
|
<div class="editor-two">
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="newPhone">新手机号</label>
|
<label for="newPhone">新手机号</label>
|
||||||
<input id="newPhone" name="newPhone" type="tel" required />
|
<input
|
||||||
|
id="newPhone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="11"
|
||||||
|
autocomplete="tel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="phoneSmsCode">短信验证码</label>
|
<label for="phoneSmsCode">短信验证码</label>
|
||||||
@@ -94,23 +101,33 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="module-panel security-danger">
|
<section class="module-panel security-danger">
|
||||||
<h2>注销账号</h2>
|
<h2>注销账号</h2>
|
||||||
<!-- 注销账号属于敏感操作,必须填写当前密码并输入确认文本。 -->
|
<p>验证已绑定手机号的短信验证码后提交注销申请。</p>
|
||||||
<form class="editor-form security-form" data-security-form="deactivate">
|
<form class="editor-form security-form" data-security-form="deactivate">
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="deactivatePassword">当前密码</label>
|
<label for="deactivateSmsCode">短信验证码</label>
|
||||||
<input id="deactivatePassword" name="password" type="password" autocomplete="current-password" required />
|
<input
|
||||||
</div>
|
id="deactivateSmsCode"
|
||||||
<div class="editor-field">
|
name="smsCode"
|
||||||
<label for="deactivateReason">注销原因</label>
|
type="text"
|
||||||
<textarea id="deactivateReason" name="reason" placeholder="可选"></textarea>
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button class="btn ghost magnetic" type="button" data-security-send-deactivate-code>
|
||||||
|
获取验证码
|
||||||
|
</button>
|
||||||
|
<p class="api-status" data-security-bound-phone>正在读取已绑定手机号</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="confirmText">确认文本</label>
|
<label for="confirmText">确认文本</label>
|
||||||
<input id="confirmText" name="confirmText" type="text" placeholder="请输入:确认注销" required />
|
<input id="confirmText" name="confirmText" type="text" placeholder="请输入:确认注销" required />
|
||||||
</div>
|
</div>
|
||||||
|
<input name="validToken" type="hidden" />
|
||||||
<div class="bottom-actions">
|
<div class="bottom-actions">
|
||||||
<button class="btn primary magnetic" type="submit">提交注销</button>
|
<button class="btn primary magnetic" type="submit">提交注销</button>
|
||||||
<button class="btn ghost magnetic" type="button" data-security-logout>退出登录</button>
|
<button class="btn ghost magnetic" type="button" data-security-logout>退出登录</button>
|
||||||
|
<span class="api-status" data-security-status>等待验证</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+40
-15
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="public/layui/css/layui.css" />
|
<link rel="stylesheet" href="public/layui/css/layui.css" />
|
||||||
<link rel="stylesheet" href="public/css/profile-module.css" />
|
<link rel="stylesheet" href="public/css/profile-module.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="page-profile-module" data-lineage-page data-feature-status="pending" data-feature-message="世系与成员服务正在开发中,当前页面仅保留设计预览。">
|
<body class="page-profile-module" data-lineage-page>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container nav">
|
<div class="container nav">
|
||||||
<a class="brand magnetic" href="profile.html"
|
<a class="brand magnetic" href="profile.html"
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
>
|
>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="profile-families.html">我的家谱</a
|
<a href="profile-families.html">我的家谱</a
|
||||||
><a class="active" href="profile-family-admin.html">家族管理</a
|
><a class="active" href="profile-family-admin.html" data-genealogy-context-link>家族管理</a
|
||||||
><a href="profile-data.html">个人资料</a>
|
><a href="profile-data.html">个人资料</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
<a href="profile-family-home.html">返回家谱主页</a
|
<a href="profile-family-home.html" data-genealogy-context-link>返回家谱主页</a
|
||||||
><a class="btn primary magnetic" href="profile-data.html">添加成员</a>
|
><a class="btn primary magnetic" href="#lineage-member-form">添加成员</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
个人中心内的树谱管理视图,用于查看成员关系、添加亲属和搜索成员。
|
个人中心内的树谱管理视图,用于查看成员关系、添加亲属和搜索成员。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn ghost magnetic" href="profile-family-admin.html"
|
<a class="btn ghost magnetic" href="profile-generation.html" data-genealogy-context-link
|
||||||
>管理世代</a
|
>管理世代</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container module-layout">
|
<div class="container module-layout">
|
||||||
<aside class="module-nav">
|
<aside class="module-nav">
|
||||||
<a href="profile-family-home.html">家谱主页</a
|
<a href="profile-family-home.html" data-genealogy-context-link>家谱主页</a
|
||||||
><a class="active" href="profile-tree.html">世系图</a
|
><a class="active" href="profile-tree.html" data-genealogy-context-link>世系图</a
|
||||||
><a href="profile-generation.html">字辈谱</a
|
><a href="profile-generation.html" data-genealogy-context-link>字辈谱</a
|
||||||
><a href="profile-family-admin.html">家族管理</a>
|
><a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="module-main">
|
<div class="module-main">
|
||||||
<section class="module-panel">
|
<section class="module-panel">
|
||||||
@@ -70,6 +70,16 @@
|
|||||||
<h3>添加子女</h3>
|
<h3>添加子女</h3>
|
||||||
<p>先选择成员,再为当前成员补充下一代关系。</p></button
|
<p>先选择成员,再为当前成员补充下一代关系。</p></button
|
||||||
>
|
>
|
||||||
|
<button class="module-card module-action-card tilt-card" type="button" data-lineage-relation="siblings"
|
||||||
|
><span class="icon">兄</span>
|
||||||
|
<h3>添加兄弟姐妹</h3>
|
||||||
|
<p>先选择成员,再为当前成员新增同辈关系。</p></button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="lineage-toolbar">
|
||||||
|
<select data-lineage-person-options aria-label="选择当前成员">
|
||||||
|
<option value="">请选择当前成员</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="module-panel">
|
<section class="module-panel">
|
||||||
@@ -82,13 +92,19 @@
|
|||||||
<section class="module-panel">
|
<section class="module-panel">
|
||||||
<h2>成员搜索</h2>
|
<h2>成员搜索</h2>
|
||||||
<div class="lineage-toolbar">
|
<div class="lineage-toolbar">
|
||||||
<input type="search" data-lineage-keyword placeholder="输入姓名或字辈搜索" />
|
<input type="search" data-lineage-keyword placeholder="输入姓名、别名或人物编号搜索" />
|
||||||
<button class="btn primary" type="button" data-lineage-search>搜索</button>
|
<button class="btn primary" type="button" data-lineage-search>搜索</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="form-status" data-lineage-list-summary></p>
|
||||||
<!-- 成员列表由世系人物分页接口渲染 -->
|
<!-- 成员列表由世系人物分页接口渲染 -->
|
||||||
<div class="module-list" data-lineage-list>
|
<div class="module-list" data-lineage-list>
|
||||||
<div class="api-empty">成员加载中...</div>
|
<div class="api-empty">成员加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bottom-actions" data-lineage-pagination hidden>
|
||||||
|
<button class="btn ghost" type="button" data-lineage-page-action="previous">上一页</button>
|
||||||
|
<span class="form-status" data-lineage-page-status></span>
|
||||||
|
<button class="btn ghost" type="button" data-lineage-page-action="next">下一页</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="module-panel">
|
<section class="module-panel">
|
||||||
<h2>成员详情</h2>
|
<h2>成员详情</h2>
|
||||||
@@ -99,11 +115,11 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="module-panel">
|
<section class="module-panel">
|
||||||
<h2>新增成员</h2>
|
<h2>新增成员</h2>
|
||||||
<form class="editor-form" data-lineage-form>
|
<form id="lineage-member-form" class="editor-form" data-lineage-form>
|
||||||
<div class="editor-two">
|
<div class="editor-two">
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="lineage-person-name">姓名</label>
|
<label for="lineage-person-name">姓名</label>
|
||||||
<input id="lineage-person-name" name="personName" type="text" placeholder="请输入成员姓名" required />
|
<input id="lineage-person-name" name="name" type="text" placeholder="请输入成员姓名" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="lineage-sex">性别</label>
|
<label for="lineage-sex">性别</label>
|
||||||
@@ -116,7 +132,7 @@
|
|||||||
<div class="editor-two">
|
<div class="editor-two">
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="lineage-generation-no">世代序号</label>
|
<label for="lineage-generation-no">世代序号</label>
|
||||||
<input id="lineage-generation-no" name="generationNo" type="number" min="1" placeholder="例如 3" />
|
<input id="lineage-generation-no" name="generation" type="number" min="1" placeholder="例如 3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="lineage-generation-name">字辈</label>
|
<label for="lineage-generation-name">字辈</label>
|
||||||
@@ -133,6 +149,14 @@
|
|||||||
<input id="lineage-alias-name" name="aliasName" type="text" placeholder="可选" />
|
<input id="lineage-alias-name" name="aliasName" type="text" placeholder="可选" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="editor-field">
|
||||||
|
<label for="lineage-sort-order">排序值</label>
|
||||||
|
<input id="lineage-sort-order" name="sortOrder" type="number" step="1" placeholder="可选,数值越小越靠前" />
|
||||||
|
</div>
|
||||||
|
<div class="editor-field">
|
||||||
|
<label for="lineage-relation-name">关系称谓</label>
|
||||||
|
<input id="lineage-relation-name" name="relationName" type="text" placeholder="添加配偶时可选,例如 妻、夫" />
|
||||||
|
</div>
|
||||||
<div class="editor-two">
|
<div class="editor-two">
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="lineage-birth-date">出生日期</label>
|
<label for="lineage-birth-date">出生日期</label>
|
||||||
@@ -145,11 +169,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field">
|
||||||
<label for="lineage-introduction">简介</label>
|
<label for="lineage-introduction">简介</label>
|
||||||
<textarea id="lineage-introduction" name="introduction" placeholder="记录成员生平简介"></textarea>
|
<textarea id="lineage-introduction" name="biography" placeholder="记录成员生平简介"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottom-actions">
|
<div class="bottom-actions">
|
||||||
<button class="btn primary" type="submit">保存成员</button>
|
<button class="btn primary" type="submit">保存成员</button>
|
||||||
<button class="btn ghost" type="reset">清空</button>
|
<button class="btn ghost" type="reset">清空</button>
|
||||||
|
<span class="form-status" data-lineage-form-status></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -166,7 +191,7 @@
|
|||||||
<script src="utils/axios.js"></script>
|
<script src="utils/axios.js"></script>
|
||||||
<script src="utils/AxiosRequestUtil.js"></script>
|
<script src="utils/AxiosRequestUtil.js"></script>
|
||||||
<script src="utils/ApiClient.js"></script>
|
<script src="utils/ApiClient.js"></script>
|
||||||
<script src="public/js/pending-pages.js"></script>
|
<script src="public/js/lineage-pages.js"></script>
|
||||||
<script src="public/js/page-effects.js"></script>
|
<script src="public/js/page-effects.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -177,7 +177,7 @@
|
|||||||
<h3>字辈谱</h3>
|
<h3>字辈谱</h3>
|
||||||
<p>管理字辈、统计各代人数和成员分布。</p></a
|
<p>管理字辈、统计各代人数和成员分布。</p></a
|
||||||
>
|
>
|
||||||
<a class="feature-tile tilt-card" href="profile-generation.html"
|
<a class="feature-tile tilt-card" href="profile-generation.html" data-genealogy-context-link
|
||||||
><span>代</span>
|
><span>代</span>
|
||||||
<h3>管理世代</h3>
|
<h3>管理世代</h3>
|
||||||
<p>设置世代、调整始祖世代、维护排行。</p></a
|
<p>设置世代、调整始祖世代、维护排行。</p></a
|
||||||
|
|||||||
@@ -209,7 +209,8 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineage-toolbar input {
|
.lineage-toolbar input,
|
||||||
|
.lineage-toolbar select {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
+96
-47
@@ -18,25 +18,26 @@
|
|||||||
var FORM_CONFIG = {
|
var FORM_CONFIG = {
|
||||||
login: {
|
login: {
|
||||||
selector: '#login-password-form',
|
selector: '#login-password-form',
|
||||||
captchaScene: 'WEB_H5_LOGIN',
|
captchaScene: 'PC_SMS_LOGIN',
|
||||||
successUrl: 'profile.html'
|
successUrl: 'profile.html'
|
||||||
},
|
},
|
||||||
'sms-login': {
|
'sms-login': {
|
||||||
selector: '#login-sms-form',
|
selector: '#login-sms-form',
|
||||||
captchaScene: 'WEB_H5_LOGIN',
|
captchaScene: 'PC_SMS_LOGIN',
|
||||||
successUrl: 'profile.html'
|
successUrl: 'profile.html'
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
selector: '#register-form',
|
selector: '#register-form',
|
||||||
captchaScene: 'WEB_H5_REGISTER',
|
captchaScene: 'PC_REGISTER',
|
||||||
successUrl: 'login.html'
|
successUrl: 'login.html'
|
||||||
},
|
},
|
||||||
'password-reset': {
|
'password-reset': {
|
||||||
selector: '#password-reset-form',
|
selector: '#password-reset-form',
|
||||||
captchaScene: 'WEB_H5_FORGOT_PASSWORD',
|
captchaScene: 'PC_FORGOT_PASSWORD',
|
||||||
successUrl: 'login.html'
|
successUrl: 'login.html'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var SMS_CODE_COOLDOWN_SECONDS = 60;
|
||||||
|
|
||||||
function hashPassword(value) {
|
function hashPassword(value) {
|
||||||
// 接口文档要求密码传 32 位 MD5;没有加载 md5.js 时保留原值,便于测试定位。
|
// 接口文档要求密码传 32 位 MD5;没有加载 md5.js 时保留原值,便于测试定位。
|
||||||
@@ -46,50 +47,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildLoginBody(values, hashFn) {
|
function buildLoginBody(values, hashFn) {
|
||||||
// 这里只组装页面字段,grantType、tenantId、clientId 由 api-client 统一补齐。
|
// 这里只组装登录 DTO 字段,grantType、tenantId、clientId 由 api-client 统一补齐。
|
||||||
var hash = hashFn || hashPassword;
|
var hash = hashFn || hashPassword;
|
||||||
var body = {
|
return {
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
password: hash(values.password)
|
password: hash(values.password)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (values.validToken) body.validToken = values.validToken;
|
|
||||||
return body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSmsLoginBody(values) {
|
function buildSmsLoginBody(values) {
|
||||||
// 短信登录接口只需要页面字段,grantType、tenantId、clientId 由 api-client 统一补齐。
|
// 短信登录接口只需要页面字段,grantType、tenantId、clientId 由 api-client 统一补齐。
|
||||||
var body = {
|
return {
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
smsCode: values.smsCode
|
smsCode: values.smsCode
|
||||||
};
|
};
|
||||||
|
|
||||||
if (values.validToken) body.validToken = values.validToken;
|
|
||||||
return body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegisterBody(values, hashFn) {
|
function buildRegisterBody(values, hashFn) {
|
||||||
// 注册验证码当前对应 validToken,可选字段只在有值时提交。
|
// 确认密码只用于页面校验;注册接口需要手机号短信验证码。
|
||||||
var hash = hashFn || hashPassword;
|
var hash = hashFn || hashPassword;
|
||||||
var body = {
|
return {
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
password: hash(values.password),
|
password: hash(values.password),
|
||||||
nickName: values.nickName || values.phone
|
nickName: values.nickName || values.phone,
|
||||||
|
smsCode: values.smsCode
|
||||||
};
|
};
|
||||||
|
|
||||||
if (values.validToken) body.validToken = values.validToken;
|
|
||||||
return body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPasswordResetBody(values, hashFn) {
|
function buildPasswordResetBody(values, hashFn) {
|
||||||
// 找回密码接口使用 newPassword 字段,页面确认密码只在提交前校验,不传给后端。
|
// 找回密码接口使用 newPassword 字段,页面确认密码只在提交前校验,不传给后端。
|
||||||
var hash = hashFn || hashPassword;
|
var hash = hashFn || hashPassword;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
smsCode: values.smsCode,
|
smsCode: values.smsCode,
|
||||||
newPassword: hash(values.newPassword),
|
newPassword: hash(values.newPassword)
|
||||||
validToken: values.validToken || ''
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +112,10 @@
|
|||||||
// 未配置 PC/H5 验证场景时不调用验证中心,也不伪造 validToken。
|
// 未配置 PC/H5 验证场景时不调用验证中心,也不伪造 validToken。
|
||||||
if (!sceneCode) return true;
|
if (!sceneCode) return true;
|
||||||
|
|
||||||
// 验证适配层不存在时不阻塞测试环境;真实页面会加载 captcha-pages.js。
|
if (!root.CaptchaPages || !root.CaptchaPages.ensureToken) {
|
||||||
if (!root.CaptchaPages || !root.CaptchaPages.ensureToken) return true;
|
showMessage('验证码组件未加载,请刷新后重试');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return Boolean(await root.CaptchaPages.ensureToken(form, {
|
return Boolean(await root.CaptchaPages.ensureToken(form, {
|
||||||
sceneCode: sceneCode,
|
sceneCode: sceneCode,
|
||||||
@@ -149,6 +142,41 @@
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearCaptchaToken(form) {
|
||||||
|
var field = query('input[name="validToken"]', form);
|
||||||
|
|
||||||
|
if (field) field.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSmsCooldown(button) {
|
||||||
|
var originalText;
|
||||||
|
var remaining = SMS_CODE_COOLDOWN_SECONDS;
|
||||||
|
var interval;
|
||||||
|
|
||||||
|
if (!button || typeof root.setInterval !== 'function') return;
|
||||||
|
if (button.__smsCooldownTimer && typeof root.clearInterval === 'function') {
|
||||||
|
root.clearInterval(button.__smsCooldownTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
originalText = button.getAttribute('data-sms-code-label') || button.textContent.trim();
|
||||||
|
button.setAttribute('data-sms-code-label', originalText);
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = remaining + ' 秒后重试';
|
||||||
|
interval = root.setInterval(function () {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
button.textContent = remaining + ' 秒后重试';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof root.clearInterval === 'function') root.clearInterval(interval);
|
||||||
|
button.__smsCooldownTimer = null;
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 1000);
|
||||||
|
button.__smsCooldownTimer = interval;
|
||||||
|
}
|
||||||
|
|
||||||
function setBusy(button, busy) {
|
function setBusy(button, busy) {
|
||||||
// 加载状态只切 class 和 aria,不在 JS 中写样式。
|
// 加载状态只切 class 和 aria,不在 JS 中写样式。
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@@ -197,7 +225,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (formType === 'register') {
|
if (formType === 'register') {
|
||||||
return validatePassword(data.password, '请填写密码');
|
if (!data.smsCode) return '请填写短信验证码';
|
||||||
|
if (!isSmsCode(data.smsCode)) return '请输入正确的短信验证码';
|
||||||
|
|
||||||
|
var registerPasswordMessage = validatePassword(data.password, '请填写密码');
|
||||||
|
if (registerPasswordMessage) return registerPasswordMessage;
|
||||||
|
if (!data.confirmPassword) return '请再次输入密码';
|
||||||
|
if (data.password !== data.confirmPassword) return '两次输入的密码不一致';
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formType === 'password-reset') {
|
if (formType === 'password-reset') {
|
||||||
@@ -261,6 +296,7 @@
|
|||||||
var isActive = panel.id === activePanelId;
|
var isActive = panel.id === activePanelId;
|
||||||
|
|
||||||
panel.hidden = !isActive;
|
panel.hidden = !isActive;
|
||||||
|
if (!isActive) clearCaptchaToken(panel);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,12 +305,11 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = readForm(form);
|
var values = readForm(form);
|
||||||
|
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
|
showMessage('接口初始化失败,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validateBeforeSubmit('login', values)) return;
|
if (!validateBeforeSubmit('login', values)) return;
|
||||||
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'login'), values.phone)) return;
|
|
||||||
|
|
||||||
values = readForm(form);
|
|
||||||
|
|
||||||
setBusy(button, true);
|
setBusy(button, true);
|
||||||
try {
|
try {
|
||||||
await api.login(buildLoginBody(values));
|
await api.login(buildLoginBody(values));
|
||||||
@@ -291,12 +326,11 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = readForm(form);
|
var values = readForm(form);
|
||||||
|
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
|
showMessage('接口初始化失败,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validateBeforeSubmit('sms-login', values)) return;
|
if (!validateBeforeSubmit('sms-login', values)) return;
|
||||||
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'sms-login'), values.phone)) return;
|
|
||||||
|
|
||||||
values = readForm(form);
|
|
||||||
|
|
||||||
setBusy(button, true);
|
setBusy(button, true);
|
||||||
try {
|
try {
|
||||||
await api.loginBySms(buildSmsLoginBody(values));
|
await api.loginBySms(buildSmsLoginBody(values));
|
||||||
@@ -313,12 +347,11 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = readForm(form);
|
var values = readForm(form);
|
||||||
|
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
|
showMessage('接口初始化失败,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validateBeforeSubmit('register', values)) return;
|
if (!validateBeforeSubmit('register', values)) return;
|
||||||
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'register'), values.phone)) return;
|
|
||||||
|
|
||||||
values = readForm(form);
|
|
||||||
|
|
||||||
setBusy(button, true);
|
setBusy(button, true);
|
||||||
try {
|
try {
|
||||||
await api.register(buildRegisterBody(values));
|
await api.register(buildRegisterBody(values));
|
||||||
@@ -338,7 +371,10 @@
|
|||||||
var formType = getFormType(form);
|
var formType = getFormType(form);
|
||||||
var sceneCode = getFormCaptchaScene(form, formType);
|
var sceneCode = getFormCaptchaScene(form, formType);
|
||||||
|
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
|
showMessage('接口初始化失败,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validateBeforeSendCode(values)) return;
|
if (!validateBeforeSendCode(values)) return;
|
||||||
if (!await ensureCaptcha(form, sceneCode, values.phone)) return;
|
if (!await ensureCaptcha(form, sceneCode, values.phone)) return;
|
||||||
|
|
||||||
@@ -351,10 +387,13 @@
|
|||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
validToken: values.validToken || ''
|
validToken: values.validToken || ''
|
||||||
});
|
});
|
||||||
|
clearCaptchaToken(form);
|
||||||
showMessage('验证码已发送');
|
showMessage('验证码已发送');
|
||||||
|
setBusy(button, false);
|
||||||
|
startSmsCooldown(button);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearCaptchaToken(form);
|
||||||
showMessage(error.message || '验证码发送失败');
|
showMessage(error.message || '验证码发送失败');
|
||||||
} finally {
|
|
||||||
setBusy(button, false);
|
setBusy(button, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,12 +403,11 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = readForm(form);
|
var values = readForm(form);
|
||||||
|
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
|
showMessage('接口初始化失败,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validateBeforeSubmit('password-reset', values)) return;
|
if (!validateBeforeSubmit('password-reset', values)) return;
|
||||||
if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'password-reset'), values.phone)) return;
|
|
||||||
|
|
||||||
values = readForm(form);
|
|
||||||
|
|
||||||
setBusy(button, true);
|
setBusy(button, true);
|
||||||
try {
|
try {
|
||||||
await api.resetPassword(buildPasswordResetBody(values));
|
await api.resetPassword(buildPasswordResetBody(values));
|
||||||
@@ -399,6 +437,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindCaptchaTokenInvalidation() {
|
||||||
|
queryAll('form input[name="phone"]').forEach(function (field) {
|
||||||
|
field.addEventListener('input', function () {
|
||||||
|
clearCaptchaToken(field.closest('form'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getFormType(form) {
|
function getFormType(form) {
|
||||||
if (!form) return '';
|
if (!form) return '';
|
||||||
|
|
||||||
@@ -417,6 +463,7 @@
|
|||||||
bindSubmit(FORM_CONFIG['password-reset'].selector, submitPasswordReset);
|
bindSubmit(FORM_CONFIG['password-reset'].selector, submitPasswordReset);
|
||||||
bindLoginModeTabs();
|
bindLoginModeTabs();
|
||||||
bindCodeButtons();
|
bindCodeButtons();
|
||||||
|
bindCaptchaTokenInvalidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -424,6 +471,8 @@
|
|||||||
buildSmsLoginBody: buildSmsLoginBody,
|
buildSmsLoginBody: buildSmsLoginBody,
|
||||||
buildRegisterBody: buildRegisterBody,
|
buildRegisterBody: buildRegisterBody,
|
||||||
buildPasswordResetBody: buildPasswordResetBody,
|
buildPasswordResetBody: buildPasswordResetBody,
|
||||||
|
clearCaptchaToken: clearCaptchaToken,
|
||||||
|
startSmsCooldown: startSmsCooldown,
|
||||||
getCaptchaScene: getCaptchaScene,
|
getCaptchaScene: getCaptchaScene,
|
||||||
getFormCaptchaScene: getFormCaptchaScene,
|
getFormCaptchaScene: getFormCaptchaScene,
|
||||||
getSuccessUrl: getSuccessUrl,
|
getSuccessUrl: getSuccessUrl,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var DEFAULT_SCENE_CODE = 'WEB_H5_LOGIN';
|
var DEFAULT_SCENE_CODE = 'PC_SMS_LOGIN';
|
||||||
var CAPTCHA_LAYER_AREA = ['318px', '318px'];
|
var CAPTCHA_LAYER_AREA = ['318px', '318px'];
|
||||||
var captchaLayerSeed = 0;
|
var captchaLayerSeed = 0;
|
||||||
|
|
||||||
|
|||||||
+160
-36
@@ -20,6 +20,24 @@
|
|||||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRedirectToLogin(api, error) {
|
||||||
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUnauthorized(api, error) {
|
||||||
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
|
if (api && api.clearToken) api.clearToken();
|
||||||
|
if (!root.location) return true;
|
||||||
|
if (typeof root.location.replace === 'function') {
|
||||||
|
root.location.replace('login.html');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
root.location.href = 'login.html';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function query(selector, rootNode) {
|
function query(selector, rootNode) {
|
||||||
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
|
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
|
||||||
}
|
}
|
||||||
@@ -62,7 +80,7 @@
|
|||||||
return text || undefined;
|
return text || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNumberOrUndefined(value) {
|
function toIntegerOrUndefined(value) {
|
||||||
var text = trimOrUndefined(value);
|
var text = trimOrUndefined(value);
|
||||||
var number;
|
var number;
|
||||||
|
|
||||||
@@ -80,23 +98,21 @@
|
|||||||
mediaOssIds: trimOrUndefined(source.mediaOssIds),
|
mediaOssIds: trimOrUndefined(source.mediaOssIds),
|
||||||
status: trimOrUndefined(source.status)
|
status: trimOrUndefined(source.status)
|
||||||
};
|
};
|
||||||
var sortOrder = toNumberOrUndefined(source.sortOrder);
|
var sortOrder = toIntegerOrUndefined(source.sortOrder);
|
||||||
|
|
||||||
if (sortOrder !== undefined) body.sortOrder = sortOrder;
|
if (sortOrder !== undefined) body.sortOrder = sortOrder;
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCommentBody(values) {
|
function buildCommentBody(values) {
|
||||||
// FamilyFeedCommentBody 的 content 是唯一必填字段,其余字段按需携带。
|
// FamilyFeedCommentBody 仅接受 commentContent 和可选的 parentCommentId。
|
||||||
var source = values || {};
|
var source = values || {};
|
||||||
var body = {
|
var body = {
|
||||||
content: String(source.content || '').trim()
|
commentContent: String(source.commentContent || '').trim()
|
||||||
};
|
};
|
||||||
var parentCommentId = toNumberOrUndefined(source.parentCommentId);
|
var parentCommentId = trimOrUndefined(source.parentCommentId);
|
||||||
var replyUserId = toNumberOrUndefined(source.replyUserId);
|
|
||||||
|
|
||||||
if (parentCommentId !== undefined) body.parentCommentId = parentCommentId;
|
if (parentCommentId !== undefined) body.parentCommentId = parentCommentId;
|
||||||
if (replyUserId !== undefined) body.replyUserId = replyUserId;
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +152,17 @@
|
|||||||
|
|
||||||
function normalizeComment(item) {
|
function normalizeComment(item) {
|
||||||
var commentId = getCommentId(item);
|
var commentId = getCommentId(item);
|
||||||
var content = item && item.content;
|
var commentContent = item && item.commentContent;
|
||||||
|
var userDeleted;
|
||||||
|
|
||||||
if (!commentId || content === undefined || content === null) return null;
|
if (!commentId || commentContent === undefined) return null;
|
||||||
|
userDeleted = String(item && item.userDeleted || '') === '1';
|
||||||
return {
|
return {
|
||||||
commentId: commentId,
|
commentId: commentId,
|
||||||
content: String(content)
|
commentContent: commentContent === null ? '该评论已删除' : String(commentContent),
|
||||||
|
appUserNickName: trimOrUndefined(item && item.appUserNickName),
|
||||||
|
replyCount: item && item.replyCount,
|
||||||
|
userDeleted: userDeleted || commentContent === null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,17 +224,46 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasReplies(comment) {
|
||||||
|
var count = comment && comment.replyCount;
|
||||||
|
|
||||||
|
return count !== undefined && count !== null && String(count) !== '' && String(count) !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommentRow(feedId, comment) {
|
||||||
|
var author = comment.appUserNickName ? '<strong>' + escapeHtml(comment.appUserNickName) + '</strong> ' : '';
|
||||||
|
var actions = '';
|
||||||
|
|
||||||
|
if (!comment.userDeleted) {
|
||||||
|
actions += '<button class="pill" type="button" data-feed-comment-reply="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">回复</button>';
|
||||||
|
actions += '<button class="pill" type="button" data-feed-comment-delete="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">删除评论</button>';
|
||||||
|
}
|
||||||
|
if (hasReplies(comment)) {
|
||||||
|
actions += '<button class="pill" type="button" data-feed-comment-replies="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">查看回复</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="module-row" data-feed-comment-node="' + escapeHtml(comment.commentId) + '"><p>' + author + escapeHtml(comment.commentContent) + '</p>' + actions + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComments(data) {
|
||||||
|
var comments = normalizeList(data).map(normalizeComment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
invalidCount: comments.filter(function (item) { return !item; }).length,
|
||||||
|
comments: comments.filter(Boolean)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function renderComments(feedId, data) {
|
function renderComments(feedId, data) {
|
||||||
var row = query('[data-feed-id="' + feedId + '"]');
|
var row = query('[data-feed-id="' + feedId + '"]');
|
||||||
var comments = normalizeList(data).map(normalizeComment);
|
var result = normalizeComments(data);
|
||||||
var invalidCount = comments.filter(function (item) { return !item; }).length;
|
var list = result.comments;
|
||||||
var list = comments.filter(Boolean);
|
|
||||||
var previous = row && row.nextElementSibling;
|
var previous = row && row.nextElementSibling;
|
||||||
|
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove();
|
if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove();
|
||||||
if (invalidCount) {
|
if (result.invalidCount) {
|
||||||
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-comment-list="' + escapeHtml(feedId) + '">评论响应缺少 commentId 或 content,请联系后端补充 DTO。</div>');
|
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-comment-list="' + escapeHtml(feedId) + '">评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
@@ -222,7 +272,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-comment-list="' + escapeHtml(feedId) + '">' + list.map(function (comment) {
|
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-comment-list="' + escapeHtml(feedId) + '">' + list.map(function (comment) {
|
||||||
return '<div class="module-row"><p>' + escapeHtml(comment.content) + '</p><button class="pill" type="button" data-feed-comment-delete="' + escapeHtml(comment.commentId) + '" data-feed-id="' + escapeHtml(feedId) + '">删除评论</button></div>';
|
return renderCommentRow(feedId, comment);
|
||||||
|
}).join('') + '</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommentReplies(feedId, parentCommentId, data) {
|
||||||
|
var row = query('[data-feed-comment-node="' + parentCommentId + '"]');
|
||||||
|
var result = normalizeComments(data);
|
||||||
|
var previous = row && row.nextElementSibling;
|
||||||
|
|
||||||
|
if (!row) return;
|
||||||
|
if (previous && previous.getAttribute('data-feed-reply-list') === parentCommentId) previous.remove();
|
||||||
|
if (result.invalidCount) {
|
||||||
|
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">回复响应缺少 commentId 或 commentContent,请联系后端补充 DTO。</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.comments.length) {
|
||||||
|
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">暂无直接回复</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">' + result.comments.map(function (comment) {
|
||||||
|
return renderCommentRow(feedId, comment);
|
||||||
}).join('') + '</div>');
|
}).join('') + '</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,39 +335,47 @@
|
|||||||
|
|
||||||
async function loadFeeds() {
|
async function loadFeeds() {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
|
|
||||||
if (!api || !genealogyId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
syncGenealogyLinks(genealogyId);
|
syncGenealogyLinks(genealogyId);
|
||||||
try {
|
try {
|
||||||
renderFeeds(await api.feedsPage(genealogyId, { pageNum: 1, pageSize: 20 }), genealogyId);
|
renderFeeds(await api.feedsPage(genealogyId, { pageNum: 1, pageSize: 20 }), genealogyId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '家族动态加载失败');
|
showMessage(error.message || '家族动态加载失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFeedForEdit() {
|
async function loadFeedForEdit() {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
var feedId = getCurrentFeedId();
|
var feedId = getCurrentFeedId();
|
||||||
|
|
||||||
if (!api || !genealogyId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
syncGenealogyLinks(genealogyId);
|
syncGenealogyLinks(genealogyId);
|
||||||
if (!feedId) return;
|
if (!feedId) return;
|
||||||
try {
|
try {
|
||||||
fillFeedForm(await api.feedDetail(genealogyId, feedId));
|
fillFeedForm(await api.feedDetail(genealogyId, feedId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '动态详情加载失败');
|
showMessage(error.message || '动态详情加载失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitFeed(form) {
|
async function submitFeed(form) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
var feedId = getCurrentFeedId();
|
var feedId = getCurrentFeedId();
|
||||||
var body;
|
var body;
|
||||||
|
|
||||||
if (!api || !genealogyId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
// 富文本编辑器在提交前同步回 textarea,保证发送的是用户当前输入。
|
// 富文本编辑器在提交前同步回 textarea,保证发送的是用户当前输入。
|
||||||
if (root.KindEditor && root.KindEditor.sync) root.KindEditor.sync('#feedContent');
|
if (root.KindEditor && root.KindEditor.sync) root.KindEditor.sync('#feedContent');
|
||||||
body = buildFeedBody(getFormValues(form));
|
body = buildFeedBody(getFormValues(form));
|
||||||
@@ -313,15 +392,18 @@
|
|||||||
showMessage('动态已保存');
|
showMessage('动态已保存');
|
||||||
root.location.href = getListUrl(genealogyId);
|
root.location.href = getListUrl(genealogyId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '动态保存失败');
|
showMessage(error.message || '动态保存失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function likeFeed(feedId, shouldLike) {
|
async function likeFeed(feedId, shouldLike) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
|
|
||||||
if (!api || !genealogyId || !feedId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !feedId) return;
|
||||||
try {
|
try {
|
||||||
if (shouldLike) {
|
if (shouldLike) {
|
||||||
await api.likeFeed(genealogyId, feedId);
|
await api.likeFeed(genealogyId, feedId);
|
||||||
@@ -330,64 +412,92 @@
|
|||||||
}
|
}
|
||||||
await loadFeeds();
|
await loadFeeds();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || (shouldLike ? '点赞失败' : '取消点赞失败'));
|
showMessage(error.message || (shouldLike ? '点赞失败' : '取消点赞失败'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFeed(feedId) {
|
async function deleteFeed(feedId) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
|
|
||||||
if (!api || !genealogyId || !feedId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !feedId) return;
|
||||||
if (root.confirm && !root.confirm('确认删除这条动态吗?')) return;
|
if (root.confirm && !root.confirm('确认删除这条动态吗?')) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteFeed(genealogyId, feedId);
|
await api.deleteFeed(genealogyId, feedId);
|
||||||
await loadFeeds();
|
await loadFeeds();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '删除动态失败');
|
showMessage(error.message || '删除动态失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showComments(feedId) {
|
async function showComments(feedId) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
|
|
||||||
if (!api || !genealogyId || !feedId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !feedId) return;
|
||||||
try {
|
try {
|
||||||
renderComments(feedId, await api.feedComments(genealogyId, feedId));
|
renderComments(feedId, await api.feedComments(genealogyId, feedId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '评论加载失败');
|
showMessage(error.message || '评论加载失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createComment(feedId) {
|
async function showCommentReplies(feedId, commentId) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
|
|
||||||
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !feedId || !commentId) return;
|
||||||
|
try {
|
||||||
|
renderCommentReplies(feedId, commentId, await api.feedCommentReplies(genealogyId, feedId, commentId));
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
showMessage(error.message || '回复加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createComment(feedId, parentCommentId) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
var content;
|
var content;
|
||||||
var body;
|
var body;
|
||||||
|
|
||||||
if (!api || !genealogyId || !feedId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
content = root.prompt ? root.prompt('请输入评论内容', '') : '';
|
genealogyId = requireGenealogyId();
|
||||||
body = buildCommentBody({ content: content });
|
if (!genealogyId || !feedId) return;
|
||||||
if (!body.content) return;
|
content = root.prompt ? root.prompt(parentCommentId ? '请输入回复内容' : '请输入评论内容', '') : '';
|
||||||
|
body = buildCommentBody({ commentContent: content, parentCommentId: parentCommentId });
|
||||||
|
if (!body.commentContent) return;
|
||||||
try {
|
try {
|
||||||
await api.createFeedComment(genealogyId, feedId, body);
|
await api.createFeedComment(genealogyId, feedId, body);
|
||||||
await showComments(feedId);
|
await showComments(feedId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '评论发布失败');
|
showMessage(error.message || '评论发布失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(feedId, commentId) {
|
async function deleteComment(feedId, commentId) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var genealogyId = requireGenealogyId();
|
var genealogyId;
|
||||||
|
|
||||||
if (!api || !genealogyId || !feedId || !commentId) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !feedId || !commentId) return;
|
||||||
if (root.confirm && !root.confirm('确认删除这条评论吗?')) return;
|
if (root.confirm && !root.confirm('确认删除这条评论吗?')) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteFeedComment(genealogyId, feedId, commentId);
|
await api.deleteFeedComment(genealogyId, feedId, commentId);
|
||||||
await showComments(feedId);
|
await showComments(feedId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '删除评论失败');
|
showMessage(error.message || '删除评论失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,6 +534,18 @@
|
|||||||
feedId = target.closest('[data-feed-comment]').getAttribute('data-feed-comment');
|
feedId = target.closest('[data-feed-comment]').getAttribute('data-feed-comment');
|
||||||
createComment(feedId);
|
createComment(feedId);
|
||||||
}
|
}
|
||||||
|
if (target.closest('[data-feed-comment-reply]')) {
|
||||||
|
event.preventDefault();
|
||||||
|
commentId = target.closest('[data-feed-comment-reply]').getAttribute('data-feed-comment-reply');
|
||||||
|
feedId = target.closest('[data-feed-comment-reply]').getAttribute('data-feed-id');
|
||||||
|
createComment(feedId, commentId);
|
||||||
|
}
|
||||||
|
if (target.closest('[data-feed-comment-replies]')) {
|
||||||
|
event.preventDefault();
|
||||||
|
commentId = target.closest('[data-feed-comment-replies]').getAttribute('data-feed-comment-replies');
|
||||||
|
feedId = target.closest('[data-feed-comment-replies]').getAttribute('data-feed-id');
|
||||||
|
showCommentReplies(feedId, commentId);
|
||||||
|
}
|
||||||
if (target.closest('[data-feed-delete]')) {
|
if (target.closest('[data-feed-delete]')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
deleteFeed(target.closest('[data-feed-delete]').getAttribute('data-feed-delete'));
|
deleteFeed(target.closest('[data-feed-delete]').getAttribute('data-feed-delete'));
|
||||||
@@ -451,7 +573,9 @@
|
|||||||
buildCommentBody: buildCommentBody,
|
buildCommentBody: buildCommentBody,
|
||||||
normalizeFeed: normalizeFeed,
|
normalizeFeed: normalizeFeed,
|
||||||
normalizeComment: normalizeComment,
|
normalizeComment: normalizeComment,
|
||||||
|
normalizeComments: normalizeComments,
|
||||||
normalizeList: normalizeList,
|
normalizeList: normalizeList,
|
||||||
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
// 字辈管理模块同时支持浏览器页面和 Node 单元测试。
|
||||||
|
if (typeof module === 'object' && module.exports) {
|
||||||
|
module.exports = factory(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.GenerationPages = factory(root);
|
||||||
|
if (root.document) {
|
||||||
|
root.document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
root.GenerationPages.init();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var documentRef = root.document;
|
||||||
|
var poemsById = Object.create(null);
|
||||||
|
var lastPreviewSignature = '';
|
||||||
|
var managementAccess = true;
|
||||||
|
var writePending = false;
|
||||||
|
|
||||||
|
function getApi() {
|
||||||
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRedirectToLogin(api, error) {
|
||||||
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbidden(error) {
|
||||||
|
return Number(error && (error.status || error.code)) === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUnauthorized(api, error) {
|
||||||
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
|
if (api && api.clearToken) api.clearToken();
|
||||||
|
if (!root.location) return true;
|
||||||
|
if (typeof root.location.replace === 'function') {
|
||||||
|
root.location.replace('login.html');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
root.location.href = 'login.html';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(selector, rootNode) {
|
||||||
|
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAll(selector, rootNode) {
|
||||||
|
return documentRef ? Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(message) {
|
||||||
|
if (root.layui && root.layui.layer) {
|
||||||
|
root.layui.layer.msg(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.alert) root.alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryParam(search, name) {
|
||||||
|
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
|
||||||
|
|
||||||
|
return params.get(name) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentGenealogyId(search) {
|
||||||
|
var page = query('[data-generation-page]');
|
||||||
|
var source = search === undefined && root.location ? root.location.search : search;
|
||||||
|
var profileGenealogyId;
|
||||||
|
|
||||||
|
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
|
||||||
|
profileGenealogyId = root.ProfileUI.getGenealogyId();
|
||||||
|
if (profileGenealogyId) return profileGenealogyId;
|
||||||
|
}
|
||||||
|
return getQueryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimOrUndefined(value) {
|
||||||
|
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||||
|
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntegerOrUndefined(value) {
|
||||||
|
var text = trimOrUndefined(value);
|
||||||
|
var number;
|
||||||
|
|
||||||
|
if (!text) return undefined;
|
||||||
|
number = Number(text);
|
||||||
|
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGenerationPoemBody(values) {
|
||||||
|
var source = values || {};
|
||||||
|
var body = {
|
||||||
|
generationNo: toIntegerOrUndefined(source.generationNo),
|
||||||
|
generationText: trimOrUndefined(source.generationText)
|
||||||
|
};
|
||||||
|
var description = trimOrUndefined(source.description);
|
||||||
|
var sortOrder = trimOrUndefined(source.sortOrder);
|
||||||
|
var status = trimOrUndefined(source.status);
|
||||||
|
|
||||||
|
if (description !== undefined) body.description = description;
|
||||||
|
if (sortOrder !== undefined) body.sortOrder = Number(sortOrder);
|
||||||
|
if (status !== undefined) body.status = status;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBatchBody(values) {
|
||||||
|
var source = values || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
poemText: String(source.poemText || '').trim(),
|
||||||
|
disableMissing: Boolean(source.disableMissing)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGenerationPoemBody(body) {
|
||||||
|
if (!body || !Number.isInteger(body.generationNo) || body.generationNo < 1 || body.generationNo > 2147483647) {
|
||||||
|
return '世代序号必须是 1 到 2147483647 之间的整数';
|
||||||
|
}
|
||||||
|
if (!body.generationText) return '请填写字辈文字';
|
||||||
|
if (body.generationText.length > 50) return '字辈文字不能超过 50 个字符';
|
||||||
|
if (body.description && body.description.length > 500) return '说明不能超过 500 个字符';
|
||||||
|
if (body.sortOrder !== undefined && (!Number.isInteger(body.sortOrder) || !Number.isSafeInteger(body.sortOrder))) {
|
||||||
|
return '排序值必须是整数';
|
||||||
|
}
|
||||||
|
if (body.sortOrder !== undefined && (body.sortOrder < -2147483648 || body.sortOrder > 2147483647)) {
|
||||||
|
return '排序值必须在 -2147483648 到 2147483647 之间';
|
||||||
|
}
|
||||||
|
if (body.status !== undefined && body.status !== '0' && body.status !== '1') return '字辈状态只能是 0 或 1';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBatchBody(body) {
|
||||||
|
var words;
|
||||||
|
|
||||||
|
if (!body || !body.poemText) return '请填写批量字辈内容';
|
||||||
|
if (body.poemText.length > 26000) return '批量字辈内容不能超过 26000 个字符';
|
||||||
|
words = splitPoemText(body.poemText);
|
||||||
|
if (words.length > 500) return '一次最多导入 500 个世代';
|
||||||
|
if (words.some(function (word) { return word.length > 50; })) return '单个字辈不能超过 50 个字符';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPoemText(value) {
|
||||||
|
return String(value || '').trim().split(/[\s,,;;、//||]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPoemId(item) {
|
||||||
|
var value = item && item.poemId;
|
||||||
|
var text;
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === '') return '';
|
||||||
|
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
|
||||||
|
text = String(value);
|
||||||
|
return /^\d+$/.test(text) ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGenerationPoem(item) {
|
||||||
|
var poemId = getPoemId(item);
|
||||||
|
var generationNo = toIntegerOrUndefined(item && item.generationNo);
|
||||||
|
var generationText = trimOrUndefined(item && item.generationText);
|
||||||
|
var status = trimOrUndefined(item && item.status);
|
||||||
|
var poem;
|
||||||
|
|
||||||
|
if (!poemId || generationNo === undefined || !generationText || (status !== '0' && status !== '1')) return null;
|
||||||
|
poem = {
|
||||||
|
poemId: poemId,
|
||||||
|
generationNo: generationNo,
|
||||||
|
generationText: generationText,
|
||||||
|
status: status
|
||||||
|
};
|
||||||
|
if (item.description !== undefined && item.description !== null) poem.description = String(item.description);
|
||||||
|
if (toIntegerOrUndefined(item.sortOrder) !== undefined) poem.sortOrder = toIntegerOrUndefined(item.sortOrder);
|
||||||
|
return poem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(data) {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && Array.isArray(data.rows)) return data.rows;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value === undefined || value === null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGenerationPoem(poem) {
|
||||||
|
var details = [];
|
||||||
|
var stateText = poem.status === '1' ? '已停用' : '正常';
|
||||||
|
|
||||||
|
if (poem.description) details.push('说明:' + poem.description);
|
||||||
|
if (poem.sortOrder !== undefined) details.push('排序:' + poem.sortOrder);
|
||||||
|
details.push('状态:' + stateText);
|
||||||
|
return '<div class="module-row" data-generation-poem-id="' + escapeHtml(poem.poemId) + '">' +
|
||||||
|
'<div><h3>第 ' + escapeHtml(poem.generationNo) + ' 代:' + escapeHtml(poem.generationText) + '</h3><p>' + escapeHtml(details.join(' · ')) + '</p></div>' +
|
||||||
|
'<div class="row-actions">' +
|
||||||
|
'<button class="pill" type="button" data-generation-edit="' + escapeHtml(poem.poemId) + '">编辑</button>' +
|
||||||
|
'<button class="pill" type="button" data-generation-status="' + escapeHtml(poem.poemId) + '">' + (poem.status === '1' ? '恢复' : '停用') + '</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGenerationPoems(data) {
|
||||||
|
var container = query('[data-generation-list]');
|
||||||
|
var normalized = normalizeList(data).map(normalizeGenerationPoem);
|
||||||
|
var invalidCount = normalized.filter(function (item) { return !item; }).length;
|
||||||
|
var poems = normalized.filter(Boolean);
|
||||||
|
|
||||||
|
poemsById = Object.create(null);
|
||||||
|
poems.forEach(function (poem) {
|
||||||
|
poemsById[poem.poemId] = poem;
|
||||||
|
});
|
||||||
|
if (!container) return;
|
||||||
|
if (invalidCount) {
|
||||||
|
container.innerHTML = '<div class="api-empty">字辈响应缺少 poemId、generationNo、generationText 或 status,请联系后端补充 DTO。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!poems.length) {
|
||||||
|
container.innerHTML = '<div class="api-empty">暂无字辈记录</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = poems.map(renderGenerationPoem).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreview(data) {
|
||||||
|
if (!data || !Array.isArray(data.items)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
createCount: toIntegerOrUndefined(data.createCount),
|
||||||
|
updateCount: toIntegerOrUndefined(data.updateCount),
|
||||||
|
keepCount: toIntegerOrUndefined(data.keepCount),
|
||||||
|
disableCount: toIntegerOrUndefined(data.disableCount),
|
||||||
|
items: data.items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBatchPreview(data) {
|
||||||
|
var container = query('[data-generation-batch-preview]');
|
||||||
|
var preview = normalizePreview(data);
|
||||||
|
var summary;
|
||||||
|
|
||||||
|
if (!container) return false;
|
||||||
|
if (!preview) {
|
||||||
|
container.innerHTML = '<div class="api-empty">批量预览响应缺少 items,请联系后端补充 DTO。</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
summary = '新增 ' + (preview.createCount === undefined ? '-' : preview.createCount) +
|
||||||
|
' 条 · 更新 ' + (preview.updateCount === undefined ? '-' : preview.updateCount) +
|
||||||
|
' 条 · 保留 ' + (preview.keepCount === undefined ? '-' : preview.keepCount) +
|
||||||
|
' 条 · 停用 ' + (preview.disableCount === undefined ? '-' : preview.disableCount) + ' 条';
|
||||||
|
container.innerHTML = '<div class="module-row"><div><h3>变更预览</h3><p>' + escapeHtml(summary) + '</p></div></div>' +
|
||||||
|
preview.items.map(function (item) {
|
||||||
|
var generationNo = item && item.generationNo;
|
||||||
|
var action = item && item.action;
|
||||||
|
var oldText = item && item.oldGenerationText;
|
||||||
|
var newText = item && item.newGenerationText;
|
||||||
|
var warning = item && item.warning;
|
||||||
|
var oldStatus = item && item.oldStatus;
|
||||||
|
var newStatus = item && item.newStatus;
|
||||||
|
var text = '第 ' + (generationNo === undefined || generationNo === null ? '-' : generationNo) + ' 代:' +
|
||||||
|
(oldText === undefined || oldText === null ? '(新增)' : oldText) + ' → ' +
|
||||||
|
(newText === undefined || newText === null ? '(无)' : newText);
|
||||||
|
|
||||||
|
if (action) text += ' · ' + action;
|
||||||
|
if (oldStatus !== undefined || newStatus !== undefined) {
|
||||||
|
text += ' · 状态:' + formatStatus(oldStatus) + ' → ' + formatStatus(newStatus);
|
||||||
|
}
|
||||||
|
if (warning) text += ' · 警告:' + warning;
|
||||||
|
return '<div class="module-row"><p>' + escapeHtml(text) + '</p></div>';
|
||||||
|
}).join('');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(value) {
|
||||||
|
if (String(value) === '0') return '正常';
|
||||||
|
if (String(value) === '1') return '停用';
|
||||||
|
return value === undefined || value === null ? '未提供' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormValues(form) {
|
||||||
|
var values = {};
|
||||||
|
|
||||||
|
queryAll('[name]', form).forEach(function (field) {
|
||||||
|
values[field.name] = field.type === 'checkbox' ? field.checked : field.value;
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBatchStatus(message) {
|
||||||
|
var target = query('[data-generation-batch-status]');
|
||||||
|
|
||||||
|
if (target) target.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshManagementControls() {
|
||||||
|
var disabled = !managementAccess || writePending;
|
||||||
|
|
||||||
|
queryAll('[data-generation-add], [data-generation-batch-action], [data-generation-edit], [data-generation-status]').forEach(function (control) {
|
||||||
|
control.disabled = disabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManagementEnabled(enabled) {
|
||||||
|
managementAccess = Boolean(enabled);
|
||||||
|
refreshManagementControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWritePending(pending) {
|
||||||
|
writePending = Boolean(pending);
|
||||||
|
refreshManagementControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForbiddenState() {
|
||||||
|
var container = query('[data-generation-list]');
|
||||||
|
|
||||||
|
if (container) container.innerHTML = '<div class="api-empty">当前账号没有字辈维护权限</div>';
|
||||||
|
lastPreviewSignature = '';
|
||||||
|
setManagementEnabled(false);
|
||||||
|
setBatchStatus('当前账号没有字辈维护权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGenealogyLinks(genealogyId) {
|
||||||
|
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
|
||||||
|
root.ProfileUI.syncGenealogyContextLinks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryAll('[data-genealogy-context-link]').forEach(function (link) {
|
||||||
|
var href = link.getAttribute('href');
|
||||||
|
var base;
|
||||||
|
|
||||||
|
if (!href || href === '#') return;
|
||||||
|
base = href.split('?')[0];
|
||||||
|
link.href = base + '?genealogyId=' + encodeURIComponent(genealogyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireGenealogyId() {
|
||||||
|
var genealogyId = getCurrentGenealogyId();
|
||||||
|
var container;
|
||||||
|
|
||||||
|
if (!genealogyId) {
|
||||||
|
container = query('[data-generation-list]');
|
||||||
|
if (container) container.innerHTML = '<div class="api-empty">请从具体家谱进入字辈管理</div>';
|
||||||
|
showMessage('请从具体家谱进入字辈管理');
|
||||||
|
}
|
||||||
|
return genealogyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptForPoem(existing) {
|
||||||
|
var generationNo;
|
||||||
|
var generationText;
|
||||||
|
var description;
|
||||||
|
var sortOrder;
|
||||||
|
|
||||||
|
if (!root.prompt) return null;
|
||||||
|
generationNo = root.prompt('世代序号(从 1 开始)', existing ? String(existing.generationNo) : '');
|
||||||
|
if (generationNo === null) return null;
|
||||||
|
generationText = root.prompt('字辈文字(最多 50 个字符)', existing ? existing.generationText : '');
|
||||||
|
if (generationText === null) return null;
|
||||||
|
description = root.prompt('说明(最多 500 个字符,可留空)', existing && existing.description ? existing.description : '');
|
||||||
|
if (description === null) return null;
|
||||||
|
sortOrder = root.prompt('排序值(可留空,越小越靠前)', existing && existing.sortOrder !== undefined ? String(existing.sortOrder) : '');
|
||||||
|
if (sortOrder === null) return null;
|
||||||
|
return buildGenerationPoemBody({
|
||||||
|
generationNo: generationNo,
|
||||||
|
generationText: generationText,
|
||||||
|
description: description,
|
||||||
|
sortOrder: sortOrder,
|
||||||
|
status: existing && existing.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGenerationPoems() {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
|
||||||
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
|
syncGenealogyLinks(genealogyId);
|
||||||
|
try {
|
||||||
|
renderGenerationPoems(await api.generationPoemsManagement(genealogyId));
|
||||||
|
setManagementEnabled(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(error.message || '字辈列表加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePoem(poemId) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var existing = poemId ? poemsById[poemId] : null;
|
||||||
|
var body;
|
||||||
|
var validation;
|
||||||
|
|
||||||
|
if (writePending || !managementAccess || redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || (poemId && !existing)) return;
|
||||||
|
body = promptForPoem(existing);
|
||||||
|
if (!body) return;
|
||||||
|
validation = validateGenerationPoemBody(body);
|
||||||
|
if (validation) {
|
||||||
|
showMessage(validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWritePending(true);
|
||||||
|
try {
|
||||||
|
if (poemId) {
|
||||||
|
await api.updateGenerationPoem(genealogyId, poemId, body);
|
||||||
|
} else {
|
||||||
|
await api.createGenerationPoem(genealogyId, body);
|
||||||
|
}
|
||||||
|
showMessage('字辈已保存');
|
||||||
|
await loadGenerationPoems();
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(error.message || '字辈保存失败');
|
||||||
|
} finally {
|
||||||
|
setWritePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePoemStatus(poemId) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var existing = poemsById[poemId];
|
||||||
|
var body;
|
||||||
|
|
||||||
|
if (writePending || !managementAccess || redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !existing) return;
|
||||||
|
body = buildGenerationPoemBody({
|
||||||
|
generationNo: existing.generationNo,
|
||||||
|
generationText: existing.generationText,
|
||||||
|
description: existing.description,
|
||||||
|
sortOrder: existing.sortOrder,
|
||||||
|
status: existing.status === '1' ? '0' : '1'
|
||||||
|
});
|
||||||
|
setWritePending(true);
|
||||||
|
try {
|
||||||
|
await api.updateGenerationPoem(genealogyId, poemId, body);
|
||||||
|
showMessage(existing.status === '1' ? '字辈已恢复' : '字辈已停用');
|
||||||
|
await loadGenerationPoems();
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(error.message || '字辈状态更新失败');
|
||||||
|
} finally {
|
||||||
|
setWritePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewBatch() {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var form = query('[data-generation-batch-form]');
|
||||||
|
var body;
|
||||||
|
var validation;
|
||||||
|
|
||||||
|
if (writePending || !managementAccess || redirectUnauthorized(api) || !form) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
|
body = buildBatchBody(getFormValues(form));
|
||||||
|
validation = validateBatchBody(body);
|
||||||
|
if (validation) {
|
||||||
|
setBatchStatus(validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchStatus('正在预览…');
|
||||||
|
setWritePending(true);
|
||||||
|
try {
|
||||||
|
if (renderBatchPreview(await api.previewGenerationPoems(genealogyId, body))) {
|
||||||
|
lastPreviewSignature = JSON.stringify(body);
|
||||||
|
setBatchStatus('预览完成,请确认后保存');
|
||||||
|
} else {
|
||||||
|
lastPreviewSignature = '';
|
||||||
|
setBatchStatus('预览响应不完整');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPreviewSignature = '';
|
||||||
|
setBatchStatus(error.message || '批量预览失败');
|
||||||
|
} finally {
|
||||||
|
setWritePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatch() {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var form = query('[data-generation-batch-form]');
|
||||||
|
var body;
|
||||||
|
var validation;
|
||||||
|
|
||||||
|
if (writePending || !managementAccess || redirectUnauthorized(api) || !form) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
|
body = buildBatchBody(getFormValues(form));
|
||||||
|
validation = validateBatchBody(body);
|
||||||
|
if (validation) {
|
||||||
|
setBatchStatus(validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastPreviewSignature !== JSON.stringify(body)) {
|
||||||
|
setBatchStatus('请先预览当前内容,再保存');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.confirm && !root.confirm('确认按当前预览保存字辈吗?')) return;
|
||||||
|
setBatchStatus('正在保存…');
|
||||||
|
setWritePending(true);
|
||||||
|
try {
|
||||||
|
await api.saveGenerationPoems(genealogyId, body);
|
||||||
|
lastPreviewSignature = '';
|
||||||
|
setBatchStatus('批量字辈已保存');
|
||||||
|
await loadGenerationPoems();
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchStatus(error.message || '批量保存失败');
|
||||||
|
} finally {
|
||||||
|
setWritePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindActions() {
|
||||||
|
if (!documentRef) return;
|
||||||
|
documentRef.addEventListener('click', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
var action = target.closest('[data-generation-batch-action]');
|
||||||
|
var poemButton = target.closest('[data-generation-edit], [data-generation-status]');
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (action.getAttribute('data-generation-batch-action') === 'preview') previewBatch();
|
||||||
|
if (action.getAttribute('data-generation-batch-action') === 'save') saveBatch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.closest('[data-generation-add]')) {
|
||||||
|
event.preventDefault();
|
||||||
|
savePoem('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!poemButton) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (poemButton.hasAttribute('data-generation-edit')) savePoem(poemButton.getAttribute('data-generation-edit'));
|
||||||
|
if (poemButton.hasAttribute('data-generation-status')) togglePoemStatus(poemButton.getAttribute('data-generation-status'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!documentRef) return;
|
||||||
|
bindActions();
|
||||||
|
if (query('[data-generation-page]')) loadGenerationPoems();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentGenealogyId: getCurrentGenealogyId,
|
||||||
|
buildGenerationPoemBody: buildGenerationPoemBody,
|
||||||
|
buildBatchBody: buildBatchBody,
|
||||||
|
validateGenerationPoemBody: validateGenerationPoemBody,
|
||||||
|
validateBatchBody: validateBatchBody,
|
||||||
|
splitPoemText: splitPoemText,
|
||||||
|
normalizeGenerationPoem: normalizeGenerationPoem,
|
||||||
|
normalizePreview: normalizePreview,
|
||||||
|
normalizeList: normalizeList,
|
||||||
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
|
isForbidden: isForbidden,
|
||||||
|
init: init
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
// 世系人物模块同时支持浏览器页面和 Node 单元测试。
|
||||||
|
if (typeof module === 'object' && module.exports) {
|
||||||
|
module.exports = factory(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.LineagePages = factory(root);
|
||||||
|
if (root.document) {
|
||||||
|
root.document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
root.LineagePages.init();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var documentRef = root.document;
|
||||||
|
var selectedPersonId = '';
|
||||||
|
var editingPersonId = '';
|
||||||
|
var relationMode = '';
|
||||||
|
var peopleById = Object.create(null);
|
||||||
|
var currentPageNum = 1;
|
||||||
|
var currentPageSize = 20;
|
||||||
|
var currentPageTotal = null;
|
||||||
|
var managementAccess = true;
|
||||||
|
var writePending = false;
|
||||||
|
|
||||||
|
function getApi() {
|
||||||
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRedirectToLogin(api, error) {
|
||||||
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbidden(error) {
|
||||||
|
return Number(error && (error.status || error.code)) === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUnauthorized(api, error) {
|
||||||
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
|
if (api && api.clearToken) api.clearToken();
|
||||||
|
if (!root.location) return true;
|
||||||
|
if (typeof root.location.replace === 'function') {
|
||||||
|
root.location.replace('login.html');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
root.location.href = 'login.html';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(selector, rootNode) {
|
||||||
|
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAll(selector, rootNode) {
|
||||||
|
return documentRef ? Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(message) {
|
||||||
|
if (root.layui && root.layui.layer) {
|
||||||
|
root.layui.layer.msg(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.alert) root.alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryParam(search, name) {
|
||||||
|
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
|
||||||
|
|
||||||
|
return params.get(name) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentGenealogyId(search) {
|
||||||
|
var page = query('[data-lineage-page]');
|
||||||
|
var source = search === undefined && root.location ? root.location.search : search;
|
||||||
|
var profileGenealogyId;
|
||||||
|
|
||||||
|
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
|
||||||
|
profileGenealogyId = root.ProfileUI.getGenealogyId();
|
||||||
|
if (profileGenealogyId) return profileGenealogyId;
|
||||||
|
}
|
||||||
|
return getQueryParam(source, 'genealogyId') || (page && page.getAttribute('data-genealogy-id')) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimOrUndefined(value) {
|
||||||
|
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||||
|
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntegerOrUndefined(value) {
|
||||||
|
var text = trimOrUndefined(value);
|
||||||
|
var number;
|
||||||
|
|
||||||
|
if (!text) return undefined;
|
||||||
|
number = Number(text);
|
||||||
|
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonId(item) {
|
||||||
|
var value = item && item.personId;
|
||||||
|
var text;
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === '') return '';
|
||||||
|
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
|
||||||
|
text = String(value);
|
||||||
|
return /^\d+$/.test(text) ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineagePersonBody(values) {
|
||||||
|
var source = values || {};
|
||||||
|
var body = { name: trimOrUndefined(source.name) };
|
||||||
|
var optionalFields = [
|
||||||
|
'personNo', 'aliasName', 'sex', 'generationName', 'birthDate', 'birthLunar', 'birthPlace',
|
||||||
|
'deathDate', 'deathLunar', 'deathPlace', 'burialPlace', 'personStatus', 'biography',
|
||||||
|
'remark', 'relationName'
|
||||||
|
];
|
||||||
|
var generation = trimOrUndefined(source.generation);
|
||||||
|
var sortOrder = trimOrUndefined(source.sortOrder);
|
||||||
|
|
||||||
|
optionalFields.forEach(function (field) {
|
||||||
|
var value = trimOrUndefined(source[field]);
|
||||||
|
|
||||||
|
if (value !== undefined) body[field] = value;
|
||||||
|
});
|
||||||
|
if (generation !== undefined) body.generation = Number(generation);
|
||||||
|
if (sortOrder !== undefined) body.sortOrder = Number(sortOrder);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLineagePersonBody(body) {
|
||||||
|
if (!body || !body.name) return '请填写成员姓名';
|
||||||
|
if (body.generation !== undefined && (!Number.isInteger(body.generation) || !Number.isSafeInteger(body.generation))) {
|
||||||
|
return '世代序号必须是整数';
|
||||||
|
}
|
||||||
|
if (body.sortOrder !== undefined && (!Number.isInteger(body.sortOrder) || !Number.isSafeInteger(body.sortOrder))) {
|
||||||
|
return '排序值必须是整数';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLineagePerson(item) {
|
||||||
|
var personId = getPersonId(item);
|
||||||
|
var name = trimOrUndefined(item && item.name);
|
||||||
|
var person;
|
||||||
|
|
||||||
|
if (!personId || !name) return null;
|
||||||
|
person = { personId: personId, name: name };
|
||||||
|
[
|
||||||
|
'genealogyName', 'genealogyNo', 'appUserNickName', 'personNo', 'aliasName', 'sex', 'generationName',
|
||||||
|
'fatherName', 'motherName', 'spouseNames', 'birthDate', 'birthLunar', 'birthPlace', 'deathDate',
|
||||||
|
'deathLunar', 'deathPlace', 'burialPlace', 'personStatus', 'biography', 'status', 'remark',
|
||||||
|
'relationType', 'relationName'
|
||||||
|
].forEach(function (field) {
|
||||||
|
if (item && item[field] !== undefined && item[field] !== null) person[field] = String(item[field]);
|
||||||
|
});
|
||||||
|
['appUserId', 'genealogyId', 'fatherId', 'motherId', 'avatarOssId'].forEach(function (field) {
|
||||||
|
if (item && item[field] !== undefined && item[field] !== null) {
|
||||||
|
if (typeof item[field] === 'number' && !Number.isSafeInteger(item[field])) return;
|
||||||
|
person[field] = String(item[field]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (toIntegerOrUndefined(item && item.generation) !== undefined) person.generation = toIntegerOrUndefined(item.generation);
|
||||||
|
if (toIntegerOrUndefined(item && item.sortOrder) !== undefined) person.sortOrder = toIntegerOrUndefined(item.sortOrder);
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(data) {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && Array.isArray(data.rows)) return data.rows;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value === undefined || value === null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonSummary(person) {
|
||||||
|
var values = [];
|
||||||
|
|
||||||
|
if (person.generation !== undefined) values.push('第 ' + person.generation + ' 世');
|
||||||
|
if (person.generationName) values.push('字辈:' + person.generationName);
|
||||||
|
if (person.aliasName) values.push('别名:' + person.aliasName);
|
||||||
|
if (person.relationName) values.push('关系称谓:' + person.relationName);
|
||||||
|
if (person.personStatus) values.push('人物状态:' + person.personStatus);
|
||||||
|
return values.join(' · ') || '未填写世代信息';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLineageList(data) {
|
||||||
|
var container = query('[data-lineage-list]');
|
||||||
|
var normalized = normalizeList(data).map(normalizeLineagePerson);
|
||||||
|
var invalidCount = normalized.filter(function (item) { return !item; }).length;
|
||||||
|
var people = normalized.filter(Boolean);
|
||||||
|
|
||||||
|
peopleById = Object.create(null);
|
||||||
|
people.forEach(function (person) {
|
||||||
|
peopleById[person.personId] = person;
|
||||||
|
});
|
||||||
|
if (!container) return;
|
||||||
|
if (invalidCount) {
|
||||||
|
container.innerHTML = '<div class="api-empty">人物响应缺少可安全使用的 personId 或 name,请联系后端补充 DTO。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!people.length) {
|
||||||
|
container.innerHTML = '<div class="api-empty">暂无世系人物</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = people.map(function (person) {
|
||||||
|
var selected = person.personId === selectedPersonId ? ' is-selected' : '';
|
||||||
|
|
||||||
|
return '<button class="module-row lineage-row' + selected + '" type="button" data-lineage-person="' + escapeHtml(person.personId) + '">' +
|
||||||
|
'<div><h3>' + escapeHtml(person.name) + '</h3><p>' + escapeHtml(getPersonSummary(person)) + '</p></div>' +
|
||||||
|
'<span class="pill">查看</span></button>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTreeNode(item, ancestry) {
|
||||||
|
var person = normalizeLineagePerson(item);
|
||||||
|
var nextAncestry;
|
||||||
|
var spouses;
|
||||||
|
var children;
|
||||||
|
var branches = '';
|
||||||
|
|
||||||
|
if (!person) return '';
|
||||||
|
if (ancestry[person.personId]) {
|
||||||
|
return '<li><span class="api-empty">' + escapeHtml(person.name) + ' 已在上级关系中展示</span></li>';
|
||||||
|
}
|
||||||
|
nextAncestry = Object.assign({}, ancestry);
|
||||||
|
nextAncestry[person.personId] = true;
|
||||||
|
spouses = normalizeList(item && item.spouses).map(function (node) { return renderTreeNode(node, nextAncestry); }).filter(Boolean).join('');
|
||||||
|
children = normalizeList(item && item.children).map(function (node) { return renderTreeNode(node, nextAncestry); }).filter(Boolean).join('');
|
||||||
|
if (spouses) branches += '<ul><li><span>配偶</span><ul>' + spouses + '</ul></li></ul>';
|
||||||
|
if (children) branches += '<ul><li><span>子女</span><ul>' + children + '</ul></li></ul>';
|
||||||
|
return '<li><button class="lineage-node" type="button" data-lineage-person="' + escapeHtml(person.personId) + '">' +
|
||||||
|
'<strong>' + escapeHtml(person.name) + '</strong><span>' + escapeHtml(getPersonSummary(person)) + '</span></button>' + branches + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(data) {
|
||||||
|
var container = query('[data-lineage-tree]');
|
||||||
|
var nodes = normalizeList(data).map(function (item) { return renderTreeNode(item, {}); }).filter(Boolean);
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
if (!nodes.length) {
|
||||||
|
container.innerHTML = '<div class="api-empty">暂无世系树</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '<ul class="lineage-tree">' + nodes.join('') + '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOptions(data) {
|
||||||
|
var select = query('[data-lineage-person-options]');
|
||||||
|
var people = normalizeList(data).map(normalizeLineagePerson).filter(Boolean);
|
||||||
|
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '<option value="">请选择当前成员</option>' + people.map(function (person) {
|
||||||
|
return '<option value="' + escapeHtml(person.personId) + '">' + escapeHtml(person.name + (person.generationName ? ' · ' + person.generationName : '')) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
select.value = selectedPersonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListSummary(data) {
|
||||||
|
var container = query('[data-lineage-list-summary]');
|
||||||
|
var count;
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
count = normalizeList(data).map(normalizeLineagePerson).filter(Boolean).length;
|
||||||
|
container.textContent = count ? '成员总览已返回 ' + count + ' 人;下方结果按页展示。' : '成员总览暂无可展示人物。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePaginationControls() {
|
||||||
|
var container = query('[data-lineage-pagination]');
|
||||||
|
var previous = query('[data-lineage-page-action="previous"]');
|
||||||
|
var next = query('[data-lineage-page-action="next"]');
|
||||||
|
var status = query('[data-lineage-page-status]');
|
||||||
|
var disabled = !managementAccess || writePending;
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
if (currentPageTotal === null) {
|
||||||
|
container.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
if (previous) previous.disabled = disabled || currentPageNum <= 1;
|
||||||
|
if (next) next.disabled = disabled || currentPageNum * currentPageSize >= currentPageTotal;
|
||||||
|
if (status) status.textContent = '第 ' + currentPageNum + ' 页,共 ' + currentPageTotal + ' 人';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(data) {
|
||||||
|
var total = data && Number(data.total);
|
||||||
|
|
||||||
|
currentPageTotal = Number.isFinite(total) && total >= 0 ? total : null;
|
||||||
|
updatePaginationControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(data) {
|
||||||
|
var container = query('[data-lineage-detail]');
|
||||||
|
var person = normalizeLineagePerson(data);
|
||||||
|
var values;
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
if (!person) {
|
||||||
|
container.innerHTML = '<div class="api-empty">人物详情缺少可安全使用的 personId 或 name,请联系后端补充 DTO。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedPersonId = person.personId;
|
||||||
|
peopleById[person.personId] = person;
|
||||||
|
values = [
|
||||||
|
person.sex ? '性别:' + person.sex : '',
|
||||||
|
person.generation !== undefined ? '世代:' + person.generation : '',
|
||||||
|
person.generationName ? '字辈:' + person.generationName : '',
|
||||||
|
person.personNo ? '人物编号:' + person.personNo : '',
|
||||||
|
person.birthDate ? '出生:' + person.birthDate : '',
|
||||||
|
person.deathDate ? '逝世:' + person.deathDate : '',
|
||||||
|
person.biography ? '简介:' + person.biography : ''
|
||||||
|
].filter(Boolean);
|
||||||
|
container.innerHTML = '<div class="module-row"><div><h3>' + escapeHtml(person.name) + '</h3><p>' + escapeHtml(values.join(' · ') || '暂无更多资料') + '</p></div>' +
|
||||||
|
'<div class="row-actions"><button class="pill" type="button" data-lineage-edit="' + escapeHtml(person.personId) + '">编辑</button>' +
|
||||||
|
'<button class="pill is-danger" type="button" data-lineage-disable="' + escapeHtml(person.personId) + '">停用</button></div></div>';
|
||||||
|
renderLineageList(Object.keys(peopleById).map(function (id) { return peopleById[id]; }));
|
||||||
|
if (query('[data-lineage-person-options]')) query('[data-lineage-person-options]').value = person.personId;
|
||||||
|
refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormValues(form) {
|
||||||
|
var values = {};
|
||||||
|
|
||||||
|
queryAll('[name]', form).forEach(function (field) {
|
||||||
|
values[field.name] = field.value;
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormStatus(message) {
|
||||||
|
var target = query('[data-lineage-form-status]');
|
||||||
|
|
||||||
|
if (target) target.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationLabel(mode) {
|
||||||
|
return {
|
||||||
|
parents: '父母',
|
||||||
|
spouses: '配偶',
|
||||||
|
children: '子女',
|
||||||
|
siblings: '兄弟姐妹'
|
||||||
|
}[mode] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGenealogyLinks(genealogyId) {
|
||||||
|
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
|
||||||
|
root.ProfileUI.syncGenealogyContextLinks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryAll('[data-genealogy-context-link]').forEach(function (link) {
|
||||||
|
var href = link.getAttribute('href');
|
||||||
|
var base;
|
||||||
|
|
||||||
|
if (!href || href === '#') return;
|
||||||
|
base = href.split('?')[0];
|
||||||
|
link.href = base + '?genealogyId=' + encodeURIComponent(genealogyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoContext() {
|
||||||
|
['[data-lineage-tree]', '[data-lineage-list]', '[data-lineage-detail]'].forEach(function (selector) {
|
||||||
|
var container = query(selector);
|
||||||
|
|
||||||
|
if (container) container.innerHTML = '<div class="api-empty">请从具体家谱进入世系管理</div>';
|
||||||
|
});
|
||||||
|
if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = '';
|
||||||
|
currentPageTotal = null;
|
||||||
|
updatePaginationControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireGenealogyId() {
|
||||||
|
var genealogyId = getCurrentGenealogyId();
|
||||||
|
|
||||||
|
if (!genealogyId) {
|
||||||
|
renderNoContext();
|
||||||
|
showMessage('请从具体家谱进入世系管理');
|
||||||
|
}
|
||||||
|
return genealogyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshControls() {
|
||||||
|
var disabled = !managementAccess || writePending;
|
||||||
|
|
||||||
|
queryAll('[data-lineage-relation], [data-lineage-search], [data-lineage-form] button, [data-lineage-person-options], [data-lineage-edit], [data-lineage-disable]').forEach(function (control) {
|
||||||
|
control.disabled = disabled;
|
||||||
|
});
|
||||||
|
updatePaginationControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLineageEnabled(enabled) {
|
||||||
|
managementAccess = Boolean(enabled);
|
||||||
|
refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWritePending(pending) {
|
||||||
|
writePending = Boolean(pending);
|
||||||
|
refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForbiddenState() {
|
||||||
|
['[data-lineage-tree]', '[data-lineage-list]', '[data-lineage-detail]'].forEach(function (selector) {
|
||||||
|
var container = query(selector);
|
||||||
|
|
||||||
|
if (container) container.innerHTML = '<div class="api-empty">当前账号没有世系人物管理权限</div>';
|
||||||
|
});
|
||||||
|
if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = '';
|
||||||
|
currentPageTotal = null;
|
||||||
|
setLineageEnabled(false);
|
||||||
|
setFormStatus('当前账号没有世系人物管理权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentKeyword() {
|
||||||
|
var input = query('[data-lineage-keyword]');
|
||||||
|
|
||||||
|
return input ? input.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLineage(keyword, pageNum) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var results;
|
||||||
|
var searchKeyword = trimOrUndefined(keyword);
|
||||||
|
var requestedPage = Number(pageNum);
|
||||||
|
|
||||||
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
|
currentPageNum = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||||
|
syncGenealogyLinks(genealogyId);
|
||||||
|
try {
|
||||||
|
results = await Promise.all([
|
||||||
|
api.lineageTree(genealogyId),
|
||||||
|
api.lineagePersons(genealogyId),
|
||||||
|
api.lineagePersonsPage(genealogyId, {
|
||||||
|
pageNum: currentPageNum,
|
||||||
|
pageSize: currentPageSize,
|
||||||
|
keyword: searchKeyword
|
||||||
|
}),
|
||||||
|
api.lineagePersonOptions(genealogyId)
|
||||||
|
]);
|
||||||
|
renderTree(results[0]);
|
||||||
|
renderListSummary(results[1]);
|
||||||
|
renderLineageList(results[2]);
|
||||||
|
renderPagination(results[2]);
|
||||||
|
renderOptions(results[3]);
|
||||||
|
setLineageEnabled(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(error.message || '世系人物加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonDetail(personId) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
|
||||||
|
if (redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !personId) return;
|
||||||
|
try {
|
||||||
|
renderDetail(await api.lineagePersonDetail(genealogyId, personId));
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(error.message || '人物详情加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRelationMode(mode) {
|
||||||
|
var person = peopleById[selectedPersonId];
|
||||||
|
var submitButton = query('[data-lineage-form] button[type="submit"]');
|
||||||
|
|
||||||
|
if (!managementAccess || writePending) return;
|
||||||
|
if (!person) {
|
||||||
|
showMessage('请先从成员列表、世系树或下拉框选择当前成员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relationMode = mode;
|
||||||
|
editingPersonId = '';
|
||||||
|
if (submitButton) submitButton.textContent = '新增' + relationLabel(mode);
|
||||||
|
setFormStatus('当前将为“' + person.name + '”新增' + relationLabel(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(person) {
|
||||||
|
var form = query('[data-lineage-form]');
|
||||||
|
var values;
|
||||||
|
var submitButton;
|
||||||
|
|
||||||
|
if (!form || !person) return;
|
||||||
|
values = {
|
||||||
|
name: person.name,
|
||||||
|
sex: person.sex,
|
||||||
|
generation: person.generation,
|
||||||
|
generationName: person.generationName,
|
||||||
|
personNo: person.personNo,
|
||||||
|
aliasName: person.aliasName,
|
||||||
|
birthDate: person.birthDate ? person.birthDate.slice(0, 10) : '',
|
||||||
|
deathDate: person.deathDate ? person.deathDate.slice(0, 10) : '',
|
||||||
|
sortOrder: person.sortOrder,
|
||||||
|
relationName: person.relationName,
|
||||||
|
biography: person.biography
|
||||||
|
};
|
||||||
|
queryAll('[name]', form).forEach(function (field) {
|
||||||
|
field.value = values[field.name] === undefined || values[field.name] === null ? '' : values[field.name];
|
||||||
|
});
|
||||||
|
relationMode = '';
|
||||||
|
editingPersonId = person.personId;
|
||||||
|
submitButton = query('[data-lineage-form] button[type="submit"]');
|
||||||
|
if (submitButton) submitButton.textContent = '保存修改';
|
||||||
|
setFormStatus('正在编辑“' + person.name + '”');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
var submitButton = query('[data-lineage-form] button[type="submit"]');
|
||||||
|
|
||||||
|
relationMode = '';
|
||||||
|
editingPersonId = '';
|
||||||
|
if (submitButton) submitButton.textContent = '保存成员';
|
||||||
|
setFormStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPerson(form) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var body = buildLineagePersonBody(getFormValues(form));
|
||||||
|
var validation = validateLineagePersonBody(body);
|
||||||
|
var result;
|
||||||
|
|
||||||
|
if (writePending || !managementAccess || redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId) return;
|
||||||
|
if (validation) {
|
||||||
|
setFormStatus(validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWritePending(true);
|
||||||
|
setFormStatus('正在保存成员...');
|
||||||
|
try {
|
||||||
|
if (editingPersonId) {
|
||||||
|
result = await api.updateLineagePerson(genealogyId, editingPersonId, body);
|
||||||
|
} else if (relationMode === 'parents') {
|
||||||
|
result = await api.createLineageParent(genealogyId, selectedPersonId, body);
|
||||||
|
} else if (relationMode === 'spouses') {
|
||||||
|
result = await api.createLineageSpouse(genealogyId, selectedPersonId, body);
|
||||||
|
} else if (relationMode === 'children') {
|
||||||
|
result = await api.createLineageChild(genealogyId, selectedPersonId, body);
|
||||||
|
} else if (relationMode === 'siblings') {
|
||||||
|
result = await api.createLineageSibling(genealogyId, selectedPersonId, body);
|
||||||
|
} else {
|
||||||
|
result = await api.createLineagePerson(genealogyId, body);
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
resetEditor();
|
||||||
|
setFormStatus('成员已保存');
|
||||||
|
await loadLineage(getCurrentKeyword(), currentPageNum);
|
||||||
|
if (getPersonId(result)) await loadPersonDetail(getPersonId(result));
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormStatus(error.message || '成员保存失败');
|
||||||
|
} finally {
|
||||||
|
setWritePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disablePerson(personId) {
|
||||||
|
var api = getApi();
|
||||||
|
var genealogyId;
|
||||||
|
var person = peopleById[personId];
|
||||||
|
|
||||||
|
if (writePending || !managementAccess || redirectUnauthorized(api)) return;
|
||||||
|
genealogyId = requireGenealogyId();
|
||||||
|
if (!genealogyId || !personId) return;
|
||||||
|
if (root.confirm && !root.confirm('确认停用“' + (person ? person.name : '该成员') + '”吗?有正常子女时后端会拒绝停用。')) return;
|
||||||
|
setWritePending(true);
|
||||||
|
try {
|
||||||
|
await api.disableLineagePerson(genealogyId, personId);
|
||||||
|
selectedPersonId = '';
|
||||||
|
resetEditor();
|
||||||
|
await loadLineage(getCurrentKeyword(), currentPageNum);
|
||||||
|
showMessage('成员已停用');
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (isForbidden(error)) {
|
||||||
|
renderForbiddenState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(error.message || '成员停用失败');
|
||||||
|
} finally {
|
||||||
|
setWritePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindActions() {
|
||||||
|
if (!documentRef) return;
|
||||||
|
documentRef.addEventListener('submit', function (event) {
|
||||||
|
var form = event.target.closest('[data-lineage-form]');
|
||||||
|
|
||||||
|
if (!form) return;
|
||||||
|
event.preventDefault();
|
||||||
|
submitPerson(form);
|
||||||
|
});
|
||||||
|
documentRef.addEventListener('reset', function (event) {
|
||||||
|
if (event.target.closest('[data-lineage-form]')) resetEditor();
|
||||||
|
});
|
||||||
|
documentRef.addEventListener('change', function (event) {
|
||||||
|
var select = event.target.closest('[data-lineage-person-options]');
|
||||||
|
|
||||||
|
if (select && select.value) loadPersonDetail(select.value);
|
||||||
|
});
|
||||||
|
documentRef.addEventListener('click', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
var personButton = target.closest('[data-lineage-person]');
|
||||||
|
var relationButton = target.closest('[data-lineage-relation]');
|
||||||
|
var editButton = target.closest('[data-lineage-edit]');
|
||||||
|
var disableButton = target.closest('[data-lineage-disable]');
|
||||||
|
var pageButton = target.closest('[data-lineage-page-action]');
|
||||||
|
|
||||||
|
if (target.closest('[data-lineage-search]')) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadLineage(getCurrentKeyword(), 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!managementAccess || writePending) return;
|
||||||
|
if (pageButton.getAttribute('data-lineage-page-action') === 'previous' && currentPageNum > 1) {
|
||||||
|
loadLineage(getCurrentKeyword(), currentPageNum - 1);
|
||||||
|
}
|
||||||
|
if (pageButton.getAttribute('data-lineage-page-action') === 'next' && currentPageTotal !== null && currentPageNum * currentPageSize < currentPageTotal) {
|
||||||
|
loadLineage(getCurrentKeyword(), currentPageNum + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (personButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadPersonDetail(personButton.getAttribute('data-lineage-person'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (relationButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
setRelationMode(relationButton.getAttribute('data-lineage-relation'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
fillForm(peopleById[editButton.getAttribute('data-lineage-edit')]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disableButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
disablePerson(disableButton.getAttribute('data-lineage-disable'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!documentRef) return;
|
||||||
|
bindActions();
|
||||||
|
if (query('[data-lineage-page]')) loadLineage(getCurrentKeyword(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentGenealogyId: getCurrentGenealogyId,
|
||||||
|
getPersonId: getPersonId,
|
||||||
|
buildLineagePersonBody: buildLineagePersonBody,
|
||||||
|
validateLineagePersonBody: validateLineagePersonBody,
|
||||||
|
normalizeLineagePerson: normalizeLineagePerson,
|
||||||
|
normalizeList: normalizeList,
|
||||||
|
relationLabel: relationLabel,
|
||||||
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
|
isForbidden: isForbidden,
|
||||||
|
init: init
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -121,6 +121,43 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGenealogyId(search) {
|
||||||
|
var source = search === undefined ? (window.location && window.location.search) : search;
|
||||||
|
var params = new URLSearchParams(String(source || '').replace(/^\?/, ''));
|
||||||
|
|
||||||
|
return params.get('genealogyId') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withGenealogyId(href, genealogyId) {
|
||||||
|
var value = String(href || '');
|
||||||
|
var hashIndex;
|
||||||
|
var hash = '';
|
||||||
|
var parts;
|
||||||
|
var params;
|
||||||
|
|
||||||
|
if (!genealogyId || !value || value.charAt(0) === '#' || /^(?:https?:|mailto:|tel:)/i.test(value)) return value;
|
||||||
|
hashIndex = value.indexOf('#');
|
||||||
|
if (hashIndex >= 0) {
|
||||||
|
hash = value.slice(hashIndex);
|
||||||
|
value = value.slice(0, hashIndex);
|
||||||
|
}
|
||||||
|
parts = value.split('?');
|
||||||
|
params = new URLSearchParams(parts[1] || '');
|
||||||
|
params.set('genealogyId', genealogyId);
|
||||||
|
return parts[0] + '?' + params.toString() + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGenealogyContextLinks() {
|
||||||
|
var genealogyId = getGenealogyId();
|
||||||
|
|
||||||
|
if (!genealogyId) return;
|
||||||
|
$('[data-genealogy-context-link]').each(function () {
|
||||||
|
var $link = $(this);
|
||||||
|
|
||||||
|
$link.attr('href', withGenealogyId($link.attr('href'), genealogyId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateDisplay($trigger, value) {
|
function updateDisplay($trigger, value) {
|
||||||
var target = $trigger.attr('data-update-target');
|
var target = $trigger.attr('data-update-target');
|
||||||
var $target = target ? $(target) : $trigger.closest('p').find('b').first();
|
var $target = target ? $(target) : $trigger.closest('p').find('b').first();
|
||||||
@@ -226,11 +263,15 @@
|
|||||||
bindSelectActions();
|
bindSelectActions();
|
||||||
bindMessageActions();
|
bindMessageActions();
|
||||||
bindSelectableCards();
|
bindSelectableCards();
|
||||||
|
syncGenealogyContextLinks();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.ProfileUI = {
|
window.ProfileUI = {
|
||||||
confirm: openConfirm,
|
confirm: openConfirm,
|
||||||
closeLogout: closeLegacyLogout,
|
closeLogout: closeLegacyLogout,
|
||||||
initLayui: initLayui
|
initLayui: initLayui,
|
||||||
|
getGenealogyId: getGenealogyId,
|
||||||
|
withGenealogyId: withGenealogyId,
|
||||||
|
syncGenealogyContextLinks: syncGenealogyContextLinks
|
||||||
};
|
};
|
||||||
})(window, window.jQuery || (window.layui && window.layui.$));
|
})(window, window.jQuery || (window.layui && window.layui.$));
|
||||||
|
|||||||
+82
-42
@@ -16,59 +16,41 @@
|
|||||||
|
|
||||||
var documentRef = root.document;
|
var documentRef = root.document;
|
||||||
|
|
||||||
function pick(item, keys, fallback) {
|
|
||||||
// 后端资料字段可能有 nickName、userName、phone 等不同名称,按优先级取值。
|
|
||||||
var source = item || {};
|
|
||||||
var index;
|
|
||||||
var value;
|
|
||||||
|
|
||||||
for (index = 0; index < keys.length; index += 1) {
|
|
||||||
value = source[keys[index]];
|
|
||||||
if (value !== undefined && value !== null && value !== '') return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimOrUndefined(value) {
|
function trimOrUndefined(value) {
|
||||||
var text = value === undefined || value === null ? '' : String(value).trim();
|
var text = value === undefined || value === null ? '' : String(value).trim();
|
||||||
return text || undefined;
|
return text || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNumberOrUndefined(value) {
|
function profileValue(profile, key, fallback) {
|
||||||
var text = trimOrUndefined(value);
|
var value = profile && profile[key];
|
||||||
var numberValue;
|
|
||||||
|
|
||||||
if (!text) return undefined;
|
return value === undefined || value === null || value === '' ? fallback : value;
|
||||||
|
|
||||||
numberValue = Number(text);
|
|
||||||
return Number.isNaN(numberValue) ? undefined : numberValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarText(profile) {
|
function getAvatarText(profile) {
|
||||||
// 头像字只取展示名第一个字符,避免接口没有头像时页面空白。
|
// 头像字只取展示名第一个字符,避免接口没有头像时页面空白。
|
||||||
var displayName = pick(profile, ['nickName', 'userName', 'phone'], '家');
|
var displayName = profileValue(profile, 'nickName', '家');
|
||||||
return String(displayName).charAt(0) || '家';
|
return String(displayName).charAt(0) || '家';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegionText(profile) {
|
function getFinalRegionCode(profile) {
|
||||||
var parts = [
|
var source = profile || {};
|
||||||
profile && profile.provinceCode,
|
|
||||||
profile && profile.cityCode,
|
|
||||||
profile && profile.districtCode
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return parts.length ? parts.join(' / ') : '待填写';
|
return source.districtCode || source.cityCode || source.provinceCode || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegionText(profile) {
|
||||||
|
return getFinalRegionCode(profile) ? '加载中...' : '待填写';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProfileView(profile) {
|
function buildProfileView(profile) {
|
||||||
// 页面展示模型和接口返回分开,后续改文案不影响接口契约。
|
// 页面展示模型和接口返回分开,后续改文案不影响接口契约。
|
||||||
return {
|
return {
|
||||||
displayName: pick(profile, ['nickName', 'userName', 'phone'], '未设置昵称'),
|
displayName: profileValue(profile, 'nickName', '未设置昵称'),
|
||||||
avatarText: getAvatarText(profile),
|
avatarText: getAvatarText(profile),
|
||||||
phone: pick(profile, ['phone', 'phonenumber', 'mobile'], '未绑定'),
|
phone: profileValue(profile, 'phone', '未绑定'),
|
||||||
sex: pick(profile, ['sex'], '待填写'),
|
sex: profileValue(profile, 'sex', '待填写'),
|
||||||
birthday: pick(profile, ['birthday'], '待填写'),
|
birthday: profileValue(profile, 'birthday', '待填写'),
|
||||||
regionText: buildRegionText(profile || {})
|
regionText: buildRegionText(profile || {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -79,7 +61,8 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
nickName: trimOrUndefined(source.nickName),
|
nickName: trimOrUndefined(source.nickName),
|
||||||
avatarOssId: toNumberOrUndefined(source.avatarOssId),
|
// int64 在浏览器中必须保留字符串,避免 Number 转换丢失精度。
|
||||||
|
avatarOssId: trimOrUndefined(source.avatarOssId),
|
||||||
sex: trimOrUndefined(source.sex),
|
sex: trimOrUndefined(source.sex),
|
||||||
birthday: trimOrUndefined(source.birthday),
|
birthday: trimOrUndefined(source.birthday),
|
||||||
provinceCode: trimOrUndefined(source.provinceCode),
|
provinceCode: trimOrUndefined(source.provinceCode),
|
||||||
@@ -92,25 +75,25 @@
|
|||||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRedirectToHome(api, error) {
|
function shouldRedirectToLogin(api, error) {
|
||||||
var status = error && (error.status || error.code);
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToHome() {
|
function redirectToLogin() {
|
||||||
if (!root.location) return;
|
if (!root.location) return;
|
||||||
if (typeof root.location.replace === 'function') {
|
if (typeof root.location.replace === 'function') {
|
||||||
root.location.replace('index.html');
|
root.location.replace('login.html');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.location.href = 'index.html';
|
root.location.href = 'login.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectUnauthorized(api, error) {
|
function redirectUnauthorized(api, error) {
|
||||||
if (!shouldRedirectToHome(api, error)) return false;
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
if (api && api.clearToken) api.clearToken();
|
if (api && api.clearToken) api.clearToken();
|
||||||
redirectToHome();
|
redirectToLogin();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,17 +167,72 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRegionPathText(items) {
|
||||||
|
return (Array.isArray(items) ? items : []).map(function (item) {
|
||||||
|
return item && item.regionName || '';
|
||||||
|
}).filter(Boolean).join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRegionText(profile, requestId) {
|
||||||
|
var api = getApi();
|
||||||
|
var regionCode = getFinalRegionCode(profile);
|
||||||
|
var path;
|
||||||
|
var text = '待填写';
|
||||||
|
|
||||||
|
if (!regionCode || !api || !api.regionPath) {
|
||||||
|
renderProfile({ regionText: text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
path = await api.regionPath(regionCode);
|
||||||
|
text = buildRegionPathText(path) || text;
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestId === undefined || requestId === latestProfileRequestId) {
|
||||||
|
renderProfile({ regionText: text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillRegionPicker(profile) {
|
||||||
|
var regionPages = root.RegionPages;
|
||||||
|
|
||||||
|
if (!regionPages || !regionPages.applyProfileRegionSelection) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await regionPages.applyProfileRegionSelection({
|
||||||
|
provinceCode: trimOrUndefined(profile && profile.provinceCode),
|
||||||
|
cityCode: trimOrUndefined(profile && profile.cityCode),
|
||||||
|
districtCode: trimOrUndefined(profile && profile.districtCode)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
var api = getApi();
|
||||||
|
|
||||||
|
if (!redirectUnauthorized(api, error)) showMessage(error.message || '地区资料加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestProfileRequestId = 0;
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var profile;
|
var profile;
|
||||||
|
var requestId = latestProfileRequestId + 1;
|
||||||
|
|
||||||
|
latestProfileRequestId = requestId;
|
||||||
|
|
||||||
if (!api || !documentRef || !documentRef.querySelector('[data-profile-page]')) return;
|
if (!api || !documentRef || !documentRef.querySelector('[data-profile-page]')) return;
|
||||||
if (redirectUnauthorized(api)) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
profile = await api.currentProfile();
|
profile = await api.currentProfile();
|
||||||
|
if (requestId !== latestProfileRequestId) return;
|
||||||
renderProfile(buildProfileView(profile));
|
renderProfile(buildProfileView(profile));
|
||||||
fillProfileForm(profile);
|
fillProfileForm(profile);
|
||||||
|
fillRegionPicker(profile);
|
||||||
|
renderRegionText(profile, requestId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (redirectUnauthorized(api, error)) return;
|
if (redirectUnauthorized(api, error)) return;
|
||||||
showMessage(error.message || '个人资料加载失败');
|
showMessage(error.message || '个人资料加载失败');
|
||||||
@@ -240,11 +278,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pick: pick,
|
|
||||||
getAvatarText: getAvatarText,
|
getAvatarText: getAvatarText,
|
||||||
|
getFinalRegionCode: getFinalRegionCode,
|
||||||
|
buildRegionText: buildRegionText,
|
||||||
buildProfileView: buildProfileView,
|
buildProfileView: buildProfileView,
|
||||||
buildProfileUpdateBody: buildProfileUpdateBody,
|
buildProfileUpdateBody: buildProfileUpdateBody,
|
||||||
shouldRedirectToHome: shouldRedirectToHome,
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
|
reloadProfile: loadProfile,
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+132
-48
@@ -17,29 +17,11 @@
|
|||||||
var documentRef = root.document;
|
var documentRef = root.document;
|
||||||
|
|
||||||
function normalizeList(data) {
|
function normalizeList(data) {
|
||||||
// 行政区划接口使用通用 ListResult,兼容常见数组包裹字段。
|
// ApiClient 已解包 PC ListResult;组件只接收正式的数组结果。
|
||||||
if (Array.isArray(data)) return data;
|
if (Array.isArray(data)) return data;
|
||||||
if (!data || typeof data !== 'object') return [];
|
|
||||||
if (Array.isArray(data.rows)) return data.rows;
|
|
||||||
if (Array.isArray(data.records)) return data.records;
|
|
||||||
if (Array.isArray(data.list)) return data.list;
|
|
||||||
if (Array.isArray(data.data)) return data.data;
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(item, keys, fallback) {
|
|
||||||
var source = item || {};
|
|
||||||
var index;
|
|
||||||
var value;
|
|
||||||
|
|
||||||
for (index = 0; index < keys.length; index += 1) {
|
|
||||||
value = source[keys[index]];
|
|
||||||
if (value !== undefined && value !== null && value !== '') return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
// 地区名称和编码进入 option 前统一转义。
|
// 地区名称和编码进入 option 前统一转义。
|
||||||
return String(value === undefined || value === null ? '' : value)
|
return String(value === undefined || value === null ? '' : value)
|
||||||
@@ -51,23 +33,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRegionCode(item) {
|
function getRegionCode(item) {
|
||||||
return pick(item, ['regionCode', 'code', 'value'], '');
|
return item && item.regionCode || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRegionName(item) {
|
function getRegionName(item) {
|
||||||
return pick(item, ['regionName', 'name', 'label'], '未命名地区');
|
return item && item.regionName || '未命名地区';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRegionLevel(item) {
|
function getRegionLevel(item) {
|
||||||
var source = item || {};
|
var level = Number(item && item.regionLevel);
|
||||||
var level = Number(pick(source, ['level', 'regionLevel'], 0));
|
|
||||||
var code = getRegionCode(source);
|
|
||||||
|
|
||||||
if (level >= 1 && level <= 3) return level;
|
return Number.isInteger(level) ? level : 0;
|
||||||
if (!/^\d{6}$/.test(code)) return 0;
|
|
||||||
if (/0000$/.test(code)) return 1;
|
|
||||||
if (/00$/.test(code)) return 2;
|
|
||||||
return 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegionSelection(items) {
|
function buildRegionSelection(items) {
|
||||||
@@ -125,11 +101,35 @@
|
|||||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRedirectToLogin(api, error) {
|
||||||
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
if (!root.location) return;
|
||||||
|
if (typeof root.location.replace === 'function') {
|
||||||
|
root.location.replace('login.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.location.href = 'login.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUnauthorized(api, error) {
|
||||||
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
|
if (api && api.clearToken) api.clearToken();
|
||||||
|
redirectToLogin();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function query(selector, rootNode) {
|
function query(selector, rootNode) {
|
||||||
|
if (!documentRef && !rootNode) return null;
|
||||||
return (rootNode || documentRef).querySelector(selector);
|
return (rootNode || documentRef).querySelector(selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryAll(selector, rootNode) {
|
function queryAll(selector, rootNode) {
|
||||||
|
if (!documentRef && !rootNode) return [];
|
||||||
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
|
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.alert(message);
|
if (root.alert) root.alert(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFormValues(form) {
|
function getFormValues(form) {
|
||||||
@@ -162,6 +162,7 @@
|
|||||||
|
|
||||||
function resetSelect(select, placeholder) {
|
function resetSelect(select, placeholder) {
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
select.__regionRequestVersion = (select.__regionRequestVersion || 0) + 1;
|
||||||
select.innerHTML = '<option value="">' + escapeHtml(placeholder) + '</option>';
|
select.innerHTML = '<option value="">' + escapeHtml(placeholder) + '</option>';
|
||||||
select.disabled = true;
|
select.disabled = true;
|
||||||
}
|
}
|
||||||
@@ -179,31 +180,53 @@
|
|||||||
|
|
||||||
async function loadChildrenInto(select, parentCode, placeholder) {
|
async function loadChildrenInto(select, parentCode, placeholder) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
|
var requestVersion;
|
||||||
|
var items;
|
||||||
|
|
||||||
if (!api || !select) return;
|
if (!api || !select) return false;
|
||||||
setOptions(select, await api.regionChildren(parentCode || '0'), placeholder);
|
requestVersion = (select.__regionRequestVersion || 0) + 1;
|
||||||
|
select.__regionRequestVersion = requestVersion;
|
||||||
|
select.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
items = await api.regionChildren(parentCode || '0');
|
||||||
|
if (select.__regionRequestVersion !== requestVersion) return false;
|
||||||
|
setOptions(select, items, placeholder);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (select.__regionRequestVersion === requestVersion) {
|
||||||
|
select.innerHTML = '<option value="">' + escapeHtml(placeholder) + '</option>';
|
||||||
|
select.disabled = true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initPicker(picker) {
|
function handlePickerError(error) {
|
||||||
var province = query('[data-region-level="province"]', picker);
|
var api = getApi();
|
||||||
var city = query('[data-region-level="city"]', picker);
|
|
||||||
var district = query('[data-region-level="district"]', picker);
|
|
||||||
|
|
||||||
resetSelect(city, '请选择市');
|
if (!redirectUnauthorized(api, error)) showMessage(error.message || '地区加载失败');
|
||||||
resetSelect(district, '请选择区县');
|
}
|
||||||
await loadChildrenInto(province, '0', '请选择省');
|
|
||||||
|
|
||||||
province.addEventListener('change', async function () {
|
function bindPickerEvents(picker, province, city, district) {
|
||||||
|
if (picker.__regionEventsBound) return;
|
||||||
|
picker.__regionEventsBound = true;
|
||||||
|
|
||||||
|
province.addEventListener('change', function () {
|
||||||
resetSelect(city, '请选择市');
|
resetSelect(city, '请选择市');
|
||||||
resetSelect(district, '请选择区县');
|
resetSelect(district, '请选择区县');
|
||||||
updateTargetCode(picker);
|
updateTargetCode(picker);
|
||||||
if (province.value) await loadChildrenInto(city, province.value, '请选择市');
|
if (!province.value) return;
|
||||||
|
|
||||||
|
loadChildrenInto(city, province.value, '请选择市').catch(handlePickerError);
|
||||||
});
|
});
|
||||||
|
|
||||||
city.addEventListener('change', async function () {
|
city.addEventListener('change', function () {
|
||||||
resetSelect(district, '请选择区县');
|
resetSelect(district, '请选择区县');
|
||||||
updateTargetCode(picker);
|
updateTargetCode(picker);
|
||||||
if (city.value) await loadChildrenInto(district, city.value, '请选择区县');
|
if (!city.value) return;
|
||||||
|
|
||||||
|
loadChildrenInto(district, city.value, '请选择区县').catch(handlePickerError);
|
||||||
});
|
});
|
||||||
|
|
||||||
district.addEventListener('change', function () {
|
district.addEventListener('change', function () {
|
||||||
@@ -211,13 +234,40 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initPicker(picker) {
|
||||||
|
var province = query('[data-region-level="province"]', picker);
|
||||||
|
var city = query('[data-region-level="city"]', picker);
|
||||||
|
var district = query('[data-region-level="district"]', picker);
|
||||||
|
|
||||||
|
if (!picker || !province || !city || !district) return Promise.resolve();
|
||||||
|
if (picker.__regionInitPromise) return picker.__regionInitPromise;
|
||||||
|
|
||||||
|
resetSelect(city, '请选择市');
|
||||||
|
resetSelect(district, '请选择区县');
|
||||||
|
bindPickerEvents(picker, province, city, district);
|
||||||
|
picker.__regionInitPromise = loadChildrenInto(province, '0', '请选择省').catch(function (error) {
|
||||||
|
picker.__regionInitPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return picker.__regionInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async function applyRegionSelection(picker, selection) {
|
async function applyRegionSelection(picker, selection) {
|
||||||
var province = query('[data-region-level="province"]', picker);
|
var province = query('[data-region-level="province"]', picker);
|
||||||
var city = query('[data-region-level="city"]', picker);
|
var city = query('[data-region-level="city"]', picker);
|
||||||
var district = query('[data-region-level="district"]', picker);
|
var district = query('[data-region-level="district"]', picker);
|
||||||
var values = selection || {};
|
var values = selection || {};
|
||||||
|
|
||||||
if (!values.provinceCode) return;
|
await initPicker(picker);
|
||||||
|
|
||||||
|
if (!values.provinceCode) {
|
||||||
|
province.value = '';
|
||||||
|
resetSelect(city, '请选择市');
|
||||||
|
resetSelect(district, '请选择区县');
|
||||||
|
updateTargetCode(picker);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
province.value = values.provinceCode;
|
province.value = values.provinceCode;
|
||||||
resetSelect(city, '请选择市');
|
resetSelect(city, '请选择市');
|
||||||
@@ -236,6 +286,12 @@
|
|||||||
updateTargetCode(picker);
|
updateTargetCode(picker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyProfileRegionSelection(selection) {
|
||||||
|
return Promise.all(queryAll('[data-region-picker]').map(function (picker) {
|
||||||
|
return applyRegionSelection(picker, selection);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function renderSearchResults(container, items) {
|
function renderSearchResults(container, items) {
|
||||||
var list = normalizeList(items);
|
var list = normalizeList(items);
|
||||||
|
|
||||||
@@ -254,7 +310,7 @@
|
|||||||
var results = query('[data-region-search-results]', form.parentNode);
|
var results = query('[data-region-search-results]', form.parentNode);
|
||||||
var pathText = query('[data-region-search-path]', form.parentNode);
|
var pathText = query('[data-region-search-path]', form.parentNode);
|
||||||
|
|
||||||
if (!api) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
if (!values.regionKeyword || !values.regionKeyword.trim()) {
|
if (!values.regionKeyword || !values.regionKeyword.trim()) {
|
||||||
showMessage('请输入地区名称或编码');
|
showMessage('请输入地区名称或编码');
|
||||||
return;
|
return;
|
||||||
@@ -268,6 +324,7 @@
|
|||||||
}));
|
}));
|
||||||
if (pathText) pathText.textContent = '请选择一个地区';
|
if (pathText) pathText.textContent = '请选择一个地区';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
if (pathText) pathText.textContent = error.message || '地区搜索失败';
|
if (pathText) pathText.textContent = error.message || '地区搜索失败';
|
||||||
showMessage(error.message || '地区搜索失败');
|
showMessage(error.message || '地区搜索失败');
|
||||||
}
|
}
|
||||||
@@ -282,7 +339,7 @@
|
|||||||
var detail;
|
var detail;
|
||||||
var path;
|
var path;
|
||||||
|
|
||||||
if (!api || !code || !picker) return;
|
if (!code || !picker || redirectUnauthorized(api)) return;
|
||||||
|
|
||||||
if (pathText) pathText.textContent = '正在定位地区...';
|
if (pathText) pathText.textContent = '正在定位地区...';
|
||||||
try {
|
try {
|
||||||
@@ -291,6 +348,7 @@
|
|||||||
await applyRegionSelection(picker, buildRegionSelection(path));
|
await applyRegionSelection(picker, buildRegionSelection(path));
|
||||||
if (pathText) pathText.textContent = buildPathText(path);
|
if (pathText) pathText.textContent = buildPathText(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
if (pathText) pathText.textContent = error.message || '地区定位失败';
|
if (pathText) pathText.textContent = error.message || '地区定位失败';
|
||||||
showMessage(error.message || '地区定位失败');
|
showMessage(error.message || '地区定位失败');
|
||||||
}
|
}
|
||||||
@@ -300,21 +358,36 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = getFormValues(form);
|
var values = getFormValues(form);
|
||||||
var body = buildProfileRegionBody(values);
|
var body = buildProfileRegionBody(values);
|
||||||
|
var status = query('[data-region-profile-status]', form);
|
||||||
|
|
||||||
if (!api) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
if (!body.provinceCode) {
|
if (!body.provinceCode) {
|
||||||
showMessage('请选择省份');
|
showMessage('请选择省份');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setSubmitting(form, true);
|
||||||
|
if (status) status.textContent = '保存中...';
|
||||||
await api.updateProfile(body);
|
await api.updateProfile(body);
|
||||||
|
if (status) status.textContent = '已保存';
|
||||||
showMessage('地区资料已保存');
|
showMessage('地区资料已保存');
|
||||||
|
if (root.ProfilePages && root.ProfilePages.reloadProfile) await root.ProfilePages.reloadProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
if (status) status.textContent = '保存失败';
|
||||||
showMessage(error.message || '地区资料保存失败');
|
showMessage(error.message || '地区资料保存失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(form, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSubmitting(form, isSubmitting) {
|
||||||
|
queryAll('button[type="submit"]', form).forEach(function (button) {
|
||||||
|
button.disabled = isSubmitting;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function bindProfileForms() {
|
function bindProfileForms() {
|
||||||
queryAll('[data-region-profile-form]').forEach(function (form) {
|
queryAll('[data-region-profile-form]').forEach(function (form) {
|
||||||
form.addEventListener('submit', function (event) {
|
form.addEventListener('submit', function (event) {
|
||||||
@@ -342,10 +415,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
var api;
|
||||||
|
|
||||||
if (!documentRef) return;
|
if (!documentRef) return;
|
||||||
|
if (!queryAll('[data-region-picker]').length && !queryAll('[data-region-search-form]').length) return;
|
||||||
|
api = getApi();
|
||||||
|
if (redirectUnauthorized(api)) return;
|
||||||
|
|
||||||
queryAll('[data-region-picker]').forEach(function (picker) {
|
queryAll('[data-region-picker]').forEach(function (picker) {
|
||||||
initPicker(picker);
|
initPicker(picker).catch(handlePickerError);
|
||||||
});
|
});
|
||||||
bindProfileForms();
|
bindProfileForms();
|
||||||
bindSearchForms();
|
bindSearchForms();
|
||||||
@@ -353,11 +431,17 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
normalizeList: normalizeList,
|
normalizeList: normalizeList,
|
||||||
|
getRegionCode: getRegionCode,
|
||||||
|
getRegionName: getRegionName,
|
||||||
|
getRegionLevel: getRegionLevel,
|
||||||
buildRegionOption: buildRegionOption,
|
buildRegionOption: buildRegionOption,
|
||||||
buildPathText: buildPathText,
|
buildPathText: buildPathText,
|
||||||
buildRegionSelection: buildRegionSelection,
|
buildRegionSelection: buildRegionSelection,
|
||||||
getFinalRegionCode: getFinalRegionCode,
|
getFinalRegionCode: getFinalRegionCode,
|
||||||
buildProfileRegionBody: buildProfileRegionBody,
|
buildProfileRegionBody: buildProfileRegionBody,
|
||||||
|
applyRegionSelection: applyRegionSelection,
|
||||||
|
applyProfileRegionSelection: applyProfileRegionSelection,
|
||||||
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+213
-35
@@ -16,9 +16,11 @@
|
|||||||
|
|
||||||
var documentRef = root.document;
|
var documentRef = root.document;
|
||||||
var CAPTCHA_SCENES = {
|
var CAPTCHA_SCENES = {
|
||||||
password: 'WEB_H5_CHANGE_PASSWORD',
|
phone: 'PC_PHONE_CHANGE',
|
||||||
phone: 'WEB_H5_CHANGE_PHONE'
|
deactivate: 'PC_ACCOUNT_DEACTIVATE'
|
||||||
};
|
};
|
||||||
|
var SMS_CODE_COOLDOWN_SECONDS = 60;
|
||||||
|
var boundPhone = '';
|
||||||
|
|
||||||
function getCaptchaScene(formType) {
|
function getCaptchaScene(formType) {
|
||||||
return CAPTCHA_SCENES[formType] || '';
|
return CAPTCHA_SCENES[formType] || '';
|
||||||
@@ -31,20 +33,13 @@
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimOrUndefined(value) {
|
|
||||||
var text = String(value === undefined || value === null ? '' : value).trim();
|
|
||||||
|
|
||||||
return text || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPasswordChangeBody(values, hashFn) {
|
function buildPasswordChangeBody(values, hashFn) {
|
||||||
var source = values || {};
|
var source = values || {};
|
||||||
var hash = hashFn || hashPassword;
|
var hash = hashFn || hashPassword;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oldPassword: hash(String(source.oldPassword || '').trim()),
|
oldPassword: hash(String(source.oldPassword || '').trim()),
|
||||||
newPassword: hash(String(source.newPassword || '').trim()),
|
newPassword: hash(String(source.newPassword || '').trim())
|
||||||
validToken: trimOrUndefined(source.validToken)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,19 +47,16 @@
|
|||||||
var source = values || {};
|
var source = values || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newPhone: String(source.newPhone || '').trim(),
|
phone: String(source.phone || '').trim(),
|
||||||
smsCode: String(source.smsCode || '').trim(),
|
smsCode: String(source.smsCode || '').trim()
|
||||||
validToken: trimOrUndefined(source.validToken)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDeactivateBody(values, hashFn) {
|
function buildDeactivateBody(values) {
|
||||||
var source = values || {};
|
var source = values || {};
|
||||||
var hash = hashFn || hashPassword;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
password: hash(String(source.password || '').trim()),
|
smsCode: String(source.smsCode || '').trim()
|
||||||
reason: trimOrUndefined(source.reason)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +64,43 @@
|
|||||||
return String(value || '').trim() === '确认注销';
|
return String(value || '').trim() === '确认注销';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPhone(value) {
|
||||||
|
return /^1\d{10}$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSmsCode(value) {
|
||||||
|
return /^\d{4,6}$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
function getApi() {
|
function getApi() {
|
||||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRedirectToLogin(api, error) {
|
||||||
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin(api) {
|
||||||
|
if (api && api.clearToken) api.clearToken();
|
||||||
|
if (!root.location) return;
|
||||||
|
if (typeof root.location.replace === 'function') {
|
||||||
|
root.location.replace('login.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.location.href = 'login.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUnauthorized(api, error) {
|
||||||
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
|
redirectToLogin(api);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureCaptcha(form, formType, subject) {
|
async function ensureCaptcha(form, formType, subject) {
|
||||||
// 改密和换绑必须先取得与当前业务场景绑定的验证码票据。
|
// 发送敏感业务短信前,先取得与当前场景绑定的验证码票据。
|
||||||
var captcha = root.CaptchaPages;
|
var captcha = root.CaptchaPages;
|
||||||
var sceneCode = getCaptchaScene(formType);
|
var sceneCode = getCaptchaScene(formType);
|
||||||
|
|
||||||
@@ -126,30 +149,110 @@
|
|||||||
if (status) status.textContent = message;
|
if (status) status.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSubmitting(form, isSubmitting) {
|
||||||
|
queryAll('button[type="submit"]', form).forEach(function (button) {
|
||||||
|
button.disabled = isSubmitting;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCaptchaToken(form) {
|
||||||
|
var field = query('input[name="validToken"]', form);
|
||||||
|
|
||||||
|
if (field) field.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSmsCooldown(button) {
|
||||||
|
var originalText;
|
||||||
|
var remaining = SMS_CODE_COOLDOWN_SECONDS;
|
||||||
|
var interval;
|
||||||
|
|
||||||
|
if (!button || typeof root.setInterval !== 'function') return;
|
||||||
|
if (button.__smsCooldownTimer && typeof root.clearInterval === 'function') {
|
||||||
|
root.clearInterval(button.__smsCooldownTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
originalText = button.getAttribute('data-sms-code-label') || button.textContent.trim();
|
||||||
|
button.setAttribute('data-sms-code-label', originalText);
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = remaining + ' 秒后重试';
|
||||||
|
interval = root.setInterval(function () {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
button.textContent = remaining + ' 秒后重试';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof root.clearInterval === 'function') root.clearInterval(interval);
|
||||||
|
button.__smsCooldownTimer = null;
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 1000);
|
||||||
|
button.__smsCooldownTimer = interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(phone) {
|
||||||
|
var value = String(phone || '').trim();
|
||||||
|
|
||||||
|
return isPhone(value) ? value.slice(0, 3) + '****' + value.slice(-4) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoundPhone() {
|
||||||
|
var api = getApi();
|
||||||
|
var profile;
|
||||||
|
var message;
|
||||||
|
|
||||||
|
if (!api || !documentRef) return;
|
||||||
|
if (redirectUnauthorized(api)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
profile = await api.currentProfile();
|
||||||
|
boundPhone = String(profile && profile.phone || '').trim();
|
||||||
|
message = isPhone(boundPhone)
|
||||||
|
? '验证码将发送至 ' + maskPhone(boundPhone)
|
||||||
|
: '未获取到已绑定手机号';
|
||||||
|
queryAll('[data-security-bound-phone]').forEach(function (node) {
|
||||||
|
node.textContent = message;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
queryAll('[data-security-bound-phone]').forEach(function (node) {
|
||||||
|
node.textContent = '读取已绑定手机号失败';
|
||||||
|
});
|
||||||
|
showMessage(error.message || '账号资料加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitPassword(form) {
|
async function submitPassword(form) {
|
||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = getFormValues(form);
|
var values = getFormValues(form);
|
||||||
|
|
||||||
if (!api) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
if (!values.oldPassword || !values.newPassword) {
|
if (!values.oldPassword || !values.newPassword) {
|
||||||
showMessage('请填写原密码和新密码');
|
showMessage('请填写原密码和新密码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (String(values.newPassword).length < 6) {
|
||||||
|
showMessage('新密码至少需要 6 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (values.newPassword !== values.confirmPassword) {
|
if (values.newPassword !== values.confirmPassword) {
|
||||||
showMessage('两次新密码不一致');
|
showMessage('两次新密码不一致');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await ensureCaptcha(form, 'password')) return;
|
|
||||||
values = getFormValues(form);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setSubmitting(form, true);
|
||||||
|
setStatus(form, '提交中...');
|
||||||
await api.changePassword(buildPasswordChangeBody(values));
|
await api.changePassword(buildPasswordChangeBody(values));
|
||||||
setStatus(form, '密码已修改');
|
setStatus(form, '密码已修改');
|
||||||
showMessage('密码已修改');
|
showMessage('密码已修改');
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
setStatus(form, '修改失败');
|
||||||
showMessage(error.message || '密码修改失败');
|
showMessage(error.message || '密码修改失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(form, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,22 +260,37 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var body = buildPhoneChangeBody(getFormValues(form));
|
var body = buildPhoneChangeBody(getFormValues(form));
|
||||||
|
|
||||||
if (!api) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
if (!body.newPhone || !body.smsCode) {
|
if (!body.phone || !body.smsCode) {
|
||||||
showMessage('请填写新手机号和验证码');
|
showMessage('请填写新手机号和验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isPhone(body.phone)) {
|
||||||
if (!await ensureCaptcha(form, 'phone', body.newPhone)) return;
|
showMessage('请输入正确的新手机号');
|
||||||
body = buildPhoneChangeBody(getFormValues(form));
|
return;
|
||||||
|
}
|
||||||
|
if (!isSmsCode(body.smsCode)) {
|
||||||
|
showMessage('请输入正确的短信验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setSubmitting(form, true);
|
||||||
|
setStatus(form, '提交中...');
|
||||||
await api.changePhone(body);
|
await api.changePhone(body);
|
||||||
|
boundPhone = body.phone;
|
||||||
|
queryAll('[data-security-bound-phone]').forEach(function (node) {
|
||||||
|
node.textContent = '验证码将发送至 ' + maskPhone(boundPhone);
|
||||||
|
});
|
||||||
setStatus(form, '手机号已换绑');
|
setStatus(form, '手机号已换绑');
|
||||||
showMessage('手机号已换绑');
|
showMessage('手机号已换绑');
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
setStatus(form, '换绑失败');
|
||||||
showMessage(error.message || '手机号换绑失败');
|
showMessage(error.message || '手机号换绑失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(form, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,26 +300,60 @@
|
|||||||
var form = query('[data-security-form="phone"]');
|
var form = query('[data-security-form="phone"]');
|
||||||
var values;
|
var values;
|
||||||
|
|
||||||
if (!api || !form) return;
|
if (!form || redirectUnauthorized(api)) return;
|
||||||
values = getFormValues(form);
|
values = getFormValues(form);
|
||||||
if (!/^1[3-9]\d{9}$/.test(String(values.newPhone || '').trim())) {
|
if (!isPhone(values.phone)) {
|
||||||
showMessage('请输入正确的新手机号');
|
showMessage('请输入正确的新手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!await ensureCaptcha(form, 'phone', values.newPhone)) return;
|
if (!await ensureCaptcha(form, 'phone', values.phone)) return;
|
||||||
values = getFormValues(form);
|
values = getFormValues(form);
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.sendSmsCode({
|
await api.sendSmsCode({
|
||||||
phone: values.newPhone,
|
phone: values.phone,
|
||||||
sceneCode: getCaptchaScene('phone'),
|
sceneCode: getCaptchaScene('phone'),
|
||||||
validToken: values.validToken || ''
|
validToken: values.validToken || ''
|
||||||
});
|
});
|
||||||
|
clearCaptchaToken(form);
|
||||||
showMessage('验证码已发送');
|
showMessage('验证码已发送');
|
||||||
|
startSmsCooldown(button);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
clearCaptchaToken(form);
|
||||||
|
showMessage(error.message || '验证码发送失败');
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDeactivateCode(button) {
|
||||||
|
var api = getApi();
|
||||||
|
var form = query('[data-security-form="deactivate"]');
|
||||||
|
var values;
|
||||||
|
|
||||||
|
if (!form || redirectUnauthorized(api)) return;
|
||||||
|
if (!isPhone(boundPhone)) {
|
||||||
|
showMessage('未获取到已绑定手机号,请刷新后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await ensureCaptcha(form, 'deactivate', boundPhone)) return;
|
||||||
|
values = getFormValues(form);
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.sendSmsCode({
|
||||||
|
phone: boundPhone,
|
||||||
|
sceneCode: getCaptchaScene('deactivate'),
|
||||||
|
validToken: values.validToken || ''
|
||||||
|
});
|
||||||
|
clearCaptchaToken(form);
|
||||||
|
showMessage('验证码已发送');
|
||||||
|
startSmsCooldown(button);
|
||||||
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
clearCaptchaToken(form);
|
||||||
showMessage(error.message || '验证码发送失败');
|
showMessage(error.message || '验证码发送失败');
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,9 +362,9 @@
|
|||||||
var api = getApi();
|
var api = getApi();
|
||||||
var values = getFormValues(form);
|
var values = getFormValues(form);
|
||||||
|
|
||||||
if (!api) return;
|
if (redirectUnauthorized(api)) return;
|
||||||
if (!values.password) {
|
if (!isSmsCode(values.smsCode)) {
|
||||||
showMessage('请填写当前密码');
|
showMessage('请输入正确的短信验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isDangerConfirmed(values.confirmText)) {
|
if (!isDangerConfirmed(values.confirmText)) {
|
||||||
@@ -222,11 +374,17 @@
|
|||||||
if (root.confirm && !root.confirm('账号注销后将退出登录,确认继续?')) return;
|
if (root.confirm && !root.confirm('账号注销后将退出登录,确认继续?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setSubmitting(form, true);
|
||||||
|
setStatus(form, '提交中...');
|
||||||
await api.deactivateAccount(buildDeactivateBody(values));
|
await api.deactivateAccount(buildDeactivateBody(values));
|
||||||
showMessage('账号已提交注销');
|
showMessage('账号已提交注销');
|
||||||
root.location.href = 'index.html';
|
root.location.href = 'index.html';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
|
setStatus(form, '注销失败');
|
||||||
showMessage(error.message || '账号注销失败');
|
showMessage(error.message || '账号注销失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(form, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +420,7 @@
|
|||||||
|
|
||||||
documentRef.addEventListener('click', function (event) {
|
documentRef.addEventListener('click', function (event) {
|
||||||
var codeButton = event.target.closest('[data-security-send-code]');
|
var codeButton = event.target.closest('[data-security-send-code]');
|
||||||
|
var deactivateCodeButton = event.target.closest('[data-security-send-deactivate-code]');
|
||||||
var button = event.target.closest('[data-security-logout]');
|
var button = event.target.closest('[data-security-logout]');
|
||||||
|
|
||||||
if (codeButton) {
|
if (codeButton) {
|
||||||
@@ -269,16 +428,32 @@
|
|||||||
sendPhoneCode(codeButton);
|
sendPhoneCode(codeButton);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (deactivateCodeButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendDeactivateCode(deactivateCodeButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
logout();
|
logout();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindCaptchaTokenInvalidation() {
|
||||||
|
queryAll('[data-security-form] input[name="phone"]').forEach(function (field) {
|
||||||
|
field.addEventListener('input', function () {
|
||||||
|
clearCaptchaToken(field.closest('[data-security-form]'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (!documentRef || !query('[data-security-page]')) return;
|
if (!documentRef || !query('[data-security-page]')) return;
|
||||||
|
if (redirectUnauthorized(getApi())) return;
|
||||||
|
|
||||||
bindActions();
|
bindActions();
|
||||||
|
bindCaptchaTokenInvalidation();
|
||||||
|
loadBoundPhone();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -287,6 +462,9 @@
|
|||||||
buildDeactivateBody: buildDeactivateBody,
|
buildDeactivateBody: buildDeactivateBody,
|
||||||
getCaptchaScene: getCaptchaScene,
|
getCaptchaScene: getCaptchaScene,
|
||||||
isDangerConfirmed: isDangerConfirmed,
|
isDangerConfirmed: isDangerConfirmed,
|
||||||
|
isPhone: isPhone,
|
||||||
|
isSmsCode: isSmsCode,
|
||||||
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,9 +17,11 @@
|
|||||||
|
|
||||||
function normalizeUploadResult(data) {
|
function normalizeUploadResult(data) {
|
||||||
var source = data || {};
|
var source = data || {};
|
||||||
|
var ossId = source.ossId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ossId: String(source.ossId || ''),
|
// int64 OSS ID 不能经由 Number 转换,直接保留服务端原始文本。
|
||||||
|
ossId: ossId === undefined || ossId === null ? '' : String(ossId),
|
||||||
fileName: source.fileName || source.originalName || '',
|
fileName: source.fileName || source.originalName || '',
|
||||||
url: source.url || ''
|
url: source.url || ''
|
||||||
};
|
};
|
||||||
@@ -38,6 +40,24 @@
|
|||||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRedirectToLogin(api, error) {
|
||||||
|
var status = error && (error.status || error.code);
|
||||||
|
|
||||||
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUnauthorized(api, error) {
|
||||||
|
if (!shouldRedirectToLogin(api, error)) return false;
|
||||||
|
if (api && api.clearToken) api.clearToken();
|
||||||
|
if (!root.location) return true;
|
||||||
|
if (typeof root.location.replace === 'function') {
|
||||||
|
root.location.replace('login.html');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
root.location.href = 'login.html';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function query(selector, rootNode) {
|
function query(selector, rootNode) {
|
||||||
return (rootNode || documentRef).querySelector(selector);
|
return (rootNode || documentRef).querySelector(selector);
|
||||||
}
|
}
|
||||||
@@ -64,7 +84,7 @@
|
|||||||
var status = query(input.getAttribute('data-upload-status'));
|
var status = query(input.getAttribute('data-upload-status'));
|
||||||
var result;
|
var result;
|
||||||
|
|
||||||
if (!api || !file || !target) return;
|
if (!file || !target || redirectUnauthorized(api)) return;
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,6 +93,7 @@
|
|||||||
target.value = result.ossId;
|
target.value = result.ossId;
|
||||||
if (status) status.textContent = buildUploadStatus(result.fileName || file.name, result.ossId);
|
if (status) status.textContent = buildUploadStatus(result.fileName || file.name, result.ossId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (redirectUnauthorized(api, error)) return;
|
||||||
if (status) status.textContent = error.message || '上传失败';
|
if (status) status.textContent = error.message || '上传失败';
|
||||||
showMessage(error.message || '上传失败');
|
showMessage(error.message || '上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,6 +122,7 @@
|
|||||||
buildUploadStatus: buildUploadStatus,
|
buildUploadStatus: buildUploadStatus,
|
||||||
getUploadMode: getUploadMode,
|
getUploadMode: getUploadMode,
|
||||||
uploadFileForPage: uploadFileForPage,
|
uploadFileForPage: uploadFileForPage,
|
||||||
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,19 @@
|
|||||||
maxlength="11"
|
maxlength="11"
|
||||||
placeholder="手机号"
|
placeholder="手机号"
|
||||||
/>
|
/>
|
||||||
|
<div class="code-row">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
name="smsCode"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="短信验证码"
|
||||||
|
/>
|
||||||
|
<button class="btn ghost" type="button" data-api-send-code>
|
||||||
|
获取验证码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
name="nickName"
|
name="nickName"
|
||||||
@@ -44,6 +57,13 @@
|
|||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="设置密码"
|
placeholder="设置密码"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="确认密码"
|
||||||
|
/>
|
||||||
<input type="hidden" name="validToken" />
|
<input type="hidden" name="validToken" />
|
||||||
<button class="btn primary" type="submit">注册账号</button>
|
<button class="btn primary" type="submit">注册账号</button>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ test('API client exposes latest document-defined PC operations', () => {
|
|||||||
'clientId', 'tenantId', 'tokenKey', 'getToken', 'setToken', 'clearToken', 'buildApiUrl',
|
'clientId', 'tenantId', 'tokenKey', 'getToken', 'setToken', 'clearToken', 'buildApiUrl',
|
||||||
'register', 'login', 'loginBySms', 'sendSmsCode',
|
'register', 'login', 'loginBySms', 'sendSmsCode',
|
||||||
'currentProfile', 'updateProfile', 'changePassword', 'resetPassword', 'changePhone',
|
'currentProfile', 'updateProfile', 'changePassword', 'resetPassword', 'changePhone',
|
||||||
'deactivateAccount', 'logout', 'uploadFile',
|
'deactivateAccount', 'logout', 'uploadFile', 'initResumableUpload', 'uploadResumableChunk',
|
||||||
|
'completeResumableUpload', 'createFileReference', 'deleteFileReference',
|
||||||
'captchaRequirement', 'captchaChallenge', 'captchaVerify',
|
'captchaRequirement', 'captchaChallenge', 'captchaVerify',
|
||||||
'regionChildren', 'regionPath', 'regionSearch', 'regionDetail',
|
'regionChildren', 'regionPath', 'regionSearch', 'regionDetail',
|
||||||
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
|
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
|
||||||
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
|
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
|
||||||
'deleteFeedComment'
|
'feedCommentReplies', 'feedCommentRepliesPage', 'deleteFeedComment',
|
||||||
|
'generationPoems', 'generationPoemsManagement', 'createGenerationPoem', 'updateGenerationPoem',
|
||||||
|
'previewGenerationPoems', 'saveGenerationPoems',
|
||||||
|
'lineagePersons', 'lineagePersonsPage', 'lineagePersonOptions', 'lineageTree',
|
||||||
|
'createLineagePerson', 'lineagePersonDetail', 'updateLineagePerson', 'disableLineagePerson',
|
||||||
|
'createLineageChild', 'createLineageParent', 'createLineageSibling', 'createLineageSpouse'
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
assert.deepEqual(Object.keys(client).sort(), allowed);
|
assert.deepEqual(Object.keys(client).sort(), allowed);
|
||||||
@@ -72,7 +78,7 @@ test('sending an SMS code uses the latest documented request fields', async () =
|
|||||||
tenantId: '000000',
|
tenantId: '000000',
|
||||||
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
||||||
phone: '13800000000',
|
phone: '13800000000',
|
||||||
sceneCode: 'WEB_H5_LOGIN',
|
sceneCode: 'PC_SMS_LOGIN',
|
||||||
validToken: 'captcha-ticket'
|
validToken: 'captcha-ticket'
|
||||||
});
|
});
|
||||||
return Promise.resolve({ data: { code: 200, data: null } });
|
return Promise.resolve({ data: { code: 200, data: null } });
|
||||||
@@ -82,11 +88,243 @@ test('sending an SMS code uses the latest documented request fields', async () =
|
|||||||
|
|
||||||
await client.sendSmsCode({
|
await client.sendSmsCode({
|
||||||
phone: '13800000000',
|
phone: '13800000000',
|
||||||
sceneCode: 'WEB_H5_LOGIN',
|
sceneCode: 'PC_SMS_LOGIN',
|
||||||
validToken: 'captcha-ticket'
|
validToken: 'captcha-ticket'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('password reset adds the documented grant, tenant and client fields', async () => {
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
assert.equal(config.method, 'put');
|
||||||
|
assert.equal(config.url, '/genealogy/pc/auth/password/reset');
|
||||||
|
assert.equal(config.headers.Authorization, undefined);
|
||||||
|
assert.deepEqual(config.data, {
|
||||||
|
grantType: 'password',
|
||||||
|
tenantId: '000000',
|
||||||
|
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
newPassword: 'password-md5'
|
||||||
|
});
|
||||||
|
return Promise.resolve({ data: { code: 200, data: null } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.resetPassword({
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
newPassword: 'password-md5'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registration and SMS login use the PC auth endpoints with SMS fields', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
calls.push(config);
|
||||||
|
if (config.url.endsWith('/login/sms')) {
|
||||||
|
return Promise.resolve({ data: { code: 200, data: { access_token: 'sms-token' } } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { code: 200, data: null } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.register({
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
nickName: '小李',
|
||||||
|
password: 'password-md5'
|
||||||
|
});
|
||||||
|
await client.loginBySms({ phone: '13800000000', smsCode: '123456' });
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||||
|
['post', '/genealogy/pc/auth/register', {
|
||||||
|
grantType: 'password',
|
||||||
|
registerSource: 'PC',
|
||||||
|
tenantId: '000000',
|
||||||
|
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
nickName: '小李',
|
||||||
|
password: 'password-md5'
|
||||||
|
}],
|
||||||
|
['post', '/genealogy/pc/auth/login/sms', {
|
||||||
|
grantType: 'sms',
|
||||||
|
tenantId: '000000',
|
||||||
|
clientId: 'ced7e5f0498645c6ec642dcf450b036f',
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456'
|
||||||
|
}]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone change and account deactivation retain their PC request shapes', async () => {
|
||||||
|
let stored = 'access-token';
|
||||||
|
const calls = [];
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: {
|
||||||
|
getItem() { return stored; },
|
||||||
|
setItem(_, value) { stored = value; },
|
||||||
|
removeItem() { stored = ''; }
|
||||||
|
},
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
calls.push(config);
|
||||||
|
return Promise.resolve({ data: { code: 200, data: null } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.changePhone({ phone: '13900000000', smsCode: '123456' });
|
||||||
|
await client.deactivateAccount({ smsCode: '654321' });
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||||
|
['put', '/genealogy/pc/auth/phone', {
|
||||||
|
phone: '13900000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
clientId: 'ced7e5f0498645c6ec642dcf450b036f'
|
||||||
|
}],
|
||||||
|
['post', '/genealogy/pc/auth/account/deactivate', {
|
||||||
|
smsCode: '654321',
|
||||||
|
clientId: 'ced7e5f0498645c6ec642dcf450b036f'
|
||||||
|
}]
|
||||||
|
]);
|
||||||
|
assert.equal(stored, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('region methods use PC paths and retain the logged-in authorization header', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
calls.push(config);
|
||||||
|
return Promise.resolve({ data: { code: 200, data: [] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.regionChildren('0');
|
||||||
|
await client.regionPath('11');
|
||||||
|
await client.regionSearch({ keyword: '北京', limit: 20 });
|
||||||
|
await client.regionDetail('110101');
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((config) => [
|
||||||
|
config.method,
|
||||||
|
config.url,
|
||||||
|
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']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remaining PC file methods use the documented paths, request forms and login header', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
calls.push(config);
|
||||||
|
return Promise.resolve({ data: { code: 200, data: {} } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const chunk = new Blob(['part'], { type: 'application/octet-stream' });
|
||||||
|
|
||||||
|
await client.initResumableUpload({
|
||||||
|
fileName: 'video.mp4',
|
||||||
|
fileSize: 8388608,
|
||||||
|
fileMd5: 'file-md5',
|
||||||
|
chunkSize: 4194304,
|
||||||
|
totalChunks: 2,
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
bizType: 'video',
|
||||||
|
usageScene: 'family_video'
|
||||||
|
});
|
||||||
|
await client.uploadResumableChunk({
|
||||||
|
uploadId: 'UPLOAD202607240001',
|
||||||
|
chunkIndex: 0,
|
||||||
|
chunkMd5: 'chunk-md5',
|
||||||
|
file: chunk
|
||||||
|
});
|
||||||
|
await client.completeResumableUpload({
|
||||||
|
uploadId: 'UPLOAD202607240001',
|
||||||
|
fileMd5: 'file-md5',
|
||||||
|
fileSize: 8388608
|
||||||
|
});
|
||||||
|
await client.createFileReference({
|
||||||
|
bizType: 'family_feed',
|
||||||
|
bizTable: 'gen_family_feed',
|
||||||
|
bizId: '900013001',
|
||||||
|
bizField: 'media_oss_ids',
|
||||||
|
ossId: '2060000000000000000'
|
||||||
|
});
|
||||||
|
await client.deleteFileReference({
|
||||||
|
bizTable: 'gen_family_feed',
|
||||||
|
bizId: '900013001',
|
||||||
|
bizField: 'media_oss_ids'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((config) => [
|
||||||
|
config.method,
|
||||||
|
config.url,
|
||||||
|
config.params,
|
||||||
|
config.headers.Authorization
|
||||||
|
]), [
|
||||||
|
['post', '/genealogy/pc/files/resumable/init', undefined, 'Bearer access-token'],
|
||||||
|
['post', '/genealogy/pc/files/resumable/chunk', undefined, 'Bearer access-token'],
|
||||||
|
['post', '/genealogy/pc/files/resumable/complete', undefined, 'Bearer access-token'],
|
||||||
|
['post', '/genealogy/pc/files/reference', undefined, 'Bearer access-token'],
|
||||||
|
['delete', '/genealogy/pc/files/reference', {
|
||||||
|
bizTable: 'gen_family_feed',
|
||||||
|
bizId: '900013001',
|
||||||
|
bizField: 'media_oss_ids'
|
||||||
|
}, 'Bearer access-token']
|
||||||
|
]);
|
||||||
|
assert.deepEqual(calls[0].data, {
|
||||||
|
fileName: 'video.mp4',
|
||||||
|
fileSize: 8388608,
|
||||||
|
fileMd5: 'file-md5',
|
||||||
|
chunkSize: 4194304,
|
||||||
|
totalChunks: 2,
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
bizType: 'video',
|
||||||
|
usageScene: 'family_video'
|
||||||
|
});
|
||||||
|
assert.equal(calls[1].data.get('uploadId'), 'UPLOAD202607240001');
|
||||||
|
assert.equal(calls[1].data.get('chunkIndex'), '0');
|
||||||
|
assert.equal(calls[1].data.get('chunkMd5'), 'chunk-md5');
|
||||||
|
assert.equal(calls[1].data.get('file').name, 'blob');
|
||||||
|
assert.deepEqual(calls[2].data, {
|
||||||
|
uploadId: 'UPLOAD202607240001',
|
||||||
|
fileMd5: 'file-md5',
|
||||||
|
fileSize: 8388608
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls[3].data, {
|
||||||
|
bizType: 'family_feed',
|
||||||
|
bizTable: 'gen_family_feed',
|
||||||
|
bizId: '900013001',
|
||||||
|
bizField: 'media_oss_ids',
|
||||||
|
ossId: '2060000000000000000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('family feed methods use the documented paths and request bodies', async () => {
|
test('family feed methods use the documented paths and request bodies', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const client = GenealogyApi.createClient({
|
const client = GenealogyApi.createClient({
|
||||||
@@ -107,6 +345,8 @@ test('family feed methods use the documented paths and request bodies', async ()
|
|||||||
status: '0'
|
status: '0'
|
||||||
});
|
});
|
||||||
await client.feedCommentsPage(900001001, 7001, { pageNum: 1, pageSize: 10 });
|
await client.feedCommentsPage(900001001, 7001, { pageNum: 1, pageSize: 10 });
|
||||||
|
await client.feedCommentReplies(900001001, 7001, 8001);
|
||||||
|
await client.feedCommentRepliesPage(900001001, 7001, 8001, { pageNum: 2, pageSize: 10 });
|
||||||
await client.deleteFeedComment(900001001, 7001, 8001);
|
await client.deleteFeedComment(900001001, 7001, 8001);
|
||||||
|
|
||||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
|
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
|
||||||
@@ -117,6 +357,120 @@ test('family feed methods use the documented paths and request bodies', async ()
|
|||||||
status: '0'
|
status: '0'
|
||||||
}],
|
}],
|
||||||
['get', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/page', { pageNum: 1, pageSize: 10 }, undefined],
|
['get', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/page', { pageNum: 1, pageSize: 10 }, undefined],
|
||||||
|
['get', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/8001/replies', undefined, undefined],
|
||||||
|
['get', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/8001/replies/page', { pageNum: 2, pageSize: 10 }, undefined],
|
||||||
['delete', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/8001', undefined, undefined]
|
['delete', '/genealogy/pc/genealogies/900001001/feeds/7001/comments/8001', undefined, undefined]
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generation poem methods use the Apifox PC paths and request bodies', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
calls.push(config);
|
||||||
|
return Promise.resolve({ data: { code: 200, data: {} } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const poem = {
|
||||||
|
generationNo: 3,
|
||||||
|
generationText: '万',
|
||||||
|
description: '第三世',
|
||||||
|
sortOrder: 30,
|
||||||
|
status: '0'
|
||||||
|
};
|
||||||
|
const batch = { poemText: '德 承 家 亦', disableMissing: true };
|
||||||
|
|
||||||
|
await client.generationPoems(900001001);
|
||||||
|
await client.generationPoemsManagement(900001001);
|
||||||
|
await client.createGenerationPoem(900001001, poem);
|
||||||
|
await client.updateGenerationPoem(900001001, 2060000000000000000n, poem);
|
||||||
|
await client.previewGenerationPoems(900001001, batch);
|
||||||
|
await client.saveGenerationPoems(900001001, batch);
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||||
|
['get', '/genealogy/pc/genealogies/900001001/generation-poems', undefined],
|
||||||
|
['get', '/genealogy/pc/genealogies/900001001/generation-poems/management', undefined],
|
||||||
|
['post', '/genealogy/pc/genealogies/900001001/generation-poems', poem],
|
||||||
|
['put', '/genealogy/pc/genealogies/900001001/generation-poems/2060000000000000000', poem],
|
||||||
|
['post', '/genealogy/pc/genealogies/900001001/generation-poems/batch/preview', batch],
|
||||||
|
['post', '/genealogy/pc/genealogies/900001001/generation-poems/batch/save', batch]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineage person methods use the Apifox PC paths, query and LineagePersonBody', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const client = GenealogyApi.createClient({
|
||||||
|
baseUrl: 'https://api.example.test',
|
||||||
|
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
|
||||||
|
axiosInstance: {
|
||||||
|
request(config) {
|
||||||
|
calls.push(config);
|
||||||
|
return Promise.resolve({ data: { code: 200, data: {} } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const person = {
|
||||||
|
name: '李明',
|
||||||
|
sex: '1',
|
||||||
|
generation: 3,
|
||||||
|
generationName: '德',
|
||||||
|
personNo: 'L-003',
|
||||||
|
aliasName: '明远',
|
||||||
|
birthDate: '1990-01-01',
|
||||||
|
deathDate: null,
|
||||||
|
biography: '人物简介'
|
||||||
|
};
|
||||||
|
const genealogyId = '2060000000000000001';
|
||||||
|
const personId = '2060000000000000002';
|
||||||
|
|
||||||
|
await client.lineagePersons(genealogyId);
|
||||||
|
await client.lineagePersonsPage(genealogyId, {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '李',
|
||||||
|
generation: 3,
|
||||||
|
personStatus: '0'
|
||||||
|
});
|
||||||
|
await client.lineagePersonOptions(genealogyId);
|
||||||
|
await client.lineageTree(genealogyId);
|
||||||
|
await client.createLineagePerson(genealogyId, person);
|
||||||
|
await client.lineagePersonDetail(genealogyId, personId);
|
||||||
|
await client.updateLineagePerson(genealogyId, personId, person);
|
||||||
|
await client.disableLineagePerson(genealogyId, personId);
|
||||||
|
await client.createLineageChild(genealogyId, personId, person);
|
||||||
|
await client.createLineageParent(genealogyId, personId, person);
|
||||||
|
await client.createLineageSibling(genealogyId, personId, person);
|
||||||
|
await client.createLineageSpouse(genealogyId, personId, person);
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((config) => [
|
||||||
|
config.method,
|
||||||
|
config.url,
|
||||||
|
config.params,
|
||||||
|
config.data,
|
||||||
|
config.headers.Authorization,
|
||||||
|
config.headers.clientid
|
||||||
|
]), [
|
||||||
|
['get', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons', undefined, undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['get', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/page', {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '李',
|
||||||
|
generation: 3,
|
||||||
|
personStatus: '0'
|
||||||
|
}, undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['get', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/options', undefined, undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['get', '/genealogy/pc/genealogies/2060000000000000001/lineage/tree', undefined, undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['post', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons', undefined, person, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['get', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002', undefined, undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['put', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002', undefined, person, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['delete', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002', undefined, undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['post', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002/children', undefined, person, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['post', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002/parents', undefined, person, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['post', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002/siblings', undefined, person, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
|
||||||
|
['post', '/genealogy/pc/genealogies/2060000000000000001/lineage/persons/2060000000000000002/spouses', undefined, person, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const AuthPages = require('../public/js/auth-pages.js');
|
||||||
|
|
||||||
|
test('registration body carries the SMS code but never sends confirmation or captcha fields', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
AuthPages.buildRegisterBody({
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
nickName: '小李',
|
||||||
|
password: 'plain-password',
|
||||||
|
confirmPassword: 'plain-password',
|
||||||
|
validToken: 'captcha-ticket'
|
||||||
|
}, (value) => 'md5-' + value),
|
||||||
|
{
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
nickName: '小李',
|
||||||
|
password: 'md5-plain-password'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password reset body carries only the reset DTO fields', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
AuthPages.buildPasswordResetBody({
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
newPassword: 'plain-password',
|
||||||
|
validToken: 'captcha-ticket'
|
||||||
|
}, (value) => 'md5-' + value),
|
||||||
|
{
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
newPassword: 'md5-plain-password'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authentication forms use the PC SMS scenes defined by the PC endpoint', () => {
|
||||||
|
assert.equal(AuthPages.getCaptchaScene('login'), 'PC_SMS_LOGIN');
|
||||||
|
assert.equal(AuthPages.getCaptchaScene('sms-login'), 'PC_SMS_LOGIN');
|
||||||
|
assert.equal(AuthPages.getCaptchaScene('register'), 'PC_REGISTER');
|
||||||
|
assert.equal(AuthPages.getCaptchaScene('password-reset'), 'PC_FORGOT_PASSWORD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registration validates SMS code and password confirmation before submission', () => {
|
||||||
|
const base = {
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
password: 'password',
|
||||||
|
confirmPassword: 'password'
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(AuthPages.validateAuthValues('register', base), '');
|
||||||
|
assert.equal(AuthPages.validateAuthValues('register', { ...base, smsCode: '' }), '请填写短信验证码');
|
||||||
|
assert.equal(AuthPages.validateAuthValues('register', { ...base, confirmPassword: 'different' }), '两次输入的密码不一致');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registration page exposes the documented SMS and confirmation inputs', () => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'register.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /name="smsCode"/);
|
||||||
|
assert.match(source, /data-api-send-code/);
|
||||||
|
assert.match(source, /name="confirmPassword"/);
|
||||||
|
});
|
||||||
@@ -33,3 +33,41 @@ test('动态列表要求服务端返回 feedId 和 feedContent', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(FeedPages.normalizeFeed({ feedId: 7001, content: '旧字段' }), null);
|
assert.equal(FeedPages.normalizeFeed({ feedId: 7001, content: '旧字段' }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('评论请求只提交 Apifox 定义的 commentContent 和父评论 ID', () => {
|
||||||
|
assert.deepEqual(FeedPages.buildCommentBody({
|
||||||
|
commentContent: '收到',
|
||||||
|
parentCommentId: '2060000000000000000',
|
||||||
|
replyUserId: '2060000000000000001'
|
||||||
|
}), {
|
||||||
|
commentContent: '收到',
|
||||||
|
parentCommentId: '2060000000000000000'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('评论响应使用 commentContent,并保留已删除评论的占位状态', () => {
|
||||||
|
assert.deepEqual(FeedPages.normalizeComment({
|
||||||
|
commentId: '2060000000000000000',
|
||||||
|
commentContent: '收到',
|
||||||
|
appUserNickName: '小李',
|
||||||
|
replyCount: 2,
|
||||||
|
userDeleted: '0'
|
||||||
|
}), {
|
||||||
|
commentId: '2060000000000000000',
|
||||||
|
commentContent: '收到',
|
||||||
|
appUserNickName: '小李',
|
||||||
|
replyCount: 2,
|
||||||
|
userDeleted: false
|
||||||
|
});
|
||||||
|
assert.deepEqual(FeedPages.normalizeComment({
|
||||||
|
commentId: '2060000000000000001',
|
||||||
|
commentContent: null,
|
||||||
|
userDeleted: '1'
|
||||||
|
}), {
|
||||||
|
commentId: '2060000000000000001',
|
||||||
|
commentContent: '该评论已删除',
|
||||||
|
appUserNickName: undefined,
|
||||||
|
replyCount: undefined,
|
||||||
|
userDeleted: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const GenerationPages = require('../public/js/generation-pages.js');
|
||||||
|
|
||||||
|
test('字辈页只从 URL 读取真实家谱编号', () => {
|
||||||
|
assert.equal(GenerationPages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
|
||||||
|
assert.equal(GenerationPages.getCurrentGenealogyId(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字辈新增和修改只构造 Apifox 定义的字段', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
GenerationPages.buildGenerationPoemBody({
|
||||||
|
generationNo: '3',
|
||||||
|
generationText: ' 万 ',
|
||||||
|
description: '第三世',
|
||||||
|
sortOrder: '30',
|
||||||
|
status: '1',
|
||||||
|
content: '旧字段',
|
||||||
|
stopMissingOldGeneration: true
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
generationNo: 3,
|
||||||
|
generationText: '万',
|
||||||
|
description: '第三世',
|
||||||
|
sortOrder: 30,
|
||||||
|
status: '1'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('批量字辈请求只提交 poemText 与 disableMissing', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
GenerationPages.buildBatchBody({
|
||||||
|
poemText: '德 承 家 亦',
|
||||||
|
disableMissing: true,
|
||||||
|
content: '旧字段',
|
||||||
|
stopMissingOldGeneration: false
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
poemText: '德 承 家 亦',
|
||||||
|
disableMissing: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字辈列表要求管理接口返回稳定的直接字段', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
GenerationPages.normalizeGenerationPoem({
|
||||||
|
poemId: '2060000000000000000',
|
||||||
|
generationNo: 3,
|
||||||
|
generationText: '万',
|
||||||
|
description: '第三世',
|
||||||
|
sortOrder: 30,
|
||||||
|
status: '0'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
poemId: '2060000000000000000',
|
||||||
|
generationNo: 3,
|
||||||
|
generationText: '万',
|
||||||
|
status: '0',
|
||||||
|
description: '第三世',
|
||||||
|
sortOrder: 30
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(GenerationPages.normalizeGenerationPoem({ poemId: 1, generationNo: 3, content: '旧字段', status: '0' }), null);
|
||||||
|
assert.equal(GenerationPages.normalizeGenerationPoem({
|
||||||
|
poemId: Number('2060000000000000000'),
|
||||||
|
generationNo: 3,
|
||||||
|
generationText: '万',
|
||||||
|
status: '0'
|
||||||
|
}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字辈批量输入按 Apifox 限制校验', () => {
|
||||||
|
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 0, generationText: '万' }), '世代序号必须是 1 到 2147483647 之间的整数');
|
||||||
|
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 3, generationText: '' }), '请填写字辈文字');
|
||||||
|
assert.equal(GenerationPages.validateGenerationPoemBody(GenerationPages.buildGenerationPoemBody({ generationNo: 3, generationText: '万', sortOrder: '1.5' })), '排序值必须是整数');
|
||||||
|
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 3, generationText: '万', sortOrder: 2147483648 }), '排序值必须在 -2147483648 到 2147483647 之间');
|
||||||
|
assert.equal(GenerationPages.validateBatchBody({ poemText: '', disableMissing: false }), '请填写批量字辈内容');
|
||||||
|
assert.deepEqual(GenerationPages.splitPoemText('德,承;家、亦/传|芳\n远'), ['德', '承', '家', '亦', '传', '芳', '远']);
|
||||||
|
assert.equal(GenerationPages.validateBatchBody({ poemText: '甲'.repeat(51), disableMissing: false }), '单个字辈不能超过 50 个字符');
|
||||||
|
assert.equal(GenerationPages.validateBatchBody({ poemText: Array(502).fill('甲').join(' '), disableMissing: false }), '一次最多导入 500 个世代');
|
||||||
|
assert.equal(GenerationPages.validateBatchBody({ poemText: '德承家亦', disableMissing: false }), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字辈批量示例明确使用 Apifox 支持的分隔符', () => {
|
||||||
|
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-generation.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /placeholder="例如:德 承 家 亦(用空格或标点分隔)"/);
|
||||||
|
assert.doesNotMatch(page, /placeholder="例如:德承家亦"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字辈管理接口的 403 不是登录失效', () => {
|
||||||
|
assert.equal(GenerationPages.isForbidden({ status: 403 }), true);
|
||||||
|
assert.equal(GenerationPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const LineagePages = require('../public/js/lineage-pages.js');
|
||||||
|
|
||||||
|
test('世系页只从 URL 读取真实家谱编号', () => {
|
||||||
|
assert.equal(LineagePages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
|
||||||
|
assert.equal(LineagePages.getCurrentGenealogyId(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
LineagePages.buildLineagePersonBody({
|
||||||
|
name: '李明',
|
||||||
|
sex: '0',
|
||||||
|
generation: '3',
|
||||||
|
generationName: '德',
|
||||||
|
personNo: 'P202607090001',
|
||||||
|
aliasName: '明远',
|
||||||
|
birthDate: '1990-01-01',
|
||||||
|
sortOrder: '1',
|
||||||
|
relationName: '妻',
|
||||||
|
biography: '人物简介',
|
||||||
|
personName: '旧字段',
|
||||||
|
generationNo: 4,
|
||||||
|
introduction: '旧字段'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: '李明',
|
||||||
|
sex: '0',
|
||||||
|
generation: 3,
|
||||||
|
generationName: '德',
|
||||||
|
personNo: 'P202607090001',
|
||||||
|
aliasName: '明远',
|
||||||
|
birthDate: '1990-01-01',
|
||||||
|
sortOrder: 1,
|
||||||
|
relationName: '妻',
|
||||||
|
biography: '人物简介'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('世系人物要求安全的 ID、姓名和有效世代值', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
LineagePages.normalizeLineagePerson({
|
||||||
|
personId: '2060000000000000000',
|
||||||
|
name: '李明',
|
||||||
|
generation: 3,
|
||||||
|
generationName: '德',
|
||||||
|
sex: '0'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
personId: '2060000000000000000',
|
||||||
|
name: '李明',
|
||||||
|
sex: '0',
|
||||||
|
generationName: '德',
|
||||||
|
generation: 3
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(LineagePages.normalizeLineagePerson({ personId: Number('2060000000000000000'), name: '李明' }), null);
|
||||||
|
assert.equal(LineagePages.validateLineagePersonBody({ name: '' }), '请填写成员姓名');
|
||||||
|
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', generation: '1.5' })), '世代序号必须是整数');
|
||||||
|
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
|
||||||
|
assert.equal(LineagePages.relationLabel('parents'), '父母');
|
||||||
|
assert.equal(LineagePages.relationLabel('spouses'), '配偶');
|
||||||
|
assert.equal(LineagePages.relationLabel('children'), '子女');
|
||||||
|
assert.equal(LineagePages.relationLabel('siblings'), '兄弟姐妹');
|
||||||
|
assert.equal(LineagePages.relationLabel('remove'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('世系页按 Apifox 关键词范围搜索并提供分页入口', () => {
|
||||||
|
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /placeholder="输入姓名、别名或人物编号搜索"/);
|
||||||
|
assert.match(page, /data-lineage-pagination/);
|
||||||
|
assert.match(page, /data-lineage-page-action="previous"/);
|
||||||
|
assert.match(page, /data-lineage-page-action="next"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('世系管理的 403 不会被当作登录失效', () => {
|
||||||
|
assert.equal(LineagePages.isForbidden({ status: 403 }), true);
|
||||||
|
assert.equal(LineagePages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
|
||||||
|
});
|
||||||
+10
-15
@@ -16,29 +16,22 @@ test('配置始终使用后端提供的 PC 接口地址', () => {
|
|||||||
|
|
||||||
assert.equal(development.getEnvironment(), 'development');
|
assert.equal(development.getEnvironment(), 'development');
|
||||||
assert.equal(production.getEnvironment(), 'production');
|
assert.equal(production.getEnvironment(), 'production');
|
||||||
assert.equal(development.getApiBaseUrl(), 'http://182.61.18.23:8080');
|
assert.equal(development.getApiBaseUrl(), 'https://backend-api.ddxcjp.cn/');
|
||||||
assert.equal(production.getApiBaseUrl(), 'http://182.61.18.23:8080');
|
assert.equal(production.getApiBaseUrl(), 'https://backend-api.ddxcjp.cn/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('最新 OpenAPI 文档包含已交付的家族圈接口', () => {
|
test('PC 对接规划明确以 Apifox 目录而非导出快照为契约源', () => {
|
||||||
const contract = JSON.parse(read('PC.openapi2.json'));
|
const plan = read('docs/PC接口对接规划.md');
|
||||||
|
|
||||||
assert.ok(contract.paths['/genealogy/pc/auth/login']);
|
assert.match(plan, /Apifox 的 PC 目录是 PC 前端接口的唯一正式契约源/);
|
||||||
assert.ok(contract.paths['/genealogy/pc/files/upload']);
|
assert.match(plan, /旧 OpenAPI 文件不再作为新增功能依据/);
|
||||||
assert.ok(contract.paths['/genealogy/region/children']);
|
|
||||||
assert.ok(contract.paths['/genealogy/pc/genealogies/{genealogyId}/feeds']);
|
|
||||||
assert.ok(contract.paths['/genealogy/pc/genealogies/{genealogyId}/feeds/{feedId}/comments/{commentId}']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('旧 YAML 明确指向最新 JSON 接口源', () => {
|
|
||||||
assert.match(read('PC.openapi.yaml'), /正式接口源为 PC\.openapi2\.json/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pages do not load scripts for unavailable business APIs', () => {
|
test('pages do not load scripts for unavailable business APIs', () => {
|
||||||
const removedScripts = [
|
const removedScripts = [
|
||||||
'album-pages.js', 'article-pages.js', 'ceremony-pages.js',
|
'album-pages.js', 'article-pages.js', 'ceremony-pages.js',
|
||||||
'feedback-pages.js', 'genealogy-pages.js', 'generation-pages.js', 'growth-pages.js',
|
'feedback-pages.js', 'genealogy-pages.js', 'growth-pages.js',
|
||||||
'help-pages.js', 'join-apply-pages.js', 'lineage-pages.js', 'member-admin-pages.js',
|
'help-pages.js', 'join-apply-pages.js', 'member-admin-pages.js',
|
||||||
'memo-pages.js', 'notification-pages.js', 'promotion-pages.js', 'vip-pages.js'
|
'memo-pages.js', 'notification-pages.js', 'promotion-pages.js', 'vip-pages.js'
|
||||||
];
|
];
|
||||||
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
|
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
|
||||||
@@ -53,4 +46,6 @@ test('pages do not load scripts for unavailable business APIs', () => {
|
|||||||
['profile-feed.html', 'profile-feed-edit.html'].forEach((page) => {
|
['profile-feed.html', 'profile-feed-edit.html'].forEach((page) => {
|
||||||
assert.equal(read(page).includes('src="public/js/feed-pages.js"'), true, `${page} must load feed-pages.js`);
|
assert.equal(read(page).includes('src="public/js/feed-pages.js"'), true, `${page} must load feed-pages.js`);
|
||||||
});
|
});
|
||||||
|
assert.equal(read('profile-generation.html').includes('src="public/js/generation-pages.js"'), true, 'profile-generation.html must load generation-pages.js');
|
||||||
|
assert.equal(read('profile-tree.html').includes('src="public/js/lineage-pages.js"'), true, 'profile-tree.html must load lineage-pages.js');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const familyPendingPages = [
|
|||||||
'profile-join-review.html',
|
'profile-join-review.html',
|
||||||
'profile-family-admin.html',
|
'profile-family-admin.html',
|
||||||
'profile-invite.html',
|
'profile-invite.html',
|
||||||
'profile-tree.html',
|
|
||||||
'profile-generation.html'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const contentPendingPages = [
|
const contentPendingPages = [
|
||||||
@@ -71,10 +69,21 @@ test('待开发页面显式加载状态脚本', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('家族圈动态页面保持真实功能状态', () => {
|
test('已接入的家谱业务页面保持真实功能状态', () => {
|
||||||
['profile-feed.html', 'profile-feed-edit.html'].forEach((page) => {
|
['profile-feed.html', 'profile-feed-edit.html', 'profile-generation.html', 'profile-tree.html'].forEach((page) => {
|
||||||
assert.doesNotMatch(read(page), /data-feature-status="pending"/, `${page} 不应标记为待开发`);
|
assert.doesNotMatch(read(page), /data-feature-status="pending"/, `${page} 不应标记为待开发`);
|
||||||
});
|
});
|
||||||
|
assert.match(read('profile-generation.html'), /src="public\/js\/generation-pages\.js"/);
|
||||||
|
assert.match(read('profile-tree.html'), /src="public\/js\/lineage-pages\.js"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('待开发的家谱管理页明确放行已接入的字辈入口并透传上下文', () => {
|
||||||
|
const familyAdmin = read('profile-family-admin.html');
|
||||||
|
const profileCommon = read('public/js/profile-common.js');
|
||||||
|
|
||||||
|
assert.match(familyAdmin, /href="profile-generation\.html" data-feature-link="available" data-genealogy-context-link/);
|
||||||
|
assert.match(profileCommon, /function syncGenealogyContextLinks\(\)/);
|
||||||
|
assert.match(profileCommon, /params\.set\('genealogyId', genealogyId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('待开发页面仅放行显式标记的可用功能入口', () => {
|
test('待开发页面仅放行显式标记的可用功能入口', () => {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const ProfilePages = require('../public/js/profile-pages.js');
|
||||||
|
const RegionPages = require('../public/js/region-pages.js');
|
||||||
|
const UploadPages = require('../public/js/upload-pages.js');
|
||||||
|
|
||||||
|
test('profile view reads only the documented profile fields', () => {
|
||||||
|
assert.deepEqual(ProfilePages.buildProfileView({
|
||||||
|
nickName: '小林',
|
||||||
|
phone: '13800000000',
|
||||||
|
birthday: '1990-01-01',
|
||||||
|
provinceCode: '11',
|
||||||
|
userName: '不应读取',
|
||||||
|
mobile: '不应读取'
|
||||||
|
}), {
|
||||||
|
displayName: '小林',
|
||||||
|
avatarText: '小',
|
||||||
|
phone: '13800000000',
|
||||||
|
sex: '待填写',
|
||||||
|
birthday: '1990-01-01',
|
||||||
|
regionText: '加载中...'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(ProfilePages.buildProfileView({
|
||||||
|
userName: '不应读取',
|
||||||
|
phonenumber: '不应读取',
|
||||||
|
mobile: '不应读取'
|
||||||
|
}), {
|
||||||
|
displayName: '未设置昵称',
|
||||||
|
avatarText: '家',
|
||||||
|
phone: '未绑定',
|
||||||
|
sex: '待填写',
|
||||||
|
birthday: '待填写',
|
||||||
|
regionText: '待填写'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile update preserves int64 OSS IDs as text', () => {
|
||||||
|
assert.deepEqual(ProfilePages.buildProfileUpdateBody({
|
||||||
|
nickName: '小林',
|
||||||
|
avatarOssId: '2060000000000000000',
|
||||||
|
sex: '0',
|
||||||
|
birthday: '1990-01-01',
|
||||||
|
provinceCode: '11',
|
||||||
|
cityCode: '1101',
|
||||||
|
districtCode: '110101'
|
||||||
|
}), {
|
||||||
|
nickName: '小林',
|
||||||
|
avatarOssId: '2060000000000000000',
|
||||||
|
sex: '0',
|
||||||
|
birthday: '1990-01-01',
|
||||||
|
provinceCode: '11',
|
||||||
|
cityCode: '1101',
|
||||||
|
districtCode: '110101'
|
||||||
|
});
|
||||||
|
assert.equal(typeof ProfilePages.buildProfileUpdateBody({ avatarOssId: '2060000000000000000' }).avatarOssId, 'string');
|
||||||
|
assert.equal(UploadPages.normalizeUploadResult({ ossId: '2060000000000000000' }).ossId, '2060000000000000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('region selector accepts only documented region fields and levels', () => {
|
||||||
|
assert.deepEqual(RegionPages.normalizeList({ data: [{ regionCode: '11' }] }), []);
|
||||||
|
assert.equal(RegionPages.getRegionCode({ value: '11' }), '');
|
||||||
|
assert.equal(RegionPages.getRegionName({ label: '北京市' }), '未命名地区');
|
||||||
|
assert.equal(RegionPages.getRegionLevel({ regionCode: '110101' }), 0);
|
||||||
|
|
||||||
|
assert.deepEqual(RegionPages.buildRegionSelection([
|
||||||
|
{ regionCode: '11', regionName: '北京市', regionLevel: 1 },
|
||||||
|
{ regionCode: '1101', regionName: '市辖区', regionLevel: 2 },
|
||||||
|
{ regionCode: '110101', regionName: '东城区', regionLevel: 3 }
|
||||||
|
]), {
|
||||||
|
provinceCode: '11',
|
||||||
|
cityCode: '1101',
|
||||||
|
districtCode: '110101'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile data page contains PC profile, upload and region flow markers', () => {
|
||||||
|
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, /src="public\/js\/upload-pages\.js"/);
|
||||||
|
assert.match(source, /src="public\/js\/region-pages\.js"/);
|
||||||
|
});
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
|
|
||||||
const SecurityPages = require('../public/js/security-pages.js');
|
const SecurityPages = require('../public/js/security-pages.js');
|
||||||
const UploadPages = require('../public/js/upload-pages.js');
|
const UploadPages = require('../public/js/upload-pages.js');
|
||||||
|
|
||||||
test('security forms use dedicated captcha scenes and carry the returned ticket', () => {
|
test('security forms use SMS captcha scenes and submit only documented business fields', () => {
|
||||||
assert.equal(SecurityPages.getCaptchaScene('password'), 'WEB_H5_CHANGE_PASSWORD');
|
assert.equal(SecurityPages.getCaptchaScene('phone'), 'PC_PHONE_CHANGE');
|
||||||
assert.equal(SecurityPages.getCaptchaScene('phone'), 'WEB_H5_CHANGE_PHONE');
|
assert.equal(SecurityPages.getCaptchaScene('deactivate'), 'PC_ACCOUNT_DEACTIVATE');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
SecurityPages.buildPasswordChangeBody({
|
SecurityPages.buildPasswordChangeBody({
|
||||||
oldPassword: 'old',
|
oldPassword: 'old',
|
||||||
@@ -15,10 +17,21 @@ test('security forms use dedicated captcha scenes and carry the returned ticket'
|
|||||||
}, (value) => 'md5-' + value),
|
}, (value) => 'md5-' + value),
|
||||||
{
|
{
|
||||||
oldPassword: 'md5-old',
|
oldPassword: 'md5-old',
|
||||||
newPassword: 'md5-new',
|
newPassword: 'md5-new'
|
||||||
validToken: 'captcha-ticket'
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
SecurityPages.buildPhoneChangeBody({
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456',
|
||||||
|
validToken: 'captcha-ticket'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
phone: '13800000000',
|
||||||
|
smsCode: '123456'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.deepEqual(SecurityPages.buildDeactivateBody({ smsCode: '654321' }), { smsCode: '654321' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('upload helper exposes only single-file upload support', () => {
|
test('upload helper exposes only single-file upload support', () => {
|
||||||
@@ -26,3 +39,12 @@ test('upload helper exposes only single-file upload support', () => {
|
|||||||
assert.equal('buildResumableInitBody' in UploadPages, false);
|
assert.equal('buildResumableInitBody' in UploadPages, false);
|
||||||
assert.equal(UploadPages.getUploadMode(5 * 1024 * 1024), 'single');
|
assert.equal(UploadPages.getUploadMode(5 * 1024 * 1024), 'single');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('security page field names match phone-change and account-deactivation DTOs', () => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-security.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /id="newPhone"\s+name="phone"/);
|
||||||
|
assert.match(source, /data-security-form="deactivate"/);
|
||||||
|
assert.match(source, /id="deactivateSmsCode"\s+name="smsCode"/);
|
||||||
|
assert.match(source, /data-security-send-deactivate-code/);
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
请继续 C:\Users\Administrator\Desktop\job\jiapu 的 PC 接口对接工作。
|
||||||
|
|
||||||
|
先完整阅读:
|
||||||
|
1. docs/交接文档.md
|
||||||
|
2. docs/PC接口对接规划.md
|
||||||
|
3. AGENTS.md
|
||||||
|
|
||||||
|
强制规则:
|
||||||
|
- Apifox 的 PC 目录是唯一接口依据。
|
||||||
|
- 不使用 APP 接口,不根据本地 OpenAPI 导出文件猜路径或字段。
|
||||||
|
- 不写死 genealogyId;没有 PC 家谱入口接口时保持阻止状态。
|
||||||
|
- 页面只能通过 utils/ApiClient.js 请求接口,不能页面里直接 Axios 或拼路径。
|
||||||
|
- 当前工作树有未提交修改,禁止 git reset --hard、git checkout -- 或删除未跟踪文件。
|
||||||
|
|
||||||
|
已完成认证、资料、家族圈、字辈谱、世系人物等 57 条已在 Apifox PC 核验的接口施工。
|
||||||
|
下一步从 Apifox 的 PC「族务记录」16 条接口开始:逐条核验 → ApiClient → 对应页面 → 聚焦测试。
|
||||||
|
每完成一组,同步更新 docs/交接文档.md、docs/PC接口对接规划.md 和测试。
|
||||||
+125
-7
@@ -63,6 +63,22 @@
|
|||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createChunkFormData(bodyOrFormData) {
|
||||||
|
var FormDataCtor = root.FormData;
|
||||||
|
var source = bodyOrFormData || {};
|
||||||
|
var formData;
|
||||||
|
|
||||||
|
if (FormDataCtor && bodyOrFormData instanceof FormDataCtor) return bodyOrFormData;
|
||||||
|
if (!FormDataCtor) throw new Error('当前环境不支持文件上传');
|
||||||
|
|
||||||
|
formData = new FormDataCtor();
|
||||||
|
formData.append('uploadId', source.uploadId);
|
||||||
|
formData.append('chunkIndex', source.chunkIndex);
|
||||||
|
formData.append('chunkMd5', source.chunkMd5);
|
||||||
|
formData.append('file', source.file);
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
function buildApiUrl(baseUrl, path, query) {
|
function buildApiUrl(baseUrl, path, query) {
|
||||||
var url = String(baseUrl || '').replace(/\/+$/, '') + '/' + String(path || '').replace(/^\/+/, '');
|
var url = String(baseUrl || '').replace(/\/+$/, '') + '/' + String(path || '').replace(/^\/+/, '');
|
||||||
var params = new URLSearchParams();
|
var params = new URLSearchParams();
|
||||||
@@ -119,6 +135,10 @@
|
|||||||
return Object.assign({ tenantId: tenantId, clientId: clientId }, body || {});
|
return Object.assign({ tenantId: tenantId, clientId: clientId }, body || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withClient(body) {
|
||||||
|
return Object.assign({}, body || {}, { clientId: clientId });
|
||||||
|
}
|
||||||
|
|
||||||
function toRequiredPathId(value, label) {
|
function toRequiredPathId(value, label) {
|
||||||
var text = String(value === undefined || value === null ? '' : value).trim();
|
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||||
|
|
||||||
@@ -143,6 +163,25 @@
|
|||||||
: path + '/' + toRequiredPathId(commentId, '评论编号');
|
: path + '/' + toRequiredPathId(commentId, '评论编号');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFeedReplyPath(genealogyId, feedId, commentId, suffix) {
|
||||||
|
return buildFeedCommentPath(genealogyId, feedId, commentId) + '/replies' + (suffix || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGenerationPoemPath(genealogyId, suffix) {
|
||||||
|
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/generation-poems' + (suffix || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineagePath(genealogyId, suffix) {
|
||||||
|
return '/genealogy/pc/genealogies/' + toRequiredPathId(genealogyId, '家谱编号') + '/lineage' + (suffix || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineagePersonPath(genealogyId, personId, suffix) {
|
||||||
|
return buildLineagePath(
|
||||||
|
genealogyId,
|
||||||
|
'/persons/' + toRequiredPathId(personId, '人物编号') + (suffix || '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function login(body) {
|
async function login(body) {
|
||||||
var data = await request('POST', '/genealogy/pc/auth/login', {
|
var data = await request('POST', '/genealogy/pc/auth/login', {
|
||||||
auth: false,
|
auth: false,
|
||||||
@@ -168,7 +207,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deactivateAccount(body) {
|
async function deactivateAccount(body) {
|
||||||
var data = await request('POST', '/genealogy/pc/auth/account/deactivate', { body: body });
|
var data = await request('POST', '/genealogy/pc/auth/account/deactivate', {
|
||||||
|
body: withClient(body)
|
||||||
|
});
|
||||||
clearToken();
|
clearToken();
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -218,17 +259,34 @@
|
|||||||
resetPassword: function (body) {
|
resetPassword: function (body) {
|
||||||
return request('PUT', '/genealogy/pc/auth/password/reset', {
|
return request('PUT', '/genealogy/pc/auth/password/reset', {
|
||||||
auth: false,
|
auth: false,
|
||||||
body: Object.assign({ tenantId: tenantId }, body || {})
|
body: Object.assign({ grantType: 'password' }, withTenant(body))
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
changePhone: function (body) {
|
changePhone: function (body) {
|
||||||
return request('PUT', '/genealogy/pc/auth/phone', { body: body });
|
return request('PUT', '/genealogy/pc/auth/phone', { body: withClient(body) });
|
||||||
},
|
},
|
||||||
deactivateAccount: deactivateAccount,
|
deactivateAccount: deactivateAccount,
|
||||||
logout: logout,
|
logout: logout,
|
||||||
uploadFile: function (fileOrFormData) {
|
uploadFile: function (fileOrFormData) {
|
||||||
return request('POST', '/genealogy/pc/files/upload', { body: createFormData(fileOrFormData) });
|
return request('POST', '/genealogy/pc/files/upload', { body: createFormData(fileOrFormData) });
|
||||||
},
|
},
|
||||||
|
initResumableUpload: function (body) {
|
||||||
|
return request('POST', '/genealogy/pc/files/resumable/init', { body: body });
|
||||||
|
},
|
||||||
|
uploadResumableChunk: function (bodyOrFormData) {
|
||||||
|
return request('POST', '/genealogy/pc/files/resumable/chunk', {
|
||||||
|
body: createChunkFormData(bodyOrFormData)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
completeResumableUpload: function (body) {
|
||||||
|
return request('POST', '/genealogy/pc/files/resumable/complete', { body: body });
|
||||||
|
},
|
||||||
|
createFileReference: function (body) {
|
||||||
|
return request('POST', '/genealogy/pc/files/reference', { body: body });
|
||||||
|
},
|
||||||
|
deleteFileReference: function (query) {
|
||||||
|
return request('DELETE', '/genealogy/pc/files/reference', { query: query });
|
||||||
|
},
|
||||||
captchaRequirement: function (body) {
|
captchaRequirement: function (body) {
|
||||||
var source = body || {};
|
var source = body || {};
|
||||||
|
|
||||||
@@ -249,16 +307,16 @@
|
|||||||
return request('POST', '/captcha/verify', { auth: false, body: withTenant(body) });
|
return request('POST', '/captcha/verify', { auth: false, body: withTenant(body) });
|
||||||
},
|
},
|
||||||
regionChildren: function (parentCode) {
|
regionChildren: function (parentCode) {
|
||||||
return request('GET', '/genealogy/region/children', { auth: false, query: { parentCode: parentCode } });
|
return request('GET', '/genealogy/region/children', { query: { parentCode: parentCode } });
|
||||||
},
|
},
|
||||||
regionPath: function (regionCode) {
|
regionPath: function (regionCode) {
|
||||||
return request('GET', '/genealogy/region/path/' + encodeURIComponent(regionCode), { auth: false });
|
return request('GET', '/genealogy/region/path/' + encodeURIComponent(regionCode));
|
||||||
},
|
},
|
||||||
regionSearch: function (query) {
|
regionSearch: function (query) {
|
||||||
return request('GET', '/genealogy/region/search', { auth: false, query: query });
|
return request('GET', '/genealogy/region/search', { query: query });
|
||||||
},
|
},
|
||||||
regionDetail: function (regionCode) {
|
regionDetail: function (regionCode) {
|
||||||
return request('GET', '/genealogy/region/' + encodeURIComponent(regionCode), { auth: false });
|
return request('GET', '/genealogy/region/' + encodeURIComponent(regionCode));
|
||||||
},
|
},
|
||||||
feeds: function (genealogyId) {
|
feeds: function (genealogyId) {
|
||||||
return request('GET', buildFeedPath(genealogyId));
|
return request('GET', buildFeedPath(genealogyId));
|
||||||
@@ -290,11 +348,71 @@
|
|||||||
feedCommentsPage: function (genealogyId, feedId, query) {
|
feedCommentsPage: function (genealogyId, feedId, query) {
|
||||||
return request('GET', buildFeedCommentPath(genealogyId, feedId) + '/page', { query: query });
|
return request('GET', buildFeedCommentPath(genealogyId, feedId) + '/page', { query: query });
|
||||||
},
|
},
|
||||||
|
feedCommentReplies: function (genealogyId, feedId, commentId) {
|
||||||
|
return request('GET', buildFeedReplyPath(genealogyId, feedId, commentId));
|
||||||
|
},
|
||||||
|
feedCommentRepliesPage: function (genealogyId, feedId, commentId, query) {
|
||||||
|
return request('GET', buildFeedReplyPath(genealogyId, feedId, commentId, '/page'), { query: query });
|
||||||
|
},
|
||||||
createFeedComment: function (genealogyId, feedId, body) {
|
createFeedComment: function (genealogyId, feedId, body) {
|
||||||
return request('POST', buildFeedCommentPath(genealogyId, feedId), { body: body });
|
return request('POST', buildFeedCommentPath(genealogyId, feedId), { body: body });
|
||||||
},
|
},
|
||||||
deleteFeedComment: function (genealogyId, feedId, commentId) {
|
deleteFeedComment: function (genealogyId, feedId, commentId) {
|
||||||
return request('DELETE', buildFeedCommentPath(genealogyId, feedId, commentId));
|
return request('DELETE', buildFeedCommentPath(genealogyId, feedId, commentId));
|
||||||
|
},
|
||||||
|
generationPoems: function (genealogyId) {
|
||||||
|
return request('GET', buildGenerationPoemPath(genealogyId));
|
||||||
|
},
|
||||||
|
generationPoemsManagement: function (genealogyId) {
|
||||||
|
return request('GET', buildGenerationPoemPath(genealogyId, '/management'));
|
||||||
|
},
|
||||||
|
createGenerationPoem: function (genealogyId, body) {
|
||||||
|
return request('POST', buildGenerationPoemPath(genealogyId), { body: body });
|
||||||
|
},
|
||||||
|
updateGenerationPoem: function (genealogyId, poemId, body) {
|
||||||
|
return request('PUT', buildGenerationPoemPath(genealogyId, '/' + toRequiredPathId(poemId, '字辈编号')), { body: body });
|
||||||
|
},
|
||||||
|
previewGenerationPoems: function (genealogyId, body) {
|
||||||
|
return request('POST', buildGenerationPoemPath(genealogyId, '/batch/preview'), { body: body });
|
||||||
|
},
|
||||||
|
saveGenerationPoems: function (genealogyId, body) {
|
||||||
|
return request('POST', buildGenerationPoemPath(genealogyId, '/batch/save'), { body: body });
|
||||||
|
},
|
||||||
|
lineagePersons: function (genealogyId) {
|
||||||
|
return request('GET', buildLineagePath(genealogyId, '/persons'));
|
||||||
|
},
|
||||||
|
lineagePersonsPage: function (genealogyId, query) {
|
||||||
|
return request('GET', buildLineagePath(genealogyId, '/persons/page'), { query: query });
|
||||||
|
},
|
||||||
|
lineagePersonOptions: function (genealogyId) {
|
||||||
|
return request('GET', buildLineagePath(genealogyId, '/persons/options'));
|
||||||
|
},
|
||||||
|
lineageTree: function (genealogyId) {
|
||||||
|
return request('GET', buildLineagePath(genealogyId, '/tree'));
|
||||||
|
},
|
||||||
|
createLineagePerson: function (genealogyId, body) {
|
||||||
|
return request('POST', buildLineagePath(genealogyId, '/persons'), { body: body });
|
||||||
|
},
|
||||||
|
lineagePersonDetail: function (genealogyId, personId) {
|
||||||
|
return request('GET', buildLineagePersonPath(genealogyId, personId));
|
||||||
|
},
|
||||||
|
updateLineagePerson: function (genealogyId, personId, body) {
|
||||||
|
return request('PUT', buildLineagePersonPath(genealogyId, personId), { body: body });
|
||||||
|
},
|
||||||
|
disableLineagePerson: function (genealogyId, personId) {
|
||||||
|
return request('DELETE', buildLineagePersonPath(genealogyId, personId));
|
||||||
|
},
|
||||||
|
createLineageChild: function (genealogyId, personId, body) {
|
||||||
|
return request('POST', buildLineagePersonPath(genealogyId, personId, '/children'), { body: body });
|
||||||
|
},
|
||||||
|
createLineageParent: function (genealogyId, personId, body) {
|
||||||
|
return request('POST', buildLineagePersonPath(genealogyId, personId, '/parents'), { body: body });
|
||||||
|
},
|
||||||
|
createLineageSibling: function (genealogyId, personId, body) {
|
||||||
|
return request('POST', buildLineagePersonPath(genealogyId, personId, '/siblings'), { body: body });
|
||||||
|
},
|
||||||
|
createLineageSpouse: function (genealogyId, personId, body) {
|
||||||
|
return request('POST', buildLineagePersonPath(genealogyId, personId, '/spouses'), { body: body });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user