From 8870136d1be330f013503549d397025a26d7a37e Mon Sep 17 00:00:00 2001 From: rain Date: Fri, 24 Jul 2026 18:11:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=9010%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PC接口对接规划.md | 647 ++++++++++++++++++++++++++ docs/交接文档.md | 117 +++++ login.html | 1 - profile-data.html | 1 + profile-family-admin.html | 6 +- profile-generation.html | 74 +-- profile-security.html | 35 +- profile-tree.html | 55 ++- profile.html | 2 +- public/css/profile-module.css | 3 +- public/js/auth-pages.js | 143 ++++-- public/js/captcha-pages.js | 2 +- public/js/feed-pages.js | 196 ++++++-- public/js/generation-pages.js | 601 ++++++++++++++++++++++++ public/js/lineage-pages.js | 697 ++++++++++++++++++++++++++++ public/js/profile-common.js | 43 +- public/js/profile-pages.js | 124 +++-- public/js/region-pages.js | 180 +++++-- public/js/security-pages.js | 248 ++++++++-- public/js/upload-pages.js | 26 +- register.html | 20 + tests/api-client-contract.test.js | 362 ++++++++++++++- tests/auth-pages.test.js | 69 +++ tests/feed-pages.test.js | 38 ++ tests/generation-pages.test.js | 99 ++++ tests/lineage-pages.test.js | 88 ++++ tests/pc-scope.test.js | 25 +- tests/pending-pages.test.js | 17 +- tests/profile-pages.test.js | 89 ++++ tests/security-upload-scope.test.js | 32 +- tmp/apifox-live.png | Bin 0 -> 4273 bytes ty.md | 17 + utils/ApiClient.js | 132 +++++- 33 files changed, 3853 insertions(+), 336 deletions(-) create mode 100644 docs/PC接口对接规划.md create mode 100644 docs/交接文档.md create mode 100644 public/js/generation-pages.js create mode 100644 public/js/lineage-pages.js create mode 100644 tests/auth-pages.test.js create mode 100644 tests/generation-pages.test.js create mode 100644 tests/lineage-pages.test.js create mode 100644 tests/profile-pages.test.js create mode 100644 tmp/apifox-live.png create mode 100644 ty.md diff --git a/docs/PC接口对接规划.md b/docs/PC接口对接规划.md new file mode 100644 index 0000000..8602a71 --- /dev/null +++ b/docs/PC接口对接规划.md @@ -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 `。 +- `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` 应提供稳定的通知类型和目标深链。 diff --git a/docs/交接文档.md b/docs/交接文档.md new file mode 100644 index 0000000..5ee4e4c --- /dev/null +++ b/docs/交接文档.md @@ -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 `。 +- 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` 和相关测试,并报告改动、验证和剩余阻塞。 diff --git a/login.html b/login.html index 7e568ea..df7b6fe 100644 --- a/login.html +++ b/login.html @@ -46,7 +46,6 @@ autocomplete="current-password" placeholder="密码" /> -
+
diff --git a/profile-family-admin.html b/profile-family-admin.html index d889b8f..9b40d71 100644 --- a/profile-family-admin.html +++ b/profile-family-admin.html @@ -63,17 +63,17 @@

管理入口

家谱概览加载中...

-

世系图

查看树谱模式、搜索成员、进入个人资料。

-

字辈谱

维护字辈和各世代人数。

-

设置世代

新增、编辑、调整始祖世代。

- + @@ -50,31 +44,31 @@
-
+

批量字辈

- +
@@ -151,7 +107,7 @@ - + diff --git a/profile-security.html b/profile-security.html index 1a00bf7..6af0dee 100644 --- a/profile-security.html +++ b/profile-security.html @@ -63,7 +63,6 @@
-
等待提交 @@ -77,7 +76,15 @@
- +
@@ -94,23 +101,33 @@

注销账号

- +

验证已绑定手机号的短信验证码后提交注销申请。

- - -
-
- - + + + +

正在读取已绑定手机号

+
+ 等待验证
diff --git a/profile-tree.html b/profile-tree.html index ac8fd05..bb832a9 100644 --- a/profile-tree.html +++ b/profile-tree.html @@ -8,7 +8,7 @@ - + @@ -36,7 +36,7 @@ 个人中心内的树谱管理视图,用于查看成员关系、添加亲属和搜索成员。

- 管理世代 @@ -45,10 +45,10 @@
@@ -70,6 +70,16 @@

添加子女

先选择成员,再为当前成员补充下一代关系。

+ +
+
+
@@ -82,13 +92,19 @@

成员搜索

- +
+

成员加载中...
+

成员详情

@@ -99,11 +115,11 @@

新增成员

-
+
- +
@@ -116,7 +132,7 @@
- +
@@ -133,6 +149,14 @@
+
+ + +
+
+ + +
@@ -145,11 +169,12 @@
- +
+
@@ -166,7 +191,7 @@ - + diff --git a/profile.html b/profile.html index 3261b03..c12c116 100644 --- a/profile.html +++ b/profile.html @@ -177,7 +177,7 @@

字辈谱

管理字辈、统计各代人数和成员分布。

-

管理世代

设置世代、调整始祖世代、维护排行。

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) { // 加载状态只切 class 和 aria,不在 JS 中写样式。 if (!button) return; @@ -197,7 +225,14 @@ } 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') { @@ -261,6 +296,7 @@ var isActive = panel.id === activePanelId; panel.hidden = !isActive; + if (!isActive) clearCaptchaToken(panel); }); } @@ -269,12 +305,11 @@ var api = getApi(); var values = readForm(form); - if (!api) return; + if (!api) { + showMessage('接口初始化失败,请刷新后重试'); + return; + } if (!validateBeforeSubmit('login', values)) return; - if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'login'), values.phone)) return; - - values = readForm(form); - setBusy(button, true); try { await api.login(buildLoginBody(values)); @@ -291,12 +326,11 @@ var api = getApi(); var values = readForm(form); - if (!api) return; + if (!api) { + showMessage('接口初始化失败,请刷新后重试'); + return; + } if (!validateBeforeSubmit('sms-login', values)) return; - if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'sms-login'), values.phone)) return; - - values = readForm(form); - setBusy(button, true); try { await api.loginBySms(buildSmsLoginBody(values)); @@ -313,12 +347,11 @@ var api = getApi(); var values = readForm(form); - if (!api) return; + if (!api) { + showMessage('接口初始化失败,请刷新后重试'); + return; + } if (!validateBeforeSubmit('register', values)) return; - if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'register'), values.phone)) return; - - values = readForm(form); - setBusy(button, true); try { await api.register(buildRegisterBody(values)); @@ -338,7 +371,10 @@ var formType = getFormType(form); var sceneCode = getFormCaptchaScene(form, formType); - if (!api) return; + if (!api) { + showMessage('接口初始化失败,请刷新后重试'); + return; + } if (!validateBeforeSendCode(values)) return; if (!await ensureCaptcha(form, sceneCode, values.phone)) return; @@ -351,10 +387,13 @@ phone: values.phone, validToken: values.validToken || '' }); + clearCaptchaToken(form); showMessage('验证码已发送'); + setBusy(button, false); + startSmsCooldown(button); } catch (error) { + clearCaptchaToken(form); showMessage(error.message || '验证码发送失败'); - } finally { setBusy(button, false); } } @@ -364,12 +403,11 @@ var api = getApi(); var values = readForm(form); - if (!api) return; + if (!api) { + showMessage('接口初始化失败,请刷新后重试'); + return; + } if (!validateBeforeSubmit('password-reset', values)) return; - if (!await ensureCaptcha(form, getFormCaptchaScene(form, 'password-reset'), values.phone)) return; - - values = readForm(form); - setBusy(button, true); try { 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) { if (!form) return ''; @@ -417,6 +463,7 @@ bindSubmit(FORM_CONFIG['password-reset'].selector, submitPasswordReset); bindLoginModeTabs(); bindCodeButtons(); + bindCaptchaTokenInvalidation(); } return { @@ -424,6 +471,8 @@ buildSmsLoginBody: buildSmsLoginBody, buildRegisterBody: buildRegisterBody, buildPasswordResetBody: buildPasswordResetBody, + clearCaptchaToken: clearCaptchaToken, + startSmsCooldown: startSmsCooldown, getCaptchaScene: getCaptchaScene, getFormCaptchaScene: getFormCaptchaScene, getSuccessUrl: getSuccessUrl, diff --git a/public/js/captcha-pages.js b/public/js/captcha-pages.js index ddcaedd..04b7d85 100644 --- a/public/js/captcha-pages.js +++ b/public/js/captcha-pages.js @@ -9,7 +9,7 @@ })(typeof globalThis !== 'undefined' ? globalThis : window, function (root) { 'use strict'; - var DEFAULT_SCENE_CODE = 'WEB_H5_LOGIN'; + var DEFAULT_SCENE_CODE = 'PC_SMS_LOGIN'; var CAPTCHA_LAYER_AREA = ['318px', '318px']; var captchaLayerSeed = 0; diff --git a/public/js/feed-pages.js b/public/js/feed-pages.js index 9c07db8..2f5d9f6 100644 --- a/public/js/feed-pages.js +++ b/public/js/feed-pages.js @@ -20,6 +20,24 @@ 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) { return documentRef ? (rootNode || documentRef).querySelector(selector) : null; } @@ -62,7 +80,7 @@ return text || undefined; } - function toNumberOrUndefined(value) { + function toIntegerOrUndefined(value) { var text = trimOrUndefined(value); var number; @@ -80,23 +98,21 @@ mediaOssIds: trimOrUndefined(source.mediaOssIds), status: trimOrUndefined(source.status) }; - var sortOrder = toNumberOrUndefined(source.sortOrder); + var sortOrder = toIntegerOrUndefined(source.sortOrder); if (sortOrder !== undefined) body.sortOrder = sortOrder; return body; } function buildCommentBody(values) { - // FamilyFeedCommentBody 的 content 是唯一必填字段,其余字段按需携带。 + // FamilyFeedCommentBody 仅接受 commentContent 和可选的 parentCommentId。 var source = values || {}; var body = { - content: String(source.content || '').trim() + commentContent: String(source.commentContent || '').trim() }; - var parentCommentId = toNumberOrUndefined(source.parentCommentId); - var replyUserId = toNumberOrUndefined(source.replyUserId); + var parentCommentId = trimOrUndefined(source.parentCommentId); if (parentCommentId !== undefined) body.parentCommentId = parentCommentId; - if (replyUserId !== undefined) body.replyUserId = replyUserId; return body; } @@ -136,12 +152,17 @@ function normalizeComment(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 { 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(''); } + 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 ? '' + escapeHtml(comment.appUserNickName) + ' ' : ''; + var actions = ''; + + if (!comment.userDeleted) { + actions += ''; + actions += ''; + } + if (hasReplies(comment)) { + actions += ''; + } + + return '

' + author + escapeHtml(comment.commentContent) + '

' + actions + '
'; + } + + 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) { var row = query('[data-feed-id="' + feedId + '"]'); - var comments = normalizeList(data).map(normalizeComment); - var invalidCount = comments.filter(function (item) { return !item; }).length; - var list = comments.filter(Boolean); + var result = normalizeComments(data); + var list = result.comments; var previous = row && row.nextElementSibling; if (!row) return; if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove(); - if (invalidCount) { - row.insertAdjacentHTML('afterend', '
评论响应缺少 commentId 或 content,请联系后端补充 DTO。
'); + if (result.invalidCount) { + row.insertAdjacentHTML('afterend', '
评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。
'); return; } if (!list.length) { @@ -222,7 +272,28 @@ } row.insertAdjacentHTML('afterend', '
' + list.map(function (comment) { - return '

' + escapeHtml(comment.content) + '

'; + return renderCommentRow(feedId, comment); + }).join('') + '
'); + } + + 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', '
回复响应缺少 commentId 或 commentContent,请联系后端补充 DTO。
'); + return; + } + if (!result.comments.length) { + row.insertAdjacentHTML('afterend', '
暂无直接回复
'); + return; + } + + row.insertAdjacentHTML('afterend', '
' + result.comments.map(function (comment) { + return renderCommentRow(feedId, comment); }).join('') + '
'); } @@ -264,39 +335,47 @@ async function loadFeeds() { var api = getApi(); - var genealogyId = requireGenealogyId(); + var genealogyId; - if (!api || !genealogyId) return; + if (redirectUnauthorized(api)) return; + genealogyId = requireGenealogyId(); + if (!genealogyId) return; syncGenealogyLinks(genealogyId); try { renderFeeds(await api.feedsPage(genealogyId, { pageNum: 1, pageSize: 20 }), genealogyId); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '家族动态加载失败'); } } async function loadFeedForEdit() { var api = getApi(); - var genealogyId = requireGenealogyId(); + var genealogyId; var feedId = getCurrentFeedId(); - if (!api || !genealogyId) return; + if (redirectUnauthorized(api)) return; + genealogyId = requireGenealogyId(); + if (!genealogyId) return; syncGenealogyLinks(genealogyId); if (!feedId) return; try { fillFeedForm(await api.feedDetail(genealogyId, feedId)); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '动态详情加载失败'); } } async function submitFeed(form) { var api = getApi(); - var genealogyId = requireGenealogyId(); + var genealogyId; var feedId = getCurrentFeedId(); var body; - if (!api || !genealogyId) return; + if (redirectUnauthorized(api)) return; + genealogyId = requireGenealogyId(); + if (!genealogyId) return; // 富文本编辑器在提交前同步回 textarea,保证发送的是用户当前输入。 if (root.KindEditor && root.KindEditor.sync) root.KindEditor.sync('#feedContent'); body = buildFeedBody(getFormValues(form)); @@ -313,15 +392,18 @@ showMessage('动态已保存'); root.location.href = getListUrl(genealogyId); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '动态保存失败'); } } async function likeFeed(feedId, shouldLike) { var api = getApi(); - var genealogyId = requireGenealogyId(); + var genealogyId; - if (!api || !genealogyId || !feedId) return; + if (redirectUnauthorized(api)) return; + genealogyId = requireGenealogyId(); + if (!genealogyId || !feedId) return; try { if (shouldLike) { await api.likeFeed(genealogyId, feedId); @@ -330,64 +412,92 @@ } await loadFeeds(); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || (shouldLike ? '点赞失败' : '取消点赞失败')); } } async function deleteFeed(feedId) { 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; try { await api.deleteFeed(genealogyId, feedId); await loadFeeds(); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '删除动态失败'); } } async function showComments(feedId) { var api = getApi(); - var genealogyId = requireGenealogyId(); + var genealogyId; - if (!api || !genealogyId || !feedId) return; + if (redirectUnauthorized(api)) return; + genealogyId = requireGenealogyId(); + if (!genealogyId || !feedId) return; try { renderComments(feedId, await api.feedComments(genealogyId, feedId)); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '评论加载失败'); } } - async function createComment(feedId) { + async function showCommentReplies(feedId, commentId) { 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 body; - if (!api || !genealogyId || !feedId) return; - content = root.prompt ? root.prompt('请输入评论内容', '') : ''; - body = buildCommentBody({ content: content }); - if (!body.content) return; + if (redirectUnauthorized(api)) return; + genealogyId = requireGenealogyId(); + if (!genealogyId || !feedId) return; + content = root.prompt ? root.prompt(parentCommentId ? '请输入回复内容' : '请输入评论内容', '') : ''; + body = buildCommentBody({ commentContent: content, parentCommentId: parentCommentId }); + if (!body.commentContent) return; try { await api.createFeedComment(genealogyId, feedId, body); await showComments(feedId); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '评论发布失败'); } } async function deleteComment(feedId, commentId) { 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; try { await api.deleteFeedComment(genealogyId, feedId, commentId); await showComments(feedId); } catch (error) { + if (redirectUnauthorized(api, error)) return; showMessage(error.message || '删除评论失败'); } } @@ -424,6 +534,18 @@ feedId = target.closest('[data-feed-comment]').getAttribute('data-feed-comment'); 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]')) { event.preventDefault(); deleteFeed(target.closest('[data-feed-delete]').getAttribute('data-feed-delete')); @@ -451,7 +573,9 @@ buildCommentBody: buildCommentBody, normalizeFeed: normalizeFeed, normalizeComment: normalizeComment, + normalizeComments: normalizeComments, normalizeList: normalizeList, + shouldRedirectToLogin: shouldRedirectToLogin, init: init }; }); diff --git a/public/js/generation-pages.js b/public/js/generation-pages.js new file mode 100644 index 0000000..5a1fb6f --- /dev/null +++ b/public/js/generation-pages.js @@ -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, '''); + } + + 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 '
' + + '

第 ' + escapeHtml(poem.generationNo) + ' 代:' + escapeHtml(poem.generationText) + '

' + escapeHtml(details.join(' · ')) + '

' + + '
' + + '' + + '' + + '
'; + } + + 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 = '
字辈响应缺少 poemId、generationNo、generationText 或 status,请联系后端补充 DTO。
'; + return; + } + if (!poems.length) { + container.innerHTML = '
暂无字辈记录
'; + 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 = '
批量预览响应缺少 items,请联系后端补充 DTO。
'; + 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 = '

变更预览

' + escapeHtml(summary) + '

' + + 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 '

' + escapeHtml(text) + '

'; + }).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 = '
当前账号没有字辈维护权限
'; + 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 = '
请从具体家谱进入字辈管理
'; + 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 + }; +}); diff --git a/public/js/lineage-pages.js b/public/js/lineage-pages.js new file mode 100644 index 0000000..05fea1a --- /dev/null +++ b/public/js/lineage-pages.js @@ -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, '''); + } + + 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 = '
人物响应缺少可安全使用的 personId 或 name,请联系后端补充 DTO。
'; + return; + } + if (!people.length) { + container.innerHTML = '
暂无世系人物
'; + return; + } + container.innerHTML = people.map(function (person) { + var selected = person.personId === selectedPersonId ? ' is-selected' : ''; + + return ''; + }).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 '
  • ' + escapeHtml(person.name) + ' 已在上级关系中展示
  • '; + } + 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 += '
    • 配偶
        ' + spouses + '
    '; + if (children) branches += '
    • 子女
        ' + children + '
    '; + return '
  • ' + branches + '
  • '; + } + + 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 = '
    暂无世系树
    '; + return; + } + container.innerHTML = '
      ' + nodes.join('') + '
    '; + } + + function renderOptions(data) { + var select = query('[data-lineage-person-options]'); + var people = normalizeList(data).map(normalizeLineagePerson).filter(Boolean); + + if (!select) return; + select.innerHTML = '' + people.map(function (person) { + return ''; + }).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 = '
    人物详情缺少可安全使用的 personId 或 name,请联系后端补充 DTO。
    '; + 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 = '

    ' + escapeHtml(person.name) + '

    ' + escapeHtml(values.join(' · ') || '暂无更多资料') + '

    ' + + '
    ' + + '
    '; + 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 = '
    请从具体家谱进入世系管理
    '; + }); + 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 = '
    当前账号没有世系人物管理权限
    '; + }); + 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 + }; +}); diff --git a/public/js/profile-common.js b/public/js/profile-common.js index b93570e..7e61e57 100644 --- a/public/js/profile-common.js +++ b/public/js/profile-common.js @@ -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) { var target = $trigger.attr('data-update-target'); var $target = target ? $(target) : $trigger.closest('p').find('b').first(); @@ -226,11 +263,15 @@ bindSelectActions(); bindMessageActions(); bindSelectableCards(); + syncGenealogyContextLinks(); }); window.ProfileUI = { confirm: openConfirm, closeLogout: closeLegacyLogout, - initLayui: initLayui + initLayui: initLayui, + getGenealogyId: getGenealogyId, + withGenealogyId: withGenealogyId, + syncGenealogyContextLinks: syncGenealogyContextLinks }; })(window, window.jQuery || (window.layui && window.layui.$)); diff --git a/public/js/profile-pages.js b/public/js/profile-pages.js index 2bf86f3..687c8f0 100644 --- a/public/js/profile-pages.js +++ b/public/js/profile-pages.js @@ -16,59 +16,41 @@ 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) { var text = value === undefined || value === null ? '' : String(value).trim(); return text || undefined; } - function toNumberOrUndefined(value) { - var text = trimOrUndefined(value); - var numberValue; + function profileValue(profile, key, fallback) { + var value = profile && profile[key]; - if (!text) return undefined; - - numberValue = Number(text); - return Number.isNaN(numberValue) ? undefined : numberValue; + return value === undefined || value === null || value === '' ? fallback : value; } function getAvatarText(profile) { // 头像字只取展示名第一个字符,避免接口没有头像时页面空白。 - var displayName = pick(profile, ['nickName', 'userName', 'phone'], '家'); + var displayName = profileValue(profile, 'nickName', '家'); return String(displayName).charAt(0) || '家'; } - function buildRegionText(profile) { - var parts = [ - profile && profile.provinceCode, - profile && profile.cityCode, - profile && profile.districtCode - ].filter(Boolean); + function getFinalRegionCode(profile) { + var source = profile || {}; - return parts.length ? parts.join(' / ') : '待填写'; + return source.districtCode || source.cityCode || source.provinceCode || ''; + } + + function buildRegionText(profile) { + return getFinalRegionCode(profile) ? '加载中...' : '待填写'; } function buildProfileView(profile) { // 页面展示模型和接口返回分开,后续改文案不影响接口契约。 return { - displayName: pick(profile, ['nickName', 'userName', 'phone'], '未设置昵称'), + displayName: profileValue(profile, 'nickName', '未设置昵称'), avatarText: getAvatarText(profile), - phone: pick(profile, ['phone', 'phonenumber', 'mobile'], '未绑定'), - sex: pick(profile, ['sex'], '待填写'), - birthday: pick(profile, ['birthday'], '待填写'), + phone: profileValue(profile, 'phone', '未绑定'), + sex: profileValue(profile, 'sex', '待填写'), + birthday: profileValue(profile, 'birthday', '待填写'), regionText: buildRegionText(profile || {}) }; } @@ -79,7 +61,8 @@ return { nickName: trimOrUndefined(source.nickName), - avatarOssId: toNumberOrUndefined(source.avatarOssId), + // int64 在浏览器中必须保留字符串,避免 Number 转换丢失精度。 + avatarOssId: trimOrUndefined(source.avatarOssId), sex: trimOrUndefined(source.sex), birthday: trimOrUndefined(source.birthday), provinceCode: trimOrUndefined(source.provinceCode), @@ -92,25 +75,25 @@ return root.GenealogyApi && root.GenealogyApi.defaultClient; } - function shouldRedirectToHome(api, error) { + function shouldRedirectToLogin(api, error) { var status = error && (error.status || error.code); return !api || !api.getToken || !api.getToken() || Number(status) === 401; } - function redirectToHome() { + function redirectToLogin() { if (!root.location) return; if (typeof root.location.replace === 'function') { - root.location.replace('index.html'); + root.location.replace('login.html'); return; } - root.location.href = 'index.html'; + root.location.href = 'login.html'; } function redirectUnauthorized(api, error) { - if (!shouldRedirectToHome(api, error)) return false; + if (!shouldRedirectToLogin(api, error)) return false; if (api && api.clearToken) api.clearToken(); - redirectToHome(); + redirectToLogin(); 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() { var api = getApi(); var profile; + var requestId = latestProfileRequestId + 1; + + latestProfileRequestId = requestId; if (!api || !documentRef || !documentRef.querySelector('[data-profile-page]')) return; if (redirectUnauthorized(api)) return; try { profile = await api.currentProfile(); + if (requestId !== latestProfileRequestId) return; renderProfile(buildProfileView(profile)); fillProfileForm(profile); + fillRegionPicker(profile); + renderRegionText(profile, requestId); } catch (error) { if (redirectUnauthorized(api, error)) return; showMessage(error.message || '个人资料加载失败'); @@ -240,11 +278,13 @@ } return { - pick: pick, getAvatarText: getAvatarText, + getFinalRegionCode: getFinalRegionCode, + buildRegionText: buildRegionText, buildProfileView: buildProfileView, buildProfileUpdateBody: buildProfileUpdateBody, - shouldRedirectToHome: shouldRedirectToHome, + shouldRedirectToLogin: shouldRedirectToLogin, + reloadProfile: loadProfile, init: init }; }); diff --git a/public/js/region-pages.js b/public/js/region-pages.js index 5a5641b..0f57cf1 100644 --- a/public/js/region-pages.js +++ b/public/js/region-pages.js @@ -17,29 +17,11 @@ var documentRef = root.document; function normalizeList(data) { - // 行政区划接口使用通用 ListResult,兼容常见数组包裹字段。 + // ApiClient 已解包 PC ListResult;组件只接收正式的数组结果。 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 []; } - 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) { // 地区名称和编码进入 option 前统一转义。 return String(value === undefined || value === null ? '' : value) @@ -51,23 +33,17 @@ } function getRegionCode(item) { - return pick(item, ['regionCode', 'code', 'value'], ''); + return item && item.regionCode || ''; } function getRegionName(item) { - return pick(item, ['regionName', 'name', 'label'], '未命名地区'); + return item && item.regionName || '未命名地区'; } function getRegionLevel(item) { - var source = item || {}; - var level = Number(pick(source, ['level', 'regionLevel'], 0)); - var code = getRegionCode(source); + var level = Number(item && item.regionLevel); - if (level >= 1 && level <= 3) return level; - if (!/^\d{6}$/.test(code)) return 0; - if (/0000$/.test(code)) return 1; - if (/00$/.test(code)) return 2; - return 3; + return Number.isInteger(level) ? level : 0; } function buildRegionSelection(items) { @@ -125,11 +101,35 @@ 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) { + if (!documentRef && !rootNode) return null; return (rootNode || documentRef).querySelector(selector); } function queryAll(selector, rootNode) { + if (!documentRef && !rootNode) return []; return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)); } @@ -139,7 +139,7 @@ return; } - root.alert(message); + if (root.alert) root.alert(message); } function getFormValues(form) { @@ -162,6 +162,7 @@ function resetSelect(select, placeholder) { if (!select) return; + select.__regionRequestVersion = (select.__regionRequestVersion || 0) + 1; select.innerHTML = ''; select.disabled = true; } @@ -179,31 +180,53 @@ async function loadChildrenInto(select, parentCode, placeholder) { var api = getApi(); + var requestVersion; + var items; - if (!api || !select) return; - setOptions(select, await api.regionChildren(parentCode || '0'), placeholder); + if (!api || !select) return false; + 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 = ''; + select.disabled = true; + } + throw error; + } } - async 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); + function handlePickerError(error) { + var api = getApi(); - resetSelect(city, '请选择市'); - resetSelect(district, '请选择区县'); - await loadChildrenInto(province, '0', '请选择省'); + if (!redirectUnauthorized(api, error)) showMessage(error.message || '地区加载失败'); + } - 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(district, '请选择区县'); 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, '请选择区县'); updateTargetCode(picker); - if (city.value) await loadChildrenInto(district, city.value, '请选择区县'); + if (!city.value) return; + + loadChildrenInto(district, city.value, '请选择区县').catch(handlePickerError); }); 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) { var province = query('[data-region-level="province"]', picker); var city = query('[data-region-level="city"]', picker); var district = query('[data-region-level="district"]', picker); 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; resetSelect(city, '请选择市'); @@ -236,6 +286,12 @@ updateTargetCode(picker); } + function applyProfileRegionSelection(selection) { + return Promise.all(queryAll('[data-region-picker]').map(function (picker) { + return applyRegionSelection(picker, selection); + })); + } + function renderSearchResults(container, items) { var list = normalizeList(items); @@ -254,7 +310,7 @@ var results = query('[data-region-search-results]', form.parentNode); var pathText = query('[data-region-search-path]', form.parentNode); - if (!api) return; + if (redirectUnauthorized(api)) return; if (!values.regionKeyword || !values.regionKeyword.trim()) { showMessage('请输入地区名称或编码'); return; @@ -268,6 +324,7 @@ })); if (pathText) pathText.textContent = '请选择一个地区'; } catch (error) { + if (redirectUnauthorized(api, error)) return; if (pathText) pathText.textContent = error.message || '地区搜索失败'; showMessage(error.message || '地区搜索失败'); } @@ -282,7 +339,7 @@ var detail; var path; - if (!api || !code || !picker) return; + if (!code || !picker || redirectUnauthorized(api)) return; if (pathText) pathText.textContent = '正在定位地区...'; try { @@ -291,6 +348,7 @@ await applyRegionSelection(picker, buildRegionSelection(path)); if (pathText) pathText.textContent = buildPathText(path); } catch (error) { + if (redirectUnauthorized(api, error)) return; if (pathText) pathText.textContent = error.message || '地区定位失败'; showMessage(error.message || '地区定位失败'); } @@ -300,21 +358,36 @@ var api = getApi(); var values = getFormValues(form); var body = buildProfileRegionBody(values); + var status = query('[data-region-profile-status]', form); - if (!api) return; + if (redirectUnauthorized(api)) return; if (!body.provinceCode) { showMessage('请选择省份'); return; } try { + setSubmitting(form, true); + if (status) status.textContent = '保存中...'; await api.updateProfile(body); + if (status) status.textContent = '已保存'; showMessage('地区资料已保存'); + if (root.ProfilePages && root.ProfilePages.reloadProfile) await root.ProfilePages.reloadProfile(); } catch (error) { + if (redirectUnauthorized(api, error)) return; + if (status) status.textContent = '保存失败'; showMessage(error.message || '地区资料保存失败'); + } finally { + setSubmitting(form, false); } } + function setSubmitting(form, isSubmitting) { + queryAll('button[type="submit"]', form).forEach(function (button) { + button.disabled = isSubmitting; + }); + } + function bindProfileForms() { queryAll('[data-region-profile-form]').forEach(function (form) { form.addEventListener('submit', function (event) { @@ -342,10 +415,15 @@ } function init() { + var api; + 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) { - initPicker(picker); + initPicker(picker).catch(handlePickerError); }); bindProfileForms(); bindSearchForms(); @@ -353,11 +431,17 @@ return { normalizeList: normalizeList, + getRegionCode: getRegionCode, + getRegionName: getRegionName, + getRegionLevel: getRegionLevel, buildRegionOption: buildRegionOption, buildPathText: buildPathText, buildRegionSelection: buildRegionSelection, getFinalRegionCode: getFinalRegionCode, buildProfileRegionBody: buildProfileRegionBody, + applyRegionSelection: applyRegionSelection, + applyProfileRegionSelection: applyProfileRegionSelection, + shouldRedirectToLogin: shouldRedirectToLogin, init: init }; }); diff --git a/public/js/security-pages.js b/public/js/security-pages.js index fb5c528..dc8bfc8 100644 --- a/public/js/security-pages.js +++ b/public/js/security-pages.js @@ -16,9 +16,11 @@ var documentRef = root.document; var CAPTCHA_SCENES = { - password: 'WEB_H5_CHANGE_PASSWORD', - phone: 'WEB_H5_CHANGE_PHONE' + phone: 'PC_PHONE_CHANGE', + deactivate: 'PC_ACCOUNT_DEACTIVATE' }; + var SMS_CODE_COOLDOWN_SECONDS = 60; + var boundPhone = ''; function getCaptchaScene(formType) { return CAPTCHA_SCENES[formType] || ''; @@ -31,20 +33,13 @@ return value; } - function trimOrUndefined(value) { - var text = String(value === undefined || value === null ? '' : value).trim(); - - return text || undefined; - } - function buildPasswordChangeBody(values, hashFn) { var source = values || {}; var hash = hashFn || hashPassword; return { oldPassword: hash(String(source.oldPassword || '').trim()), - newPassword: hash(String(source.newPassword || '').trim()), - validToken: trimOrUndefined(source.validToken) + newPassword: hash(String(source.newPassword || '').trim()) }; } @@ -52,19 +47,16 @@ var source = values || {}; return { - newPhone: String(source.newPhone || '').trim(), - smsCode: String(source.smsCode || '').trim(), - validToken: trimOrUndefined(source.validToken) + phone: String(source.phone || '').trim(), + smsCode: String(source.smsCode || '').trim() }; } - function buildDeactivateBody(values, hashFn) { + function buildDeactivateBody(values) { var source = values || {}; - var hash = hashFn || hashPassword; return { - password: hash(String(source.password || '').trim()), - reason: trimOrUndefined(source.reason) + smsCode: String(source.smsCode || '').trim() }; } @@ -72,12 +64,43 @@ 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() { 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) { - // 改密和换绑必须先取得与当前业务场景绑定的验证码票据。 + // 发送敏感业务短信前,先取得与当前场景绑定的验证码票据。 var captcha = root.CaptchaPages; var sceneCode = getCaptchaScene(formType); @@ -126,30 +149,110 @@ 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) { var api = getApi(); var values = getFormValues(form); - if (!api) return; + if (redirectUnauthorized(api)) return; if (!values.oldPassword || !values.newPassword) { showMessage('请填写原密码和新密码'); return; } + if (String(values.newPassword).length < 6) { + showMessage('新密码至少需要 6 位'); + return; + } if (values.newPassword !== values.confirmPassword) { showMessage('两次新密码不一致'); return; } - if (!await ensureCaptcha(form, 'password')) return; - values = getFormValues(form); - try { + setSubmitting(form, true); + setStatus(form, '提交中...'); await api.changePassword(buildPasswordChangeBody(values)); setStatus(form, '密码已修改'); showMessage('密码已修改'); form.reset(); } catch (error) { + if (redirectUnauthorized(api, error)) return; + setStatus(form, '修改失败'); showMessage(error.message || '密码修改失败'); + } finally { + setSubmitting(form, false); } } @@ -157,22 +260,37 @@ var api = getApi(); var body = buildPhoneChangeBody(getFormValues(form)); - if (!api) return; - if (!body.newPhone || !body.smsCode) { + if (redirectUnauthorized(api)) return; + if (!body.phone || !body.smsCode) { showMessage('请填写新手机号和验证码'); return; } - - if (!await ensureCaptcha(form, 'phone', body.newPhone)) return; - body = buildPhoneChangeBody(getFormValues(form)); + if (!isPhone(body.phone)) { + showMessage('请输入正确的新手机号'); + return; + } + if (!isSmsCode(body.smsCode)) { + showMessage('请输入正确的短信验证码'); + return; + } try { + setSubmitting(form, true); + setStatus(form, '提交中...'); await api.changePhone(body); + boundPhone = body.phone; + queryAll('[data-security-bound-phone]').forEach(function (node) { + node.textContent = '验证码将发送至 ' + maskPhone(boundPhone); + }); setStatus(form, '手机号已换绑'); showMessage('手机号已换绑'); form.reset(); } catch (error) { + if (redirectUnauthorized(api, error)) return; + setStatus(form, '换绑失败'); showMessage(error.message || '手机号换绑失败'); + } finally { + setSubmitting(form, false); } } @@ -182,26 +300,60 @@ var form = query('[data-security-form="phone"]'); var values; - if (!api || !form) return; + if (!form || redirectUnauthorized(api)) return; values = getFormValues(form); - if (!/^1[3-9]\d{9}$/.test(String(values.newPhone || '').trim())) { + if (!isPhone(values.phone)) { showMessage('请输入正确的新手机号'); return; } - if (!await ensureCaptcha(form, 'phone', values.newPhone)) return; + if (!await ensureCaptcha(form, 'phone', values.phone)) return; values = getFormValues(form); button.disabled = true; try { await api.sendSmsCode({ - phone: values.newPhone, + phone: values.phone, sceneCode: getCaptchaScene('phone'), validToken: values.validToken || '' }); + clearCaptchaToken(form); showMessage('验证码已发送'); + startSmsCooldown(button); } 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 || '验证码发送失败'); - } finally { button.disabled = false; } } @@ -210,9 +362,9 @@ var api = getApi(); var values = getFormValues(form); - if (!api) return; - if (!values.password) { - showMessage('请填写当前密码'); + if (redirectUnauthorized(api)) return; + if (!isSmsCode(values.smsCode)) { + showMessage('请输入正确的短信验证码'); return; } if (!isDangerConfirmed(values.confirmText)) { @@ -222,11 +374,17 @@ if (root.confirm && !root.confirm('账号注销后将退出登录,确认继续?')) return; try { + setSubmitting(form, true); + setStatus(form, '提交中...'); await api.deactivateAccount(buildDeactivateBody(values)); showMessage('账号已提交注销'); root.location.href = 'index.html'; } catch (error) { + if (redirectUnauthorized(api, error)) return; + setStatus(form, '注销失败'); showMessage(error.message || '账号注销失败'); + } finally { + setSubmitting(form, false); } } @@ -262,6 +420,7 @@ documentRef.addEventListener('click', function (event) { 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]'); if (codeButton) { @@ -269,16 +428,32 @@ sendPhoneCode(codeButton); return; } + if (deactivateCodeButton) { + event.preventDefault(); + sendDeactivateCode(deactivateCodeButton); + return; + } if (!button) return; event.preventDefault(); 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() { if (!documentRef || !query('[data-security-page]')) return; + if (redirectUnauthorized(getApi())) return; bindActions(); + bindCaptchaTokenInvalidation(); + loadBoundPhone(); } return { @@ -287,6 +462,9 @@ buildDeactivateBody: buildDeactivateBody, getCaptchaScene: getCaptchaScene, isDangerConfirmed: isDangerConfirmed, + isPhone: isPhone, + isSmsCode: isSmsCode, + shouldRedirectToLogin: shouldRedirectToLogin, init: init }; }); diff --git a/public/js/upload-pages.js b/public/js/upload-pages.js index 3b02805..e36d88b 100644 --- a/public/js/upload-pages.js +++ b/public/js/upload-pages.js @@ -17,9 +17,11 @@ function normalizeUploadResult(data) { var source = data || {}; + var ossId = source.ossId; return { - ossId: String(source.ossId || ''), + // int64 OSS ID 不能经由 Number 转换,直接保留服务端原始文本。 + ossId: ossId === undefined || ossId === null ? '' : String(ossId), fileName: source.fileName || source.originalName || '', url: source.url || '' }; @@ -38,6 +40,24 @@ 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) { return (rootNode || documentRef).querySelector(selector); } @@ -64,7 +84,7 @@ var status = query(input.getAttribute('data-upload-status')); var result; - if (!api || !file || !target) return; + if (!file || !target || redirectUnauthorized(api)) return; input.disabled = true; try { @@ -73,6 +93,7 @@ target.value = result.ossId; if (status) status.textContent = buildUploadStatus(result.fileName || file.name, result.ossId); } catch (error) { + if (redirectUnauthorized(api, error)) return; if (status) status.textContent = error.message || '上传失败'; showMessage(error.message || '上传失败'); } finally { @@ -101,6 +122,7 @@ buildUploadStatus: buildUploadStatus, getUploadMode: getUploadMode, uploadFileForPage: uploadFileForPage, + shouldRedirectToLogin: shouldRedirectToLogin, init: init }; }); diff --git a/register.html b/register.html index accd1a7..29036f3 100644 --- a/register.html +++ b/register.html @@ -31,6 +31,19 @@ maxlength="11" placeholder="手机号" /> +
    + + +
    +