等待后端更新接口

This commit is contained in:
2026-08-03 16:49:26 +08:00
parent ac5823547a
commit 5b79893650
131 changed files with 5324 additions and 1392 deletions
+2 -1
View File
@@ -105,7 +105,7 @@
<p>展示后台维护的应用推广、公告和下载引导内容。</p>
</div>
<div class="promotion-list" data-promotion-list>
<div class="api-empty">应用推广内容加载中</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取应用推广…</div>
</div>
</div>
</section>
@@ -142,6 +142,7 @@
</div>
</div>
</footer>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
+3 -2
View File
@@ -33,10 +33,10 @@
</header>
<div class="container">
<p class="module-meta" data-site-article-status></p>
<div class="module-meta" data-site-article-status role="status" aria-live="polite"></div>
</div>
<main data-site-article-detail>
<div class="api-empty">资讯详情加载中</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取资讯详情</div>
</main>
<footer class="footer">
@@ -74,6 +74,7 @@
</div>
</div>
</footer>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
+66 -24
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/create-genealogy.css" />
</head>
<body class="page-create-genealogy">
<body class="page-create-genealogy" data-genealogy-create-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
@@ -59,35 +59,73 @@
<section class="section alt create-form-section">
<div class="container">
<form class="card form-card create-form tilt-card" data-genealogy-form="create" data-success-url="profile-families.html">
<form class="card form-card create-form tilt-card" data-genealogy-create-form novalidate aria-describedby="public-create-status">
<div class="form-ornament"></div>
<h2>家谱信息</h2>
<p class="form-note">
先填写基本资料,后续可以继续补充成员、相册和谱文
标有 * 的字段为必填。登录后使用当前 PC 家谱服务创建,创建成功后进入对应家谱主页
</p>
<p class="form-note" data-genealogy-create-quota role="status" aria-live="polite">正在读取家谱额度…</p>
<div class="form-grid">
<input
class="input"
name="genealogyName"
placeholder="家谱名称,如:四川武胜汤氏族谱"
/>
<input class="input" name="surname" placeholder="主要姓氏" />
<!-- 地区选择器:最终行政区编码回填到 regionCode,供创建家谱接口使用。 -->
<div class="region-picker create-region-picker" data-region-picker>
<select class="input" name="provinceCode" data-region-level="province">
<option value="">请选择省</option>
</select>
<select class="input" name="cityCode" data-region-level="city">
<option value="">请选择市</option>
</select>
<select class="input" name="districtCode" data-region-level="district">
<option value="">请选择区县</option>
</select>
<input type="hidden" name="regionCode" data-region-code />
<label class="field" for="publicFamilyName">谱名 <b aria-hidden="true">*</b>
<input id="publicFamilyName" class="input" name="genealogyName" placeholder="例如:四川武胜汤氏族谱" required />
</label>
<label class="field" for="publicFamilySurname">主要姓氏 <b aria-hidden="true">*</b>
<input id="publicFamilySurname" class="input" name="surname" placeholder="例如:汤" required />
</label>
<div class="field" role="group" aria-labelledby="public-family-region-label" aria-describedby="public-region-status public-create-status">
<span id="public-family-region-label">家谱所在地 <b aria-hidden="true">*</b></span>
<div class="region-picker create-region-picker" data-region-picker>
<label for="publicFamilyProvince">
<select id="publicFamilyProvince" class="input" name="provinceCode" data-region-level="province">
<option value="">请选择省</option>
</select>
</label>
<label for="publicFamilyCity">
<select id="publicFamilyCity" class="input" name="cityCode" data-region-level="city">
<option value="">请选择市</option>
</select>
</label>
<label for="publicFamilyDistrict">区县
<select id="publicFamilyDistrict" class="input" name="districtCode" data-region-level="district">
<option value="">请选择区县</option>
</select>
</label>
<input type="hidden" name="regionCode" data-region-code />
</div>
<div id="public-region-status" class="form-note" data-region-picker-status role="status" aria-live="polite"></div>
</div>
<label class="field" for="publicAncestralHall">堂号/祠堂
<input id="publicAncestralHall" class="input" name="ancestralHall" placeholder="选填" />
</label>
<label class="field" for="publicOriginPlace">籍贯/起源地
<input id="publicOriginPlace" class="input" name="originPlace" placeholder="选填" />
</label>
<label class="field" for="publicAddressDetail">详细地址
<input id="publicAddressDetail" class="input" name="addressDetail" placeholder="选填" />
</label>
<label class="field" for="publicIntro">家谱简介
<textarea id="publicIntro" name="intro" placeholder="选填"></textarea>
</label>
<label class="field" for="publicVisibility">可见范围
<select id="publicVisibility" class="input" name="visibility">
<option value="1">公开</option>
<option value="2">成员可见</option>
<option value="0">私密</option>
</select>
</label>
<label class="field" for="publicJoinMode">加入方式
<select id="publicJoinMode" class="input" name="joinMode" aria-describedby="public-invite-mode-note">
<option value="1">审核加入</option>
<option value="2">邀请码加入</option>
<option value="0">关闭加入</option>
</select>
<span id="public-invite-mode-note" class="form-note">当前 PC 未提供邀请码生成、发送或管理接口;选择邀请模式后不能在本页面继续生成邀请码。</span>
</label>
<div id="public-create-status" class="form-note" data-genealogy-create-status role="status" aria-live="polite"></div>
<div>
<button class="btn primary magnetic submit-btn" type="submit" data-genealogy-create-submit>提交创建</button>
</div>
<input class="input" name="addressDetail" placeholder="家族所在地" />
<textarea name="intro" placeholder="家族简介"></textarea>
<button class="btn primary magnetic submit-btn" type="submit">提交创建</button>
</div>
</form>
</div>
@@ -124,12 +162,16 @@
</div>
</div>
</footer>
<script src="public/js/jquery360.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/FormUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/region-pages.js"></script>
<script src="public/js/genealogy-entry-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
# PC 后端补齐清单(交给后端 GPT)
> 状态:待后端确认与实施
>
> 前端基线:`ac58235`(“验证20%”)之后的 PC 工作区。当前前端回归为 362 项通过。
>
> 本文目的:让后端直接知道 PC 端还缺什么能力、每项最小需要补什么,以及如何验收。它是 `PC接口对接规划.md` 中 C01–C46 的精简交接版;详细的历史证据仍以该文档为准。
## 0. 不可违反的边界
1. 这是 **PC** 契约补齐,不是把 `Jiapu-App` 的接口、参数、缓存键或数据结构搬过来。路径和 DTO 以 PC 后端为唯一 owner。
2. 请先在 PC 的 Apifox/OpenAPI 中定义完整请求、响应、权限、枚举和错误码,再实现 Controller/Service;完成后重新导出契约。前端只会通过 `utils/ApiClient.js` 接入已确认的 PC 契约。
3. 不要以泛型 `ObjectResult`、无元素类型的列表或“前端自行猜字段”作为交付。每个列表、详情和写入结果都必须有明确 DTO。
4. 所有资源 ID、OSS ID、订单号等可能超过 JavaScript 安全整数的值,统一以十进制字符串传输和返回;不得要求用户手填内部 ID。
5. 不返回或展示成员、邀请人、通知发送人的原始手机号。确有业务必要的内部用户 ID 只在受权限保护的选项或写入场景返回,PC 页面不会展示它。
6. 不记录、更不要求提交真实测试账号、密码、令牌或真实家谱数据。角色验收使用既有隔离身份或后端准备的隔离夹具。
## 1. 已有能力,不要重复实现
以下 PC 能力已被当前前端使用:登录/账户基础资料、家谱列表与创建、申请加入与审核、成员名册与角色维护、成员选项、世系人物及四类关系新增、字辈、家族圈常规 CRUD、视频、成长/亲友/备忘/功德记录、谱文、相册/照片、祭祀活动/祭品/活动邀约、通知已读、反馈工单、会员套餐与会员订单、帮助文章、PC 推广和官网资讯。
本清单要求补的是这些能力的缺口或契约精度,不要求重新设计上述已有流程。已有写入接口仍必须由后端做最终权限校验,不能只依赖前端隐藏按钮。
## 2. P0:PC 当前完全缺失的业务契约
| 编号 | 前端位置与业务目标 | 后端必须补齐的最小能力 | 验收标准 |
| --- | --- | --- | --- |
| P0-01 | `profile-data-reminders.html`:查看当前家谱中待完善的成员资料 | 只读“资料缺项提醒”列表,按当前家谱和当前操作者权限过滤。每项至少返回稳定的成员/世系人物定位 ID、脱敏展示名、缺失字段枚举、可处理状态和更新时间;缺失字段至少能区分头像、出生日期、关系等。联系方式只能表达“缺失/已填写”,不能下发原始号码。 | 有权限角色能读取真实空列表和非空列表;无权限、无家谱、资源不存在分别有可区分失败;前端能从响应跳到已有成员/世系编辑页。 |
| P0-02 | `profile-invite.html`、家谱主页:邀请家人加入家谱 | 家谱成员邀请的创建、活动邀请列表、撤销/失效、受邀人校验和受邀人接受流程。邀请方式如为链接或邀请码,响应需给出 `inviteId`、受限邀请凭据/链接、过期时间、使用次数或上限、状态及可展示的创建时间;不得把可枚举的内部 ID 当邀请码。 | 仅后端定义的可管理角色可创建和撤销;过期、撤销、次数用尽、非受邀人、重复接受均有稳定业务错误;接受后能在家谱成员/审核链路中重读结果。 |
| P0-03 | `profile-family-home.html`:重要证件 | 家谱重要证件的列表、详情、新增、修改、删除(或明确仅支持其中一部分)。证件 DTO 至少含稳定 ID、家谱 ID、标题/类型、说明、文件对象、创建/更新时间、状态和权限能力。文件必须使用第 3.1 节的安全文件对象。 | 无权用户不能读写他人家谱证件;上传后重读同一条记录;删除后的可见性和是否可恢复有明确定义。 |
| P0-04 | `profile-families.html`:我的家谱自定义排序 | 读取当前账号的家谱顺序及保存顺序的接口。保存请求应提交完整有序的“当前账号可见家谱 ID”数组,后端在一个事务内校验归属、去重和完整性,不接受跨账号或不可见家谱 ID。 | 重排后重新读取顺序一致;重复、缺失、越权 ID 返回明确校验/权限错误;并发保存不会产生重复排序值。 |
| P0-05 | `profile-family-home.html`:删除家谱 | 先由产品/后端确定唯一语义:**逻辑归档** 或 **物理删除**,不能接口名叫删除而实际效果不明确。接口需只允许谱主执行,并返回影响范围(成员、人物、内容、文件、订单等)、最终状态和可否恢复;如需二次确认,应采用后端签发的一次性确认令牌或明确确认字段。 | 非谱主必定拒绝;成功后“我的家谱”重读不再出现或以归档状态出现;重复请求幂等;关联资源处理与定义一致。 |
| P0-06 | `profile-share.html``profile-services.html`:应用分享、奖励、推广收益、提现 | 一套完整账户权益契约:分享规则/分享凭据、分享记录、奖励或收益流水、账户可用/冻结/累计余额、提现方式选项、提现申请及提现记录。金额和状态必须由服务端计算与驱动,前端不能传入奖励金额、余额或审核状态。 | 普通用户只能读取自己的记录;提现前校验可用余额、金额精度、最低/最高额、风控状态和收款方式;重复提交幂等;流水、余额与提现状态可重读且一致。 |
| P0-07 | `profile-services.html`:个性化推荐开关 | 当前用户的推荐偏好读取与保存。至少定义可配置项枚举、默认值、保存后的版本/更新时间,以及未登录、无权限或功能未开通的处理。 | 未知枚举和值非法会被拒绝;保存后重读一致;不允许操作其他用户的偏好。 |
| P0-08 | `profile-services.html`:会员订单在线支付 | 现有“创建订单/读取订单”之外,补支付发起、支付参数、安全回跳/异步通知后的订单状态查询和关闭/超时语义。`payType` 必须有正式枚举,订单状态机由后端维护,不能让前端提交“已支付”。 | 创建支付后返回可安全使用的支付会话参数;支付成功、失败、取消、超时、退款均能重读到准确状态;重复通知和用户刷新幂等。 |
## 3. P1:已有 PC 页面被契约限制的能力
### 3.1 统一安全文件访问能力(C21、C23、C25、C28、C30、C36、C38、C40
下列资源目前大多只返回 `ossId``mediaOssIds`,PC 无法安全回显图片、封面、视频、附件名称或下载入口:家族圈、视频、成长记录、亲友记录、备忘录、谱文、相册/照片、祭祀活动。
请在资源 View 内直接返回文件对象数组,或提供一个受资源权限保护的 PC 文件解析接口。两种方案只保留一种,推荐统一文件对象:
| 字段 | 要求 |
| --- | --- |
| `ossId` | string,原始文件稳定标识 |
| `fileName` | 可安全展示的文件名 |
| `mediaType` | 明确枚举,例如 image/video/document |
| `accessUrl` | 短时授权 URL;不能由前端用 OSS ID 拼接 |
| `expiresAt` | 授权 URL 失效时间,使用统一日期时间格式 |
文件解析必须根据“该文件所属业务资源 + 当前操作者”校验权限,不能做成任意登录用户可按 OSS ID 探测或下载文件的接口。
### 3.2 停用记录的管理、详情与恢复(C22、C24、C26、C29、C31、C33、C37、C39、C41
以下资源可以或可能写入 `status=1`,但普通列表不返回、普通详情拒绝读取,导致管理员无法恢复或验证:家族圈、视频、成长记录、亲友记录、备忘录、功德、谱文、相册/照片、祭祀活动。
请为每类资源提供唯一且一致的方案:
1. 给有管理权限的用户提供 `management` 列表和详情,包含正常/停用状态;或
2. 提供受权限保护的独立状态读取与恢复接口。
必须明确 `status=0/1` 的中文业务含义、谁可停用/恢复、停用后文件和关联数据如何处理、普通用户是否仍可读取。前端在此完成前会持续固定提交正常状态,避免产生不可重读数据。
### 3.3 金额、分类与可清空字段(C18、C27、C32、C35、C42
| 范围 | 缺失内容 | 必须定义 |
| --- | --- | --- |
| 亲友往来 `giftAmount`、功德金额、祭品金额 | 只有 number/BigDecimal,缺少精度和边界 | 货币单位、`precision``scale``minimum``maximum`、是否允许负数及其业务含义。建议金额响应使用十进制字符串,避免浮点误差。 |
| 谱文分类 | 列表的 `categoryId` 未进入正式契约,且没有分类选项来源 | 正式 query、当前家谱文章分类选项接口、分类 DTO、是否允许无分类和分类停用后的处理。 |
| 所有可选更新字段 | 未定义“不传字段”、`null`、空字符串、空数组的差别 | 每个更新 DTO 对每个可清空字段写明语义,并用后端测试覆盖。特别是备忘录提醒时间、说明、附件和成员绑定。 |
### 3.4 个人资料、成员、通知与关系契约(C04–C08、C11C13、C19、C34、C43C46
| 范围 | 当前问题 | 后端补齐要求 |
| --- | --- | --- |
| 当前用户资料 | 资料读取仍可能是泛型响应;更新 DTO 没有地区字段 | 提供完整 `ProfileView`;如产品保留现居地区,更新/读取都使用 `regionCode`;明确头像 ID 类型和所有可清空字段语义。 |
| 行政区划 | 历史上存在双路径和无元素 DTO | 只保留一套 PC 正式路径;`RegionView` 固定 `regionCode``regionName``regionLevel`、父级关系;删除旧路径。 |
| 世系树 | `LineagePersonTreeView` 曾无属性 | OpenAPI 展开递归节点:人物 ID、基础展示资料、配偶、子女及必要的状态/权限。所有日期必须声明 `date` 或带时区规则的 `date-time`。 |
| 枚举与能力 | `completed``feedType``recordType` 等可能无完整枚举;多数 View 缺 `canEdit/canDelete` | 明确自由文本或完整枚举;对页面需要的操作返回稳定的严格布尔能力字段,例如 `canManage``canEditContent``canEdit``canDelete`。后端仍做最终鉴权。 |
| 通知 | 声明的家谱名、发送人、业务摘要等字段实际常为空,且含隐私风险 | 补齐真正可用的非敏感字段,或从 Schema 删除不会返回的字段;提供安全、明确的 `bizType/bizId` 深链规则,不能让前端猜路径;始终不返回发送人手机号。 |
| 家谱成员 | `GenealogyMemberVo` 声明字段与实际填充不一致,角色枚举也不一致 | View 补齐真实需要的非敏感展示字段,或收紧 Schema;可分配角色正式收敛为后端实际支持的 `admin``editor``member`,不能接受 `owner``visitor`。 |
| 世系关系解除 | 只有新增关系,没有解除关系 | 新增带家谱权限与关系完整性校验的解除接口;需明确父母、配偶、子女、兄弟姐妹解除后的双向影响。 |
| 成员与世系人物解绑 | 当前更新中空 `lineagePersonId` 只代表不修改 | 提供专用解绑动作,或正式约定 `null`/空值表示解绑并给出可验证示例;不能由前端猜测。 |
| 个人亲属关系 | `profile-data.html` 当前无个人关系数据 | 这是独立于家谱世系的产品决策:若保留个人关系页,请提供当前用户私有的列表/维护契约;若不做,请明确下线该需求,继续引导到家谱世系维护。 |
## 4. P2:契约质量与一致性必须同时修复
这些事项不一定新增页面,却会使 PC 前后端长期出现“接口能调但不知道如何正确用”的问题:
1. 删除/废弃缺少开头 `/` 的旧短信路径,只保留正式 PC 路径。
2. 未登录可调用的注册、登录、短信、找回密码、验证接口在 OpenAPI 中标为公开;登录后接口标明 Bearer 鉴权。不要让公开接口错误继承 Authorization。
3. 所有列表、详情、上传初始化/完成、写入结果都展开具体 Schema,尤其不能保留无元素的通用列表。上传初始化要明确 `uploadId`、秒传、已传分片、完成后的 OSS 信息。
4. 用统一错误响应表达:未登录、无权限、资源不存在、字段校验失败、业务冲突、状态不可操作、频率/幂等冲突。每一类要有稳定 code 和用户可读 message;不要只返回模糊失败文本。
5. 统一日期格式、时区、分页字段和排序字段;同一资源的列表、详情、创建、更新应使用同一 ID 类型和状态枚举。
6. 更新 Apifox/OpenAPI、DTO、Controller、Service 和集成测试必须在同一提交完成;不要只改其中一处,也不要保留 APP 路径 fallback。
## 5. 权限与数据安全验收矩阵
后端实施每个新增或补齐接口后,至少用既有隔离的谱主、管理员/编辑、普通成员、非成员和未登录身份验证。不要为了验收在真实家谱中创建成员或写入数据。
| 场景 | 必须验证 |
| --- | --- |
| 未登录 | 仅公开认证接口可用;所有用户/家谱资源拒绝。 |
| 非成员 | 不能读取私有家谱内容、成员、文件、邀请或管理列表。 |
| 普通成员 | 只能使用后端允许的只读或本人操作,不能修改成员角色、家谱设置、排序、删除、证件或全局邀请。 |
| 管理员/编辑 | 仅获得已声明能力范围内的内容或成员维护权限;不得越过谱主做家谱删除、谱主转让等操作。 |
| 谱主 | 可执行后端定义的家谱级管理动作,但仍受资源状态、确认和业务完整性约束。 |
每个写入接口还要验证:重复请求幂等、写后可精确重读、越权返回明确错误、停用/恢复状态可验证、敏感字段不会越权泄漏。
## 6. 建议实施顺序
1. 先完成第 4 节的 OpenAPI/DTO 基础收口,并冻结唯一 PC 契约。
2. 其次完成 3.1 文件访问和 3.2 停用管理;这两项能同时解除多个已有内容页的展示和状态闭环。
3. 再完成 3.3、3.4 的金额、分类、资料、成员和关系精度。
4. 最后按 P0-01 至 P0-08 补齐新业务;其中家谱删除、提现和支付必须先确认产品状态机与风控规则,不能只给一个无约束写接口。
## 7. 给后端 GPT 的执行指令
```text
你负责为现有“家谱”项目补齐 PC API,不得复用或复制 Jiapu-App API。
先阅读 docs/PC后端补齐清单.md 与 docs/PC接口对接规划.md,确认当前 PC OpenAPI 和 Controller 的实际差异;不要重做“已有能力”章节列出的功能。
按第 6 节顺序实施。每个模块都要在同一改动中完成:PC OpenAPI/Apifox、请求 DTO、响应 View、Controller/Service、权限校验、错误码和集成测试。不要返回泛型 Object,不要让前端手填内部 ID,不要返回原始手机号,也不要新增 APP 路径兼容层。
实施前先输出:将新增/修改的 PC 接口、每个请求和响应字段、角色权限、状态机、数据库迁移(如有)以及与本清单的对应编号。实施后用隔离角色完成读写、越权、重复提交和写后重读验证,并重新导出 OpenAPI。
```
## 8. 交付回执模板
后端完成后,请按以下格式回传,便于前端逐项接入:
| 清单编号 | 状态 | 正式 PC method/path | 请求 DTO | 响应 View | 权限/状态机 | OpenAPI 位置 | 集成测试证据 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 例如 P0-01 | 已完成/不做(附产品结论) | 仅填 PC 路径 | 字段和必填性 | 字段和类型 | 可用角色与错误码 | tag/schema 名 | 隔离测试名称,不含真实凭据 |
如某项决定不做,必须给出明确产品结论和对应前端页面应继续保留的不可操作文案;不能以“暂时没有接口”结束。
+1 -1
View File
@@ -1557,7 +1557,7 @@ PC 端只开放三条业务用户接口:
- C35–C46 中记录的分类选项、文件 URL、停用记录、金额业务精度/上限、响应 enrichment 与解绑契约;
- 世系关系解除没有 PC 接口;成员移出/退出不会伪造成世系关系解除或成员与世系人物解绑;
- 真实账号 `19181970173` 已验证登录和无家谱上下文分支:阶段 6 页面均阻止业务请求、隐藏管理操作并返回家谱选择页,浏览器控制台无错误。该账号当前无家谱数据,因此列表有数据态、详情以及真实写操作仍待有数据账号复核,未制造家谱或业务记录。
- 隔离测试账号已验证登录和无家谱上下文分支:阶段 6 页面均阻止业务请求、隐藏管理操作并返回家谱选择页,浏览器控制台无错误。该账号当前无家谱数据,因此列表有数据态、详情以及真实写操作仍待有数据账号复核,未制造家谱或业务记录。
### 阶段 7:剩余 PC 页面分批开放
@@ -37,7 +37,7 @@
await client.submitFeedback({
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
contactInfo: '<测试联系电话>',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
});
@@ -50,7 +50,7 @@ assert.deepEqual(calls, [
body: {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
contactInfo: '<测试联系电话>'
}
},
{ method: 'GET', url: '/genealogy/pc/feedback' }
@@ -120,13 +120,13 @@ Expected: PASS。
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: ' bug ',
feedbackContent: ' 页面按钮无响应 ',
contactInfo: ' 19181970173 ',
contactInfo: ' <测试联系电话> ',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
}), {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
contactInfo: '<测试联系电话>'
});
```
@@ -48,7 +48,7 @@ await client.createGenealogy({
await client.applyToGenealogy(genealogyId, {
applicantName: '申请人',
phone: '19100000000',
phone: '<测试联系电话>',
relationDesc: '族亲',
applyReason: '申请加入',
inviterUserId: 'drop-without-safe-source'
@@ -43,13 +43,13 @@
```js
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' 叶子 ',
phone: ' 19181970173 ',
phone: ' <测试联系电话> ',
relationDesc: ' 族亲 ',
applyReason: ' 申请加入 ',
inviterUserId: 'must-drop'
}), {
applicantName: '叶子',
phone: '19181970173',
phone: '<测试联系电话>',
relationDesc: '族亲',
applyReason: '申请加入'
});
+40
View File
@@ -0,0 +1,40 @@
# 个人中心能力映射
## 使用边界
`Jiapu-App` 只作为产品信息架构参考。本项目的真实读取、写入和权限判断只使用当前 PC 契约及 `utils/ApiClient.js`;不得复制 App 请求路径、参数、缓存编号或测试身份信息。
没有当前 PC 接口契约的能力必须保留明确的不可操作状态,不能以静态数据、二维码、金额或成功提示替代。
## 账户与服务
| 参考能力 | PC 页面 | 当前状态 |
| --- | --- | --- |
| 账户概览、个人资料 | `profile.html``profile-data.html` | 读取并编辑当前 PC 资料字段;未声明的地区和个人亲属列表不伪造。 |
| 修改密码、换绑手机、注销、退出登录 | `profile-security.html` | 使用认证 PC 契约;表单具备校验、提交中和失败反馈。 |
| 意见反馈、我的工单 | `profile-feedback.html``submit-ticket.html``my-tickets.html``ticket-detail.html` | 使用当前账号的反馈集合,提交后重读确认。 |
| 帮助中心、平台联系与协议 | `help.html``profile-services.html` | 帮助文章读取使用 PC 契约;平台说明使用站内页面。 |
| 会员套餐与订单 | `profile-services.html` | 可选择服务端套餐和我的家谱创建订单并重读;PC 未开放在线支付。 |
| 分享奖励、推广收益、提现、推荐偏好 | `profile-share.html``profile-services.html` | 当前 PC 无对应读取或写入契约,明确不可操作。 |
## 家谱工作台
| 参考能力 | PC 页面 | 当前状态 |
| --- | --- | --- |
| 家谱列表、创建、加入、我的申请 | `profile-families.html``profile-create-family.html``profile-join-family.html` | 使用真实家谱列表、额度和申请契约;家谱编号只来自上下文或响应。 |
| 当前家谱主页 | `profile-family-home.html` | 以概览和世系树组成桌面工作台,并透传当前家谱上下文。 |
| 成员名册、角色、谱主转移、入谱审核 | `profile-family-admin.html``profile-admin-permissions.html``profile-join-review.html` | 成员维护和审核只由服务端能力字段决定。 |
| 世系人物与父母、配偶、子女、兄弟姐妹关系 | `profile-tree.html` | 列表、详情、编辑和四类关系维护使用世系 PC 契约。 |
| 字辈 | `profile-generation.html` | 字辈列表、单条和批量维护使用当前 PC 契约。 |
| 家族圈 | `profile-feed.html``profile-feed-detail.html``profile-feed-edit.html` | 动态、评论、回复和点赞使用当前家谱上下文。 |
| 谱文、相册、视频 | `profile-article.html``profile-album.html``profile-video.html` 及各自编辑/详情页 | 已提供列表、详情和编辑闭环;没有文件访问 URL 时只展示安全业务元数据。 |
| 祭祀、贺礼邀约 | `profile-ceremony.html``profile-ceremony-detail.html``profile-gift.html``profile-gift-edit.html` | 活动、祭品和当前账号邀约使用当前 PC 契约。 |
| 成长、亲友、备忘、功德记录 | 对应 `profile-*-edit.html` 与列表页 | 各自使用当前 PC CRUD 契约和真实家谱上下文。 |
| 家谱证件、成员邀请、家谱排序、家谱删除 | `profile-family-home.html``profile-invite.html``profile-families.html` | 当前 PC 没有完整契约,明确不可操作。 |
| 个人亲属关系页 | `profile-data.html` | 当前 PC 未提供个人关系列表;页面明确引导至当前家谱的成员管理与世系维护。 |
## 验收边界
- 页面统一提供加载、空数据、请求失败和无权限状态;写入表单提供字段标签、校验、提交中和失败反馈。
- 所有家谱内操作由同一当前家谱上下文提供稳定字符串 ID,不接受手工填写业务 ID 或 OSS ID。
- 权限以 PC 家谱详情的严格布尔能力字段和服务端写入结果为准;没有隔离管理员或普通成员测试实体时,运行时角色验收记录原因后跳过,不创建测试数据。
+1
View File
@@ -2,6 +2,7 @@
> 更新:2026-07-25
> 历史交接记录:本文的 79/85 条接口数量已过期,不再作为 AI 施工依据。当前接口基线、字段分类、阻断项和执行顺序统一以 [PC接口对接规划.md](PC接口对接规划.md) 为准;执行前仍须在 Apifox 实时复核并重新导出。
> **失效范围:** 本文第二至第七节均为当日历史快照,可能包含已被现行 PC 契约和页面实现替代的接口、页面状态与验收结论;不得据此新增、关闭或阻断任何功能。
## 一、必须遵守的范围
+1 -1
View File
@@ -9,7 +9,7 @@
- 当前工作区共有 55 个 HTML 页面,现有页面之间没有指向不存在 HTML 页的站内链接。
- 本文保留项目级历史规划,不再维护接口数量和字段契约。当前 PC 接口基线、字段页面归属和 AI 执行顺序统一见 [PC接口对接规划.md](PC接口对接规划.md)。
- 已完成真实接口闭环:注册、密码登录、短信登录、找回密码、个人资料、安全设置、头像上传、地区选择、家族圈动态。
- 家谱列表、家谱创建/加入、成员、权限、世系、谱文、相册、视频、贺礼、成长、备忘、功德、消息、反馈等页面已保留设计,但尚无对应正式接口,不得伪造请求或静态数据为真实业务。
- 家谱列表、家谱创建/加入、成员、权限、世系、谱文、相册、视频、贺礼、成长、备忘、功德、消息、反馈等页面已接入当前 PC 契约;仍未声明 PC 契约的能力以 [个人中心能力映射.md](个人中心能力映射.md) 标注为不可操作,不得伪造请求或静态数据为真实业务。
## 实施进度
+11 -1
View File
@@ -19,10 +19,20 @@
## 验证方式
- 自动回归:`npm test`298 项通过。
- 自动回归:`npm test`362 项通过。
- 真实流程:登录后进入真实家谱列表、选择家谱主页,并在 1249px 桌面与 390px 手机视口检查导航和首屏。
- 验收使用隔离浏览器配置;测试账号、登录令牌及真实家谱标识不写入仓库。
## 20% 后续维护记录
- 个人中心继续以 Jiapu-App 的账户与家谱能力为信息架构参考;真实读写仅使用当前 PC 客户端契约。
- 已补齐当前家谱上下文传递、业务页统一加载/空数据/失败/无权限状态,以及账户、家谱和内容表单的标签与失败反馈。
- PC 尚未声明契约的资料提醒、家谱邀请、家谱删除、家谱排序、分享奖励、推广收益与提现,保留明确的不可操作状态;不展示模拟金额、记录、二维码或成功结果。
- 交给后端的完整补齐范围、最小 DTO、权限和验收要求见 `docs/PC后端补齐清单.md`;该清单只描述 PC 能力,不迁用 App 接口。
- 本次当前全量回归为 362 项通过;测试账号、密码、令牌和真实家谱信息仍不写入代码、文档或 Git。
- 角色运行时验收受阻:已用隔离身份只读确认谱主能力和未认证访问闭环,但该身份可访问的家谱中没有管理员或普通成员实体。未创建成员、未提交申请或写入家谱数据;解除条件是提供已有的隔离管理员、普通成员身份,或预先在隔离家谱中准备对应角色关系。
- 最新只读复核跳过:隔离登录响应未提供可明确消费的令牌字段;为避免打印原始响应或猜测字段,未继续请求角色接口。该项不影响现有静态权限回归,待测试环境恢复标准 PC 登录响应后再验收。
## 后续优先级
1. 统一业务页的加载中、空数据、请求失败和无权限状态,减少页面之间的割裂感。
+21 -35
View File
@@ -21,43 +21,28 @@
<div class="eyebrow">Password Recovery</div>
<h1 id="recover-title">找回密码</h1>
<p class="lead">输入注册手机号,获取验证码后重设登录密码。</p>
<form id="password-reset-form" class="form-grid">
<input
class="input"
name="phone"
type="tel"
inputmode="numeric"
autocomplete="tel"
maxlength="11"
placeholder="手机号"
/>
<div class="code-row">
<input
class="input"
name="smsCode"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="4"
pattern="\d{4}"
placeholder="验证码"
/>
<button class="btn ghost" type="button" data-api-send-code>获取验证码</button>
<form id="password-reset-form" class="form-grid" novalidate>
<label class="auth-field" for="reset-phone">
<span>手机号 <b aria-hidden="true">*</b></span>
<input id="reset-phone" class="input" name="phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="请输入注册手机号" required />
</label>
<div class="auth-field">
<label for="reset-sms-code">短信验证码 <b aria-hidden="true">*</b></label>
<div class="code-row">
<input id="reset-sms-code" class="input" name="smsCode" autocomplete="one-time-code" inputmode="numeric" maxlength="4" pattern="\d{4}" placeholder="请输入四位验证码" required />
<button class="btn ghost" type="button" data-api-send-code>获取验证码</button>
</div>
</div>
<input
class="input"
name="newPassword"
type="password"
autocomplete="new-password"
placeholder="新密码"
/>
<input
class="input"
name="confirmPassword"
type="password"
autocomplete="new-password"
placeholder="确认新密码"
/>
<label class="auth-field" for="reset-password">
<span>新密码 <b aria-hidden="true">*</b></span>
<input id="reset-password" class="input" name="newPassword" type="password" autocomplete="new-password" placeholder="至少六位" required />
</label>
<label class="auth-field" for="reset-confirm-password">
<span>确认新密码 <b aria-hidden="true">*</b></span>
<input id="reset-confirm-password" class="input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="请再次输入新密码" required />
</label>
<input type="hidden" name="validToken" />
<div id="reset-status" class="auth-form-status" role="status" aria-live="polite" data-auth-form-status></div>
<button class="btn primary" type="submit">重设密码</button>
</form>
</section>
@@ -73,6 +58,7 @@
<script src="public/layui/layui.js"></script>
<script src="public/tac/js/tac.min.js"></script>
<script src="public/js/captcha-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/auth-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+3 -2
View File
@@ -67,9 +67,9 @@
<p>选择问题后读取平台发布的完整解答。</p>
<button class="btn ghost" type="button" data-help-refresh>刷新帮助</button>
</div>
<p class="muted" data-help-status aria-live="polite">正在读取帮助文章…</p>
<div class="muted" data-help-status role="status" aria-live="polite"></div>
<div class="faq-list is-loading" data-help-list>
<div class="api-empty">正在读取帮助文章…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取帮助文章…</div>
</div>
</div>
</section>
@@ -111,6 +111,7 @@
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/page-effects.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/help-pages.js"></script>
</body>
</html>
+39 -30
View File
@@ -19,36 +19,44 @@
<div class="eyebrow">Join Family</div>
<h1 id="join-title">加入家谱</h1>
<p class="lead">搜索并选择要加入的家谱,再补充申请资料。家谱编号由系统自动处理。</p>
<form class="form-grid" data-join-genealogy-search>
<label class="field">
<span>搜索家谱</span>
<input name="keyword" type="search" maxlength="50" placeholder="输入谱名或姓氏" />
</label>
<button class="btn ghost" type="submit">搜索</button>
</form>
<div class="module-list" data-join-genealogy-options></div>
<p class="form-note" data-join-selected-genealogy>尚未选择家谱</p>
<form class="form-grid" data-join-apply-form>
<label class="field">
<span>申请人姓名(选填)</span>
<input name="applicantName" maxlength="50" autocomplete="name" />
</label>
<label class="field">
<span>联系电话(选填)</span>
<input name="phone" maxlength="30" autocomplete="tel" />
</label>
<label class="field">
<span>与家谱关系(选填)</span>
<input name="relationDesc" maxlength="100" placeholder="例如:族亲" />
</label>
<label class="field">
<span>申请理由(选填)</span>
<textarea name="applyReason" maxlength="500" rows="4"></textarea>
</label>
<p class="form-note" aria-live="polite" data-join-apply-status></p>
<button class="btn primary" type="submit" data-join-apply-submit>提交申请</button>
<a class="btn ghost" href="profile-join-family.html">查看我的申请</a>
</form>
<div class="join-page-grid">
<section class="join-search-panel" aria-labelledby="join-search-title">
<h2 id="join-search-title">选择家谱</h2>
<form class="form-grid" data-join-genealogy-search novalidate aria-describedby="join-options-status">
<label class="field">
<span>搜索家谱</span>
<input name="keyword" type="search" maxlength="50" placeholder="输入谱名或姓氏" />
</label>
<button class="btn ghost" type="submit">搜索</button>
</form>
<div id="join-options-status" class="form-note" data-join-options-status role="status" aria-live="polite"></div>
<div class="module-list" data-join-genealogy-options aria-live="polite"></div>
<p class="form-note" data-join-selected-genealogy role="status" aria-live="polite">尚未选择家谱</p>
</section>
<form class="form-grid join-apply-form" data-join-apply-form novalidate aria-describedby="join-apply-status">
<h2>补充申请资料</h2>
<p class="form-note">以下资料均为选填,审核人将据此确认您的加入申请。</p>
<label class="field">
<span>申请人姓名(选填)</span>
<input name="applicantName" maxlength="50" autocomplete="name" />
</label>
<label class="field">
<span>联系电话(选填)</span>
<input name="phone" maxlength="30" autocomplete="tel" />
</label>
<label class="field">
<span>与家谱关系(选填)</span>
<input name="relationDesc" maxlength="100" placeholder="例如:族亲" />
</label>
<label class="field">
<span>申请理由(选填)</span>
<textarea name="applyReason" maxlength="500" rows="4"></textarea>
</label>
<div id="join-apply-status" class="form-note" data-join-apply-status role="status" aria-live="polite"></div>
<button class="btn primary" type="submit" data-join-apply-submit>提交申请</button>
<a class="btn ghost" href="profile-join-family.html">查看我的申请</a>
</form>
</div>
</section>
</main>
<script src="config.js"></script>
@@ -56,6 +64,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/join-pages.js"></script>
</body>
</html>
+23 -38
View File
@@ -29,55 +29,39 @@
<form
id="login-password-form"
class="form-grid login-mode-panel"
novalidate
>
<input
class="input"
name="phone"
type="tel"
inputmode="numeric"
autocomplete="tel"
maxlength="11"
placeholder="手机号"
/>
<input
class="input"
name="password"
type="password"
autocomplete="current-password"
placeholder="密码"
/>
<label class="auth-field" for="login-password-phone">
<span>手机号 <b aria-hidden="true">*</b></span>
<input id="login-password-phone" class="input" name="phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="请输入手机号" required />
</label>
<label class="auth-field" for="login-password-value">
<span>密码 <b aria-hidden="true">*</b></span>
<input id="login-password-value" class="input" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" required />
</label>
<input type="hidden" name="validToken" />
<div id="login-password-status" class="auth-form-status" role="status" aria-live="polite" data-auth-form-status></div>
<button class="btn primary" type="submit">登录</button>
</form>
<form
id="login-sms-form"
class="form-grid login-mode-panel"
hidden
novalidate
>
<input
class="input"
name="phone"
type="tel"
inputmode="numeric"
autocomplete="tel"
maxlength="11"
placeholder="手机号"
/>
<div class="code-row">
<input
class="input"
name="smsCode"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="4"
pattern="\d{4}"
placeholder="短信验证码"
/>
<button class="btn ghost" type="button" data-api-send-code>
获取验证码
</button>
<label class="auth-field" for="login-sms-phone">
<span>手机号 <b aria-hidden="true">*</b></span>
<input id="login-sms-phone" class="input" name="phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="请输入手机号" required />
</label>
<div class="auth-field">
<label for="login-sms-code">短信验证码 <b aria-hidden="true">*</b></label>
<div class="code-row">
<input id="login-sms-code" class="input" name="smsCode" autocomplete="one-time-code" inputmode="numeric" maxlength="4" pattern="\d{4}" placeholder="请输入四位验证码" required />
<button class="btn ghost" type="button" data-api-send-code>获取验证码</button>
</div>
</div>
<input type="hidden" name="validToken" />
<div id="login-sms-status" class="auth-form-status" role="status" aria-live="polite" data-auth-form-status></div>
<button class="btn primary" type="submit">短信登录</button>
</form>
<div class="auth-links">
@@ -97,6 +81,7 @@
<script src="public/layui/layui.js"></script>
<script src="public/tac/js/tac.min.js"></script>
<script src="public/js/captcha-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/auth-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+4 -2
View File
@@ -42,8 +42,10 @@
<h2>我的反馈记录</h2>
<button class="btn ghost" type="button" data-feedback-refresh>刷新</button>
</div>
<p class="form-note" aria-live="polite" data-feedback-status></p>
<div class="module-list" data-feedback-list></div>
<div class="form-status" role="status" aria-live="polite" data-feedback-status></div>
<div class="module-list" data-feedback-list>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取反馈记录…</div>
</div>
<div class="site-info-actions">
<a class="btn primary magnetic" href="submit-ticket.html">提交新工单</a>
<a class="btn ghost magnetic" href="help.html">返回帮助中心</a>
+3 -2
View File
@@ -43,9 +43,9 @@
<section class="site-info-section">
<div class="container site-info-layout">
<div>
<p class="module-meta" data-site-news-status></p>
<div class="module-meta" data-site-news-status role="status" aria-live="polite"></div>
<div class="site-info-list" data-site-news-list>
<div class="api-empty">资讯内容加载中</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取资讯…</div>
</div>
</div>
<aside class="site-info-panel">
@@ -70,6 +70,7 @@
</div>
</div>
</footer>
<script src="public/js/profile-common.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
+20 -28
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-admin-permission-page data-feature-status="pending" data-feature-message="管理员权限服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-admin-permission-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -19,8 +19,8 @@
><a class="active" href="profile-family-admin.html">家族管理</a>
</nav>
<div class="nav-actions">
<a href="profile-family-admin.html">返回管理</a
><button class="btn primary magnetic" type="submit" form="admin-permission-form">确定</button>
<a href="profile-family-admin.html" data-genealogy-context-link>返回管理</a
><a class="btn primary magnetic" href="profile-family-admin.html" data-genealogy-context-link>管理成员</a>
</div>
</div>
</header>
@@ -32,7 +32,7 @@
<div>
<h1>管理员权限</h1>
<p>
分配全权管理员、全系管理员、支系管理员,以及入谱审核等具体权限
当前 PC 端通过成员名册维护管理员、编辑和成员角色;具体可操作范围以服务端返回的当前角色为准
</p>
</div>
</div>
@@ -41,34 +41,26 @@
<section class="section">
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-family-admin.html">家族管理</a
<a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a
><a class="active" href="profile-admin-permissions.html"
>管理员权限</a
><a href="profile-messages.html">入谱审核</a>
><a href="profile-join-review.html" data-genealogy-context-link>入谱审核</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>权限设置</h2>
<form id="admin-permission-form" class="editor-form" data-permission-form>
<!-- 成员下拉由 /members/options 接口渲染 -->
<div class="editor-field">
<label for="permission-member">选择成员</label>
<select id="permission-member" data-member-select>
<option value="">成员加载中...</option>
</select>
</div>
<div class="editor-field">
<label for="permission-role">角色权限</label>
<select id="permission-role" name="roleType">
<option value="admin">管理员</option>
<option value="member">普通成员</option>
</select>
</div>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存权限</button>
<button class="btn ghost" type="button" data-owner-transfer>转让所有者</button>
</div>
</form>
<h2>当前权限</h2>
<div data-admin-permission-current>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在核对当前家谱角色与权限…</div>
</div>
</section>
<section class="module-panel">
<h2>角色与权限</h2>
<div class="module-list">
<article class="module-row"><div><h3>谱主</h3><p>可维护管理员、编辑和成员角色,并可转让谱主。</p></div></article>
<article class="module-row"><div><h3>管理员</h3><p>可维护编辑和成员角色,不能修改谱主或其他管理员。</p></div></article>
<article class="module-row"><div><h3>编辑、成员</h3><p>不显示成员角色编辑入口;最终权限由服务端校验。</p></div></article>
</div>
<div class="bottom-actions"><a class="btn primary magnetic" href="profile-family-admin.html" data-genealogy-context-link>进入成员名册管理</a></div>
</section>
</div>
</div>
@@ -83,7 +75,7 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/admin-permission-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+10 -6
View File
@@ -48,25 +48,29 @@
<section class="module-panel">
<h2>相册信息</h2>
<div data-album-detail>
<div class="api-empty">相册加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
相册加载中...
</div>
</div>
</section>
<section class="module-panel">
<h2>照片列表</h2>
<div class="module-list" data-album-photo-list>
<div class="api-empty">照片加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
照片加载中...
</div>
</div>
</section>
<section class="module-panel" data-album-photo-editor hidden>
<h2>添加照片</h2>
<form class="editor-form" data-album-photo-form>
<form class="editor-form" data-album-photo-form novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="albumPhotoFile">照片文件</label>
<label for="albumPhotoFile">照片文件 <span class="field-required" aria-hidden="true">*</span></label>
<input id="albumPhotoOssId" name="ossId" type="hidden" />
<label class="upload-control" for="albumPhotoFile">选择照片</label>
<input id="albumPhotoFile" class="upload-input" type="file" accept="image/*" data-upload-target="#albumPhotoOssId" data-upload-status="#albumPhotoUploadStatus" />
<p id="albumPhotoUploadStatus" class="upload-status">未选择文件</p>
<p id="albumPhotoUploadStatus" class="upload-status" role="status" aria-live="polite">未选择文件</p>
</div>
<div class="editor-field">
<label for="albumPhotoTitle">照片标题</label>
@@ -92,7 +96,7 @@
<input id="albumPhotoSortOrder" name="sortOrder" type="number" step="1" placeholder="数字越小越靠前" />
</div>
<input name="status" type="hidden" value="0" />
<p class="api-status" data-album-photo-status></p>
<div class="api-status" data-album-photo-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary" type="submit">添加照片</button>
</div>
+5 -5
View File
@@ -47,10 +47,10 @@
</aside>
<div class="module-main">
<section class="module-panel">
<div class="api-empty" data-album-editor-placeholder>正在读取家谱权限...</div>
<form id="album-edit-form" class="editor-form" data-album-form data-album-editor hidden>
<div class="api-state api-state--loading" data-api-state="loading" data-album-editor-placeholder role="status" aria-live="polite" aria-busy="true">正在读取家谱权限...</div>
<form id="album-edit-form" class="editor-form" data-album-form data-album-editor hidden novalidate>
<div class="editor-field">
<label for="albumName">相册名称</label>
<label for="albumName">相册名称 <span class="field-required" aria-hidden="true">*</span></label>
<input id="albumName" name="albumName" type="text" required placeholder="请输入相册名称" />
</div>
<div class="editor-field">
@@ -63,7 +63,7 @@
<input id="albumCoverOssId" name="coverOssId" type="hidden" />
<label class="upload-control" for="albumCoverFile">选择封面图片</label>
<input id="albumCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#albumCoverOssId" data-upload-status="#albumCoverStatus" />
<p id="albumCoverStatus" class="upload-status">未选择文件</p>
<p id="albumCoverStatus" class="upload-status" role="status" aria-live="polite">未选择文件</p>
</div>
<div class="editor-field">
<label for="albumSortOrder">排序</label>
@@ -71,7 +71,7 @@
</div>
</div>
<input name="status" type="hidden" value="0" />
<p class="api-status" data-album-form-status></p>
<div class="api-status" data-album-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存相册</button>
<a class="btn ghost" href="profile-album.html" data-genealogy-context-link>取消</a>
+3 -1
View File
@@ -51,7 +51,9 @@
<section class="module-panel">
<h2>相册列表</h2>
<div class="module-list" data-album-list>
<div class="api-empty">相册加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
相册加载中...
</div>
</div>
</section>
</div>
+8 -6
View File
@@ -50,10 +50,12 @@
<div class="module-main">
<section class="module-panel">
<h2>谱文内容</h2>
<div class="api-empty" data-article-editor-placeholder>正在读取家谱权限...</div>
<form id="article-edit-form" class="editor-form layui-form" data-article-form data-article-editor hidden>
<div data-article-editor-placeholder>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取家谱权限…</div>
</div>
<form id="article-edit-form" class="editor-form layui-form" data-article-form data-article-editor hidden novalidate>
<div class="editor-field">
<label for="articleTitle">谱文标题</label
<label for="articleTitle">谱文标题<span class="field-required" aria-hidden="true"> *</span></label
><input
id="articleTitle"
name="articleTitle"
@@ -84,7 +86,7 @@
</div>
</div>
<div class="editor-field">
<label for="articleContent">正文</label
<label for="articleContent">正文<span class="field-required" aria-hidden="true"> *</span></label
><textarea
id="articleContent"
name="articleContent"
@@ -102,10 +104,10 @@
<input id="articleCoverOssId" name="coverOssId" type="hidden" />
<label class="upload-control" for="articleCoverFile">选择封面图片</label>
<input id="articleCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#articleCoverOssId" data-upload-status="#articleCoverStatus" />
<p id="articleCoverStatus" class="upload-status">未选择文件</p>
<p id="articleCoverStatus" class="upload-status" role="status" aria-live="polite">未选择文件</p>
</div>
<input name="status" type="hidden" value="0" />
<p class="api-status" data-article-form-status></p>
<div class="form-status" data-article-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button
><a class="btn ghost magnetic" href="profile-article.html"
+2 -2
View File
@@ -52,13 +52,13 @@
<section class="module-panel">
<h2>谱文列表</h2>
<div class="module-list" data-article-list>
<div class="api-empty">谱文加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载谱文…</div>
</div>
</section>
<section class="module-panel">
<h2>谱文详情</h2>
<div data-article-detail>
<div class="api-empty">请选择一篇谱文查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一篇谱文查看详情</div>
</div>
</section>
</div>
+28 -9
View File
@@ -24,27 +24,46 @@
<div class="container module-layout">
<aside class="module-nav"><a href="profile-content.html">内容发布</a><a href="profile-gift.html">我的邀请</a><a class="active" href="profile-ceremony.html">祭祀活动</a><a href="profile-merit.html">功德录</a></aside>
<div class="module-main">
<section class="module-panel"><h2>活动信息</h2><div data-ceremony-detail aria-live="polite"><div class="api-empty">正在加载活动详情…</div></div></section>
<section class="module-panel">
<h2>活动信息</h2>
<div data-ceremony-detail aria-live="polite">
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载活动详情…
</div>
</div>
</section>
<section class="module-panel">
<h2>祭品记录</h2>
<div class="module-list" data-ceremony-gift-list aria-live="polite"><div class="api-empty">正在加载祭品…</div></div>
<form class="editor-form" data-ceremony-gift-form data-ceremony-admin-editor hidden>
<div class="module-list" data-ceremony-gift-list aria-live="polite">
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载祭品…
</div>
</div>
<form class="editor-form" data-ceremony-gift-form data-ceremony-admin-editor hidden novalidate>
<div class="editor-two">
<div class="editor-field"><label for="ceremonyGiverName">赠送人姓名</label><input id="ceremonyGiverName" name="giverName" type="text" placeholder="可选" /></div>
<div class="editor-field"><label for="ceremonyGiftAmount">礼金金额</label><input id="ceremonyGiftAmount" name="giftAmount" type="number" min="0" step="any" required /></div>
<div class="editor-field"><label for="ceremonyGiftAmount">礼金金额 <span class="field-required" aria-hidden="true">*</span></label><input id="ceremonyGiftAmount" name="giftAmount" type="number" min="0" step="any" required /></div>
</div>
<div class="editor-field"><label for="ceremonyGiftMessage">祭品留言</label><textarea id="ceremonyGiftMessage" name="giftMessage" placeholder="可选"></textarea></div>
<p class="upload-status" data-ceremony-gift-status aria-live="polite"></p>
<div class="upload-status" data-ceremony-gift-status role="status" aria-live="polite"></div>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">添加祭品</button></div>
</form>
</section>
<section class="module-panel" data-ceremony-admin-editor hidden>
<h2>受邀名单</h2>
<div class="module-list" data-ceremony-invitation-list aria-live="polite"><div class="api-empty">正在加载邀请记录…</div></div>
<form class="editor-form" data-ceremony-invitee-form>
<div class="module-list" data-ceremony-invitation-list aria-live="polite">
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载邀请记录…
</div>
</div>
<form class="editor-form" data-ceremony-invitee-form data-ceremony-admin-editor hidden novalidate>
<p>仅显示当前家谱中已绑定业务账号的正常成员;提交完整勾选名单,取消勾选会撤销尚未响应的邀请。</p>
<div class="editor-choice-list" data-ceremony-invitee-options><div class="api-empty">正在加载成员选项…</div></div>
<p class="upload-status" data-ceremony-invitee-status aria-live="polite"></p>
<div class="editor-choice-list" data-ceremony-invitee-options>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载成员选项…
</div>
</div>
<div class="upload-status" data-ceremony-invitee-status role="status" aria-live="polite"></div>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">更新受邀名单</button></div>
</form>
</section>
+5 -1
View File
@@ -29,7 +29,11 @@
<div class="module-main">
<section class="module-panel">
<div class="module-panel-head"><h2>活动列表</h2><a class="pill" href="profile-gift-edit.html" data-ceremony-create-link data-genealogy-context-link hidden>创建活动</a></div>
<div class="module-list" data-ceremony-list aria-live="polite"><div class="api-empty">正在加载祭祀活动…</div></div>
<div class="module-list" data-ceremony-list aria-live="polite">
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载祭祀活动…
</div>
</div>
</section>
</div>
</div>
+5 -5
View File
@@ -99,19 +99,19 @@
>
<a class="module-card tilt-card" href="profile-relative.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>亲友往来</h3>
<p>记录亲友关系、事项和礼金。</p></a
<h3>亲友往来(贺礼簿)</h3>
<p>记录亲友关系、往来事项和礼金,承接 APP 贺礼簿</p></a
>
<a class="module-card tilt-card" href="profile-memo.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>备忘录</h3>
<p>记录家族事务、提醒和完成状态</p></a
<h3>备忘录(家族恩人)</h3>
<p>以标题、内容和提醒记录家族恩人等事项;当前 PC 不区分独立分类</p></a
>
</div>
</section>
<section class="module-panel">
<h2>使用说明</h2>
<div class="api-empty">请先选择家谱,再进入对应模块查看和维护真实内容。</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请先选择家谱,再进入对应模块查看和维护真实内容。</div>
</section>
</div>
</div>
+29 -21
View File
@@ -32,7 +32,7 @@
<div>
<h1>创建家谱</h1>
<p>
个人中心专用创建流程,用于录入姓氏、谱名、堂号、地望、祠堂和访问权限
填写当前 PC 服务支持的家谱信息;标有 * 的字段为必填,创建后可进入家谱工作区继续维护
</p>
</div>
</div>
@@ -48,16 +48,16 @@
<div class="module-main">
<section class="module-panel">
<h2>基础信息</h2>
<p class="upload-status" data-genealogy-create-quota>正在读取家谱额度…</p>
<form class="editor-form profile-create-family-form" data-genealogy-create-form>
<p class="upload-status" data-genealogy-create-quota role="status" aria-live="polite">正在读取家谱额度…</p>
<form class="editor-form profile-create-family-form" data-genealogy-create-form novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="familySurname">姓氏</label>
<input id="familySurname" name="surname" type="text" required />
<label for="familySurname">姓氏 <span class="field-required" aria-hidden="true">*</span></label>
<input id="familySurname" name="surname" type="text" required placeholder="例如:王" />
</div>
<div class="editor-field">
<label for="familyName">谱名</label>
<input id="familyName" name="genealogyName" type="text" required />
<label for="familyName">谱名 <span class="field-required" aria-hidden="true">*</span></label>
<input id="familyName" name="genealogyName" type="text" required placeholder="例如:王氏家谱" />
</div>
</div>
<div class="editor-two">
@@ -70,20 +70,27 @@
<input id="originPlace" name="originPlace" type="text" />
</div>
</div>
<div class="editor-field">
<label>家族所在地</label>
<div class="editor-field" role="group" aria-labelledby="family-region-label" aria-describedby="family-create-status">
<span id="family-region-label" class="editor-label">家族所在地 <span class="field-required" aria-hidden="true">*</span></span>
<div class="profile-region-picker" data-region-picker>
<select name="provinceCode" data-region-level="province">
<option value="">请选择省</option>
</select>
<select name="cityCode" data-region-level="city">
<option value="">请选择市</option>
</select>
<select name="districtCode" data-region-level="district">
<option value="">请选择区县</option>
</select>
<label for="familyProvinceCode">
<select id="familyProvinceCode" name="provinceCode" data-region-level="province">
<option value="">请选择省</option>
</select>
</label>
<label for="familyCityCode">
<select id="familyCityCode" name="cityCode" data-region-level="city">
<option value="">请选择</option>
</select>
</label>
<label for="familyDistrictCode">区县
<select id="familyDistrictCode" name="districtCode" data-region-level="district">
<option value="">请选择区县</option>
</select>
</label>
<input type="hidden" name="regionCode" data-region-code />
</div>
<div class="upload-status" data-region-picker-status role="status" aria-live="polite"></div>
</div>
<div class="editor-field">
<label for="addressDetail">详细地址</label>
@@ -105,7 +112,7 @@
data-upload-target="#familyCoverOssId"
data-upload-status="#familyCoverStatus"
/>
<p id="familyCoverStatus" class="upload-status">未选择文件</p>
<p id="familyCoverStatus" class="upload-status" role="status" aria-live="polite">未选择文件</p>
</div>
<div class="editor-two">
<div class="editor-field">
@@ -118,14 +125,15 @@
</div>
<div class="editor-field">
<label for="joinMode">加入方式</label>
<select id="joinMode" name="joinMode">
<select id="joinMode" name="joinMode" aria-describedby="family-invite-mode-note">
<option value="1">审核加入</option>
<option value="2">邀请码加入</option>
<option value="0">关闭加入</option>
</select>
<p id="family-invite-mode-note" class="form-status">当前 PC 未提供邀请码生成、发送或管理接口;选择邀请模式后不能在本页面继续生成邀请码。</p>
</div>
</div>
<p class="upload-status" data-genealogy-create-status aria-live="polite"></p>
<div id="family-create-status" class="upload-status" data-genealogy-create-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit" data-genealogy-create-submit>确定创建</button>
<a class="btn ghost magnetic" href="profile-families.html">返回列表</a>
+3 -13
View File
@@ -50,19 +50,9 @@
<section class="module-panel">
<h2>待完善成员</h2>
<div class="module-list">
<a class="module-row" href="profile-data.html"
><div>
<h3>鹿野 Loyel</h3>
<p>缺少出生日期、出生地址和微信信息</p>
</div>
<span class="pill">完善</span></a
><a class="module-row" href="profile-data.html"
><div>
<h3>张三</h3>
<p>缺少头像和亲属关系</p>
</div>
<span class="pill">编辑</span></a
>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">
当前 PC 接口未提供资料缺项提醒列表,暂不能展示或处理成员提醒。
</div>
</div>
</section>
</div>
+48 -53
View File
@@ -38,7 +38,7 @@
<div>
<h1>个人资料</h1>
<p>
管理个人资料、亲属关系和账号安全信息。
管理个人资料和账号安全信息;家谱成员关系请在当前家谱中维护
</p>
</div>
<a class="btn primary magnetic" href="#profile-edit-form"
@@ -60,7 +60,7 @@
<div class="module-main">
<section class="module-panel">
<h2>编辑资料</h2>
<form class="editor-form profile-data-form" id="profile-edit-form" data-profile-form>
<form class="editor-form profile-data-form" id="profile-edit-form" data-profile-form novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="profileNickName">昵称</label>
@@ -105,7 +105,7 @@
data-upload-biz-type="avatar"
data-upload-usage-scene="profile_avatar"
/>
<p id="profileAvatarStatus" class="upload-status">请选择头像文件</p>
<p id="profileAvatarStatus" class="upload-status" role="status" aria-live="polite">请选择头像文件</p>
</div>
</div>
<div class="editor-two">
@@ -135,30 +135,35 @@
/>
</div>
<div class="bottom-actions profile-form-actions">
<button class="btn primary magnetic" type="submit">保存资料</button>
<span class="form-status" data-profile-status></span>
<button class="btn primary magnetic" type="submit" data-profile-submit>保存资料</button>
<div class="form-status" data-profile-status role="status" aria-live="polite"></div>
</div>
</form>
</section>
<section class="module-panel">
<h2>基本资料</h2>
<div data-profile-load-status><div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取账户资料…</div></div>
<div class="form-like">
<p>
<span>头像</span><b>已上传</b
<span>头像</span><b data-profile-text="avatarStatus">正在读取</b
><a class="link-btn" href="profile-data.html">更换</a>
</p>
<p>
<span>姓名</span><b data-profile-text="displayName">鹿野 Loyel</b
<span>昵称</span><b data-profile-text="displayName">正在读取</b
><a class="link-btn" href="profile-data.html">编辑</a>
</p>
<p>
<span>绑定账号</span><b data-profile-text="phone">已绑定</b
<span>真实姓名</span><b data-profile-text="realName">正在读取</b
><a class="link-btn" href="#profile-edit-form">编辑</a>
</p>
<p>
<span>绑定账号</span><b data-profile-text="phone">正在读取</b
><a class="link-btn" href="profile-security.html">安全设置</a>
</p>
<p>
<span>血亲关系</span><b>第二代 顺字辈</b
><a class="link-btn" href="profile-family-admin.html"
>查看世系</a
<span>家谱关系</span><b>请在当前家谱中查看</b
><a class="link-btn" href="profile-family-admin.html" data-genealogy-context-link
>成员管理</a
>
</p>
</div>
@@ -167,78 +172,68 @@
<h2>个人信息</h2>
<div class="form-like">
<p>
<span>父亲</span><b>某某</b
><a class="link-btn" href="profile-data.html">编辑</a>
<span>性别</span><b data-profile-text="sex">正在读取</b
><a class="link-btn" href="#profile-edit-form">编辑</a>
</p>
<p>
<span>出生日期</span><b data-profile-text="birthday">待选择</b
><a class="link-btn" href="profile-data.html">选择</a>
><a class="link-btn" href="#profile-edit-form">编辑</a>
</p>
<p>
<span>现居地址</span><b data-profile-text="regionText">待填写</b
><a class="link-btn" href="profile-data.html">填写</a>
</p>
<p>
<span>微信 / QQ</span><b>待填写</b
><a class="link-btn" href="profile-data.html">填写</a>
</p>
<p>
<span>学历 / 职业</span><b>待填写</b
><a class="link-btn" href="profile-data.html">填写</a>
<span>邮箱</span><b data-profile-text="email">正在读取</b
><a class="link-btn" href="#profile-edit-form">编辑</a>
</p>
</div>
</section>
<section class="module-panel">
<h2>现居地区</h2>
<div class="editor-form region-profile-form">
<h2 id="profile-region-heading">现居地区</h2>
<div class="editor-form region-profile-form" role="group" aria-labelledby="profile-region-heading" aria-describedby="profile-region-contract-note profile-region-picker-status">
<div class="region-picker profile-region-picker" data-region-picker>
<select class="input" name="provinceCode" data-region-level="province">
<option value="">请选择省</option>
</select>
<select class="input" name="cityCode" data-region-level="city">
<option value="">请选择市</option>
</select>
<select class="input" name="districtCode" data-region-level="district">
<option value="">请选择区县</option>
</select>
<label for="profileProvinceCode">
<select id="profileProvinceCode" class="input" name="provinceCode" data-region-level="province">
<option value="">请选择省</option>
</select>
</label>
<label for="profileCityCode">
<select id="profileCityCode" class="input" name="cityCode" data-region-level="city">
<option value="">请选择</option>
</select>
</label>
<label for="profileDistrictCode">区县
<select id="profileDistrictCode" class="input" name="districtCode" data-region-level="district">
<option value="">请选择区县</option>
</select>
</label>
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="button" disabled>暂不支持保存地区</button>
<span class="form-status">当前 YAML 的 ProfileUpdateBody 没有地区字段</span>
<span id="profile-region-contract-note" class="form-status">当前 YAML 的 ProfileUpdateBody 没有地区字段</span>
</div>
<div id="profile-region-picker-status" class="form-status" data-region-picker-status role="status" aria-live="polite"></div>
</div>
<form class="editor-form" data-region-search-form data-region-search-panel>
<form class="editor-form" data-region-search-form data-region-search-panel novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="profileRegionKeyword">搜索地区</label>
<input id="profileRegionKeyword" name="regionKeyword" type="search" placeholder="地区名称或编码" />
<input id="profileRegionKeyword" name="regionKeyword" type="search" placeholder="地区名称或编码" aria-describedby="profile-region-search-status" required />
</div>
<div class="bottom-actions">
<button class="btn ghost magnetic" type="submit">搜索</button>
</div>
</div>
<div id="profile-region-search-status" class="form-status" data-region-search-status role="status" aria-live="polite"></div>
</form>
<p class="api-status" data-region-search-path></p>
<div class="module-list" data-region-search-results></div>
</section>
<section class="module-panel">
<h2>亲属</h2>
<h2>亲属关系</h2>
<div class="module-list">
<div class="module-row">
<div>
<h3>张三</h3>
<p>父亲 · 已绑定成员资料</p>
</div>
<span class="pill">查看</span>
</div>
<div class="module-row">
<div>
<h3>张三</h3>
<p>父亲 · 未绑定账号</p>
</div>
<span class="pill">邀请激活</span>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">
当前 PC 资料接口未提供个人亲属关系列表;请在选定家谱的成员管理中查看和维护。
</div>
</div>
<div class="bottom-actions"><a class="btn ghost magnetic" href="profile-family-admin.html" data-genealogy-context-link>前往成员管理</a></div>
</section>
</div>
</div>
@@ -253,11 +248,11 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/md5.js"></script>
<script src="public/js/upload-pages.js"></script>
<script src="public/js/region-pages.js"></script>
<script src="public/js/profile-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+10 -3
View File
@@ -60,9 +60,11 @@
<section class="module-panel">
<h2>家谱入口</h2>
<p>对应 PC 的“我的家谱”入口;进入具体业务前必须取得真实家谱编号。</p>
<p class="api-status" data-genealogy-entry-status>正在读取我的家谱…</p>
<div class="api-status" data-genealogy-entry-status role="status" aria-live="polite">正在读取家谱额度</div>
<div class="module-list" data-genealogy-list="mine">
<div class="api-empty">正在加载列表…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在读取我的家谱…
</div>
</div>
</section>
<section class="module-panel">
@@ -80,6 +82,11 @@
<h3>加入家谱</h3>
<p>搜索并选择可加入的家谱,提交关系和申请说明等待审核。</p></a
>
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>家谱排序</h3>
<p>当前 PC 家谱契约未提供自定义排序的读取或保存接口,暂不能调整家谱显示顺序。</p>
</div>
</div>
</section>
</div>
@@ -94,8 +101,8 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/genealogy-entry-pages.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="public/js/genealogy-entry-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+11 -10
View File
@@ -31,7 +31,7 @@
<div class="module-main">
<section class="module-panel">
<h2>家谱概览</h2>
<p data-member-overview>正在加载家谱和当前权限…</p>
<div data-member-overview><div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载家谱和当前权限…</div></div>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-tree.html" data-feature-link="available" data-genealogy-context-link><span class="icon"></span><h3>世系图</h3><p>查看或维护世系人物与亲属关系。</p></a>
<a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link><span class="icon"></span><h3>字辈谱</h3><p>查看或维护家谱字辈。</p></a>
@@ -43,11 +43,12 @@
<section class="module-panel">
<h2>正常成员</h2>
<p>成员新增必须通过后端已有的加入或邀请流程;本页不伪造直接新增成员。</p>
<div class="module-list" data-member-list aria-live="polite"><div class="api-empty">成员加载中…</div></div>
<div class="form-status" role="status" aria-live="polite" data-member-action-status></div>
<div class="module-list" data-member-list aria-live="polite"><div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">成员加载中…</div></div>
</section>
<section class="module-panel" data-member-management hidden>
<h2 data-member-form-title>编辑成员</h2>
<form class="editor-form" data-member-form hidden>
<form class="editor-form" data-member-form novalidate hidden>
<div class="editor-two">
<div class="editor-field"><label for="memberAdminName">成员名称</label><input id="memberAdminName" name="memberName" type="text" maxlength="50" /></div>
<div class="editor-field"><label for="memberAdminRelation">与家谱关系</label><input id="memberAdminRelation" name="relationName" type="text" maxlength="100" /></div>
@@ -56,18 +57,18 @@
<div class="editor-field"><label for="memberAdminRole">成员角色</label><select id="memberAdminRole" name="roleType" data-member-role-options></select></div>
<div class="editor-field"><label for="memberAdminLineage">绑定世系人物</label><select id="memberAdminLineage" name="lineagePersonId" data-member-lineage-options><option value="">不修改世系人物绑定</option></select></div>
</div>
<p class="upload-status">当前 PC 更新接口不提供解除世系人物绑定留空表示不修改,不会发送空 ID</p>
<p class="upload-status" data-member-form-status aria-live="polite"></p>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存成员</button><button class="btn ghost" type="reset">取消</button></div>
<p class="upload-status">当前 PC 更新接口未声明空值清除语义;成员名称、关系和世系人物绑定留空表示不修改,不会发送空</p>
<div class="upload-status" role="status" aria-live="polite" data-member-form-status></div>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit" data-member-submit>保存成员</button><button class="btn ghost" type="reset">取消</button></div>
</form>
</section>
<section class="module-panel" data-owner-transfer-section hidden>
<h2>转让谱主</h2>
<form class="editor-form" data-owner-transfer-form>
<div class="editor-field"><label for="ownerTransferTarget">新谱主</label><select id="ownerTransferTarget" name="targetMemberId" data-owner-transfer-target><option value="">请选择新谱主</option></select></div>
<form class="editor-form" data-owner-transfer-form novalidate>
<div class="editor-field"><label for="ownerTransferTarget">新谱主 <span class="field-required" aria-hidden="true">*</span></label><select id="ownerTransferTarget" name="targetMemberId" data-owner-transfer-target required><option value="">请选择新谱主</option></select></div>
<p>只能选择当前正常成员。转让后当前账号变为管理员,新谱主受其账号家谱拥有额度约束。</p>
<p class="upload-status" data-owner-transfer-status aria-live="polite"></p>
<div class="bottom-actions"><button class="btn ghost" type="submit">确认转让谱主</button></div>
<div class="upload-status" role="status" aria-live="polite" data-owner-transfer-status></div>
<div class="bottom-actions"><button class="btn ghost" type="submit" data-owner-transfer-submit>确认转让谱主</button></div>
</form>
</section>
<section class="module-panel" data-member-leave-section hidden>
+74 -8
View File
@@ -61,9 +61,9 @@
<h2>家谱概览</h2>
<div class="module-grid">
<div class="module-card" data-family-home-overview>
<span class="icon"></span>
<h3>家谱信息</h3>
<p>家谱概览加载中...</p>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载家谱概览…
</div>
</div>
<div class="module-card">
<span class="icon"></span>
@@ -75,23 +75,27 @@
<h3>世系人物</h3>
<p><strong data-family-home-person-count>--</strong></p>
</div>
<div class="module-card">
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>邀请家人</h3>
<p>PC 暂未开放邀请,请等待后端提供正式接口。</p>
<span class="pill">暂不可操作</span>
</div>
</div>
<p class="api-status" data-family-home-status>正在读取家谱概览</p>
<div class="api-status" data-family-home-status role="status" aria-live="polite">正在读取家谱概览</div>
</section>
<section class="module-panel">
<h2>世系预览</h2>
<!-- 家谱主页只展示世系树预览,完整维护进入世系图页面 -->
<div class="lineage-tree-wrap is-compact" data-lineage-home-tree>
<div class="api-empty">世系树加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载世系树…
</div>
</div>
</section>
<section class="module-panel">
<h2>常用入口</h2>
<h2>家族文化</h2>
<p>围绕当前家谱集中维护谱文、影像与家族资料。</p>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-article.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
@@ -105,14 +109,76 @@
><span class="icon"></span>
<h3>视频</h3>
<p>发布和编辑家族视频。</p></a
>
</div>
</section>
<section class="module-panel">
<h2>家族记录与协作</h2>
<p>以列表、详情与编辑分工承接 APP 的动态、日志、人情簿、备忘和活动能力。</p>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-feed.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>家族圈</h3>
<p>发布图文动态、查看评论和家族互动。</p></a
><a class="module-card tilt-card" href="profile-growth.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>成长日志</h3>
<p>记录成员成长节点、日期和附件。</p></a
><a class="module-card tilt-card" href="profile-relative.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>亲友往来(贺礼簿)</h3>
<p>记录亲友关系、往来事项和礼金,承接 APP 贺礼簿。</p></a
><a class="module-card tilt-card" href="profile-memo.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>备忘录(家族恩人)</h3>
<p>以标题、内容和提醒记录家族恩人等事项;当前 PC 不区分独立分类。</p></a
><a class="module-card tilt-card" href="profile-merit.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>功德录</h3>
<p>记录功德人事迹。</p></a
<p>记录功德人事迹和相关金额</p></a
><a class="module-card tilt-card" href="profile-ceremony.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>祭祀活动</h3>
<p>维护活动、祭品和受邀名单。</p></a
><a class="module-card tilt-card" href="profile-gift.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>贺礼邀请</h3>
<p>查看和响应当前家谱的活动邀请。</p></a
>
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>重要证件</h3>
<p>当前 PC 接口未提供家谱证件的列表、上传或维护能力,暂不能查看或保存。</p>
<span class="pill">暂不可操作</span>
</div>
</div>
</section>
<section class="module-panel">
<h2>家谱维护</h2>
<p>世系、字辈和成员维护均与当前家谱绑定;管理权限始终以服务端结果为准。</p>
<div class="module-grid">
<a class="module-card tilt-card" href="profile-tree.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>世系图</h3>
<p>查看世系结构并维护人物与关系。</p></a
><a class="module-card tilt-card" href="profile-generation.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>字辈谱</h3>
<p>查询和维护家谱字辈。</p></a
><a class="module-card tilt-card" href="profile-family-admin.html" data-feature-link="available" data-family-home-management data-genealogy-context-link hidden
><span class="icon"></span>
<h3>家族管理</h3>
<p>管理成员、入谱审核和谱主转移。</p></a
><a class="module-card tilt-card" href="profile-services.html" data-feature-link="available" data-genealogy-context-link
><span class="icon"></span>
<h3>会员与订单</h3>
<p>关联当前家谱创建会员订单,并查看已有订单。</p></a
><div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>删除家谱</h3>
<p>当前 PC 家谱契约未提供删除接口,暂不能查看或执行删除操作。</p>
<span class="pill">暂不可操作</span>
</div>
>
</div>
</section>
+24 -2
View File
@@ -45,12 +45,34 @@
</aside>
<div class="module-main">
<section class="module-panel" data-feed-detail>
<div class="api-empty">动态详情加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载动态详情…
</div>
</section>
<section class="module-panel">
<h2>发表评论</h2>
<form class="editor-form" data-feed-comment-form novalidate>
<input name="parentCommentId" type="hidden" />
<p class="module-meta" data-feed-comment-form-target>发表公开评论</p>
<div class="editor-field">
<label for="feedCommentContent">评论内容 <span class="field-required" aria-hidden="true">*</span></label>
<textarea id="feedCommentContent" name="commentContent" rows="3" maxlength="1000" required disabled placeholder="写下想说的话"></textarea>
</div>
<div class="form-status" role="status" aria-live="polite" data-feed-comment-form-status>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取动态评论权限…</div>
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit" disabled>发表评论</button>
<button class="btn ghost" type="button" data-feed-comment-cancel disabled>取消回复</button>
</div>
</form>
</section>
<section class="module-panel">
<h2>评论与回复</h2>
<div data-feed-detail-comments>
<div class="api-empty">评论加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载评论…
</div>
</div>
</section>
</div>
+6 -5
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-feed.html" data-feed-list-link data-genealogy-context-link>返回动态</a
><button class="btn primary magnetic" type="submit" form="feed-edit-form">发布</button>
><button class="btn primary magnetic" type="submit" form="feed-edit-form" data-feed-editor-action hidden disabled>发布</button>
</div>
</div>
</header>
@@ -48,10 +48,10 @@
<div class="module-main">
<section class="module-panel">
<h2>动态内容</h2>
<form id="feed-edit-form" class="editor-form layui-form" data-feed-form>
<form id="feed-edit-form" class="editor-form layui-form" data-feed-form novalidate>
<input name="feedType" type="hidden" value="text" />
<div class="editor-field">
<label for="feedContent">内容</label
<label for="feedContent">内容 <span class="field-required" aria-hidden="true">*</span></label
><textarea
id="feedContent"
name="feedContent"
@@ -65,7 +65,7 @@
<input id="feedMedia" name="mediaOssIds" type="hidden" />
<label class="upload-control" for="feedMediaFile">选择动态图片</label>
<input id="feedMediaFile" class="upload-input" type="file" accept="image/*" multiple data-upload-multiple="true" data-upload-target="#feedMedia" data-upload-status="#feedMediaStatus" />
<p id="feedMediaStatus" class="upload-status">未选择图片</p>
<p id="feedMediaStatus" class="upload-status" role="status" aria-live="polite">未选择图片</p>
</div>
<div class="editor-two" data-feed-advanced hidden>
<div class="editor-field">
@@ -80,8 +80,9 @@
<input id="feedSortOrder" name="sortOrder" type="number" min="0" value="0" disabled />
</div>
</div>
<div class="upload-status" role="status" aria-live="polite" data-feed-form-status></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">发布</button
<button class="btn primary magnetic" type="submit" data-feed-editor-action hidden disabled>发布</button
><a class="btn ghost magnetic" href="profile-feed.html" data-feed-list-link data-genealogy-context-link
>取消</a
>
+6 -6
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-family-home.html">返回家谱主页</a
><a class="btn primary magnetic" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link
><a class="btn primary magnetic" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link hidden
>发布</a
>
</div>
@@ -53,21 +53,21 @@
<div class="form-like">
<p>
<span>内容</span><b>在发布页填写</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>编辑</a>
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link hidden>编辑</a>
</p>
<p>
<span>图片</span><b>可直接选择并上传多张图片</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>添加</a
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link hidden>添加</a
>
</p>
<p>
<span>动态类型</span><b>图文动态</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>编辑</a
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link hidden>编辑</a
>
</p>
<p>
<span>发布状态</span><b>正常或停用</b
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link>设置</a
><a class="link-btn" href="profile-feed-edit.html" data-feed-create-link data-genealogy-context-link hidden>设置</a
>
</p>
</div>
@@ -76,7 +76,7 @@
<h2>最近动态</h2>
<!-- 动态列表由 /feeds/page 接口渲染,点赞和评论走对应子接口 -->
<div class="module-list" data-feed-list>
<div class="api-empty">动态加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载家族动态…</div>
</div>
<div class="row-actions">
<button class="pill" type="button" data-feed-page-prev disabled>上一页</button>
+7 -5
View File
@@ -46,7 +46,7 @@
<div class="module-main">
<section class="module-panel">
<h2>提交反馈</h2>
<form class="editor-form" id="profileFeedbackForm" data-feedback-form>
<form class="editor-form" id="profileFeedbackForm" data-feedback-form novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="feedbackType">反馈类型</label>
@@ -63,10 +63,10 @@
</div>
</div>
<div class="editor-field">
<label for="feedbackContent">反馈描述</label>
<label for="feedbackContent">反馈描述 <span class="field-required" aria-hidden="true">*</span></label>
<textarea id="feedbackContent" name="feedbackContent" required placeholder="请描述问题、页面位置和期望结果"></textarea>
</div>
<p class="form-note" aria-live="polite" data-feedback-status></p>
<div class="form-status" role="status" aria-live="polite" data-feedback-status></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit" data-feedback-submit>提交反馈</button>
</div>
@@ -77,7 +77,9 @@
<h2>历史反馈</h2>
<button class="btn ghost" type="button" data-feedback-refresh>刷新</button>
</div>
<div class="module-list" data-feedback-list></div>
<div class="module-list" data-feedback-list>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取反馈记录…</div>
</div>
</section>
</div>
</div>
@@ -91,8 +93,8 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/feedback-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/feedback-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+35 -5
View File
@@ -55,9 +55,9 @@
<section class="module-panel generation-batch-panel" data-generation-management hidden>
<h2>批量字辈</h2>
<!-- 批量字辈表单:字段对应 Apifox 的 GenerationPoemBatchBody。 -->
<form class="editor-form generation-batch-form" data-generation-batch-form>
<form class="editor-form generation-batch-form" data-generation-batch-form novalidate>
<div class="editor-field">
<label for="generationBatchContent">字辈内容</label>
<label for="generationBatchContent">字辈内容 <span class="field-required" aria-hidden="true">*</span></label>
<textarea
id="generationBatchContent"
name="poemText"
@@ -84,17 +84,47 @@
type="button"
data-generation-batch-action="save"
>批量保存</button>
<span class="form-status" data-generation-batch-status></span>
<span class="form-status" role="status" aria-live="polite" data-generation-batch-status></span>
</div>
</form>
<div class="module-list generation-batch-preview" data-generation-batch-preview>
<div class="api-empty">批量预览结果将在这里显示</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">批量预览结果将在这里显示</div>
</div>
</section>
<section id="generation-editor" class="module-panel" data-generation-editor hidden>
<h2 data-generation-editor-title>新增字辈</h2>
<form class="editor-form" data-generation-form novalidate>
<input name="poemId" type="hidden" />
<input name="status" type="hidden" />
<div class="editor-two">
<div class="editor-field">
<label for="generationNo">世代序号 <span class="field-required" aria-hidden="true">*</span></label>
<input id="generationNo" name="generationNo" type="number" min="1" max="2147483647" step="1" required />
</div>
<div class="editor-field">
<label for="generationText">字辈文字 <span class="field-required" aria-hidden="true">*</span></label>
<input id="generationText" name="generationText" type="text" maxlength="50" required />
</div>
</div>
<div class="editor-field">
<label for="generationDescription">说明</label>
<textarea id="generationDescription" name="description" rows="3" maxlength="500" placeholder="可选,最多 500 个字符"></textarea>
</div>
<div class="editor-field">
<label for="generationSortOrder">排序值</label>
<input id="generationSortOrder" name="sortOrder" type="number" min="-2147483648" max="2147483647" step="1" placeholder="可留空,越小越靠前" />
</div>
<div class="form-status" role="status" aria-live="polite" data-generation-form-status></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit" data-generation-editor-submit>保存字辈</button>
<button class="btn ghost" type="button" data-generation-editor-cancel>取消</button>
</div>
</form>
</section>
<section class="module-panel">
<h2>字辈列表</h2>
<div class="module-list" data-generation-list>
<div class="api-empty">正在加载字辈记录…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载字辈记录…</div>
</div>
</section>
</div>
+15 -15
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>创建贺礼 - 个人中心 - 代代相传</title>
<title>创建祭祀活动 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
@@ -17,18 +17,18 @@
>
<nav class="nav-links">
<a href="profile-content.html">内容发布</a
><a class="active" href="profile-gift.html">贺礼邀请</a>
><a class="active" href="profile-ceremony.html">祭祀活动</a>
</nav>
<div class="nav-actions">
<a href="profile-gift.html">返回贺礼</a
><button class="btn primary magnetic" type="submit" form="ceremony-edit-form">确定创建</button>
<a href="profile-ceremony.html">返回活动列表</a
><button class="btn primary magnetic" type="submit" form="ceremony-edit-form" data-ceremony-editor-action hidden>确定创建</button>
</div>
</div>
</header>
<main>
<section class="module-hero">
<div class="container">
<div class="module-kicker">Gift Editor</div>
<div class="module-kicker">Ceremony Editor</div>
<div class="module-title-row">
<div>
<h1 data-ceremony-editor-title>创建祭祀活动</h1>
@@ -42,17 +42,17 @@
<aside class="module-nav">
<a href="profile-content.html">内容发布</a
><a href="profile-merit.html">功德录</a
><a class="active" href="profile-gift.html">贺礼邀请</a
><a class="active" href="profile-ceremony.html">祭祀活动</a
><a href="profile-feed.html">发布动态</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>活动信息</h2>
<div class="api-empty" data-ceremony-editor-placeholder>正在加载活动编辑信息…</div>
<form id="ceremony-edit-form" class="editor-form layui-form" data-ceremony-form data-ceremony-editor hidden>
<div class="api-state api-state--loading" data-api-state="loading" data-ceremony-editor-placeholder role="status" aria-live="polite" aria-busy="true">正在加载活动编辑信息…</div>
<form id="ceremony-edit-form" class="editor-form layui-form" data-ceremony-form data-ceremony-editor hidden novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="giftType">活动类型</label
<label for="giftType">活动类型 <span class="field-required" aria-hidden="true">*</span></label
><input id="giftType" name="ceremonyType" type="text" placeholder="请输入后端约定的活动类型" required />
</div>
<div class="editor-field">
@@ -66,7 +66,7 @@
</div>
</div>
<div class="editor-field">
<label for="giftTitle">贺礼标题</label
<label for="giftTitle">活动标题 <span class="field-required" aria-hidden="true">*</span></label
><input id="giftTitle" name="ceremonyTitle" type="text" placeholder="请输入标题" required />
</div>
<div class="editor-field">
@@ -93,11 +93,11 @@
</div>
</div>
<div class="editor-field">
<label for="giftContent">贺礼内容</label
<label for="giftContent">活动说明</label
><textarea class="js-rich-editor"
id="giftContent"
name="ceremonyDesc"
placeholder="请输入贺礼内容"
placeholder="请输入活动说明"
></textarea>
</div>
<div class="editor-field">
@@ -105,17 +105,17 @@
<input id="giftCoverOssId" name="coverOssId" type="hidden" />
<label class="upload-control" for="giftCoverFile">选择封面图片</label>
<input id="giftCoverFile" class="upload-input" type="file" accept="image/*" data-upload-target="#giftCoverOssId" data-upload-status="#giftCoverStatus" />
<p id="giftCoverStatus" class="upload-status">未选择文件</p>
<p id="giftCoverStatus" class="upload-status" role="status" aria-live="polite">未选择文件</p>
</div>
<div class="editor-field">
<label for="giftSortOrder">排序值</label
><input id="giftSortOrder" name="sortOrder" type="number" step="1" placeholder="可选,数值越小越靠前" />
</div>
<input name="status" type="hidden" value="0" />
<p class="upload-status" data-ceremony-form-status aria-live="polite"></p>
<div class="upload-status" data-ceremony-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">确定创建</button
><a class="btn ghost magnetic" href="profile-gift.html"
><a class="btn ghost magnetic" href="profile-ceremony.html"
>取消</a
>
</div>
+7 -2
View File
@@ -49,8 +49,11 @@
<section class="module-panel">
<h2>我的邀请</h2>
<div class="module-list" data-my-invitation-list aria-live="polite">
<div class="api-empty">正在加载我的邀请…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载我的邀请…
</div>
</div>
<div class="form-status" data-my-invitation-status role="status" aria-live="polite"></div>
</section>
<section class="module-panel">
<div class="module-panel-head">
@@ -61,7 +64,9 @@
<section class="module-panel">
<h2>邀请详情</h2>
<div data-my-invitation-detail aria-live="polite">
<div class="api-empty">请选择一条邀请查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">
请选择一条邀请查看详情
</div>
</div>
</section>
</div>
+7 -5
View File
@@ -48,7 +48,7 @@
<div class="module-main">
<section class="module-panel">
<h2>记录内容</h2>
<form id="growth-edit-form" class="editor-form" data-growth-form data-growth-editor hidden>
<form id="growth-edit-form" class="editor-form" data-growth-form data-growth-editor hidden novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="growthPersonId">关联世系人物</label
@@ -72,7 +72,7 @@
</div>
<div class="editor-two">
<div class="editor-field">
<label for="growthTitle">标题</label>
<label for="growthTitle">标题<span class="field-required" aria-hidden="true"> *</span></label>
<input id="growthTitle" name="recordTitle" type="text" placeholder="请输入成长记录标题" required />
</div>
<div class="editor-field">
@@ -99,7 +99,7 @@
<input id="growthMediaOssIds" name="mediaOssIds" type="hidden" />
<label class="upload-control" for="growthMediaFiles">选择附件</label>
<input id="growthMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#growthMediaOssIds" data-upload-status="#growthMediaStatus" data-growth-media-input />
<p id="growthMediaStatus" class="upload-status" data-growth-media-status>未选择文件</p>
<p id="growthMediaStatus" class="upload-status" data-growth-media-status role="status" aria-live="polite">未选择文件</p>
<button class="pill" type="button" data-growth-clear-media>清除附件</button>
</div>
<div class="editor-field">
@@ -108,15 +108,17 @@
</div>
</div>
<input id="growthStatus" name="status" type="hidden" value="0" />
<div class="form-status" data-growth-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button
><a class="btn ghost magnetic" href="profile-growth.html" data-genealogy-context-link
>取消</a
>
<span class="form-status" data-growth-form-status></span>
</div>
</form>
<div class="api-empty" data-growth-editor-placeholder>正在加载成长记录编辑信息…</div>
<div data-growth-editor-placeholder>
<div class="api-state api-state--loading" role="status" aria-live="polite" aria-busy="true">正在加载成长记录编辑信息…</div>
</div>
</section>
</div>
</div>
+3 -3
View File
@@ -20,7 +20,7 @@
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a
><a class="btn primary magnetic" href="profile-growth-edit.html" data-genealogy-context-link
><a class="btn primary magnetic" href="profile-growth-edit.html" data-genealogy-context-link data-growth-create-link hidden
>新建记录</a
>
</div>
@@ -50,13 +50,13 @@
<section class="module-panel">
<h2>记录列表</h2>
<div class="module-list" data-growth-list aria-live="polite">
<div class="api-empty">正在加载成长记录…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载成长记录…</div>
</div>
</section>
<section class="module-panel">
<h2>记录详情</h2>
<div data-growth-detail aria-live="polite">
<div class="api-empty">请选择一条成长记录查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一条成长记录查看详情</div>
</div>
</section>
</div>
+3 -3
View File
@@ -32,7 +32,7 @@
<h1>邀请家人</h1>
<p>生成家谱邀请链接或邀请码,引导亲人加入家谱并激活账号。</p>
</div>
<a class="btn primary magnetic" href="join-genealogy.html">打开加入页</a>
<a class="btn ghost magnetic" href="profile-family-admin.html" data-feature-link="available" data-genealogy-context-link>返回家族管理</a>
</div>
</div>
</section>
@@ -50,12 +50,12 @@
<div class="module-card">
<span class="icon"></span>
<h3>邀请码</h3>
<p>当前邀请码:<b data-invite-code>加载中...</b></p>
<p>当前 PC 接口未提供邀请码,暂不能生成或展示。</p>
</div>
<div class="module-card">
<span class="icon"></span>
<h3>邀请链接</h3>
<p data-invite-link>邀请链接加载中...</p>
<p>当前 PC 接口未提供邀请链接,暂不能生成或复制。</p>
</div>
</div>
</section>
+1 -1
View File
@@ -53,7 +53,7 @@
<h2>我的加入申请</h2>
<button class="btn ghost" type="button" data-join-apply-refresh>刷新</button>
</div>
<p class="form-note" aria-live="polite" data-my-join-applies-status></p>
<div class="form-note" role="status" aria-live="polite" data-my-join-applies-status></div>
<div class="module-list" data-join-apply-list="mine"></div>
</section>
</div>
+23 -3
View File
@@ -42,7 +42,7 @@
<a href="profile-messages.html">消息中心</a
><a class="active" href="profile-join-review.html" data-genealogy-context-link>入谱审核</a
><span class="module-nav-unavailable">邀请家人<small>即将开放</small></span
><span class="module-nav-unavailable">管理员权限<small>即将开放</small></span>
><a href="profile-admin-permissions.html" data-genealogy-context-link>管理员权限</a>
</aside>
<div class="module-main">
<section class="module-panel">
@@ -50,8 +50,28 @@
<h2>待审核申请</h2>
<button class="btn ghost" type="button" data-join-review-refresh>刷新</button>
</div>
<p class="form-note" aria-live="polite" data-join-review-status></p>
<div class="module-list" data-join-apply-list="pending"></div>
<div class="form-note" role="status" aria-live="polite" data-join-review-status></div>
<div class="module-list" data-join-apply-list="pending">
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在读取待审核申请…
</div>
</div>
</section>
<section id="join-review-form" class="module-panel" data-join-review-editor hidden>
<h2 data-join-review-form-title>审核加入申请</h2>
<form class="editor-form" data-join-review-form novalidate>
<input name="applyId" type="hidden" />
<input name="status" type="hidden" />
<div class="editor-field">
<label for="joinAuditRemark">审核说明</label>
<textarea id="joinAuditRemark" name="auditRemark" rows="4" maxlength="500" disabled placeholder="可选;拒绝时可说明原因,最多 500 个字符"></textarea>
</div>
<div class="form-status" role="status" aria-live="polite" data-join-review-form-status></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit" data-join-review-submit disabled>提交审核</button>
<button class="btn ghost" type="button" data-join-review-cancel disabled>取消</button>
</div>
</form>
</section>
</div>
</div>
+6 -5
View File
@@ -26,21 +26,22 @@
<div class="module-main">
<section class="module-panel">
<h2>备忘内容</h2>
<form id="memo-edit-form" class="editor-form" data-memo-form data-memo-editor hidden>
<div class="editor-field"><label for="memoTitle">备忘标题</label><input id="memoTitle" name="memoTitle" type="text" required /></div>
<form id="memo-edit-form" class="editor-form" data-memo-form data-memo-editor hidden novalidate>
<div class="editor-field"><label for="memoTitle">备忘标题<span class="field-required" aria-hidden="true"> *</span></label><input id="memoTitle" name="memoTitle" type="text" required /></div>
<div class="editor-field"><label for="memoContent">备忘内容</label><textarea id="memoContent" name="memoContent" placeholder="可选"></textarea></div>
<div class="editor-two">
<div class="editor-field"><label for="memoRemindTime">提醒时间</label><input id="memoRemindTime" name="remindTime" type="datetime-local" /></div>
<div class="editor-field"><label for="memoCompleted">完成状态</label><select id="memoCompleted" name="completed"><option value="0">未完成</option><option value="1">已完成</option></select></div>
</div>
<div class="editor-two">
<div class="editor-field"><label for="memoMediaFiles">附件文件</label><input id="memoMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="memoMediaFiles">选择附件</label><input id="memoMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#memoMediaOssIds" data-upload-status="#memoMediaStatus" data-memo-media-input /><p id="memoMediaStatus" class="upload-status" data-memo-media-status>未选择文件</p><button class="pill" type="button" data-memo-clear-media>清除附件</button></div>
<div class="editor-field"><label for="memoMediaFiles">附件文件</label><input id="memoMediaOssIds" name="mediaOssIds" type="hidden" /><label class="upload-control" for="memoMediaFiles">选择附件</label><input id="memoMediaFiles" class="upload-input" type="file" multiple data-upload-multiple="true" data-upload-target="#memoMediaOssIds" data-upload-status="#memoMediaStatus" data-memo-media-input /><p id="memoMediaStatus" class="upload-status" data-memo-media-status role="status" aria-live="polite">未选择文件</p><button class="pill" type="button" data-memo-clear-media>清除附件</button></div>
<div class="editor-field"><label for="memoSortOrder">排序值</label><input id="memoSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" /></div>
</div>
<input name="status" type="hidden" value="0" />
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存</button><a class="btn ghost magnetic" href="profile-memo.html" data-genealogy-context-link>取消</a><span class="form-status" data-memo-form-status></span></div>
<div class="form-status" data-memo-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions"><button class="btn primary magnetic" type="submit">保存</button><a class="btn ghost magnetic" href="profile-memo.html" data-genealogy-context-link>取消</a></div>
</form>
<div class="api-empty" data-memo-editor-placeholder>正在加载备忘录编辑信息…</div>
<div data-memo-editor-placeholder><div class="api-state api-state--loading" role="status" aria-live="polite" aria-busy="true">正在加载备忘录编辑信息…</div></div>
</section>
</div>
</div>
+3 -3
View File
@@ -13,7 +13,7 @@
<div class="container nav">
<a class="brand magnetic" href="profile.html"><img class="brand-logo brand-logo-mark" src="public/images/logo-mark.png" alt="" /><span class="brand-text">个人中心</span></a>
<nav class="nav-links"><a href="profile-content.html">内容发布</a><a class="active" href="profile-memo.html" data-genealogy-context-link>备忘录</a></nav>
<div class="nav-actions"><a href="profile-content.html">返回发布中心</a><a class="btn primary magnetic" href="profile-memo-edit.html" data-genealogy-context-link>新建备忘</a></div>
<div class="nav-actions"><a href="profile-content.html">返回发布中心</a><a class="btn primary magnetic" href="profile-memo-edit.html" data-genealogy-context-link data-memo-create-link hidden>新建备忘</a></div>
</div>
</header>
<main>
@@ -30,13 +30,13 @@
<section class="module-panel">
<h2>备忘列表</h2>
<div class="module-list" data-memo-list aria-live="polite">
<div class="api-empty">正在加载备忘录…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载备忘录…</div>
</div>
</section>
<section class="module-panel">
<h2>备忘详情</h2>
<div data-memo-detail aria-live="polite">
<div class="api-empty">请选择一条备忘录查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一条备忘录查看详情</div>
</div>
</section>
</div>
+7 -5
View File
@@ -49,10 +49,10 @@
<div class="module-main">
<section class="module-panel">
<h2>功德资料</h2>
<form id="merit-edit-form" class="editor-form" data-merit-form data-merit-editor hidden>
<form id="merit-edit-form" class="editor-form" data-merit-form data-merit-editor hidden novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="meritName">功德人姓名</label>
<label for="meritName">功德人姓名<span class="field-required" aria-hidden="true"> *</span></label>
<input id="meritName" name="donorName" type="text" placeholder="请输入功德人姓名" required />
</div>
<div class="editor-field">
@@ -61,7 +61,7 @@
</div>
</div>
<div class="editor-field">
<label for="meritTitle">功德标题</label>
<label for="meritTitle">功德标题<span class="field-required" aria-hidden="true"> *</span></label>
<input id="meritTitle" name="meritTitle" type="text" placeholder="请输入功德标题" required />
</div>
<div class="editor-field">
@@ -88,13 +88,15 @@
<input id="meritSortOrder" name="sortOrder" type="number" step="1" placeholder="可选;安全整数" />
</div>
<input name="status" type="hidden" value="0" />
<div class="form-status" data-merit-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button>
<a class="btn ghost magnetic" href="profile-merit.html" data-genealogy-context-link>取消</a>
<span class="form-status" data-merit-form-status></span>
</div>
</form>
<div class="api-empty" data-merit-editor-placeholder>正在加载功德记录编辑信息…</div>
<div data-merit-editor-placeholder>
<div class="api-state api-state--loading" role="status" aria-live="polite" aria-busy="true">正在加载功德记录编辑信息…</div>
</div>
</section>
</div>
</div>
+3 -3
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a>
<a class="btn primary magnetic" href="profile-merit-edit.html" data-genealogy-context-link>新增功德记录</a>
<a class="btn primary magnetic" href="profile-merit-edit.html" data-genealogy-context-link data-merit-create-link hidden>新增功德记录</a>
</div>
</div>
</header>
@@ -50,13 +50,13 @@
<section class="module-panel">
<h2>功德记录</h2>
<div class="module-list" data-merit-list aria-live="polite">
<div class="api-empty">正在加载功德记录…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载功德记录…</div>
</div>
</section>
<section class="module-panel">
<h2>记录详情</h2>
<div data-merit-detail aria-live="polite">
<div class="api-empty">请选择一条功德记录查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一条功德记录查看详情</div>
</div>
</section>
</div>
+3 -3
View File
@@ -69,15 +69,15 @@
</select>
</label>
</div>
<p class="api-empty" data-notification-status>正在加载消息…</p>
<div class="form-status" data-notification-status role="status" aria-live="polite"></div>
<div class="module-list" data-notification-list aria-live="polite">
<div class="api-empty">正在加载消息…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载消息…</div>
</div>
</section>
<section class="module-panel">
<h2>通知详情</h2>
<div data-notification-detail aria-live="polite">
<div class="api-empty">请选择一条通知查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一条通知查看详情</div>
</div>
</section>
<section class="module-panel">
+7 -5
View File
@@ -48,10 +48,10 @@
<div class="module-main">
<section class="module-panel">
<h2>记录内容</h2>
<form id="relative-edit-form" class="editor-form" data-relative-form data-relative-editor hidden>
<form id="relative-edit-form" class="editor-form" data-relative-form data-relative-editor hidden novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="relativeName">亲友姓名</label>
<label for="relativeName">亲友姓名<span class="field-required" aria-hidden="true"> *</span></label>
<input id="relativeName" name="relativeName" type="text" placeholder="请输入亲友姓名" required />
</div>
<div class="editor-field">
@@ -97,17 +97,19 @@
data-upload-status="#relativeMediaStatus"
data-relative-media-input
/>
<p id="relativeMediaStatus" class="upload-status" data-relative-media-status>未选择文件</p>
<p id="relativeMediaStatus" class="upload-status" data-relative-media-status role="status" aria-live="polite">未选择文件</p>
<button class="pill" type="button" data-relative-clear-media>清除附件</button>
</div>
<input name="status" type="hidden" value="0" />
<div class="form-status" data-relative-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存</button>
<a class="btn ghost magnetic" href="profile-relative.html" data-genealogy-context-link>取消</a>
<span class="form-status" data-relative-form-status></span>
</div>
</form>
<div class="api-empty" data-relative-editor-placeholder>正在加载亲友记录编辑信息…</div>
<div data-relative-editor-placeholder>
<div class="api-state api-state--loading" role="status" aria-live="polite" aria-busy="true">正在加载亲友记录编辑信息…</div>
</div>
</section>
</div>
</div>
+3 -3
View File
@@ -21,7 +21,7 @@
</nav>
<div class="nav-actions">
<a href="profile-content.html">返回发布中心</a>
<a class="btn primary magnetic" href="profile-relative-edit.html" data-genealogy-context-link>新建记录</a>
<a class="btn primary magnetic" href="profile-relative-edit.html" data-genealogy-context-link data-relative-create-link hidden>新建记录</a>
</div>
</div>
</header>
@@ -49,13 +49,13 @@
<section class="module-panel">
<h2>记录列表</h2>
<div class="module-list" data-relative-list aria-live="polite">
<div class="api-empty">正在加载亲友记录…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在加载亲友记录…</div>
</div>
</section>
<section class="module-panel">
<h2>记录详情</h2>
<div data-relative-detail aria-live="polite">
<div class="api-empty">请选择一条亲友记录查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一条亲友记录查看详情</div>
</div>
</section>
</div>
+19 -19
View File
@@ -31,7 +31,7 @@
<div class="module-title-row">
<div>
<h1>安全设置</h1>
<p>维护登录密码、手机号、微信快捷登录和账号安全提醒</p>
<p>维护登录密码、手机号与账号注销安全操作</p>
</div>
</div>
</div>
@@ -48,34 +48,34 @@
<section class="module-panel">
<h2>账号安全</h2>
<!-- 修改密码表单:字段对应 PasswordChangeBody。 -->
<form class="editor-form security-form" data-security-form="password">
<form class="editor-form security-form" data-security-form="password" novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="oldPassword">当前密码</label>
<label for="oldPassword">当前密码 <span class="field-required" aria-hidden="true">*</span></label>
<input id="oldPassword" name="oldPassword" type="password" autocomplete="current-password" required />
</div>
<div class="editor-field">
<label for="newPassword">新密码</label>
<label for="newPassword">新密码 <span class="field-required" aria-hidden="true">*</span></label>
<input id="newPassword" name="newPassword" type="password" autocomplete="new-password" required />
</div>
</div>
<div class="editor-field">
<label for="confirmPassword">确认新密码</label>
<label for="confirmPassword">确认新密码 <span class="field-required" aria-hidden="true">*</span></label>
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" required />
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">修改密码</button>
<span class="api-status" data-security-status>等待提交</span>
<button class="btn primary magnetic" type="submit" data-security-submit>修改密码</button>
<div class="form-status" data-security-status role="status" aria-live="polite"></div>
</div>
</form>
</section>
<section class="module-panel">
<h2>换绑手机号</h2>
<!-- 换绑手机号表单:字段对应 PhoneChangeBody。 -->
<form class="editor-form security-form" data-security-form="phone">
<form class="editor-form security-form" data-security-form="phone" novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="newPhone">新手机号</label>
<label for="newPhone">新手机号 <span class="field-required" aria-hidden="true">*</span></label>
<input
id="newPhone"
name="phone"
@@ -87,7 +87,7 @@
/>
</div>
<div class="editor-field">
<label for="phoneSmsCode">短信验证码</label>
<label for="phoneSmsCode">短信验证码 <span class="field-required" aria-hidden="true">*</span></label>
<input
id="phoneSmsCode"
name="smsCode"
@@ -103,17 +103,17 @@
</div>
<input id="phoneValidToken" name="validToken" type="hidden" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">确认换绑</button>
<span class="api-status" data-security-status>等待验证码</span>
<button class="btn primary magnetic" type="submit" data-security-submit>确认换绑</button>
<div class="form-status" data-security-status role="status" aria-live="polite"></div>
</div>
</form>
</section>
<section class="module-panel security-danger">
<h2>注销账号</h2>
<p>验证已绑定手机号的短信验证码后提交注销申请。</p>
<form class="editor-form security-form" data-security-form="deactivate">
<form class="editor-form security-form" data-security-form="deactivate" novalidate>
<div class="editor-field">
<label for="deactivateSmsCode">短信验证码</label>
<label for="deactivateSmsCode">短信验证码 <span class="field-required" aria-hidden="true">*</span></label>
<input
id="deactivateSmsCode"
name="smsCode"
@@ -127,17 +127,17 @@
<button class="btn ghost magnetic" type="button" data-security-send-deactivate-code>
获取验证码
</button>
<p class="api-status" data-security-bound-phone>正在读取已绑定手机号</p>
<div class="form-status" data-security-bound-phone role="status" aria-live="polite"><div class="api-state api-state--loading" data-api-state="loading" aria-busy="true">正在读取已绑定手机号</div></div>
</div>
<div class="editor-field">
<label for="confirmText">确认文本</label>
<label for="confirmText">确认文本 <span class="field-required" aria-hidden="true">*</span></label>
<input id="confirmText" name="confirmText" type="text" placeholder="请输入:确认注销" required />
</div>
<input name="validToken" type="hidden" />
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">提交注销</button>
<button class="btn primary magnetic" type="submit" data-security-submit>提交注销</button>
<button class="btn ghost magnetic" type="button" data-security-logout>退出登录</button>
<span class="api-status" data-security-status>等待验证</span>
<div class="form-status" data-security-status role="status" aria-live="polite"></div>
</div>
</form>
</section>
@@ -155,10 +155,10 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/tac/js/tac.min.js"></script>
<script src="public/js/captcha-pages.js"></script>
<script src="public/js/security-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+40 -8
View File
@@ -65,10 +65,16 @@
<div class="module-grid">
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>应用分享</h3>
<p>邀请奖励服务正在开发中,当前不提供不可用的跳转入口。</p>
<h3>应用分享与收益</h3>
<p>分享奖励、推广收益与提现服务正在开发中,当前不提供不可用的跳转入口。</p>
<span class="pill">即将开放</span>
</div>
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>个性化推荐</h3>
<p>当前 PC 接口未提供推荐偏好的读取或保存,暂不能设置推荐开关。</p>
<span class="pill">暂不可设置</span>
</div>
<a class="module-card tilt-card" href="profile-feedback.html" data-feature-link="available"
><span class="icon"></span>
<h3>意见反馈</h3>
@@ -96,14 +102,40 @@
>
</div>
</section>
<section class="module-panel">
<h2>平台设置与协议</h2>
<p>集中查看 APP“设置”中的平台说明、联系方式及协议入口;正式生效内容以平台和法务发布版本为准。</p>
<div class="module-grid">
<a class="module-card tilt-card" href="about.html"
><span class="icon"></span>
<h3>关于我们与联系我们</h3>
<p>查看平台介绍、客服信息和服务说明。</p></a
>
<a class="module-card tilt-card" href="privacy.html"
><span class="icon"></span>
<h3>隐私政策</h3>
<p>查看个人信息处理说明。</p></a
>
<a class="module-card tilt-card" href="children-privacy.html"
><span class="icon"></span>
<h3>儿童信息保护</h3>
<p>查看儿童个人信息处理规则。</p></a
>
<a class="module-card tilt-card" href="terms.html"
><span class="icon"></span>
<h3>服务协议</h3>
<p>查看平台服务协议入口。</p></a
>
</div>
</section>
<section class="module-panel">
<h2>会员服务</h2>
<p>选择适合的会员套餐并创建订单。当前 PC 暂未开放在线支付。</p>
<div class="vip-package-list" data-vip-package-list>
<div class="api-empty">会员套餐加载中</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取会员套餐</div>
</div>
<form class="editor-form vip-order-form" data-vip-order-form>
<p class="api-status" data-vip-selected-package>尚未选择套餐</p>
<form class="editor-form vip-order-form" data-vip-order-form novalidate>
<p class="form-status" data-vip-selected-package role="status" aria-live="polite">尚未选择套餐</p>
<div class="editor-field">
<label for="vipGenealogy">关联家谱(可选)</label>
<select id="vipGenealogy" data-vip-genealogy-options>
@@ -112,7 +144,7 @@
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit" data-vip-order-submit>创建会员订单</button>
<span class="api-status" data-vip-order-status>等待选择套餐</span>
<div class="form-status" data-vip-order-status role="status" aria-live="polite"></div>
</div>
</form>
</section>
@@ -122,7 +154,7 @@
<button class="btn outline" type="button" data-vip-order-refresh>刷新订单</button>
</div>
<div class="module-list" data-vip-order-list>
<div class="api-empty">会员订单加载中</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取会员订单</div>
</div>
</section>
<section class="module-panel">
@@ -155,8 +187,8 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/vip-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/vip-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+29 -20
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>应用分享 - 个人中心 - 代代相传</title>
<title>应用分享与收益 - 个人中心 - 代代相传</title>
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/layui/css/layui.css" />
<link rel="stylesheet" href="public/css/profile-module.css" />
</head>
<body class="page-profile-module" data-family-share-page data-feature-status="pending" data-feature-message="应用分享与邀请奖励服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-family-share-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -16,12 +16,11 @@
>
<nav class="nav-links">
<a href="profile-services.html">帮助与服务</a
><a class="active" href="profile-share.html">应用分享</a>
><a class="active" href="profile-share.html">应用分享与收益</a>
</nav>
<div class="nav-actions">
<a href="profile-services.html">返回服务中心</a
><a class="btn primary magnetic" href="profile-share.html"
>立即分享</a
<a class="btn ghost magnetic" href="profile-services.html"
>返回服务中心</a
>
</div>
</div>
@@ -32,8 +31,8 @@
<div class="module-kicker">Share</div>
<div class="module-title-row">
<div>
<h1>应用分享</h1>
<p>邀请好友创建和维护家谱,查看应用下载、分享奖励和推广入口</p>
<h1>应用分享与收益</h1>
<p>当前 PC 未声明应用分享奖励、收益或提现的正式接口;本页仅说明不可操作边界</p>
</div>
</div>
</div>
@@ -42,7 +41,7 @@
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-services.html">帮助与服务</a
><a class="active" href="profile-share.html">应用分享</a
><a class="active" href="profile-share.html">应用分享与收益</a
><a href="profile-feedback.html">意见反馈</a
><a href="help.html">帮助中心</a>
</aside>
@@ -50,27 +49,38 @@
<section class="module-panel">
<h2>邀请好友奖励</h2>
<div class="module-grid">
<div class="module-card">
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>每邀请一位好友</h3>
<p data-member-overview>分享概览加载中...</p>
<p>当前 PC 接口未提供邀请奖励规则,暂不能展示奖励。</p>
</div>
<div class="module-card">
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>分享二维码</h3>
<p data-invite-link>分享链接加载中...</p>
<p>当前 PC 接口未提供分享链接或二维码,暂不能生成。</p>
</div>
</div>
</section>
<section class="module-panel">
<h2>分享记录</h2>
<div class="module-list">
<div class="module-row">
<div>
<h3>本月邀请</h3>
<p>3 人访问 · 1 人完成注册</p>
</div>
<span class="pill">查看</span>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">
当前 PC 接口未提供分享记录,暂不能展示邀请访问或注册结果。
</div>
</div>
</section>
<section class="module-panel">
<h2>收益与提现</h2>
<div class="module-grid two">
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>推广收益</h3>
<p>当前 PC 接口未提供收益账户或余额,暂不能展示收益金额。</p>
</div>
<div class="module-card is-unavailable" aria-disabled="true">
<span class="icon"></span>
<h3>提现记录</h3>
<p>当前 PC 接口未提供提现方式、申请或记录,暂不能提交提现。</p>
</div>
</div>
</section>
@@ -87,7 +97,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/pending-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+20 -16
View File
@@ -86,13 +86,13 @@
<h2>世系树</h2>
<!-- 世系树由 public/js/lineage-pages.js 按接口数据渲染 -->
<div class="lineage-tree-wrap" data-lineage-tree>
<div class="api-empty">世系树加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">世系树加载中</div>
</div>
</section>
<section class="module-panel">
<h2>成员搜索</h2>
<div class="lineage-toolbar lineage-filter-toolbar">
<input type="search" data-lineage-keyword placeholder="输入姓名、别名或人物编号搜索" />
<input type="search" data-lineage-keyword aria-label="按姓名、别名或人物编号搜索成员" placeholder="输入姓名、别名或人物编号搜索" />
<select data-lineage-generation-filter aria-label="按世代筛选">
<option value="">全部世代</option>
</select>
@@ -104,14 +104,15 @@
</select>
<button class="btn primary" type="button" data-lineage-search>搜索</button>
</div>
<p class="form-status" data-lineage-list-summary></p>
<p class="form-status" role="status" aria-live="polite" data-lineage-list-summary></p>
<div class="form-status" role="status" aria-live="polite" data-lineage-action-status></div>
<!-- 成员列表由世系人物分页接口渲染 -->
<div class="module-list" data-lineage-list>
<div class="api-empty">成员加载中...</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">世系人物加载中…</div>
</div>
<div class="bottom-actions" data-lineage-pagination hidden>
<button class="btn ghost" type="button" data-lineage-page-action="previous">上一页</button>
<span class="form-status" data-lineage-page-status></span>
<span class="form-status" data-lineage-page-status role="status" aria-live="polite"></span>
<button class="btn ghost" type="button" data-lineage-page-action="next">下一页</button>
</div>
</section>
@@ -119,24 +120,27 @@
<h2>成员详情</h2>
<!-- 点击成员列表或世系树节点后渲染详情 -->
<div data-lineage-detail>
<div class="api-empty">请选择一个成员查看资料</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">请选择一个成员查看资料</div>
</div>
</section>
<section class="module-panel" data-lineage-management hidden>
<h2>新增 / 编辑成员</h2>
<form id="lineage-member-form" class="editor-form" data-lineage-form>
<form id="lineage-member-form" class="editor-form" data-lineage-form novalidate>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-binding-mode">账号绑定</label>
<label for="lineage-binding-mode">账号绑定 <span class="field-required" aria-hidden="true">*</span></label>
<select id="lineage-binding-mode" name="bindingMode" required>
<option value="NONE" selected>不绑定账号</option>
<option value="SELF">绑定当前登录账号</option>
<option value="SPECIFIED">指定其他账号</option>
</select>
<select name="appUserId" data-lineage-member-options hidden>
<option value="">请选择已绑定账号的家谱成员</option>
</select>
<p class="upload-status" data-lineage-binding-status>指定账号仅可从当前家谱正常成员中选择。</p>
<div class="editor-field" data-lineage-specified-member hidden>
<label for="lineage-specified-member">指定账号</label>
<select id="lineage-specified-member" name="appUserId" data-lineage-member-options hidden>
<option value="">请选择已绑定账号的家谱成员</option>
</select>
</div>
<p class="upload-status" data-lineage-binding-status role="status" aria-live="polite">指定账号仅可从当前家谱正常成员中选择。</p>
</div>
<div class="editor-field">
<label for="lineage-avatar-file">头像</label>
@@ -153,12 +157,12 @@
data-upload-biz-type="avatar"
data-upload-usage-scene="lineage_avatar"
/>
<p id="lineage-avatar-status" class="upload-status">请选择头像文件</p>
<p id="lineage-avatar-status" class="upload-status" role="status" aria-live="polite">请选择头像文件</p>
</div>
</div>
<div class="editor-two">
<div class="editor-field">
<label for="lineage-person-name">姓名</label>
<label for="lineage-person-name">姓名 <span class="field-required" aria-hidden="true">*</span></label>
<input id="lineage-person-name" name="name" type="text" placeholder="请输入成员姓名" required />
</div>
<div class="editor-field">
@@ -273,9 +277,9 @@
<textarea id="lineage-remark" name="remark" placeholder="可选"></textarea>
</div>
<div class="bottom-actions">
<button class="btn primary" type="submit">保存成员</button>
<button class="btn primary" type="submit" data-lineage-submit>保存成员</button>
<button class="btn ghost" type="reset">清空</button>
<span class="form-status" data-lineage-form-status></span>
<span class="form-status" role="status" aria-live="polite" data-lineage-form-status></span>
</div>
</form>
</section>
+7 -7
View File
@@ -47,9 +47,9 @@
<div class="module-main">
<section class="module-panel">
<h2>视频内容</h2>
<form id="video-edit-form" class="editor-form" data-video-form data-video-management hidden>
<form id="video-edit-form" class="editor-form" data-video-form data-video-management hidden novalidate>
<div class="editor-field">
<label for="video-title">视频标题</label>
<label for="video-title">视频标题 <span class="field-required" aria-hidden="true">*</span></label>
<input id="video-title" name="videoTitle" type="text" placeholder="请输入视频标题" required />
</div>
<div class="editor-field">
@@ -58,7 +58,7 @@
</div>
<div class="editor-two">
<div class="editor-field">
<label for="video-file">视频文件</label>
<label for="video-file">视频文件 <span class="field-required" aria-hidden="true">*</span></label>
<input id="video-oss-id" name="videoOssId" type="hidden" />
<label class="upload-control" for="video-file">选择视频文件</label>
<input
@@ -70,7 +70,7 @@
data-upload-status="#video-file-status"
data-video-duration-target="#video-duration"
/>
<p id="video-file-status" class="upload-status" data-video-file-status>请选择视频文件</p>
<p id="video-file-status" class="upload-status" data-video-file-status role="status" aria-live="polite">请选择视频文件</p>
</div>
<div class="editor-field">
<label for="video-cover-file">封面图片</label>
@@ -84,7 +84,7 @@
data-upload-target="#video-cover-oss-id"
data-upload-status="#video-cover-status"
/>
<p id="video-cover-status" class="upload-status" data-video-cover-status>未选择封面</p>
<p id="video-cover-status" class="upload-status" data-video-cover-status role="status" aria-live="polite">未选择封面</p>
</div>
</div>
<div class="editor-two">
@@ -99,13 +99,13 @@
</div>
</div>
<input name="status" type="hidden" value="0" />
<div class="form-status" data-video-form-status role="status" aria-live="polite"></div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">保存视频</button>
<a class="btn ghost magnetic" href="profile-video.html" data-genealogy-context-link>取消</a>
<span class="form-status" data-video-form-status></span>
</div>
</form>
<div class="api-empty" data-video-permission-placeholder>正在核对视频维护权限…</div>
<div class="api-state api-state--loading" data-api-state="loading" data-video-permission-placeholder role="status" aria-live="polite" aria-busy="true">正在核对视频维护权限…</div>
</section>
</div>
</div>
+6 -2
View File
@@ -50,13 +50,17 @@
<section class="module-panel">
<h2>视频列表</h2>
<div class="module-list" data-video-list aria-live="polite">
<div class="api-empty">正在加载视频…</div>
<div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">
正在加载视频…
</div>
</div>
</section>
<section class="module-panel">
<h2>视频详情</h2>
<div data-video-detail aria-live="polite">
<div class="api-empty">请选择一个视频查看详情</div>
<div class="api-state api-state--empty" data-api-state="empty" role="status" aria-live="polite">
请选择一个视频查看详情
</div>
</div>
</section>
</div>
+22 -3
View File
@@ -46,7 +46,7 @@
<div class="profile-tags">
<span>已登录账户</span><span>资料以实际接口为准</span>
</div>
<p class="profile-load-status" data-profile-load-status role="status" aria-live="polite">正在读取账户资料…</p>
<div class="profile-load-status" data-profile-load-status role="status" aria-live="polite">正在读取账户资料…</div>
<div class="toolbar profile-hero-actions">
<a class="btn primary magnetic" href="profile-families.html">进入我的家谱</a>
<a class="btn ghost magnetic" href="profile-data.html">完善个人资料</a>
@@ -111,8 +111,27 @@
<div class="data-list">
<p><span>绑定账号</span><b data-profile-text="phone">加载中</b></p>
<p><span>昵称</span><b data-profile-text="displayName">加载中</b></p>
<p><span>真实姓名</span><b data-profile-text="realName">加载中</b></p>
<p><span>性别</span><b data-profile-text="sex">加载中</b></p>
<p><span>出生日期</span><b data-profile-text="birthday">加载中</b></p>
<p><span>现居地区</span><b data-profile-text="regionText">加载中</b></p>
<p><span>邮箱</span><b data-profile-text="email">加载中</b></p>
</div>
</section>
<section class="panel">
<div class="panel-head compact">
<div>
<h2>账户与服务</h2>
<p>将 APP 的“我的”功能整理为桌面服务入口;家谱内容仍需从具体家谱进入。</p>
</div>
<a class="link-btn" href="profile-services.html">查看服务中心</a>
</div>
<div class="service-grid" aria-label="账户与服务入口">
<a href="profile-messages.html"><strong>消息中心</strong><span>查看真实通知与业务提醒</span></a>
<a href="profile-data.html"><strong>个人资料</strong><span>维护昵称、实名和联系方式</span></a>
<a href="profile-security.html"><strong>账户安全</strong><span>修改密码、换绑手机或注销账号</span></a>
<a href="profile-services.html"><strong>会员服务</strong><span>查看套餐、订单、帮助与服务说明</span></a>
<a href="profile-feedback.html"><strong>意见反馈</strong><span>提交产品问题和改进建议</span></a>
<a href="app.html"><strong>移动端</strong><span>下载 APP,处理移动端场景</span></a>
</div>
</section>
</div>
@@ -172,8 +191,8 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/profile-pages.js"></script>
<script src="public/js/profile-common.js?v=20260725-entry"></script>
<script src="public/js/profile-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+12
View File
@@ -169,6 +169,18 @@
transition: border-color .22s ease, box-shadow .22s ease, background .22s ease, transform .22s ease;
}
.create-form .field {
display: grid;
gap: 8px;
color: var(--ink);
font-weight: 800;
}
.create-region-picker label {
display: grid;
gap: 8px;
}
.create-region-picker {
/* 创建家谱页地区选择器占满表单宽度,编码通过隐藏字段提交 */
display: grid;
+46 -1
View File
@@ -1,6 +1,51 @@
/* 加入家谱页只保留单页差异,公共认证卡片样式在 public.css */
.page-join-genealogy .auth-card {
width: min(500px, 100%);
width: min(1080px, 100%);
}
.join-page-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(340px, .85fr);
gap: 28px;
align-items: start;
}
.join-search-panel,
.join-apply-form {
display: grid;
gap: 16px;
}
.join-search-panel {
padding-right: 28px;
border-right: 1px solid var(--line);
}
.join-page-grid h2 {
margin: 0;
font-size: 22px;
}
.join-page-grid .module-list {
max-height: 420px;
overflow: auto;
}
@media (max-width: 800px) {
.page-join-genealogy .auth-card {
width: min(500px, 100%);
}
.join-page-grid {
grid-template-columns: 1fr;
}
.join-search-panel {
padding-right: 0;
padding-bottom: 24px;
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
.invite-box {
+11 -1
View File
@@ -522,6 +522,11 @@
gap: 12px;
}
.profile-region-picker > label {
display: grid;
gap: 6px;
}
.profile-region-picker select {
min-height: 48px;
padding: 12px 14px;
@@ -745,11 +750,16 @@
gap: 10px;
}
.editor-field label {
.editor-field label,
.editor-label {
color: var(--ink);
font-weight: 900;
}
.field-required {
color: var(--red);
}
.editor-field input,
.editor-field select,
.editor-field textarea {
+20
View File
@@ -458,6 +458,26 @@
transform: translateY(-3px);
}
.service-grid strong,
.service-grid span {
display: block;
text-align: center;
}
.service-grid strong {
font-size: 17px;
}
.service-grid span {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.service-grid a:hover span {
color: rgba(255, 255, 255, .8);
}
.tilt-card {
transform: perspective(900px) rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg)) translateY(var(--lift, 0));
transition: transform .18s ease, box-shadow .24s ease, border-color .24s ease;
+29 -2
View File
@@ -551,6 +551,21 @@ textarea {
gap: 14px;
}
.auth-card .auth-field {
display: grid;
gap: 8px;
color: var(--ink);
font-weight: 800;
}
.auth-card .auth-field b {
color: var(--red);
}
.auth-card .auth-form-status:empty {
display: none;
}
.auth-card .input {
height: 50px;
background: #fffaf1;
@@ -637,8 +652,9 @@ textarea {
opacity: .72;
}
/* 接口列表为空时的统一占位样式 */
.api-empty {
/* 业务请求的加载、空数据、失败和无权限状态 */
.api-empty,
.api-state {
padding: 22px;
border: 1px dashed var(--line);
border-radius: 14px;
@@ -648,6 +664,17 @@ textarea {
font-weight: 800;
}
.api-state--loading {
color: var(--muted);
}
.api-state--error,
.api-state--forbidden {
border-color: rgba(160, 50, 45, .28);
color: var(--red);
background: rgba(160, 50, 45, .06);
}
.footer {
padding: 60px 0 36px;
color: rgba(255, 255, 255, .78);
+171
View File
@@ -0,0 +1,171 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.AdminPermissionPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.AdminPermissionPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var roleLabels = {
owner: '谱主',
admin: '管理员',
editor: '编辑',
member: '成员',
visitor: '访客'
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector) {
return documentRef ? documentRef.querySelector(selector) : null;
}
function normalizeId(value) {
var text;
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
if (value === undefined || value === null) return '';
text = String(value).trim();
return /^[1-9][0-9]*$/.test(text) ? text : '';
}
function getCurrentGenealogyId(search) {
var source = search === undefined && root.location ? root.location.search : search;
var params;
var contextId;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
contextId = normalizeId(root.ProfileUI.getGenealogyId());
if (contextId) return contextId;
}
params = new URLSearchParams(String(source || '').replace(/^\?/, ''));
return normalizeId(params.get('genealogyId'));
}
function normalizePermissionOverview(data, expectedGenealogyId) {
var source = data || {};
var genealogyId = normalizeId(source.genealogyId);
var roleType = String(source.roleType === undefined || source.roleType === null ? '' : source.roleType).trim();
var expectedId = normalizeId(expectedGenealogyId);
if (!genealogyId || !expectedId || genealogyId !== expectedId || !Object.prototype.hasOwnProperty.call(roleLabels, roleType)) {
return null;
}
return {
genealogyId: genealogyId,
roleType: roleType,
canManage: source.canManage === true,
canEditContent: source.canEditContent === true
};
}
function canMaintainContent(access) {
return Boolean(access && (access.canEditContent === true || access.canManage === true));
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderCurrentPermissions(access) {
if (!access) return '';
return '<div class="form-like admin-permission-current">' +
'<p><span>当前角色</span><b>' + escapeHtml(roleLabels[access.roleType]) + '</b></p>' +
'<p><span>成员角色维护</span><b>' + (access.canManage ? '可管理' : '不可管理') + '</b></p>' +
'<p><span>入谱审核</span><b>' + (access.canManage ? '可审核' : '不可审核') + '</b></p>' +
'<p><span>内容维护</span><b>' + (canMaintainContent(access) ? '可维护' : '不可维护') + '</b></p>' +
'</div><p class="module-meta">实际写入操作仍由服务端按当前角色与家谱权限校验。</p>';
}
function setState(type, message) {
var container = query('[data-admin-permission-current]');
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.textContent = message;
}
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');
else root.location.href = 'login.html';
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
async function loadPage() {
var api = getApi();
var genealogyId;
var overview;
var container;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
genealogyId = getCurrentGenealogyId();
if (!genealogyId) {
setState('empty', '请先选择家谱,再查看当前角色权限。');
return;
}
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
setState('loading', '正在核对当前家谱角色与权限…');
try {
overview = normalizePermissionOverview(await api.genealogyDetail(genealogyId), genealogyId);
if (!overview) throw new Error('家谱权限响应缺少稳定角色或能力字段');
container = query('[data-admin-permission-current]');
if (container) container.innerHTML = renderCurrentPermissions(overview);
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setState(
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看该家谱权限。' : '当前角色权限读取失败,请稍后重试。'
);
}
}
function init() {
if (!documentRef || !query('[data-admin-permission-page]')) return;
loadPage();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
normalizePermissionOverview: normalizePermissionOverview,
canMaintainContent: canMaintainContent,
renderCurrentPermissions: renderCurrentPermissions,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+104 -39
View File
@@ -295,7 +295,7 @@
function renderAlbumDetail(album) {
var actions = '';
if (!album) return '<div class="api-empty">未找到该相册</div>';
if (!album) return '';
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(album.genealogyId, album.albumId)) + '">编辑相册</a>' +
@@ -327,8 +327,8 @@
function renderPhotoList(data) {
var photos = Array.isArray(data) ? data.map(normalizeAlbumPhoto) : [];
if (photos.some(function (photo) { return !photo; })) return '<div class="api-empty">照片响应字段不完整</div>';
if (!photos.length) return '<div class="api-empty">暂无照片</div>';
if (photos.some(function (photo) { return !photo; })) return '';
if (!photos.length) return '';
return photos.map(function (photo) {
var action = canEditContent
? '<button class="pill is-danger" type="button" data-album-photo-delete-id="' +
@@ -369,10 +369,23 @@
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setStatus(selector, message) {
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function setStatus(selector, message, type) {
var target = query(selector);
if (target) target.textContent = message || '';
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setEditorEnabled(enabled, message) {
@@ -397,12 +410,12 @@
function renderNoContext() {
var message = '请先选择家谱,再查看或维护相册。';
if (query('[data-album-list]')) query('[data-album-list]').innerHTML = '<div class="api-empty">' + message + '</div>';
if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
if (query('[data-album-photo-list]')) query('[data-album-photo-list]').innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setStatus('[data-album-list]', message, 'empty');
setStatus('[data-album-detail]', '当前没有家谱上下文。', 'empty');
setStatus('[data-album-photo-list]', '当前没有家谱上下文。', 'empty');
setEditorEnabled(false, message);
setStatus('[data-album-form-status]', message);
setStatus('[data-album-photo-status]', message);
setStatus('[data-album-form-status]', message, 'forbidden');
setStatus('[data-album-photo-status]', message, 'forbidden');
}
function requireGenealogyId() {
@@ -412,10 +425,14 @@
return genealogyId;
}
function canEditAlbums(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
async function loadCapability(api, genealogyId) {
var detail = await api.genealogyDetail(genealogyId);
canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage));
canEditContent = canEditAlbums(detail);
queryAll('[data-album-create-link], [data-album-editor-action], [data-album-photo-editor]').forEach(function (element) {
element.hidden = !canEditContent;
});
@@ -430,11 +447,11 @@
albums.forEach(function (album) { albumsById[album.albumId] = album; });
if (!container) return albums;
if (!Array.isArray(data) || (data.length && !albums.length)) {
container.innerHTML = '<div class="api-empty">相册响应缺少稳定 AlbumVo 字段,请联系后端核对。</div>';
setStatus('[data-album-list]', '相册响应缺少稳定 AlbumVo 字段,请联系后端核对。', 'error');
return [];
}
if (!albums.length) {
container.innerHTML = '<div class="api-empty">暂无相册</div>';
setStatus('[data-album-list]', '暂无相册', 'empty');
return [];
}
container.innerHTML = albums.map(renderAlbumRow).join('');
@@ -449,16 +466,17 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
setStatus('[data-album-list]', '正在加载相册…', 'loading');
try {
await loadCapability(api, genealogyId);
renderAlbums(await api.albums(genealogyId));
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-album-list]')) {
query('[data-album-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的相册。' : '相册加载失败,请稍后重试。') +
'</div>';
}
setStatus(
'[data-album-list]',
isForbidden(error) ? '当前账号无权查看该家谱的相册。' : '相册加载失败,请稍后重试。',
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的相册。' : (error.message || '相册加载失败'));
}
}
@@ -498,6 +516,7 @@
albumId = getCurrentAlbumId();
syncLinks();
setEditorEnabled(false, '正在加载相册编辑信息…');
setStatus('[data-album-form-status]', '正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的相册。' };
@@ -509,25 +528,38 @@
if (query('[data-album-editor-title]')) query('[data-album-editor-title]').textContent = '编辑相册';
}
setEditorEnabled(true);
setStatus('[data-album-form-status]', '');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败'));
setStatus('[data-album-form-status]', isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败'));
setStatus(
'[data-album-form-status]',
isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败'),
getApiStateType(error)
);
}
}
async function loadPhotos(api, genealogyId, albumId) {
var data = await api.albumPhotos(genealogyId, albumId);
var photos = normalizeAlbumPhotos(data);
var data;
var photos;
var container = query('[data-album-photo-list]');
setStatus('[data-album-photo-list]', '正在加载照片…', 'loading');
data = await api.albumPhotos(genealogyId, albumId);
photos = normalizeAlbumPhotos(data);
photosById = Object.create(null);
photos.forEach(function (photo) { photosById[photo.photoId] = photo; });
if (container) {
container.innerHTML = !Array.isArray(data) || (data.length && !photos.length)
? '<div class="api-empty">照片响应缺少稳定 AlbumPhotoVo 字段,请联系后端核对。</div>'
: renderPhotoList(photos);
if (!container) return photos;
if (!Array.isArray(data) || (data.length && !photos.length)) {
setStatus('[data-album-photo-list]', '照片响应缺少稳定 AlbumPhotoVo 字段,请联系后端核对。', 'error');
return [];
}
if (!photos.length) {
setStatus('[data-album-photo-list]', '暂无照片', 'empty');
return [];
}
container.innerHTML = renderPhotoList(photos);
return photos;
}
@@ -541,10 +573,15 @@
genealogyId = requireGenealogyId();
albumId = getCurrentAlbumId();
if (!genealogyId || !albumId) {
if (genealogyId && query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '<div class="api-empty">缺少有效相册编号。</div>';
if (genealogyId) {
setStatus('[data-album-detail]', '缺少有效相册编号。', 'error');
setStatus('[data-album-photo-list]', '缺少有效相册编号。', 'empty');
}
return;
}
syncLinks();
setStatus('[data-album-detail]', '正在加载相册信息…', 'loading');
setStatus('[data-album-photo-list]', '正在加载照片…', 'loading');
try {
await loadCapability(api, genealogyId);
albums = await api.albums(genealogyId);
@@ -552,12 +589,31 @@
if (!currentAlbum) throw new Error('相册列表未返回当前相册');
if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = renderAlbumDetail(currentAlbum);
if (query('[data-album-detail-title]')) query('[data-album-detail-title]').textContent = currentAlbum.albumName;
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(
'[data-album-detail]',
isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败'),
getApiStateType(error)
);
setStatus(
'[data-album-photo-list]',
isForbidden(error) ? '当前账号无权查看该相册。' : '照片加载失败,请稍后重试。',
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败'));
return;
}
try {
await loadPhotos(api, genealogyId, albumId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该相册。' : escapeHtml(error.message || '相册详情加载失败')) + '</div>';
showMessage(isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败'));
setStatus(
'[data-album-photo-list]',
isForbidden(error) ? '当前账号无权查看该相册照片。' : (error.message || '照片加载失败'),
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看该相册照片。' : (error.message || '照片加载失败'));
}
}
@@ -572,17 +628,17 @@
var savedId;
var verified;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
body = buildAlbumBody(getFormValues(form));
validation = validateAlbumBody(body);
if (validation) {
setStatus('[data-album-form-status]', validation);
setStatus('[data-album-form-status]', validation, 'error');
return;
}
delete body.invalidCoverOssId;
delete body.invalidSortOrder;
setWritePending(true);
setStatus('[data-album-form-status]', '正在保存相册…');
setStatus('[data-album-form-status]', '正在保存相册…', 'loading');
try {
result = albumId
? await api.updateAlbum(genealogyId, albumId, body)
@@ -595,7 +651,11 @@
if (root.location) root.location.href = buildDetailUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-album-form-status]', isForbidden(error) ? '当前账号无权保存该相册。' : (error.message || '相册保存失败'));
setStatus(
'[data-album-form-status]',
isForbidden(error) ? '当前账号无权保存该相册。' : (error.message || '相册保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -609,17 +669,17 @@
var validation;
var saved;
if (writePending || !genealogyId || !albumId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !albumId || redirectUnauthorized(api)) return;
body = buildAlbumPhotoBody(getFormValues(form));
validation = validateAlbumPhotoBody(body);
if (validation) {
setStatus('[data-album-photo-status]', validation);
setStatus('[data-album-photo-status]', validation, 'error');
return;
}
delete body.invalidOssId;
delete body.invalidSortOrder;
setWritePending(true);
setStatus('[data-album-photo-status]', '正在添加照片…');
setStatus('[data-album-photo-status]', '正在添加照片…', 'loading');
try {
saved = normalizeAlbumPhoto(await api.createAlbumPhoto(genealogyId, albumId, body));
if (!saved || saved.albumId !== albumId) throw new Error('添加照片响应缺少稳定 photoId');
@@ -630,7 +690,11 @@
setStatus('[data-album-photo-status]', '照片已添加');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-album-photo-status]', isForbidden(error) ? '当前账号无权添加照片。' : (error.message || '照片添加失败'));
setStatus(
'[data-album-photo-status]',
isForbidden(error) ? '当前账号无权添加照片。' : (error.message || '照片添加失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -641,7 +705,7 @@
var genealogyId = requireGenealogyId();
var album = albumsById[albumId] || currentAlbum;
if (writePending || !genealogyId || !normalizeId(albumId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(albumId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (album ? album.albumName : '该相册') + '”及其中全部照片吗?删除后不可恢复。')) return;
setWritePending(true);
try {
@@ -662,7 +726,7 @@
var albumId = getCurrentAlbumId();
var photo = photosById[photoId];
if (writePending || !genealogyId || !albumId || !normalizeId(photoId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !albumId || !normalizeId(photoId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (photo && photo.photoTitle || '该照片') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
@@ -722,6 +786,7 @@
validateAlbumBody: validateAlbumBody,
buildAlbumPhotoBody: buildAlbumPhotoBody,
validateAlbumPhotoBody: validateAlbumPhotoBody,
canEditAlbums: canEditAlbums,
normalizeAlbum: normalizeAlbum,
normalizeAlbumPhoto: normalizeAlbumPhoto,
normalizeAlbums: normalizeAlbums,
+28 -11
View File
@@ -86,12 +86,16 @@
function renderPromotionList(data) {
var items = normalizePromotionList(data);
if (!items.length) return '<div class="api-empty">当前暂无应用推广</div>';
return items.map(renderPromotionCard).join('');
}
function renderPromotionLoginRequired() {
return '<div class="api-empty">登录后可查看应用推广,<a href="login.html">立即登录</a></div>';
function setPromotionState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.textContent = message;
}
async function loadPromotions(api) {
@@ -111,6 +115,10 @@
return Number(error && (error.status || error.code)) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
async function init() {
var page = root.document && root.document.querySelector('[data-promotion-page]');
var list = root.document && root.document.querySelector('[data-promotion-list]');
@@ -118,22 +126,30 @@
if (!page || !list) return;
if (!api || !api.getToken || !api.getToken()) {
list.innerHTML = renderPromotionLoginRequired();
setPromotionState(list, 'forbidden', '登录后可查看应用推广。');
return;
}
setPromotionState(list, 'loading', '正在读取应用推广…');
try {
list.classList.add('is-loading');
list.innerHTML = renderPromotionList(await loadPromotions(api));
var items = await loadPromotions(api);
if (!items.length) {
setPromotionState(list, 'empty', '当前暂无应用推广。');
return;
}
list.innerHTML = renderPromotionList(items);
} catch (error) {
if (isUnauthorized(error)) {
if (api.clearToken) api.clearToken();
list.innerHTML = renderPromotionLoginRequired();
setPromotionState(list, 'forbidden', '登录后可查看应用推广。');
return;
}
list.innerHTML = '<div class="api-empty">应用推广读取失败,请稍后重试</div>';
} finally {
list.classList.remove('is-loading');
setPromotionState(
list,
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看应用推广。' : '应用推广读取失败,请稍后重试。'
);
}
}
@@ -142,9 +158,10 @@
normalizePromotionList: normalizePromotionList,
normalizeTargetUrl: normalizeTargetUrl,
renderPromotionList: renderPromotionList,
renderPromotionLoginRequired: renderPromotionLoginRequired,
setPromotionState: setPromotionState,
loadPromotions: loadPromotions,
isUnauthorized: isUnauthorized,
isForbidden: isForbidden,
init: init
};
});
+80 -28
View File
@@ -192,6 +192,10 @@
return articleId ? url + '&articleId=' + encodeURIComponent(articleId) : url;
}
function canEditArticles(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
@@ -199,7 +203,7 @@
function renderArticleDetail(article) {
var actions = '';
if (!article) return '<div class="api-empty">请选择一篇谱文查看详情</div>';
if (!article) return stateHtml('empty', '请选择一篇谱文查看详情');
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(article.genealogyId, article.articleId)) + '">编辑谱文</a>' +
@@ -256,17 +260,49 @@
else if (root.alert) root.alert(message);
}
function stateHtml(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message);
return '<div class="api-state api-state--' + type + '" role="' +
(type === 'error' || type === 'forbidden' ? 'alert' : 'status') +
'" aria-live="polite">' + escapeHtml(message) + '</div>';
}
function setState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.innerHTML = stateHtml(type, message);
}
function getStateType(message) {
if (message.indexOf('无权') >= 0) return 'forbidden';
if (/失败|缺少/.test(message)) return 'error';
if (/正在|读取中|加载中/.test(message)) return 'loading';
return 'empty';
}
function syncLinks() {
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setFormStatus(message) {
function setFormStatus(message, type) {
var target = query('[data-article-form-status]');
if (target) target.textContent = message || '';
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setEditorEnabled(enabled, message) {
function setEditorEnabled(enabled, message, type) {
var placeholder = query('[data-article-editor-placeholder]');
queryAll('[data-article-editor], [data-article-editor-action]').forEach(function (element) {
@@ -274,15 +310,22 @@
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
if (message) setState(placeholder, type || getStateType(message), message);
}
}
function setWritePending(value) {
function setWritePending(value, label) {
var submit = query('[data-article-form] [type="submit"]');
writePending = Boolean(value);
queryAll('[data-article-form] button, [data-article-form] input, [data-article-form] textarea, [data-article-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
if (submit) {
if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent;
submit.textContent = writePending && label ? label : submit.dataset.defaultLabel;
submit.setAttribute('aria-busy', writePending ? 'true' : 'false');
}
}
function renderNoContext() {
@@ -290,10 +333,10 @@
var list = query('[data-article-list]');
var detail = query('[data-article-detail]');
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
setState(list, 'empty', message);
setState(detail, 'empty', '当前没有家谱上下文。');
setEditorEnabled(false, message, 'empty');
setFormStatus(message, 'empty');
}
function requireGenealogyId() {
@@ -306,7 +349,7 @@
async function loadCapability(api, genealogyId) {
var detail = await api.genealogyDetail(genealogyId);
canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage));
canEditContent = canEditArticles(detail);
queryAll('[data-article-create-link], [data-article-editor-action]').forEach(function (element) {
element.hidden = !canEditContent;
});
@@ -323,11 +366,11 @@
});
if (!container) return articles;
if (!Array.isArray(data) || (data.length && !articles.length)) {
container.innerHTML = '<div class="api-empty">谱文响应缺少稳定 ArticleVo 字段,请联系后端核对。</div>';
setState(container, 'error', '谱文响应缺少稳定 ArticleVo 字段,请联系后端核对。');
return [];
}
if (!articles.length) {
container.innerHTML = '<div class="api-empty">暂无谱文</div>';
setState(container, 'empty', '暂无谱文');
return [];
}
container.innerHTML = articles.map(renderArticleRow).join('');
@@ -346,6 +389,7 @@
var article;
if (!genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return null;
setState(query('[data-article-detail]'), 'loading', '正在加载谱文详情…');
try {
article = normalizeArticle(await api.articleDetail(genealogyId, articleId));
if (!article || article.articleId !== normalizeId(articleId)) {
@@ -356,7 +400,8 @@
return article;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
setState(query('[data-article-detail]'), isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看该谱文。' : '谱文详情加载失败,请稍后重试。');
showMessage(isForbidden(error) ? '当前账号无权查看该谱文。' : (error.message || '谱文详情加载失败'));
return null;
}
@@ -372,6 +417,7 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
setState(query('[data-article-list]'), 'loading', '正在加载谱文…');
try {
await loadCapability(api, genealogyId);
articles = renderArticles(await api.articles(genealogyId));
@@ -381,11 +427,8 @@
else renderDetail(null);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-article-list]')) {
query('[data-article-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : '谱文加载失败,请稍后重试。') +
'</div>';
}
setState(query('[data-article-list]'), isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : '谱文加载失败,请稍后重试。');
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : (error.message || '谱文加载失败'));
}
}
@@ -438,7 +481,8 @@
if (!genealogyId) return;
articleId = getCurrentArticleId();
syncLinks();
setEditorEnabled(false, '正在加载谱文编辑信息…');
setEditorEnabled(false, '正在加载谱文编辑信息…', 'loading');
setFormStatus('正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的谱文。' };
@@ -449,12 +493,16 @@
if (query('[data-article-editor-title]')) query('[data-article-editor-title]').textContent = '编辑谱文';
}
setEditorEnabled(true);
setFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的谱文。'
: (error.message || '谱文编辑信息加载失败'));
setFormStatus(isForbidden(error) ? '当前账号无权维护该家谱的谱文。' : (error.message || '谱文编辑信息加载失败'));
: (error.message || '谱文编辑信息加载失败'), isForbidden(error) ? 'forbidden' : 'error');
setFormStatus(
isForbidden(error) ? '当前账号无权维护该家谱的谱文。' : (error.message || '谱文编辑信息加载失败'),
isForbidden(error) ? 'forbidden' : 'error'
);
}
}
@@ -468,18 +516,18 @@
var saved;
var savedId;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll();
body = buildArticleBody(getFormValues(form));
validation = validateArticleBody(body);
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
delete body.invalidCoverOssId;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存谱文…');
setWritePending(true, '正在保存…');
setFormStatus('正在保存谱文…', 'loading');
try {
result = articleId
? await api.updateArticle(genealogyId, articleId, body)
@@ -493,7 +541,10 @@
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该谱文。' : (error.message || '谱文保存失败'));
setFormStatus(
isForbidden(error) ? '当前账号无权保存该谱文。' : (error.message || '谱文保存失败'),
isForbidden(error) ? 'forbidden' : 'error'
);
} finally {
setWritePending(false);
}
@@ -504,7 +555,7 @@
var genealogyId = requireGenealogyId();
var article = articlesById[articleId];
if (writePending || !genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (article ? article.articleTitle : '该谱文') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
@@ -557,6 +608,7 @@
buildArticleBody: buildArticleBody,
validateArticleBody: validateArticleBody,
normalizeArticle: normalizeArticle,
canEditArticles: canEditArticles,
normalizeArticles: normalizeArticles,
matchesSavedArticle: matchesSavedArticle,
renderArticleDetail: renderArticleDetail,
+69 -36
View File
@@ -229,6 +229,33 @@
button.setAttribute('aria-busy', busy ? 'true' : 'false');
}
function getApiStateType(error) {
return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error';
}
function setFormStatus(form, message, type) {
var status = query('[data-auth-form-status]', form);
if (!status) return;
if (!message) {
status.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(status, type || 'error', message);
return;
}
status.textContent = message;
}
function setSubmitPending(button, pending, pendingLabel) {
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = Boolean(pending);
button.textContent = pending ? pendingLabel : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
}
function showMessage(message) {
if (root.MessageUtil && root.MessageUtil.show) {
root.MessageUtil.show(message);
@@ -292,14 +319,6 @@
return '';
}
function validateBeforeSubmit(formType, values) {
var message = validateAuthValues(formType, values);
if (!message) return true;
showMessage(message);
return false;
}
function validatePhoneOnly(values) {
var data = values || {};
@@ -308,11 +327,13 @@
return '';
}
function validateBeforeSendCode(values) {
var message = validatePhoneOnly(values);
function validateForm(form, formType, values) {
var message = formType === 'send-code'
? validatePhoneOnly(values)
: validateAuthValues(formType, values);
if (!message) return true;
showMessage(message);
setFormStatus(form, message, 'error');
return false;
}
@@ -350,21 +371,25 @@
var values = readForm(form);
if (!api) {
showMessage('接口初始化失败,请刷新后重试');
setFormStatus(form, '接口初始化失败,请刷新后重试', 'error');
return;
}
if (!validateForm(form, 'login', values)) return;
if (!await ensureCaptcha(form, getFormCaptchaOperation(form, 'login'), values.phone)) {
setFormStatus(form, '请完成验证后再提交登录。', 'error');
return;
}
if (!validateBeforeSubmit('login', values)) return;
if (!await ensureCaptcha(form, getFormCaptchaOperation(form, 'login'), values.phone)) return;
values = readForm(form);
values.validToken = takeCaptchaToken(form);
setBusy(button, true);
setSubmitPending(button, true, '正在登录…');
setFormStatus(form, '正在验证账号…', 'loading');
try {
await api.login(buildLoginBody(values));
root.location.href = await resolvePostLoginUrl(api, 'login');
} catch (error) {
showMessage(error.message || '登录失败');
setFormStatus(form, error.message || '登录失败', getApiStateType(error));
} finally {
setBusy(button, false);
setSubmitPending(button, false, '正在登录…');
}
}
@@ -374,18 +399,19 @@
var values = readForm(form);
if (!api) {
showMessage('接口初始化失败,请刷新后重试');
setFormStatus(form, '接口初始化失败,请刷新后重试', 'error');
return;
}
if (!validateBeforeSubmit('sms-login', values)) return;
setBusy(button, true);
if (!validateForm(form, 'sms-login', values)) return;
setSubmitPending(button, true, '正在登录…');
setFormStatus(form, '正在验证短信验证码…', 'loading');
try {
await api.loginBySms(buildSmsLoginBody(values));
root.location.href = await resolvePostLoginUrl(api, 'sms-login');
} catch (error) {
showMessage(error.message || '短信登录失败');
setFormStatus(form, error.message || '短信登录失败', getApiStateType(error));
} finally {
setBusy(button, false);
setSubmitPending(button, false, '正在登录…');
}
}
@@ -395,18 +421,19 @@
var values = readForm(form);
if (!api) {
showMessage('接口初始化失败,请刷新后重试');
setFormStatus(form, '接口初始化失败,请刷新后重试', 'error');
return;
}
if (!validateBeforeSubmit('register', values)) return;
setBusy(button, true);
if (!validateForm(form, 'register', values)) return;
setSubmitPending(button, true, '正在注册…');
setFormStatus(form, '正在创建账号…', 'loading');
try {
await api.register(buildRegisterBody(values));
root.location.href = getSuccessUrl('register');
} catch (error) {
showMessage(error.message || '注册失败');
setFormStatus(form, error.message || '注册失败', getApiStateType(error));
} finally {
setBusy(button, false);
setSubmitPending(button, false, '正在注册…');
}
}
@@ -419,26 +446,31 @@
var operationCode = getFormCaptchaOperation(form, formType);
if (!api) {
showMessage('接口初始化失败,请刷新后重试');
setFormStatus(form, '接口初始化失败,请刷新后重试', 'error');
return;
}
if (!validateForm(form, 'send-code', values)) return;
if (!await ensureCaptcha(form, operationCode, values.phone)) {
setFormStatus(form, '请完成验证后再获取验证码。', 'error');
return;
}
if (!validateBeforeSendCode(values)) return;
if (!await ensureCaptcha(form, operationCode, values.phone)) return;
values = readForm(form);
values.validToken = takeCaptchaToken(form);
setBusy(button, true);
setFormStatus(form, '正在发送验证码…', 'loading');
try {
await api.sendSmsCode(operationCode, {
phone: values.phone,
validToken: values.validToken || ''
});
showMessage('验证码已发送');
setFormStatus(form, '');
setBusy(button, false);
startSmsCooldown(button);
} catch (error) {
showMessage(error.message || '验证码发送失败');
setFormStatus(form, error.message || '验证码发送失败', getApiStateType(error));
setBusy(button, false);
}
}
@@ -449,19 +481,20 @@
var values = readForm(form);
if (!api) {
showMessage('接口初始化失败,请刷新后重试');
setFormStatus(form, '接口初始化失败,请刷新后重试', 'error');
return;
}
if (!validateBeforeSubmit('password-reset', values)) return;
setBusy(button, true);
if (!validateForm(form, 'password-reset', values)) return;
setSubmitPending(button, true, '正在重设…');
setFormStatus(form, '正在重设密码…', 'loading');
try {
await api.resetPassword(buildPasswordResetBody(values));
showMessage('密码已重设,请重新登录');
root.location.href = getSuccessUrl('password-reset');
} catch (error) {
showMessage(error.message || '重设密码失败');
setFormStatus(form, error.message || '重设密码失败', getApiStateType(error));
} finally {
setBusy(button, false);
setSubmitPending(button, false, '正在重设…');
}
}
+177 -50
View File
@@ -390,7 +390,7 @@
var mapLink = '';
var actions = '';
if (!ceremony) return '<div class="api-empty">未找到该活动</div>';
if (!ceremony) return '';
if (ceremony.longitude !== undefined && ceremony.latitude !== undefined) {
mapLink = ' <a class="pill" target="_blank" rel="noopener noreferrer" href="https://uri.amap.com/marker?position=' +
encodeURIComponent(ceremony.longitude + ',' + ceremony.latitude) + '&amp;name=' +
@@ -432,8 +432,8 @@
function renderGiftList(data) {
var gifts = Array.isArray(data) ? data.map(normalizeCeremonyGift) : [];
if (gifts.some(function (gift) { return !gift; })) return '<div class="api-empty">祭品响应字段不完整</div>';
if (!gifts.length) return '<div class="api-empty">暂无祭品记录</div>';
if (gifts.some(function (gift) { return !gift; })) return '';
if (!gifts.length) return '';
return gifts.map(function (gift) {
var action = canEditContent
? '<button class="pill is-danger" type="button" data-ceremony-gift-delete-id="' +
@@ -450,8 +450,8 @@
function renderInvitationList(data) {
var invitations = normalizeList(data, normalizeInvitation);
if (!Array.isArray(data) || (data.length && !invitations.length)) return '<div class="api-empty">邀请响应字段不完整</div>';
if (!invitations.length) return '<div class="api-empty">暂无邀请记录</div>';
if (!Array.isArray(data) || (data.length && !invitations.length)) return '';
if (!invitations.length) return '';
return invitations.map(function (invitation) {
var option = inviteeOptionsByUserId[invitation.inviteeUserId];
var label = option && (option.memberName || option.appUserNickName) || '已邀请成员';
@@ -486,10 +486,23 @@
else if (root.alert) root.alert(message);
}
function setStatus(selector, message) {
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function setStatus(selector, message, type) {
var target = query(selector);
if (target) target.textContent = message || '';
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function syncLinks() {
@@ -518,10 +531,13 @@
function renderNoContext() {
var message = '请先选择家谱,再查看或维护祭祀活动。';
if (query('[data-ceremony-list]')) query('[data-ceremony-list]').innerHTML = '<div class="api-empty">' + message + '</div>';
if (query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setStatus('[data-ceremony-list]', message, 'empty');
setStatus('[data-ceremony-detail]', '当前没有家谱上下文。', 'empty');
setStatus('[data-ceremony-gift-list]', '当前没有家谱上下文。', 'empty');
setStatus('[data-ceremony-invitation-list]', '当前没有家谱上下文。', 'empty');
setStatus('[data-ceremony-invitee-options]', '当前没有家谱上下文。', 'empty');
setEditorEnabled(false, message);
setStatus('[data-ceremony-form-status]', message);
setStatus('[data-ceremony-form-status]', message, 'forbidden');
}
function requireGenealogyId() {
@@ -531,10 +547,14 @@
return genealogyId;
}
function canEditCeremonies(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
async function loadCapability(api, genealogyId) {
var detail = await api.genealogyDetail(genealogyId);
canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage));
canEditContent = canEditCeremonies(detail);
queryAll('[data-ceremony-create-link], [data-ceremony-editor-action], [data-ceremony-admin-editor]').forEach(function (element) {
element.hidden = !canEditContent;
});
@@ -549,12 +569,14 @@
ceremonies.forEach(function (ceremony) { ceremoniesById[ceremony.ceremonyId] = ceremony; });
if (!container) return ceremonies;
if (!Array.isArray(data) || (data.length && !ceremonies.length)) {
container.innerHTML = '<div class="api-empty">活动响应缺少稳定 CeremonyVo 字段,请联系后端核对。</div>';
setStatus('[data-ceremony-list]', '活动响应缺少稳定 CeremonyVo 字段,请联系后端核对。', 'error');
return [];
}
container.innerHTML = ceremonies.length
? ceremonies.map(renderCeremonyRow).join('')
: '<div class="api-empty">暂无祭祀活动</div>';
if (!ceremonies.length) {
setStatus('[data-ceremony-list]', '暂无祭祀活动', 'empty');
return [];
}
container.innerHTML = ceremonies.map(renderCeremonyRow).join('');
return ceremonies;
}
@@ -566,13 +588,17 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
setStatus('[data-ceremony-list]', '正在加载祭祀活动…', 'loading');
try {
await loadCapability(api, genealogyId);
renderCeremonies(await api.ceremonies(genealogyId));
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-ceremony-list]')) query('[data-ceremony-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的祭祀活动。' : '活动加载失败,请稍后重试。') + '</div>';
setStatus(
'[data-ceremony-list]',
isForbidden(error) ? '当前账号无权查看该家谱的祭祀活动。' : '活动加载失败,请稍后重试。',
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的祭祀活动。' : (error.message || '活动加载失败'));
}
}
@@ -622,6 +648,7 @@
ceremonyId = getCurrentCeremonyId();
syncLinks();
setEditorEnabled(false, '正在加载活动编辑信息…');
setStatus('[data-ceremony-form-status]', '正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的祭祀活动。' };
@@ -632,25 +659,38 @@
if (query('[data-ceremony-editor-title]')) query('[data-ceremony-editor-title]').textContent = '编辑祭祀活动';
}
setEditorEnabled(true);
setStatus('[data-ceremony-form-status]', '');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的祭祀活动。' : (error.message || '活动编辑信息加载失败'));
setStatus('[data-ceremony-form-status]', isForbidden(error) ? '当前账号无权维护该家谱的祭祀活动。' : (error.message || '活动编辑信息加载失败'));
setStatus(
'[data-ceremony-form-status]',
isForbidden(error) ? '当前账号无权维护该家谱的祭祀活动。' : (error.message || '活动编辑信息加载失败'),
getApiStateType(error)
);
}
}
async function loadGifts(api, genealogyId, ceremonyId) {
var data = await api.ceremonyGifts(genealogyId, ceremonyId);
var gifts = normalizeList(data, normalizeCeremonyGift);
var data;
var gifts;
var container = query('[data-ceremony-gift-list]');
setStatus('[data-ceremony-gift-list]', '正在加载祭品…', 'loading');
data = await api.ceremonyGifts(genealogyId, ceremonyId);
gifts = normalizeList(data, normalizeCeremonyGift);
giftsById = Object.create(null);
gifts.forEach(function (gift) { giftsById[gift.giftId] = gift; });
if (container) {
container.innerHTML = !Array.isArray(data) || (data.length && !gifts.length)
? '<div class="api-empty">祭品响应缺少稳定 CeremonyGiftVo 字段,请联系后端核对。</div>'
: renderGiftList(gifts);
if (!container) return gifts;
if (!Array.isArray(data) || (data.length && !gifts.length)) {
setStatus('[data-ceremony-gift-list]', '祭品响应缺少稳定 CeremonyGiftVo 字段,请联系后端核对。', 'error');
return [];
}
if (!gifts.length) {
setStatus('[data-ceremony-gift-list]', '暂无祭品记录', 'empty');
return [];
}
container.innerHTML = renderGiftList(gifts);
return gifts;
}
@@ -662,31 +702,48 @@
if (invitation.inviteStatus !== 'CANCELED') selected[invitation.inviteeUserId] = true;
});
if (!container) return;
container.innerHTML = options.length ? options.map(function (option) {
if (!options.length) {
setStatus('[data-ceremony-invitee-options]', '暂无已绑定账号的正常成员', 'empty');
return;
}
container.innerHTML = options.map(function (option) {
var label = option.memberName || option.appUserNickName || '家谱成员';
return '<label class="editor-choice"><input type="checkbox" data-invitee-user-id="' +
escapeHtml(option.appUserId) + '"' + (selected[option.appUserId] ? ' checked' : '') +
' /> <span>' + escapeHtml(label) + '</span></label>';
}).join('') : '<div class="api-empty">暂无已绑定账号的正常成员</div>';
}).join('');
}
async function loadInvitees(api, genealogyId, ceremonyId) {
var results = await Promise.all([
var results;
var options;
var invitations;
var list = query('[data-ceremony-invitation-list]');
setStatus('[data-ceremony-invitation-list]', '正在加载邀请记录…', 'loading');
setStatus('[data-ceremony-invitee-options]', '正在加载成员选项…', 'loading');
results = await Promise.all([
api.genealogyMemberOptions(genealogyId),
api.ceremonyInvitations(genealogyId, ceremonyId)
]);
var options = normalizeList(results[0], normalizeInviteeOption);
var invitations = normalizeList(results[1], normalizeInvitation);
var list = query('[data-ceremony-invitation-list]');
options = normalizeList(results[0], normalizeInviteeOption);
invitations = normalizeList(results[1], normalizeInvitation);
inviteeOptionsByUserId = Object.create(null);
options.forEach(function (option) { inviteeOptionsByUserId[option.appUserId] = option; });
if (!Array.isArray(results[0]) || (results[0].length && !options.length)) {
if (query('[data-ceremony-invitee-options]')) query('[data-ceremony-invitee-options]').innerHTML = '<div class="api-empty">成员选项响应字段不完整</div>';
setStatus('[data-ceremony-invitee-options]', '成员选项响应字段不完整', 'error');
} else {
renderInviteeOptions(options, invitations);
}
if (list) list.innerHTML = renderInvitationList(results[1]);
if (!list) return { options: options, invitations: invitations };
if (!Array.isArray(results[1]) || (results[1].length && !invitations.length)) {
setStatus('[data-ceremony-invitation-list]', '邀请响应字段不完整', 'error');
} else if (!invitations.length) {
setStatus('[data-ceremony-invitation-list]', '暂无邀请记录', 'empty');
} else {
list.innerHTML = renderInvitationList(results[1]);
}
return { options: options, invitations: invitations };
}
@@ -699,10 +756,17 @@
genealogyId = requireGenealogyId();
ceremonyId = getCurrentCeremonyId();
if (!genealogyId || !ceremonyId) {
if (genealogyId && query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = '<div class="api-empty">缺少有效活动编号。</div>';
if (genealogyId) {
setStatus('[data-ceremony-detail]', '缺少有效活动编号。', 'error');
setStatus('[data-ceremony-gift-list]', '缺少有效活动编号。', 'empty');
setStatus('[data-ceremony-invitation-list]', '缺少有效活动编号。', 'empty');
setStatus('[data-ceremony-invitee-options]', '缺少有效活动编号。', 'empty');
}
return;
}
syncLinks();
setStatus('[data-ceremony-detail]', '正在加载活动详情…', 'loading');
setStatus('[data-ceremony-gift-list]', '正在加载祭品…', 'loading');
try {
await loadCapability(api, genealogyId);
currentCeremony = normalizeCeremony(await api.ceremonyDetail(genealogyId, ceremonyId));
@@ -710,13 +774,62 @@
if (query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = renderCeremonyDetail(currentCeremony);
if (query('[data-ceremony-detail-title]')) query('[data-ceremony-detail-title]').textContent = currentCeremony.ceremonyTitle;
if (query('[data-ceremony-editor-action]')) query('[data-ceremony-editor-action]').href = buildEditUrl(genealogyId, ceremonyId);
await loadGifts(api, genealogyId, ceremonyId);
if (canEditContent) await loadInvitees(api, genealogyId, ceremonyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-ceremony-detail]')) query('[data-ceremony-detail]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该活动。' : escapeHtml(error.message || '活动详情加载失败')) + '</div>';
setStatus(
'[data-ceremony-detail]',
isForbidden(error) ? '当前账号无权查看该活动。' : (error.message || '活动详情加载失败'),
getApiStateType(error)
);
setStatus(
'[data-ceremony-gift-list]',
isForbidden(error) ? '当前账号无权查看该活动。' : '祭品加载失败,请稍后重试。',
getApiStateType(error)
);
setStatus(
'[data-ceremony-invitation-list]',
isForbidden(error) ? '当前账号无权查看该活动。' : '邀请记录加载失败,请稍后重试。',
getApiStateType(error)
);
setStatus(
'[data-ceremony-invitee-options]',
isForbidden(error) ? '当前账号无权查看该活动。' : '成员选项加载失败,请稍后重试。',
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看该活动。' : (error.message || '活动详情加载失败'));
return;
}
try {
await loadGifts(api, genealogyId, ceremonyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(
'[data-ceremony-gift-list]',
isForbidden(error) ? '当前账号无权查看该活动祭品。' : (error.message || '祭品加载失败'),
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看该活动祭品。' : (error.message || '祭品加载失败'));
}
if (!canEditContent) {
setStatus('[data-ceremony-invitation-list]', '当前账号无权查看或维护受邀名单。', 'forbidden');
setStatus('[data-ceremony-invitee-options]', '当前账号无权查看或维护受邀名单。', 'forbidden');
return;
}
try {
await loadInvitees(api, genealogyId, ceremonyId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(
'[data-ceremony-invitation-list]',
isForbidden(error) ? '当前账号无权查看或维护受邀名单。' : (error.message || '邀请记录加载失败'),
getApiStateType(error)
);
setStatus(
'[data-ceremony-invitee-options]',
isForbidden(error) ? '当前账号无权查看或维护受邀名单。' : '成员选项加载失败,请稍后重试。',
getApiStateType(error)
);
showMessage(isForbidden(error) ? '当前账号无权查看或维护受邀名单。' : (error.message || '邀请记录加载失败'));
}
}
@@ -730,11 +843,12 @@
var savedId;
var verified;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll();
body = buildCeremonyBody(getFormValues(form));
validation = validateCeremonyBody(body);
if (validation) {
setStatus('[data-ceremony-form-status]', validation);
setStatus('[data-ceremony-form-status]', validation, 'error');
return;
}
delete body.invalidLongitude;
@@ -742,7 +856,7 @@
delete body.invalidCoverOssId;
delete body.invalidSortOrder;
setWritePending(true);
setStatus('[data-ceremony-form-status]', '正在保存活动…');
setStatus('[data-ceremony-form-status]', '正在保存活动…', 'loading');
try {
saved = normalizeCeremony(ceremonyId
? await api.updateCeremony(genealogyId, ceremonyId, body)
@@ -754,7 +868,11 @@
if (root.location) root.location.href = buildDetailUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-ceremony-form-status]', isForbidden(error) ? '当前账号无权保存该活动。' : (error.message || '活动保存失败'));
setStatus(
'[data-ceremony-form-status]',
isForbidden(error) ? '当前账号无权保存该活动。' : (error.message || '活动保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -768,16 +886,16 @@
var validation;
var saved;
if (writePending || !genealogyId || !ceremonyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !ceremonyId || redirectUnauthorized(api)) return;
body = buildCeremonyGiftBody(getFormValues(form));
validation = validateCeremonyGiftBody(body);
if (validation) {
setStatus('[data-ceremony-gift-status]', validation);
setStatus('[data-ceremony-gift-status]', validation, 'error');
return;
}
delete body.invalidGiftAmount;
setWritePending(true);
setStatus('[data-ceremony-gift-status]', '正在添加祭品…');
setStatus('[data-ceremony-gift-status]', '正在添加祭品…', 'loading');
try {
saved = normalizeCeremonyGift(await api.createCeremonyGift(genealogyId, ceremonyId, body));
if (!saved || saved.ceremonyId !== ceremonyId) throw new Error('添加祭品响应缺少稳定 giftId');
@@ -788,7 +906,11 @@
setStatus('[data-ceremony-gift-status]', '祭品已添加');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-ceremony-gift-status]', isForbidden(error) ? '当前账号无权添加祭品。' : (error.message || '祭品添加失败'));
setStatus(
'[data-ceremony-gift-status]',
isForbidden(error) ? '当前账号无权添加祭品。' : (error.message || '祭品添加失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -800,19 +922,23 @@
var ceremonyId = getCurrentCeremonyId();
var body;
if (writePending || !genealogyId || !ceremonyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !ceremonyId || redirectUnauthorized(api)) return;
body = buildInviteesBody(queryAll('[data-invitee-user-id]:checked').map(function (input) {
return input.getAttribute('data-invitee-user-id');
}));
setWritePending(true);
setStatus('[data-ceremony-invitee-status]', '正在更新受邀人…');
setStatus('[data-ceremony-invitee-status]', '正在更新受邀人…', 'loading');
try {
await api.replaceCeremonyInvitees(genealogyId, ceremonyId, body.inviteeUserIds);
await loadInvitees(api, genealogyId, ceremonyId);
setStatus('[data-ceremony-invitee-status]', '受邀名单已更新');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-ceremony-invitee-status]', isForbidden(error) ? '当前账号无权维护受邀名单。' : (error.message || '受邀名单更新失败'));
setStatus(
'[data-ceremony-invitee-status]',
isForbidden(error) ? '当前账号无权维护受邀名单。' : (error.message || '受邀名单更新失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -823,7 +949,7 @@
var genealogyId = requireGenealogyId();
var ceremony = ceremoniesById[ceremonyId] || currentCeremony;
if (writePending || !genealogyId || !normalizeId(ceremonyId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(ceremonyId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (ceremony ? ceremony.ceremonyTitle : '该活动') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
@@ -844,7 +970,7 @@
var ceremonyId = getCurrentCeremonyId();
var gift = giftsById[giftId];
if (writePending || !genealogyId || !ceremonyId || !normalizeId(giftId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !ceremonyId || !normalizeId(giftId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (gift && (gift.giverName || gift.giverNickName) || '该祭品') + '”的记录吗?')) return;
setWritePending(true);
try {
@@ -911,6 +1037,7 @@
normalizeInvitation: normalizeInvitation,
normalizeInviteeOption: normalizeInviteeOption,
buildInviteesBody: buildInviteesBody,
canEditCeremonies: canEditCeremonies,
renderCeremonyDetail: renderCeremonyDetail,
renderGiftList: renderGiftList,
shouldRedirectToLogin: shouldRedirectToLogin,
+46 -12
View File
@@ -40,6 +40,10 @@
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();
@@ -171,7 +175,7 @@
function renderInvitationDetail(invitation) {
var mapLink = '';
if (!invitation) return '<div class="api-empty">请选择一条邀请查看详情</div>';
if (!invitation) return '';
if (invitation.longitude !== undefined && invitation.latitude !== undefined) {
mapLink = ' <a class="pill" target="_blank" rel="noopener noreferrer" href="https://uri.amap.com/marker?position=' +
encodeURIComponent(invitation.longitude + ',' + invitation.latitude) + '&amp;name=' +
@@ -194,7 +198,12 @@
var container = query('[data-my-invitation-detail]');
selectedInvitationId = invitation ? invitation.invitationId : '';
if (container) container.innerHTML = renderInvitationDetail(invitation);
if (!container) return;
if (!invitation) {
setInvitationState('[data-my-invitation-detail]', 'empty', '请选择一条邀请查看详情');
return;
}
container.innerHTML = renderInvitationDetail(invitation);
}
function renderInvitations(data) {
@@ -209,12 +218,12 @@
});
if (!container) return invitations;
if (invalidCount) {
container.innerHTML = '<div class="api-empty">邀请响应缺少稳定 ID 或合法状态,请联系后端核对 DTO。</div>';
renderDetail(null);
setInvitationState('[data-my-invitation-list]', 'error', '邀请响应缺少稳定 ID 或合法状态,请联系后端核对 DTO。');
setInvitationState('[data-my-invitation-detail]', 'error', '邀请详情暂不可用,请联系后端核对 DTO。');
return [];
}
if (!invitations.length) {
container.innerHTML = '<div class="api-empty">暂无贺礼邀请</div>';
setInvitationState('[data-my-invitation-list]', 'empty', '暂无贺礼邀请');
renderDetail(null);
return [];
}
@@ -234,23 +243,41 @@
});
}
function setListState(message) {
var container = query('[data-my-invitation-list]');
function setInvitationState(selector, type, message) {
var container = query(selector);
if (container) container.innerHTML = '<div class="api-empty">' + escapeHtml(message) + '</div>';
if (!container) return;
if (!message) {
container.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.textContent = message;
}
async function loadMyInvitations() {
var api = getApi();
if (redirectUnauthorized(api)) return;
setListState('正在加载我的邀请…');
setInvitationState('[data-my-invitation-list]', 'loading', '正在加载我的邀请…');
setInvitationState('[data-my-invitation-detail]', 'loading', '正在加载邀请详情…');
try {
renderInvitations(await api.myCeremonyInvitations());
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权查看邀请' : (error.message || '邀请加载失败'));
renderDetail(null);
setInvitationState(
'[data-my-invitation-list]',
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看邀请。' : (error.message || '邀请加载失败')
);
setInvitationState(
'[data-my-invitation-detail]',
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看邀请。' : '邀请详情加载失败,请稍后重试。'
);
}
}
@@ -263,13 +290,19 @@
if (writePending || redirectUnauthorized(api) || !invitation || !canRespondToInvitation(invitation)) return;
if (validation) return;
setWritePending(true);
setInvitationState('[data-my-invitation-status]', 'loading', '正在更新邀请状态…');
try {
await api.respondCeremonyInvitation(invitation.genealogyId, invitation.ceremonyId, body);
selectedInvitationId = invitation.invitationId;
await loadMyInvitations();
setInvitationState('[data-my-invitation-status]', '', '邀请状态已更新。');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权响应该邀请' : (error.message || '邀请响应失败'));
setInvitationState(
'[data-my-invitation-status]',
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权响应该邀请。' : (error.message || '邀请响应失败')
);
} finally {
setWritePending(false);
}
@@ -310,6 +343,7 @@
renderInvitationRow: renderInvitationRow,
renderInvitationDetail: renderInvitationDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
loadMyInvitations: loadMyInvitations,
init: init
};
+86 -18
View File
@@ -94,6 +94,10 @@
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
async function loadFamilyHome(api, genealogyId) {
var normalizedId = normalizeId(genealogyId);
var results;
@@ -135,11 +139,38 @@
if (element) element.textContent = value;
}
function setHomeState(selector, type, message) {
var element = query(selector);
if (!element) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(element, type, message);
return;
}
element.textContent = message;
}
function setHomeStatus(message, type) {
var element = query('[data-family-home-status]');
if (!element) return;
if (!message) {
element.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(element, type, message);
return;
}
element.textContent = message;
}
async function initFamilyHomePage() {
var page = query('[data-family-home-page]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var genealogyId;
var result;
var treeHtml;
if (!page) return;
if (shouldRedirectToLogin(api)) {
@@ -157,36 +188,72 @@
root.ProfileUI.syncGenealogyContextLinks();
}
if (!root.LineagePages || !root.LineagePages.renderLineageTreeHtml) {
setText('[data-family-home-status]', '世系预览初始化失败,请刷新后重试');
setHomeState('[data-family-home-overview]', 'error', '家谱主页初始化失败,请刷新后重试');
setHomeState('[data-lineage-home-tree]', 'error', '世系预览初始化失败,请刷新后重试。');
setHomeStatus('世系预览初始化失败,请刷新后重试。', 'error');
return;
}
setHomeState('[data-family-home-overview]', 'loading', '正在加载家谱概览…');
setHomeState('[data-lineage-home-tree]', 'loading', '正在加载世系树…');
setHomeStatus('正在读取家谱概览…', 'loading');
try {
result = await loadFamilyHome(api, genealogyId);
result = normalizeFamilyOverview(await api.genealogyOverview(genealogyId), genealogyId);
if (!result) throw new Error('家谱概览响应无效');
if (root.ProfileUI && root.ProfileUI.setCurrentGenealogyContext) {
root.ProfileUI.setCurrentGenealogyContext(result.overview.genealogyId, result.overview.genealogyName);
root.ProfileUI.setCurrentGenealogyContext(result.genealogyId, result.genealogyName);
}
setText('[data-family-home-title]', result.overview.genealogyName);
setText('[data-family-home-context]', result.overview.genealogyName);
query('[data-family-home-overview]').innerHTML = renderFamilyOverview(result.overview);
setText('[data-family-home-member-count]', String(result.overview.memberCount));
setText('[data-family-home-person-count]', String(result.overview.personCount));
setText('[data-family-home-title]', result.genealogyName);
setText('[data-family-home-context]', result.genealogyName);
query('[data-family-home-overview]').innerHTML = renderFamilyOverview(result);
setText('[data-family-home-member-count]', String(result.memberCount));
setText('[data-family-home-person-count]', String(result.personCount));
queryAll('[data-family-home-management]').forEach(function (element) {
element.hidden = !shouldShowFamilyManagement(result.overview);
element.hidden = !shouldShowFamilyManagement(result);
});
query('[data-lineage-home-tree]').innerHTML =
root.LineagePages.renderLineageTreeHtml(result.tree);
setText('[data-family-home-status]', '家谱概览已更新');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
query('[data-family-home-overview]').innerHTML =
'<div class="api-empty">家谱概览读取失败,请稍后重试</div>';
query('[data-lineage-home-tree]').innerHTML =
'<div class="api-empty">世系树读取失败,请稍后重试</div>';
setText('[data-family-home-status]', error.message || '家谱主页读取失败,请稍后重试');
setHomeState(
'[data-family-home-overview]',
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看该家谱。' : '家谱概览读取失败,请稍后重试。'
);
setHomeState(
'[data-lineage-home-tree]',
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看该家谱。' : '世系树读取失败,请稍后重试。'
);
setHomeStatus(
isForbidden(error) ? '当前账号无权查看该家谱。' : (error.message || '家谱主页读取失败,请稍后重试。'),
isForbidden(error) ? 'forbidden' : 'error'
);
return;
}
try {
result = await api.lineageTree(genealogyId);
treeHtml = Array.isArray(result) ? root.LineagePages.renderLineageTreeHtml(result) : '';
if (!treeHtml) {
setHomeState('[data-lineage-home-tree]', 'empty', '暂无世系树');
} else {
query('[data-lineage-home-tree]').innerHTML = treeHtml;
}
setHomeStatus('家谱概览已更新');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setHomeState(
'[data-lineage-home-tree]',
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看该家谱世系树。' : '世系树读取失败,请稍后重试。'
);
setHomeStatus(
isForbidden(error) ? '当前账号无权查看该家谱世系树。' : (error.message || '世系树读取失败,请稍后重试。'),
isForbidden(error) ? 'forbidden' : 'error'
);
}
}
@@ -199,6 +266,7 @@
renderFamilyOverview: renderFamilyOverview,
shouldShowFamilyManagement: shouldShowFamilyManagement,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
loadFamilyHome: loadFamilyHome,
initFamilyHomePage: initFamilyHomePage,
init: init
+307 -59
View File
@@ -65,6 +65,125 @@
if (root.alert) root.alert(message);
}
function getApiStateType(error) {
return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error';
}
function setFeedFormStatus(message, type) {
var target = query('[data-feed-form-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type || 'error', message);
return;
}
target.textContent = message;
}
function setFeedContainerState(selector, type, message) {
var target = query(selector);
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.innerHTML = '<div class="api-state api-state--' + type + '" data-api-state="' + type + '">' +
escapeHtml(message) + '</div>';
}
function setFeedFormEnabled(enabled) {
var form = query('[data-feed-form]');
if (form) {
queryAll('input, textarea, select, button', form).forEach(function (field) {
field.disabled = !enabled;
});
}
queryAll('[data-feed-editor-action]').forEach(function (action) {
action.hidden = !enabled;
action.disabled = !enabled;
});
}
function setFeedSubmitPending(pending) {
var selectors = '[data-feed-form] [type="submit"], [type="submit"][form="feed-edit-form"]';
queryAll(selectors).forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = Boolean(pending);
button.textContent = pending ? '正在保存…' : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
});
}
function setFeedCommentFormStatus(message, type) {
var target = query('[data-feed-comment-form-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type || 'error', message);
return;
}
target.textContent = message;
}
function setFeedCommentFormEnabled(enabled) {
var form = query('[data-feed-comment-form]');
if (!form) return;
queryAll('input, textarea, button', form).forEach(function (field) {
field.disabled = !enabled;
});
}
function setFeedCommentSubmitPending(pending) {
queryAll('[data-feed-comment-form] [type="submit"]').forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.textContent = pending ? '正在发布…' : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
});
}
function setFeedCommentTarget(parentCommentId) {
var form = query('[data-feed-comment-form]');
var target = query('[data-feed-comment-form-target]');
var parent = parentCommentId ? toId(parentCommentId) : '';
if (!form) return;
if (query('[name="parentCommentId"]', form)) query('[name="parentCommentId"]', form).value = parent;
if (target) target.textContent = parent ? '正在回复这条评论' : '发表公开评论';
}
function openFeedCommentForm(feedId, parentCommentId) {
var genealogyId = getCurrentGenealogyId();
var form;
var submitButton;
if (!feedId || !genealogyId) return;
if (!query('[data-feed-detail-page]')) {
if (root.location) root.location.href = buildFeedUrl('detail', genealogyId, feedId);
return;
}
if (getCurrentFeedId() !== toId(feedId)) return;
form = query('[data-feed-comment-form]');
submitButton = form && query('[type="submit"]', form);
if (!form || !submitButton || submitButton.disabled) return;
setFeedCommentTarget(parentCommentId);
setFeedCommentFormStatus('');
if (query('[name="commentContent"]', form) && query('[name="commentContent"]', form).focus) {
query('[name="commentContent"]', form).focus();
}
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
@@ -280,6 +399,39 @@
.replace(/'/g, '&#39;');
}
function renderFeedState(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) {
return root.ProfileUI.renderApiState(type, message);
}
return '<div class="api-state api-state--' + escapeHtml(type) + '" data-api-state="' +
escapeHtml(type) + '">' + escapeHtml(message) + '</div>';
}
function replaceInlineCommentList(feedId, content) {
var row = query('[data-feed-id="' + feedId + '"]');
var previous = row && row.nextElementSibling;
if (!row) return;
if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove();
row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
}
function replaceReplyList(parentCommentId, content) {
var row = query('[data-feed-comment-node="' + parentCommentId + '"]');
var previous = row && row.nextElementSibling;
if (!row) return;
if (previous && previous.getAttribute('data-feed-reply-list') === parentCommentId) previous.remove();
row.insertAdjacentHTML('afterend', content);
}
function setReplyState(parentCommentId, type, message) {
replaceReplyList(
parentCommentId,
'<div data-feed-reply-list="' + escapeHtml(parentCommentId) + '">' + renderFeedState(type, message) + '</div>'
);
}
function getListUrl(genealogyId, feedId) {
return buildFeedUrl(feedId ? 'edit' : 'list', genealogyId, feedId);
}
@@ -323,11 +475,11 @@
if (!container) return;
if (invalidCount) {
container.innerHTML = '<div class="api-empty">动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO。</div>';
setFeedContainerState('[data-feed-list]', 'error', '动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO。');
return;
}
if (!feeds.length) {
container.innerHTML = '<div class="api-empty">暂无家族动态</div>';
setFeedContainerState('[data-feed-list]', 'empty', '暂无家族动态');
return;
}
@@ -393,21 +545,19 @@
var row = query('[data-feed-id="' + feedId + '"]');
var result = normalizeComments(data);
var list = result.comments;
var previous = row && row.nextElementSibling;
var content;
if (!row && !detailContainer) return;
if (previous && previous.getAttribute('data-feed-comment-list') === feedId) previous.remove();
if (result.invalidCount) {
content = '<div class="api-empty">评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。</div>';
if (detailContainer) detailContainer.innerHTML = content;
else row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
content = renderFeedState('error', '评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。');
if (detailContainer) setFeedContainerState('[data-feed-detail-comments]', 'error', '评论响应缺少 commentId 或 commentContent,请联系后端补充 DTO。');
else replaceInlineCommentList(feedId, content);
return;
}
if (!list.length) {
content = '<div class="api-empty">暂无评论</div>';
if (detailContainer) detailContainer.innerHTML = content;
else row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
content = renderFeedState('empty', '暂无评论');
if (detailContainer) setFeedContainerState('[data-feed-detail-comments]', 'empty', '暂无评论');
else replaceInlineCommentList(feedId, content);
return;
}
@@ -415,26 +565,24 @@
return renderCommentRow(feedId, comment);
}).join('') + '</div>' + renderPageControls('comments', feedId, '', currentCommentPages[feedId] || 1, getPageTotal(data));
if (detailContainer) detailContainer.innerHTML = content;
else row.insertAdjacentHTML('afterend', '<div data-feed-comment-list="' + escapeHtml(feedId) + '">' + content + '</div>');
else replaceInlineCommentList(feedId, content);
}
function renderCommentReplies(feedId, parentCommentId, data) {
var row = query('[data-feed-comment-node="' + parentCommentId + '"]');
var result = normalizeComments(data);
var previous = row && row.nextElementSibling;
if (!row) return;
if (previous && previous.getAttribute('data-feed-reply-list') === parentCommentId) previous.remove();
if (result.invalidCount) {
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">回复响应缺少 commentId 或 commentContent,请联系后端补充 DTO。</div>');
setReplyState(parentCommentId, 'error', '回复响应缺少 commentId 或 commentContent,请联系后端补充 DTO。');
return;
}
if (!result.comments.length) {
row.insertAdjacentHTML('afterend', '<div class="api-empty" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">暂无直接回复</div>');
setReplyState(parentCommentId, 'empty', '暂无直接回复');
return;
}
row.insertAdjacentHTML('afterend', '<div class="module-list" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">' + result.comments.map(function (comment) {
replaceReplyList(parentCommentId, '<div class="module-list" data-feed-reply-list="' + escapeHtml(parentCommentId) + '">' + result.comments.map(function (comment) {
return renderCommentRow(feedId, comment);
}).join('') + renderPageControls('replies', feedId, parentCommentId, currentReplyPages[parentCommentId] || 1, getPageTotal(data)) + '</div>');
}
@@ -471,11 +619,21 @@
});
}
function canEditFeeds(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function setFeedContentEditingEnabled(enabled) {
queryAll('[data-feed-create-link]').forEach(function (link) {
link.hidden = !enabled;
});
}
function normalizeViewerContext(genealogy, profile) {
return {
userId: toId(profile && profile.userId),
canEditContent: genealogy && genealogy.canEditContent === true,
canManage: genealogy && genealogy.canManage === true
canEditContent: canEditFeeds(genealogy),
canManage: Boolean(genealogy && genealogy.canManage === true)
};
}
@@ -494,6 +652,7 @@
profile = null;
}
viewerContext = normalizeViewerContext(genealogy, profile);
setFeedContentEditingEnabled(viewerContext.canEditContent);
return viewerContext;
}
@@ -544,7 +703,11 @@
if (!genealogyId) {
container = query('[data-feed-list]');
if (container) container.innerHTML = '<div class="api-empty">请从具体家谱进入家族圈动态</div>';
if (container) setFeedContainerState('[data-feed-list]', 'forbidden', '请从具体家谱进入家族圈动态。');
setFeedFormEnabled(false);
setFeedFormStatus('请从具体家谱进入动态发布。', 'forbidden');
setFeedCommentFormEnabled(false);
setFeedCommentFormStatus('请从具体家谱进入动态详情后发表评论。', 'forbidden');
showMessage('请从具体家谱进入家族圈动态');
}
return genealogyId;
@@ -560,6 +723,7 @@
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
currentFeedPage = buildPageQuery(pageNum || getQueryParam(root.location && root.location.search, 'pageNum')).pageNum;
setFeedContainerState('[data-feed-list]', 'loading', '正在加载家族动态…');
try {
await loadViewerContext(api, genealogyId);
data = await api.feedsPage(genealogyId, buildPageQuery(currentFeedPage));
@@ -567,7 +731,11 @@
updateListPager(data);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-list]')) query('[data-feed-list]').innerHTML = '<div class="api-empty">家族动态加载失败,请稍后重试。</div>';
setFeedContainerState(
'[data-feed-list]',
getApiStateType(error),
getApiStateType(error) === 'forbidden' ? '当前账号无权查看该家谱的动态。' : '家族动态加载失败,请稍后重试。'
);
showMessage(error.message || '家族动态加载失败');
}
}
@@ -581,19 +749,33 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
setFeedFormEnabled(false);
setFeedFormStatus('正在读取家谱权限…', 'loading');
try {
await loadViewerContext(api, genealogyId);
if (!viewerContext.canEditContent) {
setFeedFormStatus('当前账号无权发布或编辑该家谱的动态。', 'forbidden');
return;
}
setFeedFormEnabled(true);
if (query('[data-feed-advanced]')) {
query('[data-feed-advanced]').hidden = !viewerContext.canManage;
queryAll('[name]', query('[data-feed-advanced]')).forEach(function (field) {
field.disabled = !viewerContext.canManage;
});
}
if (!feedId) return;
if (!feedId) {
setFeedFormStatus('');
return;
}
fillFeedForm(await api.feedDetail(genealogyId, feedId));
setFeedFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '动态详情加载失败');
setFeedFormStatus(
getApiStateType(error) === 'forbidden' ? '当前账号无权维护该家谱的动态。' : (error.message || '动态详情加载失败'),
getApiStateType(error)
);
}
}
@@ -604,21 +786,35 @@
var feed;
if (redirectUnauthorized(api)) return;
setFeedCommentFormEnabled(false);
setFeedCommentFormStatus('正在读取动态评论权限…', 'loading');
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) {
if (query('[data-feed-detail]')) query('[data-feed-detail]').innerHTML = '<div class="api-empty">动态链接缺少有效编号</div>';
setFeedContainerState('[data-feed-detail]', 'error', '动态链接缺少有效编号。');
setFeedCommentFormStatus('动态链接缺少有效编号。', 'error');
return;
}
syncGenealogyLinks(genealogyId);
setFeedContainerState('[data-feed-detail]', 'loading', '正在加载动态详情…');
try {
await loadViewerContext(api, genealogyId);
feed = normalizeFeed(await api.feedDetail(genealogyId, feedId));
if (!feed) throw new Error('动态响应缺少 feedId 或 feedContent,请联系后端补充 DTO。');
renderFeedDetail(feed, genealogyId);
setFeedCommentFormEnabled(true);
setFeedCommentFormStatus('');
await showComments(feedId, currentCommentPages[feedId] || 1);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-detail]')) query('[data-feed-detail]').innerHTML = '<div class="api-empty">动态详情加载失败,请返回列表重试。</div>';
setFeedContainerState(
'[data-feed-detail]',
getApiStateType(error),
getApiStateType(error) === 'forbidden' ? '当前账号无权查看该动态。' : '动态详情加载失败,请返回列表重试。'
);
setFeedCommentFormStatus(
getApiStateType(error) === 'forbidden' ? '当前账号无权评论该动态。' : '动态详情加载失败,暂不能发表评论。',
getApiStateType(error)
);
showMessage(error.message || '动态详情加载失败');
}
}
@@ -632,24 +828,28 @@
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId) return;
if (!viewerContext.canEditContent) {
setFeedFormStatus('当前账号无权发布或编辑该家谱的动态。', 'forbidden');
return;
}
// 富文本编辑器在提交前同步回 textarea,保证发送的是用户当前输入。
if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll();
try {
body = buildFeedBody(getFormValues(form));
} catch (error) {
showMessage(error.message || '动态表单校验失败');
setFeedFormStatus(error.message || '动态表单校验失败', 'error');
return;
}
if (!body.feedContent) {
showMessage('请填写动态内容');
setFeedFormStatus('请填写动态内容', 'error');
return;
}
return withActionLock('save:' + (feedId || 'new'), async function () {
var saved;
var savedFeedId;
var buttons = queryAll('[type="submit"]', form);
buttons.forEach(function (button) { button.disabled = true; });
setFeedSubmitPending(true);
setFeedFormStatus('正在保存动态…', 'loading');
try {
if (feedId) {
saved = await api.updateFeed(genealogyId, feedId, body);
@@ -665,9 +865,12 @@
root.location.href = buildPostSaveUrl(genealogyId, savedFeedId, body.status);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '动态保存失败');
setFeedFormStatus(
getApiStateType(error) === 'forbidden' ? '当前账号无权保存该家谱的动态。' : (error.message || '动态保存失败'),
getApiStateType(error)
);
} finally {
buttons.forEach(function (button) { button.disabled = false; });
setFeedSubmitPending(false);
}
});
}
@@ -723,6 +926,11 @@
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) return;
currentCommentPages[feedId] = buildPageQuery(pageNum).pageNum;
if (query('[data-feed-detail-comments]')) {
setFeedContainerState('[data-feed-detail-comments]', 'loading', '正在加载评论…');
} else {
replaceInlineCommentList(feedId, renderFeedState('loading', '正在加载评论…'));
}
try {
renderComments(
feedId,
@@ -731,7 +939,19 @@
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-detail-comments]')) {
query('[data-feed-detail-comments]').innerHTML = '<div class="api-empty">评论加载失败,请稍后重试。</div>';
setFeedContainerState(
'[data-feed-detail-comments]',
getApiStateType(error),
getApiStateType(error) === 'forbidden' ? '当前账号无权查看评论。' : '评论加载失败,请稍后重试。'
);
} else {
replaceInlineCommentList(
feedId,
renderFeedState(
getApiStateType(error),
getApiStateType(error) === 'forbidden' ? '当前账号无权查看评论。' : '评论加载失败,请稍后重试。'
)
);
}
showMessage(error.message || '评论加载失败');
}
@@ -745,6 +965,7 @@
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId || !commentId) return;
currentReplyPages[commentId] = buildPageQuery(pageNum).pageNum;
setReplyState(commentId, 'loading', '正在加载回复…');
try {
renderCommentReplies(
feedId,
@@ -758,46 +979,60 @@
);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-feed-comment-node="' + commentId + '"]')) {
query('[data-feed-comment-node="' + commentId + '"]').insertAdjacentHTML(
'afterend',
'<div class="api-empty">回复加载失败,请稍后重试。</div>'
);
}
setReplyState(
commentId,
getApiStateType(error),
getApiStateType(error) === 'forbidden' ? '当前账号无权查看回复。' : '回复加载失败,请稍后重试。'
);
showMessage(error.message || '回复加载失败');
}
}
async function createComment(feedId, parentCommentId) {
async function submitComment(form) {
var api = getApi();
var genealogyId;
var content;
var feedId = getCurrentFeedId();
var body;
var values;
var forbidden = false;
if (redirectUnauthorized(api)) return;
if (redirectUnauthorized(api) || !form) return;
genealogyId = requireGenealogyId();
if (!genealogyId || !feedId) return;
content = root.prompt ? root.prompt(parentCommentId ? '请输入回复内容' : '请输入评论内容', '') : '';
body = buildCommentBody({ commentContent: content, parentCommentId: parentCommentId });
if (!body.commentContent) return;
if (body.commentContent.length > 1000) {
showMessage('评论不能超过 1000 字');
values = getFormValues(form);
body = buildCommentBody(values);
if (!body.commentContent) {
setFeedCommentFormStatus('请填写评论内容。', 'error');
return;
}
return withActionLock('comment:' + feedId + ':' + (parentCommentId || 'root'), async function () {
if (body.commentContent.length > 1000) {
setFeedCommentFormStatus('评论不能超过 1000 字。', 'error');
return;
}
return withActionLock('comment:' + feedId + ':' + (body.parentCommentId || 'root'), async function () {
setFeedCommentFormEnabled(false);
setFeedCommentSubmitPending(true);
setFeedCommentFormStatus('正在发布评论…', 'loading');
try {
await api.createFeedComment(genealogyId, feedId, body);
await showComments(feedId, currentCommentPages[feedId] || 1);
if (parentCommentId) await showCommentReplies(feedId, parentCommentId, currentReplyPages[parentCommentId] || 1);
if (query('[data-feed-detail-page]')) {
var feed = normalizeFeed(await api.feedDetail(genealogyId, feedId));
if (feed) renderFeedDetail(feed, genealogyId);
} else {
await loadFeeds(currentFeedPage);
}
if (body.parentCommentId) await showCommentReplies(feedId, body.parentCommentId, currentReplyPages[body.parentCommentId] || 1);
form.reset();
setFeedCommentTarget('');
setFeedCommentFormStatus('');
var feed = normalizeFeed(await api.feedDetail(genealogyId, feedId));
if (feed) renderFeedDetail(feed, genealogyId);
showMessage('评论已发布');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
showMessage(error.message || '评论发布失败');
forbidden = getApiStateType(error) === 'forbidden';
setFeedCommentFormStatus(
forbidden ? '当前账号无权评论该动态。' : (error.message || '评论发布失败,请稍后重试。'),
getApiStateType(error)
);
} finally {
setFeedCommentSubmitPending(false);
if (!forbidden) setFeedCommentFormEnabled(true);
}
});
}
@@ -832,9 +1067,16 @@
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-feed-form]');
if (!form) return;
event.preventDefault();
submitFeed(form);
if (form) {
event.preventDefault();
submitFeed(form);
return;
}
form = event.target.closest('[data-feed-comment-form]');
if (form) {
event.preventDefault();
submitComment(form);
}
});
documentRef.addEventListener('click', function (event) {
var target = event.target;
@@ -858,13 +1100,18 @@
if (target.closest('[data-feed-comment]')) {
event.preventDefault();
feedId = target.closest('[data-feed-comment]').getAttribute('data-feed-comment');
createComment(feedId);
openFeedCommentForm(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);
openFeedCommentForm(feedId, commentId);
}
if (target.closest('[data-feed-comment-cancel]')) {
event.preventDefault();
setFeedCommentTarget('');
setFeedCommentFormStatus('');
}
if (target.closest('[data-feed-comment-replies]')) {
event.preventDefault();
@@ -937,6 +1184,7 @@
withActionLock: withActionLock,
normalizeFeed: normalizeFeed,
normalizeComment: normalizeComment,
canEditFeeds: canEditFeeds,
normalizeViewerContext: normalizeViewerContext,
normalizeComments: normalizeComments,
normalizeList: normalizeList,
+75 -29
View File
@@ -181,6 +181,10 @@
else root.location.href = 'login.html';
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function getFormValues(form) {
var values = {};
@@ -190,10 +194,35 @@
return values;
}
function setStatus(message) {
function setApiState(target, type, message) {
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message || '';
}
function setStatus(type, message) {
var status = root.document && root.document.querySelector('[data-feedback-status]');
if (status) status.textContent = message || '';
setApiState(status, type, message);
}
function setFeedbackList(list, items, detailPage) {
if (!list) return;
if (!items.length) {
setApiState(list, 'empty', '当前没有反馈记录。');
return;
}
list.innerHTML = renderFeedbackList(items, { detailPage: detailPage || 'ticket-detail.html' });
}
function setPendingButton(button, pending, label) {
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = pending;
button.textContent = pending ? label : button.dataset.defaultLabel;
}
async function readMyFeedback(api) {
@@ -225,24 +254,32 @@
var items;
if (!list) return [];
setStatus('正在读取反馈记录…');
setApiState(list, 'loading', '正在读取反馈记录…');
setStatus('loading', '正在读取反馈记录…');
try {
items = await readMyFeedback(api);
list.innerHTML = renderFeedbackList(items, { detailPage: 'ticket-detail.html' });
setStatus('');
setFeedbackList(list, items, 'ticket-detail.html');
setStatus('', '');
return items;
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return [];
}
list.innerHTML = '<div class="api-empty">读取反馈记录失败</div>';
setStatus(error.message || '读取反馈记录失败,请稍后重试');
setApiState(list, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看反馈记录。' : '读取反馈记录失败,请稍后重试。');
setStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看反馈记录。' : (error.message || '读取反馈记录失败,请稍后重试'));
return [];
}
}
if (refresh) refresh.addEventListener('click', loadList);
if (refresh) refresh.addEventListener('click', async function () {
setPendingButton(refresh, true, '正在刷新…');
try {
await loadList();
} finally {
setPendingButton(refresh, false, '正在刷新…');
}
});
form.addEventListener('submit', async function (event) {
var body;
var validation;
@@ -255,12 +292,12 @@
body = buildFeedbackBody(getFormValues(form));
validation = validateFeedbackBody(body);
if (validation) {
setStatus(validation);
setStatus('error', validation);
return;
}
writePending = true;
if (button) button.disabled = true;
setStatus('正在提交反馈…');
setPendingButton(button, true, '正在提交…');
setStatus('loading', '正在提交反馈…');
try {
created = normalizeFeedback(await api.submitFeedback(body));
if (!created) throw new Error('提交响应缺少有效反馈信息');
@@ -272,18 +309,18 @@
if (root.location) root.location.href = buildFeedbackDetailUrl(created.feedbackId);
return;
}
if (list) list.innerHTML = renderFeedbackList(refreshed, { detailPage: 'ticket-detail.html' });
setFeedbackList(list, refreshed, 'ticket-detail.html');
form.reset();
setStatus('反馈已提交');
setStatus('', '反馈已提交');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '提交反馈失败,请稍后重试');
setStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限提交反馈。' : (error.message || '提交反馈失败,请稍后重试'));
} finally {
writePending = false;
if (button) button.disabled = false;
setPendingButton(button, false, '正在提交…');
}
});
await loadList();
@@ -303,22 +340,28 @@
async function loadList() {
try {
setStatus('正在读取反馈记录…');
list.innerHTML = renderFeedbackList(await readMyFeedback(api), {
detailPage: 'ticket-detail.html'
});
setStatus('');
setApiState(list, 'loading', '正在读取反馈记录…');
setStatus('loading', '正在读取反馈记录…');
setFeedbackList(list, await readMyFeedback(api), 'ticket-detail.html');
setStatus('', '');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
list.innerHTML = '<div class="api-empty">读取反馈记录失败</div>';
setStatus(error.message || '读取反馈记录失败,请稍后重试');
setApiState(list, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看反馈记录。' : '读取反馈记录失败,请稍后重试。');
setStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看反馈记录。' : (error.message || '读取反馈记录失败,请稍后重试'));
}
}
if (refresh) refresh.addEventListener('click', loadList);
if (refresh) refresh.addEventListener('click', async function () {
setPendingButton(refresh, true, '正在刷新…');
try {
await loadList();
} finally {
setPendingButton(refresh, false, '正在刷新…');
}
});
await loadList();
}
@@ -331,7 +374,7 @@
if (!page || !detail) return;
if (!feedbackId) {
detail.innerHTML = '<div class="api-empty">反馈记录不存在或无权查看</div>';
setApiState(detail, 'empty', '反馈记录不存在或无权查看。');
return;
}
if (shouldRedirectToLogin(api)) {
@@ -339,17 +382,19 @@
return;
}
try {
setStatus('正在读取反馈详情…');
setApiState(detail, 'loading', '正在读取反馈详情…');
setStatus('loading', '正在读取反馈详情…');
item = findFeedbackById(await api.myFeedback(), feedbackId);
detail.innerHTML = renderFeedbackDetail(item);
setStatus(item ? '' : '反馈记录不存在或无权查看');
if (item) detail.innerHTML = renderFeedbackDetail(item);
else setApiState(detail, 'empty', '反馈记录不存在或无权查看');
setStatus('', item ? '' : '反馈记录不存在或无权查看。');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
detail.innerHTML = '<div class="api-empty">反馈详情读取失败</div>';
setStatus(error.message || '反馈详情读取失败,请稍后重试');
setApiState(detail, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该反馈详情。' : '反馈详情读取失败,请稍后重试。');
setStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该反馈详情。' : (error.message || '反馈详情读取失败,请稍后重试'));
}
}
@@ -377,6 +422,7 @@
buildFeedbackDetailUrl: buildFeedbackDetailUrl,
renderFeedbackList: renderFeedbackList,
renderFeedbackDetail: renderFeedbackDetail,
isForbidden: isForbidden,
initFeedbackFormPage: initFeedbackFormPage,
initFeedbackListPage: initFeedbackListPage,
initFeedbackDetailPage: initFeedbackDetailPage,
+109 -23
View File
@@ -13,11 +13,25 @@
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
function normalizeTargetPage(value) {
var target = String(value || '');
var hashIndex = target.indexOf('#');
var hash = hashIndex < 0 ? '' : target.slice(hashIndex);
var parts;
var params;
if (hashIndex >= 0) target = target.slice(0, hashIndex);
parts = target.split('?');
if (!/^profile-[a-z-]+\.html$/.test(parts[0])) return 'profile-family-home.html';
params = new URLSearchParams(parts[1] || '');
params.delete('genealogyId');
return parts[0] + (params.toString() ? '?' + params.toString() : '') + hash;
}
function getTargetPage(search) {
var params = new URLSearchParams(String(search === undefined && root.location ? root.location.search : search || '').replace(/^\?/, ''));
var target = params.get('next') || '';
return /^profile-[a-z-]+\.html$/.test(target) ? target : 'profile-family-home.html';
return normalizeTargetPage(params.get('next'));
}
function buildStatus(quota) {
@@ -142,16 +156,57 @@
return values;
}
function setCreateStatus(message) {
function getApiStateType(error) {
return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error';
}
function setCreateStatus(message, type) {
var status = root.document && root.document.querySelector('[data-genealogy-create-status]');
if (status) status.textContent = message || '';
if (!status) return;
if (!message) {
status.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(status, type || 'error', message);
return;
}
status.textContent = message;
}
function setEntryStatus(message, type) {
var status = root.document && root.document.querySelector('[data-genealogy-entry-status]');
if (!status) return;
if (!message) {
status.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(status, type, message);
return;
}
status.textContent = message;
}
function setGenealogyListState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.textContent = message;
}
function setCreatePending(pending) {
var button = root.document && root.document.querySelector('[data-genealogy-create-submit]');
if (button) button.disabled = Boolean(pending);
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = Boolean(pending);
button.textContent = pending ? '正在创建…' : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
}
async function initGenealogyCreatePage() {
@@ -166,22 +221,24 @@
redirectToLogin(api);
return;
}
setCreateStatus('正在读取创建额度…', 'loading');
try {
quota = await api.genealogyQuota();
if (quotaElement) quotaElement.textContent = buildStatus(quota) || '当前额度信息不可用';
if (!canCreateGenealogy(quota)) {
setCreateStatus('当前没有可用的家谱创建额度');
setCreatePending(true);
setCreateStatus('当前没有可用的家谱创建额度', 'empty');
form.querySelector('[data-genealogy-create-submit]').disabled = true;
return;
}
setCreateStatus('');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
if (quotaElement) quotaElement.textContent = '家谱额度读取失败';
setCreateStatus(error.message || '无法读取家谱创建额度');
setCreatePending(true);
setCreateStatus(error.message || '无法读取家谱创建额度', getApiStateType(error));
form.querySelector('[data-genealogy-create-submit]').disabled = true;
return;
}
if (form.__genealogyCreateBound) return;
@@ -197,13 +254,13 @@
body = buildGenealogyCreateBody(getFormValues(form));
validation = validateGenealogyCreateBody(body);
if (validation) {
setCreateStatus(validation);
setCreateStatus(validation, 'error');
return;
}
delete body.invalidCoverOssId;
writePending = true;
setCreatePending(true);
setCreateStatus('正在创建家谱…');
setCreateStatus('正在创建家谱…', 'loading');
try {
created = normalizeCreatedGenealogy(await api.createGenealogy(body));
if (!created) throw new Error('创建响应缺少有效家谱信息');
@@ -218,7 +275,7 @@
redirectToLogin(api);
return;
}
setCreateStatus(error.message || '创建家谱失败,请稍后重试');
setCreateStatus(error.message || '创建家谱失败,请稍后重试', getApiStateType(error));
} finally {
writePending = false;
setCreatePending(false);
@@ -246,19 +303,28 @@
}
function buildEntryUrl(genealogyId, target) {
var params = new URLSearchParams();
var normalizedTarget = normalizeTargetPage(target);
var parts = normalizedTarget.split('#');
var pathAndQuery = parts[0];
var hash = parts.length > 1 ? '#' + parts.slice(1).join('#') : '';
var pathParts = pathAndQuery.split('?');
var params = new URLSearchParams(pathParts[1] || '');
params.set('genealogyId', String(genealogyId));
return (target || 'profile-family-home.html') + '?' + params.toString();
return pathParts[0] + '?' + params.toString() + hash;
}
function renderGenealogies(container, data, target) {
var items = Array.isArray(data) ? data.map(normalizeGenealogy).filter(Boolean) : [];
if (!container) return;
if (!container) return items;
if (!Array.isArray(data) || (data.length && !items.length)) {
setGenealogyListState(container, 'error', '家谱响应字段不完整,请联系后端核对。');
return [];
}
if (!items.length) {
container.innerHTML = '<div class="api-empty">当前账号还没有可进入的家谱</div>';
return;
setGenealogyListState(container, 'empty', '当前账号还没有可进入的家谱');
return [];
}
container.innerHTML = items.map(function (item) {
var meta = [
@@ -274,6 +340,7 @@
'<div><h3>' + escapeHtml(item.genealogyName) + '</h3><p>' + escapeHtml(meta) +
'</p></div><span class="pill">' + (item.canManage ? '管理并进入' : '进入家谱') + '</span></a>';
}).join('');
return items;
}
async function init() {
@@ -293,24 +360,43 @@
return;
}
if (!api || !api.genealogyQuota || !api.genealogiesMine) {
status.textContent = '家谱入口接口初始化失败,请刷新后重试。';
setEntryStatus('家谱入口接口初始化失败,请刷新后重试。', 'error');
setGenealogyListState(container, 'error', '家谱列表接口初始化失败,请刷新后重试。');
return;
}
setGenealogyListState(container, 'loading', '正在读取我的家谱…');
setEntryStatus('正在读取家谱额度…', 'loading');
try {
result = await Promise.all([api.genealogiesMine(), api.genealogyQuota()]);
renderGenealogies(container, result[0], target);
status.textContent = buildStatus(result[1]);
result = await api.genealogiesMine();
renderGenealogies(container, result, target);
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
status.textContent = '无法读取我的家谱,请稍后重试。';
setGenealogyListState(
container,
getApiStateType(error),
getApiStateType(error) === 'forbidden' ? '当前账号无权查看我的家谱。' : '无法读取我的家谱,请稍后重试。'
);
}
try {
result = await api.genealogyQuota();
setEntryStatus(buildStatus(result));
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setEntryStatus(
getApiStateType(error) === 'forbidden' ? '当前账号无权读取家谱额度。' : '家谱额度读取失败,请稍后重试。',
getApiStateType(error)
);
}
}
return {
normalizeTargetPage: normalizeTargetPage,
getTargetPage: getTargetPage,
buildStatus: buildStatus,
buildGenealogyCreateBody: buildGenealogyCreateBody,
+126 -41
View File
@@ -63,6 +63,33 @@
if (root.alert) root.alert(message);
}
function setGenerationState(selector, type, message) {
var target = query(selector);
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.innerHTML = '<div class="api-state api-state--' + escapeHtml(type) + '" data-api-state="' + escapeHtml(type) + '">' +
escapeHtml(message) + '</div>';
}
function setGenerationFormStatus(message, type) {
var target = query('[data-generation-form-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type || 'error', message);
return;
}
target.textContent = message;
}
function getQueryParam(search, name) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
@@ -196,8 +223,12 @@
return poem;
}
function canEditGenerations(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function normalizeGenerationAccess(genealogy) {
var canEditContent = Boolean(genealogy && genealogy.canEditContent === true);
var canEditContent = canEditGenerations(genealogy);
return {
canEditContent: canEditContent,
@@ -251,11 +282,11 @@
});
if (!container) return;
if (invalidCount) {
container.innerHTML = '<div class="api-empty">字辈响应缺少 poemId、generationNo、generationText 或 status,请联系后端补充 DTO。</div>';
setGenerationState('[data-generation-list]', 'error', '字辈响应缺少 poemId、generationNo、generationText 或 status,请联系后端补充 DTO。');
return;
}
if (!poems.length) {
container.innerHTML = '<div class="api-empty">暂无字辈记录</div>';
setGenerationState('[data-generation-list]', 'empty', '暂无字辈记录');
return;
}
container.innerHTML = poems.map(renderGenerationPoem).join('');
@@ -335,7 +366,7 @@
if (!container) return false;
if (!preview) {
container.innerHTML = '<div class="api-empty">批量预览响应缺少 items,请联系后端补充 DTO。</div>';
setGenerationState('[data-generation-batch-preview]', 'error', '批量预览响应缺少 items,请联系后端补充 DTO。');
return false;
}
summary = '新增 ' + (preview.createCount === undefined ? '-' : preview.createCount) +
@@ -386,18 +417,35 @@
if (target) target.textContent = message || '';
}
function setGenerationEditorVisible(visible) {
var editor = query('[data-generation-editor]');
if (editor) editor.hidden = !visible;
}
function setGenerationSubmitPending(pending) {
queryAll('[data-generation-editor-submit]').forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = Boolean(pending) || !managementAccess;
button.textContent = pending ? '正在保存…' : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
});
}
function refreshManagementControls() {
var disabled = !managementAccess || writePending;
queryAll('[data-generation-management]').forEach(function (element) {
element.hidden = !managementAccess;
});
if (!managementAccess) setGenerationEditorVisible(false);
queryAll('[data-generation-add], [data-generation-batch-action], [data-generation-edit], [data-generation-status]').forEach(function (control) {
control.disabled = disabled;
});
queryAll('[data-generation-management] textarea, [data-generation-management] input').forEach(function (control) {
queryAll('[data-generation-management] textarea, [data-generation-management] input, [data-generation-editor] textarea, [data-generation-editor] input, [data-generation-editor] select, [data-generation-editor] button').forEach(function (control) {
control.disabled = disabled;
});
setGenerationSubmitPending(writePending);
}
function setManagementEnabled(enabled) {
@@ -411,12 +459,11 @@
}
function renderForbiddenState() {
var container = query('[data-generation-list]');
if (container) container.innerHTML = '<div class="api-empty">当前账号没有字辈维护权限</div>';
setGenerationState('[data-generation-list]', 'forbidden', '当前账号没有字辈维护权限');
lastPreviewSignature = '';
setManagementEnabled(false);
setBatchStatus('当前账号没有字辈维护权限');
setGenerationFormStatus('当前账号没有字辈维护权限', 'forbidden');
}
function syncGenealogyLinks(genealogyId) {
@@ -440,34 +487,47 @@
if (!genealogyId) {
container = query('[data-generation-list]');
if (container) container.innerHTML = '<div class="api-empty">请从具体家谱进入字辈管理</div>';
if (container) setGenerationState('[data-generation-list]', 'error', '请从具体家谱进入字辈管理');
setGenerationEditorVisible(false);
setGenerationFormStatus('请从具体家谱进入字辈管理', 'error');
showMessage('请从具体家谱进入字辈管理');
}
return genealogyId;
}
function promptForPoem(existing) {
var generationNo;
var generationText;
var description;
var sortOrder;
function openPoemEditor(poemId) {
var form = query('[data-generation-form]');
var existing = poemId ? poemsById[poemId] : null;
var title = query('[data-generation-editor-title]');
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
if (!managementAccess || writePending || !form || (poemId && !existing)) return;
form.reset();
queryAll('[name]', form).forEach(function (field) {
var value;
if (field.name === 'poemId') value = poemId || '';
if (field.name === 'generationNo') value = existing ? existing.generationNo : '';
if (field.name === 'generationText') value = existing ? existing.generationText : '';
if (field.name === 'description') value = existing && existing.description ? existing.description : '';
if (field.name === 'sortOrder') value = existing && existing.sortOrder !== undefined ? existing.sortOrder : '';
if (field.name === 'status') value = existing ? existing.status : '';
if (value !== undefined) field.value = value;
});
if (title) title.textContent = existing ? '编辑字辈' : '新增字辈';
setGenerationFormStatus('');
setGenerationEditorVisible(true);
if (root.location && root.location.hash !== '#generation-editor') root.location.hash = 'generation-editor';
if (query('[name="generationNo"]', form) && query('[name="generationNo"]', form).focus) {
query('[name="generationNo"]', form).focus();
}
}
function closePoemEditor() {
var form = query('[data-generation-form]');
if (form) form.reset();
setGenerationFormStatus('');
setGenerationEditorVisible(false);
}
async function loadGenerationPoems() {
@@ -479,6 +539,7 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks(genealogyId);
setGenerationState('[data-generation-list]', 'loading', '正在加载字辈记录…');
try {
access = normalizeGenerationAccess(await api.genealogyDetail(genealogyId));
setManagementEnabled(access.canEditContent);
@@ -489,27 +550,37 @@
renderForbiddenState();
return;
}
setGenerationState('[data-generation-list]', 'error', '字辈列表加载失败,请稍后重试。');
showMessage(error.message || '字辈列表加载失败');
}
}
async function savePoem(poemId) {
async function submitPoem(form) {
var api = getApi();
var genealogyId;
var existing = poemId ? poemsById[poemId] : null;
var poemId;
var body;
var validation;
if (writePending || !managementAccess || redirectUnauthorized(api)) return;
if (writePending || !managementAccess || redirectUnauthorized(api) || !form) return;
genealogyId = requireGenealogyId();
if (!genealogyId || (poemId && !existing)) return;
body = promptForPoem(existing);
if (!body) return;
validation = validateGenerationPoemBody(body);
if (validation) {
showMessage(validation);
if (!genealogyId) return;
poemId = normalizeId(getFormValues(form).poemId);
if (getFormValues(form).poemId && !poemId) {
setGenerationFormStatus('字辈编号无效,请返回列表重新编辑。', 'error');
return;
}
if (poemId && !poemsById[poemId]) {
setGenerationFormStatus('当前字辈不存在或已更新,请返回列表刷新后重试。', 'error');
return;
}
body = buildGenerationPoemBody(getFormValues(form));
validation = validateGenerationPoemBody(body);
if (validation) {
setGenerationFormStatus(validation, 'error');
return;
}
setGenerationFormStatus('正在保存字辈…', 'loading');
setWritePending(true);
try {
if (poemId) {
@@ -518,6 +589,7 @@
await api.createGenerationPoem(genealogyId, body);
}
showMessage('字辈已保存');
closePoemEditor();
await loadGenerationPoems();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
@@ -525,7 +597,7 @@
renderForbiddenState();
return;
}
showMessage(error.message || '字辈保存失败');
setGenerationFormStatus(error.message || '字辈保存失败', 'error');
} finally {
setWritePending(false);
}
@@ -645,6 +717,13 @@
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('submit', function (event) {
var form = event.target.closest('[data-generation-form]');
if (!form) return;
event.preventDefault();
submitPoem(form);
});
documentRef.addEventListener('click', function (event) {
var target = event.target;
var action = target.closest('[data-generation-batch-action]');
@@ -658,12 +737,17 @@
}
if (target.closest('[data-generation-add]')) {
event.preventDefault();
savePoem('');
openPoemEditor('');
return;
}
if (target.closest('[data-generation-editor-cancel]')) {
event.preventDefault();
closePoemEditor();
return;
}
if (!poemButton) return;
event.preventDefault();
if (poemButton.hasAttribute('data-generation-edit')) savePoem(poemButton.getAttribute('data-generation-edit'));
if (poemButton.hasAttribute('data-generation-edit')) openPoemEditor(poemButton.getAttribute('data-generation-edit'));
if (poemButton.hasAttribute('data-generation-status')) togglePoemStatus(poemButton.getAttribute('data-generation-status'));
});
}
@@ -680,6 +764,7 @@
buildBatchBody: buildBatchBody,
validateGenerationPoemBody: validateGenerationPoemBody,
validateBatchBody: validateBatchBody,
canEditGenerations: canEditGenerations,
splitPoemText: splitPoemText,
normalizeGenerationPoem: normalizeGenerationPoem,
normalizeGenerationAccess: normalizeGenerationAccess,
+124 -37
View File
@@ -15,6 +15,7 @@
var documentRef = root.document;
var recordsById = Object.create(null);
var canEditContent = false;
var writePending = false;
function getApi() {
@@ -253,19 +254,26 @@
return recordId ? url + '&recordId=' + encodeURIComponent(recordId) : url;
}
function canEditRecords(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function renderGrowthRow(record) {
var summary = [];
var actions = '<button class="pill" type="button" data-growth-detail-id="' +
escapeHtml(record.recordId) + '">查看详情</button>';
if (record.lineagePersonName) summary.push(record.lineagePersonName);
if (record.recordType) summary.push(record.recordType);
if (record.recordDate) summary.push(record.recordDate);
if (!summary.length) summary.push('未填写日期和关联人物');
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.recordId)) +
'">编辑</a><button class="pill is-danger" type="button" data-growth-delete-id="' +
escapeHtml(record.recordId) + '">删除</button>';
}
return '<article class="module-row growth-row"><div><h3>' + escapeHtml(record.recordTitle) + '</h3><p>' +
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-growth-detail-id="' + escapeHtml(record.recordId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.recordId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-growth-delete-id="' + escapeHtml(record.recordId) + '">删除</button>' +
'</div></article>';
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' + actions + '</div></article>';
}
function detailValue(label, value) {
@@ -274,9 +282,16 @@
function renderGrowthDetail(record) {
var count;
var actions = '';
if (!record) return '<div class="api-empty">请选择一条成长记录查看详情</div>';
if (!record) return stateHtml('empty', '请选择一条成长记录查看详情');
count = attachmentCount(record.mediaOssIds);
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.recordId)) + '">编辑记录</a>' +
'<button class="btn ghost" type="button" data-growth-delete-id="' +
escapeHtml(record.recordId) + '">删除记录</button></div>';
}
return '<div class="form-like growth-detail">' +
detailValue('标题', record.recordTitle) +
detailValue('关联人物', record.lineagePersonName || record.lineagePersonNo) +
@@ -288,9 +303,7 @@
detailValue('记录人', record.appUserNickName) +
detailValue('状态', record.status === '0' ? '正常' : '停用') +
detailValue('备注', record.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.recordId)) + '">编辑记录</a>' +
'<button class="btn ghost" type="button" data-growth-delete-id="' + escapeHtml(record.recordId) + '">删除记录</button></div>';
'</div>' + actions;
}
function showMessage(message) {
@@ -311,6 +324,10 @@
return Number(error && (error.status || error.code)) === 403;
}
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
@@ -327,13 +344,38 @@
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function setFormStatus(message) {
var status = query('[data-growth-form-status]');
if (status) status.textContent = message || '';
function stateHtml(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message);
return '<div class="api-state api-state--' + type + '" role="' +
(type === 'error' || type === 'forbidden' ? 'alert' : 'status') +
'" aria-live="polite">' + escapeHtml(message) + '</div>';
}
function setEditorEnabled(enabled, message) {
function setState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.innerHTML = stateHtml(type, message);
}
function setFormStatus(message, type) {
var status = query('[data-growth-form-status]');
if (!status) return;
if (!message) {
status.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(status, type, message);
return;
}
status.textContent = message;
}
function setEditorEnabled(enabled, message, type) {
var placeholder = query('[data-growth-editor-placeholder]');
queryAll('[data-growth-editor], [data-growth-editor-action]').forEach(function (element) {
@@ -341,19 +383,37 @@
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
if (!enabled && message) {
if (type) setState(placeholder, type, message);
else placeholder.textContent = message;
}
}
}
function setEditAccess(enabled) {
canEditContent = Boolean(enabled);
queryAll('[data-growth-create-link]').forEach(function (element) {
element.hidden = !canEditContent;
});
}
async function loadCapability(api, genealogyId) {
var genealogy = await api.genealogyDetail(genealogyId);
setEditAccess(canEditRecords(genealogy));
return genealogy;
}
function renderNoContext() {
var list = query('[data-growth-list]');
var detail = query('[data-growth-detail]');
var message = '请先选择家谱,再查看或维护成长记录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
setState(list, 'empty', message);
setState(detail, 'empty', '当前没有家谱上下文。');
setEditAccess(false);
setEditorEnabled(false, message, 'empty');
setFormStatus(message, 'empty');
}
function requireGenealogyId() {
@@ -363,11 +423,18 @@
return genealogyId;
}
function setWritePending(pending) {
function setWritePending(pending, label) {
var submit = query('[data-growth-form] [type="submit"]');
writePending = Boolean(pending);
queryAll('[data-growth-form] input, [data-growth-form] textarea, [data-growth-form] select, [data-growth-form] button, [data-growth-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
if (submit) {
if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent;
submit.textContent = writePending && label ? label : submit.dataset.defaultLabel;
submit.setAttribute('aria-busy', writePending ? 'true' : 'false');
}
}
function renderGrowthRecords(data) {
@@ -382,11 +449,11 @@
});
if (!container) return records;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">成长记录响应缺少稳定 GrowthRecordVo 字段,请联系后端核对。</div>';
setState(container, 'error', '成长记录响应缺少稳定 GrowthRecordVo 字段,请联系后端核对。');
return [];
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无成长记录</div>';
setState(container, 'empty', '暂无成长记录');
return [];
}
container.innerHTML = records.map(renderGrowthRow).join('');
@@ -405,6 +472,7 @@
var record;
if (!genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return null;
setState(query('[data-growth-detail]'), 'loading', '正在加载成长记录详情…');
try {
record = normalizeGrowthRecord(await api.growthRecordDetail(genealogyId, recordId));
if (!record) throw new Error('成长记录详情响应缺少稳定 GrowthRecordVo 字段');
@@ -413,7 +481,11 @@
return record;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
setState(
query('[data-growth-detail]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该成长记录。' : '成长记录详情加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该成长记录。' : (error.message || '成长记录详情加载失败'));
return null;
}
@@ -429,7 +501,9 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks();
setState(query('[data-growth-list]'), 'loading', '正在加载成长记录…');
try {
await loadCapability(api, genealogyId);
records = renderGrowthRecords(await api.growthRecords(genealogyId));
requestedRecordId = getCurrentRecordId();
if (requestedRecordId && recordsById[requestedRecordId]) {
@@ -441,11 +515,12 @@
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-growth-list]')) {
query('[data-growth-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : '成长记录加载失败,请稍后重试。') +
'</div>';
}
setEditAccess(false);
setState(
query('[data-growth-list]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : '成长记录加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : (error.message || '成长记录加载失败'));
}
}
@@ -510,8 +585,11 @@
if (!genealogyId) return;
recordId = getCurrentRecordId();
syncGenealogyLinks();
setEditorEnabled(false, '正在加载成长记录编辑信息…');
setEditorEnabled(false, '正在加载成长记录编辑信息…', 'loading');
setFormStatus('正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的成长记录。' };
result = await Promise.all([
api.lineagePersonOptions(genealogyId),
recordId ? api.growthRecordDetail(genealogyId, recordId) : Promise.resolve(null)
@@ -524,12 +602,17 @@
if (query('[data-growth-editor-title]')) query('[data-growth-editor-title]').textContent = '编辑成长记录';
}
setEditorEnabled(true);
setFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditAccess(false);
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的成长记录。'
: (error.message || '成长记录编辑信息加载失败'));
setFormStatus(error.message || '成长记录编辑信息加载失败');
: (error.message || '成长记录编辑信息加载失败'), getApiStateType(error));
setFormStatus(
isForbidden(error) ? '当前账号无权维护该家谱的成长记录。' : (error.message || '成长记录编辑信息加载失败'),
getApiStateType(error)
);
}
}
@@ -544,16 +627,16 @@
var savedId;
var verifiedDetail;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
body = buildGrowthRecordBody(getFormValues(form));
validation = validateGrowthRecordBody(body);
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存成长记录…');
setWritePending(true, '正在保存…');
setFormStatus('正在保存成长记录…', 'loading');
try {
result = recordId
? await api.updateGrowthRecord(genealogyId, recordId, body)
@@ -566,7 +649,10 @@
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该成长记录。' : (error.message || '成长记录保存失败'));
setFormStatus(
isForbidden(error) ? '当前账号无权保存该成长记录。' : (error.message || '成长记录保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -577,7 +663,7 @@
var genealogyId = requireGenealogyId();
var record = recordsById[recordId];
if (writePending || !genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (record ? record.recordTitle : '该成长记录') + '”吗?删除后不可恢复,并会释放附件引用。')) return;
setWritePending(true);
try {
@@ -647,6 +733,7 @@
validateGrowthRecordBody: validateGrowthRecordBody,
normalizeGrowthRecord: normalizeGrowthRecord,
matchesSavedRecord: matchesSavedRecord,
canEditRecords: canEditRecords,
renderLineageOptions: renderLineageOptions,
renderGrowthDetail: renderGrowthDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
+47 -10
View File
@@ -133,6 +133,10 @@
else root.location.href = 'login.html';
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
async function init() {
var list = root.document && root.document.querySelector('[data-help-list]');
var status = root.document && root.document.querySelector('[data-help-status]');
@@ -145,23 +149,48 @@
return;
}
function setStatus(message) {
if (status) status.textContent = message || '';
function setApiState(target, type, message) {
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message || '';
}
function setStatus(type, message) {
setApiState(status, type, message);
}
function setHelpList(items) {
if (!items.length) {
setApiState(list, 'empty', '当前暂无帮助文章。');
return;
}
list.innerHTML = renderHelpArticleList(items);
}
function setPendingButton(button, pending, label) {
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = pending;
button.textContent = pending ? label : button.dataset.defaultLabel;
}
async function refreshList() {
try {
list.classList.add('is-loading');
setStatus('正在读取帮助文章…');
list.innerHTML = renderHelpArticleList(await loadHelpArticles(api));
setStatus('');
setApiState(list, 'loading', '正在读取帮助文章…');
setStatus('loading', '正在读取帮助文章…');
setHelpList(await loadHelpArticles(api));
setStatus('', '');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
list.innerHTML = '<div class="api-empty">帮助文章读取失败,请稍后重试</div>';
setStatus(error.message || '帮助文章读取失败');
setApiState(list, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看帮助文章。' : '帮助文章读取失败,请稍后重试。');
setStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看帮助文章。' : (error.message || '帮助文章读取失败,请稍后重试。'));
} finally {
list.classList.remove('is-loading');
}
@@ -177,7 +206,7 @@
panel = list.querySelector('[data-help-detail-panel="' + helpId + '"]');
if (!panel) return;
button.disabled = true;
panel.textContent = '正在读取完整解答…';
setApiState(panel, 'loading', '正在读取完整解答…');
try {
panel.innerHTML = renderHelpArticleDetail(await loadHelpArticleDetail(api, helpId));
} catch (error) {
@@ -185,11 +214,18 @@
redirectToLogin(api);
return;
}
panel.textContent = error.message || '帮助文章详情读取失败';
setApiState(panel, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该帮助文章。' : (error.message || '帮助文章详情读取失败,请稍后重试。'));
}
});
if (refresh) refresh.addEventListener('click', refreshList);
if (refresh) refresh.addEventListener('click', async function () {
setPendingButton(refresh, true, '正在刷新…');
try {
await refreshList();
} finally {
setPendingButton(refresh, false, '正在刷新…');
}
});
await refreshList();
}
@@ -200,6 +236,7 @@
renderHelpArticleDetail: renderHelpArticleDetail,
loadHelpArticleDetail: loadHelpArticleDetail,
loadHelpArticles: loadHelpArticles,
isForbidden: isForbidden,
shouldRedirectToLogin: shouldRedirectToLogin,
init: init
};
+67 -22
View File
@@ -29,6 +29,14 @@
.replace(/'/g, '&#39;');
}
function renderApiState(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) {
return root.ProfileUI.renderApiState(type, message);
}
return '<div class="api-state api-state--' + type + '" data-api-state="' + type + '">' +
escapeHtml(message) + '</div>';
}
function normalizeId(value) {
var text;
@@ -129,9 +137,9 @@
function renderJoinGenealogyOptions(data) {
var items;
if (!Array.isArray(data)) return '<div class="api-empty">家谱列表数据无效,请刷新重试</div>';
if (!Array.isArray(data)) return renderApiState('error', '家谱列表数据无效,请刷新重试');
items = data.map(normalizeJoinGenealogy).filter(Boolean);
if (!items.length) return '<div class="api-empty">没有找到可申请加入的家谱</div>';
if (!items.length) return renderApiState('empty', '没有找到可申请加入的家谱');
return items.map(function (item) {
var meta = [
item.surname + '氏',
@@ -150,7 +158,7 @@
function renderMyJoinApplies(data) {
var items = normalizeJoinApplies(data);
if (!items.length) return '<div class="api-empty">当前没有加入申请</div>';
if (!items.length) return renderApiState('empty', '当前没有加入申请');
return items.map(function (item) {
var details = [
item.surname ? item.surname + '氏' : '',
@@ -184,10 +192,40 @@
else root.location.href = 'login.html';
}
function setText(selector, message) {
function getApiStateType(error) {
return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error';
}
function setText(selector, message, type) {
var element = root.document && root.document.querySelector(selector);
if (element) element.textContent = message || '';
if (!element) return;
if (!message) {
element.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(element, type || 'error', message);
return;
}
element.textContent = message;
}
function setOptionsState(options, type, message) {
if (!options) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(options, type, message);
return;
}
options.innerHTML = renderApiState(type, message);
}
function setSubmitPending(button, pending, pendingLabel) {
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = Boolean(pending);
button.textContent = pending ? pendingLabel : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
}
function getFormValues(form) {
@@ -222,27 +260,34 @@
return;
}
function clearSelectedGenealogy() {
selectedGenealogyId = '';
if (selected) selected.textContent = '尚未选择家谱';
}
async function loadOptions(keyword) {
var data;
setText('[data-join-apply-status]', '正在读取可申请家谱…');
clearSelectedGenealogy();
setText('[data-join-options-status]', '正在读取可申请家谱…', 'loading');
setOptionsState(options, 'loading', '正在读取可申请家谱…');
try {
data = await api.genealogyOptions(keyword ? { keyword: keyword } : {});
options.innerHTML = renderJoinGenealogyOptions(data);
setText('[data-join-apply-status]', '');
setText('[data-join-options-status]', '');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
options.innerHTML = '<div class="api-empty">读取家谱列表失败</div>';
setText('[data-join-apply-status]', error.message || '读取家谱列表失败,请稍后重试');
setOptionsState(options, getApiStateType(error), '读取家谱列表失败');
setText('[data-join-options-status]', error.message || '读取家谱列表失败,请稍后重试', getApiStateType(error));
}
}
try {
if (!canJoinGenealogy(await api.genealogyQuota())) {
setText('[data-join-apply-status]', '当前没有可用的家谱加入额度');
setText('[data-join-apply-status]', '当前没有可用的家谱加入额度', 'empty');
applyForm.querySelector('[data-join-apply-submit]').disabled = true;
return;
}
@@ -251,7 +296,7 @@
redirectToLogin(api);
return;
}
setText('[data-join-apply-status]', error.message || '无法读取家谱加入额度');
setText('[data-join-apply-status]', error.message || '无法读取家谱加入额度', getApiStateType(error));
applyForm.querySelector('[data-join-apply-submit]').disabled = true;
return;
}
@@ -280,18 +325,18 @@
event.preventDefault();
if (writePending) return;
if (!selectedGenealogyId) {
setText('[data-join-apply-status]', '请先从家谱列表中选择要加入的家谱');
setText('[data-join-apply-status]', '请先从家谱列表中选择要加入的家谱', 'error');
return;
}
body = buildJoinApplyBody(getFormValues(applyForm));
validation = validateJoinApplyBody(body);
if (validation) {
setText('[data-join-apply-status]', validation);
setText('[data-join-apply-status]', validation, 'error');
return;
}
writePending = true;
applyForm.querySelector('[data-join-apply-submit]').disabled = true;
setText('[data-join-apply-status]', '正在提交申请…');
setSubmitPending(applyForm.querySelector('[data-join-apply-submit]'), true, '正在提交…');
setText('[data-join-apply-status]', '正在提交申请…', 'loading');
try {
created = normalizeJoinApply(await api.applyToGenealogy(selectedGenealogyId, body));
if (!created) throw new Error('申请响应缺少有效申请信息');
@@ -305,10 +350,10 @@
redirectToLogin(api);
return;
}
setText('[data-join-apply-status]', error.message || '提交加入申请失败,请稍后重试');
setText('[data-join-apply-status]', error.message || '提交加入申请失败,请稍后重试', getApiStateType(error));
} finally {
writePending = false;
applyForm.querySelector('[data-join-apply-submit]').disabled = false;
setSubmitPending(applyForm.querySelector('[data-join-apply-submit]'), false, '正在提交…');
}
});
await loadOptions('');
@@ -330,7 +375,7 @@
async function loadApplies() {
var data;
setText('[data-my-join-applies-status]', '正在读取申请记录…');
setText('[data-my-join-applies-status]', '正在读取申请记录…', 'loading');
try {
data = await api.myGenealogyJoinApplies();
list.innerHTML = renderMyJoinApplies(data);
@@ -341,8 +386,8 @@
redirectToLogin(api);
return [];
}
list.innerHTML = '<div class="api-empty">读取加入申请失败</div>';
setText('[data-my-join-applies-status]', error.message || '读取加入申请失败,请稍后重试');
list.innerHTML = renderApiState(getApiStateType(error), '读取加入申请失败');
setText('[data-my-join-applies-status]', error.message || '读取加入申请失败,请稍后重试', getApiStateType(error));
return [];
}
}
@@ -361,7 +406,7 @@
if (root.confirm && !root.confirm('确认撤销这条加入申请吗?')) return;
writePending = true;
button.disabled = true;
setText('[data-my-join-applies-status]', '正在撤销申请…');
setText('[data-my-join-applies-status]', '正在撤销申请…', 'loading');
try {
await api.cancelGenealogyJoinApply(applyId);
refreshed = await loadApplies();
@@ -373,7 +418,7 @@
redirectToLogin(api);
return;
}
setText('[data-my-join-applies-status]', error.message || '撤销加入申请失败,请稍后重试');
setText('[data-my-join-applies-status]', error.message || '撤销加入申请失败,请稍后重试', getApiStateType(error));
} finally {
writePending = false;
}
+161 -24
View File
@@ -91,7 +91,7 @@
function renderPendingJoinApplies(data, canManage) {
var items = normalizePendingApplies(data);
if (!items.length) return '<div class="api-empty">当前没有待审核申请</div>';
if (!items.length) return '';
return items.map(function (item) {
var details = [
item.phone ? '申请联系电话:' + item.phone : '',
@@ -124,16 +124,103 @@
else root.location.href = 'login.html';
}
function setStatus(message) {
function getApiStateType(error) {
return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error';
}
function setStatus(message, type) {
var element = root.document && root.document.querySelector('[data-join-review-status]');
if (element) element.textContent = message || '';
if (!element) return;
if (!message) {
element.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(element, type || 'error', message);
return;
}
element.textContent = message;
}
function setReviewFormStatus(message, type) {
var element = root.document && root.document.querySelector('[data-join-review-form-status]');
if (!element) return;
if (!message) {
element.innerHTML = '';
return;
}
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(element, type || 'error', message);
return;
}
element.textContent = message;
}
function setPendingListState(message, type) {
var element = root.document && root.document.querySelector('[data-join-apply-list="pending"]');
if (!element) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(element, type || 'error', message);
return;
}
element.textContent = message;
}
function setReviewFormEnabled(enabled) {
var form = root.document && root.document.querySelector('[data-join-review-form]');
if (!form) return;
Array.prototype.slice.call(form.querySelectorAll('textarea, button')).forEach(function (field) {
field.disabled = !enabled;
});
}
function setReviewSubmitPending(pending) {
var button = root.document && root.document.querySelector('[data-join-review-submit]');
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.textContent = pending ? '正在提交…' : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
}
function openReviewForm(applyId, status) {
var form = root.document && root.document.querySelector('[data-join-review-form]');
var panel = root.document && root.document.querySelector('[data-join-review-editor]');
var title = root.document && root.document.querySelector('[data-join-review-form-title]');
var submit = root.document && root.document.querySelector('[data-join-review-submit]');
if (!form || !panel || !applyId || ['1', '2'].indexOf(status) < 0) return;
form.reset();
form.querySelector('[name="applyId"]').value = applyId;
form.querySelector('[name="status"]').value = status;
if (title) title.textContent = status === '1' ? '通过加入申请' : '拒绝加入申请';
if (submit) submit.textContent = status === '1' ? '确认通过' : '确认拒绝';
setReviewFormStatus('');
panel.hidden = false;
if (root.location && root.location.hash !== '#join-review-form') root.location.hash = 'join-review-form';
if (form.querySelector('[name="auditRemark"]') && form.querySelector('[name="auditRemark"]').focus) {
form.querySelector('[name="auditRemark"]').focus();
}
}
function closeReviewForm() {
var form = root.document && root.document.querySelector('[data-join-review-form]');
var panel = root.document && root.document.querySelector('[data-join-review-editor]');
if (form) form.reset();
if (panel) panel.hidden = true;
setReviewFormStatus('');
}
async function initJoinReviewPage() {
var page = root.document && root.document.querySelector('[data-join-review-page]');
var list = root.document && root.document.querySelector('[data-join-apply-list="pending"]');
var refresh = root.document && root.document.querySelector('[data-join-review-refresh]');
var form = root.document && root.document.querySelector('[data-join-review-form]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var genealogyId = getCurrentGenealogyId();
var canManage = false;
@@ -141,7 +228,8 @@
if (!page) return;
if (!genealogyId) {
setStatus('请先从“我的家谱”选择要管理的家谱');
setStatus('请先从“我的家谱”选择要管理的家谱', 'forbidden');
setReviewFormEnabled(false);
list.innerHTML = '<a class="module-row" href="profile-families.html?next=profile-join-review.html">选择家谱后继续</a>';
return;
}
@@ -156,19 +244,32 @@
async function loadPending() {
var data;
setStatus('正在读取待审核申请…');
setStatus('正在读取待审核申请…', 'loading');
try {
data = await api.pendingGenealogyJoinApplies(genealogyId);
if (!Array.isArray(data) || (data.length && !normalizePendingApplies(data).length)) {
setPendingListState('待审核申请响应字段不完整,请联系后端核对。', 'error');
setStatus('待审核申请响应字段不完整,请联系后端核对。', 'error');
return [];
}
if (!data.length) {
setPendingListState('当前没有待审核申请', 'empty');
setStatus('');
return [];
}
list.innerHTML = renderPendingJoinApplies(data, canManage);
setStatus(canManage ? '' : '当前账号没有审核该家谱申请的权限');
setStatus(canManage ? '' : '当前账号没有审核该家谱申请的权限', canManage ? undefined : 'forbidden');
return normalizePendingApplies(data);
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return [];
}
list.innerHTML = '<div class="api-empty">读取待审核申请失败</div>';
setStatus(error.message || '读取待审核申请失败,请稍后重试');
setPendingListState(
getApiStateType(error) === 'forbidden' ? '当前账号没有审核该家谱申请的权限。' : '读取待审核申请失败,请稍后重试',
getApiStateType(error)
);
setStatus(error.message || '读取待审核申请失败,请稍后重试', getApiStateType(error));
return [];
}
}
@@ -178,8 +279,9 @@
canManage = detail && detail.canManage === true;
if (!canManage) {
list.innerHTML = '<div class="api-empty">当前账号没有审核该家谱申请的权限</div>';
setStatus('当前账号没有审核该家谱申请的权限');
setPendingListState('当前账号没有审核该家谱申请的权限。', 'forbidden');
setStatus('当前账号没有审核该家谱申请的权限', 'forbidden');
setReviewFormEnabled(false);
return;
}
} catch (error) {
@@ -187,55 +289,90 @@
redirectToLogin(api);
return;
}
setStatus(error.message || '无法核验当前家谱管理权限');
setStatus(error.message || '无法核验当前家谱管理权限', getApiStateType(error));
setPendingListState(
getApiStateType(error) === 'forbidden' ? '当前账号没有审核该家谱申请的权限。' : '无法读取待审核申请,请稍后重试。',
getApiStateType(error)
);
setReviewFormEnabled(false);
return;
}
setReviewFormEnabled(true);
refresh.addEventListener('click', function () {
loadPending();
});
list.addEventListener('click', async function (event) {
list.addEventListener('click', function (event) {
var button = event.target.closest('[data-join-audit-id]');
var applyId;
var status;
var auditRemark = '';
var body;
var validation;
var refreshed;
if (!button || !list.contains(button) || writePending || !canManage) return;
applyId = normalizeId(button.getAttribute('data-join-audit-id'));
status = button.getAttribute('data-join-audit-status');
if (!applyId || ['1', '2'].indexOf(status) < 0) return;
if (status === '1' && root.confirm && !root.confirm('确认通过这条加入申请吗?')) return;
if (status === '2' && root.prompt) {
auditRemark = root.prompt('可填写拒绝原因(最多 500 个字符)', '') || '';
openReviewForm(applyId, status);
});
form.addEventListener('submit', async function (event) {
var values;
var applyId;
var body;
var validation;
var refreshed;
var forbidden = false;
event.preventDefault();
if (writePending || !canManage) return;
values = {};
Array.prototype.slice.call(form.querySelectorAll('[name]')).forEach(function (field) {
values[field.name] = field.value;
});
applyId = normalizeId(values.applyId);
body = buildJoinAuditBody(values);
if (!applyId) {
setReviewFormStatus('申请编号无效,请返回列表重新操作。', 'error');
return;
}
body = buildJoinAuditBody({ status: status, auditRemark: auditRemark });
validation = validateJoinAuditBody(body);
if (validation) {
setStatus(validation);
setReviewFormStatus(validation, 'error');
return;
}
writePending = true;
button.disabled = true;
setStatus('正在提交审核结果…');
setReviewFormEnabled(false);
setReviewSubmitPending(true);
setReviewFormStatus('正在提交审核结果…', 'loading');
try {
await api.auditGenealogyJoinApply(genealogyId, applyId, body);
refreshed = await loadPending();
if (refreshed.some(function (item) { return item.applyId === applyId; })) {
throw new Error('审核后申请仍在待审核列表');
}
closeReviewForm();
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '提交审核结果失败,请稍后重试');
forbidden = getApiStateType(error) === 'forbidden';
setReviewFormStatus(
forbidden ? '当前账号无权审核该家谱申请。' : (error.message || '提交审核结果失败,请稍后重试。'),
getApiStateType(error)
);
if (forbidden) {
canManage = false;
setStatus('当前账号无权审核该家谱申请。', 'forbidden');
}
} finally {
writePending = false;
setReviewSubmitPending(false);
if (!forbidden) setReviewFormEnabled(true);
}
});
root.document.querySelector('[data-join-review-cancel]').addEventListener('click', function () {
if (!writePending) closeReviewForm();
});
await loadPending();
}
+86 -25
View File
@@ -304,11 +304,11 @@
});
if (!container) return;
if (invalidCount) {
container.innerHTML = '<div class="api-empty">人物响应缺少可安全使用的 personId 或 name,请联系后端补充 DTO。</div>';
setLineageState('[data-lineage-list]', 'error', '人物响应缺少可安全使用的 personId 或 name,请联系后端补充 DTO。');
return;
}
if (!people.length) {
container.innerHTML = '<div class="api-empty">暂无世系人物</div>';
setLineageState('[data-lineage-list]', 'empty', '暂无世系人物');
return;
}
container.innerHTML = people.map(function (person) {
@@ -344,16 +344,20 @@
function renderLineageTreeHtml(data) {
var nodes = normalizeList(data).map(function (item) { return renderTreeNode(item, {}); }).filter(Boolean);
if (!nodes.length) {
return '<div class="api-empty">暂无世系树</div>';
}
if (!nodes.length) return '';
return '<ul class="lineage-tree">' + nodes.join('') + '</ul>';
}
function renderTree(data) {
var container = query('[data-lineage-tree]');
var html = renderLineageTreeHtml(data);
if (container) container.innerHTML = renderLineageTreeHtml(data);
if (!container) return;
if (!html) {
setLineageState('[data-lineage-tree]', 'empty', '暂无世系树');
return;
}
container.innerHTML = html;
}
function renderOptions(data) {
@@ -397,9 +401,11 @@
function updateBindingFields() {
var bindingMode = query('[name="bindingMode"]');
var memberSelect = query('[data-lineage-member-options]');
var specifiedMember = query('[data-lineage-specified-member]');
var specified = Boolean(bindingMode && bindingMode.value === 'SPECIFIED');
if (!memberSelect) return;
if (specifiedMember) specifiedMember.hidden = !specified;
memberSelect.hidden = !specified;
memberSelect.required = specified;
if (!specified) memberSelect.value = '';
@@ -469,7 +475,7 @@
if (!container) return;
if (!person) {
container.innerHTML = '<div class="api-empty">人物详情缺少可安全使用的 personId 或 name,请联系后端补充 DTO。</div>';
setLineageState('[data-lineage-detail]', 'error', '人物详情缺少可安全使用的 personId 或 name,请联系后端补充 DTO。');
return;
}
selectedPersonId = person.personId;
@@ -513,10 +519,61 @@
return values;
}
function setFormStatus(message) {
function setLineageState(selector, type, message) {
var target = query(selector);
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.innerHTML = '<div class="api-state api-state--' + escapeHtml(type) + '" data-api-state="' + escapeHtml(type) + '">' +
escapeHtml(message) + '</div>';
}
function setFormStatus(message, type) {
var target = query('[data-lineage-form-status]');
if (target) target.textContent = message || '';
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setActionStatus(message, type) {
var target = query('[data-lineage-action-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setLineageSubmitPending(pending) {
queryAll('[data-lineage-submit]').forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.textContent = pending ? '正在保存…' : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
});
}
function setLineageSubmitLabel(label) {
queryAll('[data-lineage-submit]').forEach(function (button) {
button.dataset.defaultLabel = label;
if (!writePending) button.textContent = label;
});
}
function relationLabel(mode) {
@@ -545,13 +602,12 @@
function renderNoContext() {
['[data-lineage-tree]', '[data-lineage-list]', '[data-lineage-detail]'].forEach(function (selector) {
var container = query(selector);
if (container) container.innerHTML = '<div class="api-empty">请从具体家谱进入世系管理</div>';
setLineageState(selector, 'error', '请从具体家谱进入世系管理');
});
if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = '';
currentPageTotal = null;
setLineageEnabled(false);
setActionStatus('请从具体家谱进入世系管理。', 'error');
updatePaginationControls();
}
@@ -588,18 +644,18 @@
function setWritePending(pending) {
writePending = Boolean(pending);
refreshControls();
setLineageSubmitPending(writePending);
}
function renderForbiddenState() {
['[data-lineage-tree]', '[data-lineage-list]', '[data-lineage-detail]'].forEach(function (selector) {
var container = query(selector);
if (container) container.innerHTML = '<div class="api-empty">当前账号没有世系人物管理权限</div>';
setLineageState(selector, 'forbidden', '当前账号没有世系人物管理权限');
});
if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = '';
currentPageTotal = null;
setLineageEnabled(false);
setFormStatus('当前账号没有世系人物管理权限');
setFormStatus('当前账号没有世系人物管理权限', 'forbidden');
setActionStatus('当前账号没有世系人物管理权限', 'forbidden');
}
function getCurrentKeyword() {
@@ -648,6 +704,9 @@
if (!genealogyId) return;
currentPageNum = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
syncGenealogyLinks(genealogyId);
setLineageState('[data-lineage-tree]', 'loading', '正在加载世系树…');
setLineageState('[data-lineage-list]', 'loading', '正在加载世系人物…');
setActionStatus('');
try {
results = await Promise.all([
api.genealogyDetail(genealogyId),
@@ -681,9 +740,7 @@
return;
}
['[data-lineage-tree]', '[data-lineage-list]'].forEach(function (selector) {
var container = query(selector);
if (container) container.innerHTML = '<div class="api-empty">世系人物加载失败,请稍后重试</div>';
setLineageState(selector, 'error', '世系人物加载失败,请稍后重试');
});
showMessage(error.message || '世系人物加载失败');
}
@@ -696,6 +753,7 @@
if (redirectUnauthorized(api)) return;
genealogyId = requireGenealogyId();
if (!genealogyId || !personId) return;
setLineageState('[data-lineage-detail]', 'loading', '正在加载成员详情…');
try {
renderDetail(await api.lineagePersonDetail(genealogyId, personId));
} catch (error) {
@@ -704,6 +762,7 @@
renderForbiddenState();
return;
}
setLineageState('[data-lineage-detail]', 'error', '人物详情加载失败,请稍后重试。');
showMessage(error.message || '人物详情加载失败');
}
}
@@ -720,7 +779,7 @@
relationMode = mode;
editingPersonId = '';
setRelationFieldVisibility(mode === 'spouses');
if (submitButton) submitButton.textContent = '新增' + relationLabel(mode);
if (submitButton) setLineageSubmitLabel('新增' + relationLabel(mode));
setFormStatus('当前将为“' + person.name + '”新增' + relationLabel(mode));
}
@@ -777,7 +836,7 @@
editingPersonId = person.personId;
setRelationFieldVisibility(false);
submitButton = query('[data-lineage-form] button[type="submit"]');
if (submitButton) submitButton.textContent = '保存修改';
if (submitButton) setLineageSubmitLabel('保存修改');
setFormStatus('正在编辑“' + person.name + '”');
}
@@ -787,7 +846,7 @@
relationMode = '';
editingPersonId = '';
setRelationFieldVisibility(false);
if (submitButton) submitButton.textContent = '保存成员';
if (submitButton) setLineageSubmitLabel('保存成员');
setFormStatus('');
if (root.setTimeout) root.setTimeout(updateBindingFields, 0);
}
@@ -803,11 +862,11 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
setWritePending(true);
setFormStatus('正在保存成员...');
setFormStatus('正在保存成员…', 'loading');
try {
if (editingPersonId) {
result = await api.updateLineagePerson(genealogyId, editingPersonId, body);
@@ -833,7 +892,7 @@
renderForbiddenState();
return;
}
setFormStatus(error.message || '成员保存失败');
setFormStatus(error.message || '成员保存失败', 'error');
} finally {
setWritePending(false);
}
@@ -849,6 +908,7 @@
if (!genealogyId || !personId) return;
if (root.confirm && !root.confirm('确认停用“' + (person ? person.name : '该成员') + '”吗?有正常子女时后端会拒绝停用。')) return;
setWritePending(true);
setActionStatus('正在停用成员…', 'loading');
try {
await api.disableLineagePerson(genealogyId, personId);
selectedPersonId = '';
@@ -861,6 +921,7 @@
renderForbiddenState();
return;
}
setActionStatus(error.message || '成员停用失败', 'error');
showMessage(error.message || '成员停用失败');
} finally {
setWritePending(false);
+65 -22
View File
@@ -133,9 +133,7 @@
var source = values || {};
var body = {};
var memberName = trimOrUndefined(source.memberName);
var relationName = source.relationName === undefined || source.relationName === null
? undefined
: stringValue(source.relationName).trim();
var relationName = trimOrUndefined(source.relationName);
var roleType = trimOrUndefined(source.roleType);
var lineagePersonId = normalizeId(source.lineagePersonId);
@@ -175,8 +173,8 @@
return Boolean(role && role !== 'owner');
}
function canTransferOwner(role) {
return role === 'owner';
function canTransferOwner(role, canManage) {
return role === 'owner' && canManage === true;
}
function buildOwnerTransferBody(targetMemberId) {
@@ -246,10 +244,39 @@
else if (root.alert) root.alert(message);
}
function setStatus(selector, message) {
function setStatus(selector, message, type) {
var target = query(selector);
if (target) target.textContent = message || '';
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setMemberListState(type, message) {
var target = query('[data-member-list]');
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.innerHTML = '<div class="api-state api-state--' + escapeHtml(type) + '" data-api-state="' + escapeHtml(type) + '">' +
escapeHtml(message) + '</div>';
}
function setPendingButton(selector, pending, pendingLabel) {
queryAll(selector).forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.textContent = pending ? pendingLabel : button.dataset.defaultLabel;
button.setAttribute('aria-busy', pending ? 'true' : 'false');
});
}
function syncLinks() {
@@ -261,6 +288,9 @@
queryAll('[data-member-form] input, [data-member-form] select, [data-member-form] button, [data-owner-transfer-form] select, [data-owner-transfer-form] button, [data-member-edit-id], [data-member-remove-id], [data-member-leave]').forEach(function (control) {
control.disabled = writePending;
});
setPendingButton('[data-member-submit]', writePending, '正在保存…');
setPendingButton('[data-owner-transfer-submit]', writePending, '正在转让…');
setPendingButton('[data-member-leave]', writePending, '正在退出…');
}
function renderLineageOptions(data) {
@@ -299,16 +329,21 @@
members.forEach(function (member) { membersById[member.memberId] = member; });
if (!container) return members;
if (!Array.isArray(data) || (data.length && !members.length)) {
container.innerHTML = '<div class="api-empty">成员响应缺少稳定 GenealogyMemberVo 字段,请联系后端核对。</div>';
setMemberListState('error', '成员响应缺少稳定 GenealogyMemberVo 字段,请联系后端核对。');
return [];
}
container.innerHTML = members.length ? members.map(function (member) {
if (!members.length) {
setMemberListState('empty', '暂无正常成员');
renderTransferOptions(members);
return members;
}
container.innerHTML = members.map(function (member) {
return renderMemberRow(member, {
actorRole: actorRole,
currentMemberId: currentMemberId,
canManage: managementAccess
});
}).join('') : '<div class="api-empty">暂无正常成员</div>';
}).join('');
renderTransferOptions(members);
return members;
}
@@ -358,8 +393,9 @@
}
function renderNoContext() {
if (query('[data-member-list]')) query('[data-member-list]').innerHTML = '<div class="api-empty">请先选择家谱,再管理成员。</div>';
setStatus('[data-member-overview]', '当前没有家谱上下文。');
setMemberListState('forbidden', '请先选择家谱,再管理成员。');
setStatus('[data-member-overview]', '当前没有家谱上下文。', 'forbidden');
setStatus('[data-member-action-status]', '请先选择家谱,再进行成员操作。', 'forbidden');
queryAll('[data-member-management], [data-member-leave-section], [data-owner-transfer-section]').forEach(function (element) {
element.hidden = true;
});
@@ -381,6 +417,9 @@
return;
}
syncLinks();
setStatus('[data-member-overview]', '正在加载家谱和当前权限…', 'loading');
setMemberListState('loading', '正在加载成员…');
setStatus('[data-member-action-status]', '');
try {
results = await Promise.all([
api.genealogyDetail(genealogyId),
@@ -399,13 +438,13 @@
setStatus('[data-member-overview]', (detail.genealogyName || '当前家谱') + ' · ' + members.length + ' 位正常成员 · 当前角色:' + (roleLabels[actorRole] || '成员'));
queryAll('[data-member-management]').forEach(function (element) { element.hidden = !managementAccess; });
queryAll('[data-member-leave-section]').forEach(function (element) { element.hidden = !currentMember || !canLeaveGenealogy(actorRole); });
queryAll('[data-owner-transfer-section]').forEach(function (element) { element.hidden = !currentMember || !canTransferOwner(actorRole); });
queryAll('[data-owner-transfer-section]').forEach(function (element) { element.hidden = !currentMember || !canTransferOwner(actorRole, managementAccess); });
renderLineageOptions(results[3]);
renderMembers(results[2]);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-member-list]')) query('[data-member-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。') + '</div>';
setMemberListState(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。');
setStatus('[data-member-overview]', isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。', isForbidden(error) ? 'forbidden' : 'error');
showMessage(isForbidden(error) ? '当前账号无权查看该家谱成员。' : (error.message || '成员加载失败'));
}
}
@@ -429,12 +468,12 @@
body = buildMemberUpdateBody(getFormValues(form));
validation = validateMemberUpdateBody(body);
if (validation) {
setStatus('[data-member-form-status]', validation);
setStatus('[data-member-form-status]', validation, 'error');
return;
}
delete body.invalidLineagePersonId;
setWritePending(true);
setStatus('[data-member-form-status]', '正在保存成员…');
setStatus('[data-member-form-status]', '正在保存成员…', 'loading');
try {
saved = normalizeMember(await api.updateGenealogyMember(genealogyId, selectedMemberId, body));
if (!saved || saved.memberId !== selectedMemberId) throw new Error('成员修改响应缺少稳定 memberId');
@@ -445,7 +484,7 @@
showMessage('成员信息已更新');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-member-form-status]', isForbidden(error) ? '当前账号无权修改该成员。' : (error.message || '成员修改失败'));
setStatus('[data-member-form-status]', isForbidden(error) ? '当前账号无权修改该成员。' : (error.message || '成员修改失败'), isForbidden(error) ? 'forbidden' : 'error');
} finally {
setWritePending(false);
}
@@ -459,6 +498,7 @@
if (writePending || !genealogyId || !target || !managementAccess || !canRemoveMember(actorRole, target.roleType) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认将“' + memberDisplayName(target) + '”移出当前家谱吗?该操作不会删除其世系人物资料。')) return;
setWritePending(true);
setStatus('[data-member-action-status]', '正在移出成员…', 'loading');
try {
await api.removeGenealogyMember(genealogyId, memberId);
if ((await fetchAndRenderMembers(api, genealogyId)).some(function (member) { return member.memberId === memberId; })) {
@@ -468,6 +508,7 @@
showMessage('成员已移出家谱');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-member-action-status]', isForbidden(error) ? '当前账号无权移出该成员。' : (error.message || '移出成员失败'), isForbidden(error) ? 'forbidden' : 'error');
showMessage(isForbidden(error) ? '当前账号无权移出该成员。' : (error.message || '移出成员失败'));
} finally {
setWritePending(false);
@@ -481,11 +522,13 @@
if (writePending || !genealogyId || !currentMemberId || !canLeaveGenealogy(actorRole) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认退出当前家谱吗?退出后需重新受邀才能进入。世系人物资料不会被删除。')) return;
setWritePending(true);
setStatus('[data-member-action-status]', '正在退出当前家谱…', 'loading');
try {
await api.leaveGenealogy(genealogyId);
if (root.location) root.location.href = 'profile-families.html';
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-member-action-status]', isForbidden(error) ? '当前账号无权退出该家谱。' : (error.message || '退出家谱失败'), isForbidden(error) ? 'forbidden' : 'error');
showMessage(isForbidden(error) ? '当前账号无权退出该家谱。' : (error.message || '退出家谱失败'));
} finally {
setWritePending(false);
@@ -499,21 +542,21 @@
var validation = validateOwnerTransferBody(body, currentMemberId);
var target = membersById[body.targetMemberId];
if (writePending || !genealogyId || !canTransferOwner(actorRole) || redirectUnauthorized(api)) return;
if (writePending || !genealogyId || !canTransferOwner(actorRole, managementAccess) || redirectUnauthorized(api)) return;
if (validation || !target) {
setStatus('[data-owner-transfer-status]', validation || '请选择成员列表中的新谱主');
setStatus('[data-owner-transfer-status]', validation || '请选择成员列表中的新谱主', 'error');
return;
}
if (root.confirm && !root.confirm('确认将谱主转让给“' + memberDisplayName(target) + '”吗?转让后当前账号将变为管理员。')) return;
setWritePending(true);
setStatus('[data-owner-transfer-status]', '正在转让谱主…');
setStatus('[data-owner-transfer-status]', '正在转让谱主…', 'loading');
try {
await api.transferGenealogyOwner(genealogyId, body);
await loadPage();
setStatus('[data-owner-transfer-status]', '谱主已转让');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus('[data-owner-transfer-status]', isForbidden(error) ? '只有当前谱主可以执行转让。' : (error.message || '谱主转让失败'));
setStatus('[data-owner-transfer-status]', isForbidden(error) ? '只有当前谱主可以执行转让。' : (error.message || '谱主转让失败'), isForbidden(error) ? 'forbidden' : 'error');
} finally {
setWritePending(false);
}
+124 -37
View File
@@ -15,6 +15,7 @@
var documentRef = root.document;
var memosById = Object.create(null);
var canEditContent = false;
var writePending = false;
function getApi() {
@@ -228,18 +229,25 @@
return memoId ? url + '&memoId=' + encodeURIComponent(memoId) : url;
}
function canEditRecords(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function renderMemoRow(memo) {
var summary = [];
var actions = '<button class="pill" type="button" data-memo-detail-id="' +
escapeHtml(memo.memoId) + '">查看详情</button>';
summary.push(memo.completed === '1' ? '已完成' : '未完成');
if (memo.remindTime) summary.push('提醒:' + memo.remindTime);
if (memo.memoContent) summary.push(memo.memoContent);
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(memo.genealogyId, memo.memoId)) +
'">编辑</a><button class="pill is-danger" type="button" data-memo-delete-id="' +
escapeHtml(memo.memoId) + '">删除</button>';
}
return '<article class="module-row memo-row"><div><h3>' + escapeHtml(memo.memoTitle) + '</h3><p>' +
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-memo-detail-id="' + escapeHtml(memo.memoId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(memo.genealogyId, memo.memoId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-memo-delete-id="' + escapeHtml(memo.memoId) + '">删除</button>' +
'</div></article>';
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' + actions + '</div></article>';
}
function detailValue(label, value) {
@@ -248,9 +256,16 @@
function renderMemoDetail(memo) {
var count;
var actions = '';
if (!memo) return '<div class="api-empty">请选择一条备忘录查看详情</div>';
if (!memo) return stateHtml('empty', '请选择一条备忘录查看详情');
count = attachmentCount(memo.mediaOssIds);
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(memo.genealogyId, memo.memoId)) + '">编辑备忘录</a>' +
'<button class="btn ghost" type="button" data-memo-delete-id="' +
escapeHtml(memo.memoId) + '">删除备忘录</button></div>';
}
return '<div class="form-like memo-detail">' +
detailValue('备忘标题', memo.memoTitle) +
detailValue('备忘内容', memo.memoContent) +
@@ -259,9 +274,7 @@
detailValue('附件', count ? count + ' 个附件' : '未上传') +
detailValue('记录人', memo.appUserNickName) +
detailValue('备注', memo.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(memo.genealogyId, memo.memoId)) + '">编辑备忘录</a>' +
'<button class="btn ghost" type="button" data-memo-delete-id="' + escapeHtml(memo.memoId) + '">删除备忘录</button></div>';
'</div>' + actions;
}
function showMessage(message) {
@@ -279,6 +292,10 @@
return Number(error && (error.status || error.code)) === 403;
}
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
@@ -288,13 +305,38 @@
return true;
}
function setFormStatus(message) {
var target = query('[data-memo-form-status]');
if (target) target.textContent = message || '';
function stateHtml(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message);
return '<div class="api-state api-state--' + type + '" role="' +
(type === 'error' || type === 'forbidden' ? 'alert' : 'status') +
'" aria-live="polite">' + escapeHtml(message) + '</div>';
}
function setEditorEnabled(enabled, message) {
function setState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.innerHTML = stateHtml(type, message);
}
function setFormStatus(message, type) {
var target = query('[data-memo-form-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setEditorEnabled(enabled, message, type) {
var placeholder = query('[data-memo-editor-placeholder]');
queryAll('[data-memo-editor], [data-memo-editor-action]').forEach(function (element) {
@@ -302,15 +344,39 @@
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
if (!enabled && message) {
if (type) setState(placeholder, type, message);
else placeholder.textContent = message;
}
}
}
function setWritePending(value) {
function setEditAccess(enabled) {
canEditContent = Boolean(enabled);
queryAll('[data-memo-create-link]').forEach(function (element) {
element.hidden = !canEditContent;
});
}
async function loadCapability(api, genealogyId) {
var genealogy = await api.genealogyDetail(genealogyId);
setEditAccess(canEditRecords(genealogy));
return genealogy;
}
function setWritePending(value, label) {
var submit = query('[data-memo-form] [type="submit"]');
writePending = Boolean(value);
queryAll('[data-memo-form] button, [data-memo-form] input, [data-memo-form] textarea, [data-memo-form] select, [data-memo-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
if (submit) {
if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent;
submit.textContent = writePending && label ? label : submit.dataset.defaultLabel;
submit.setAttribute('aria-busy', writePending ? 'true' : 'false');
}
}
function syncLinks() {
@@ -322,10 +388,11 @@
var detail = query('[data-memo-detail]');
var message = '请先选择家谱,再查看或维护备忘录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
setState(list, 'empty', message);
setState(detail, 'empty', '当前没有家谱上下文。');
setEditAccess(false);
setEditorEnabled(false, message, 'empty');
setFormStatus(message, 'empty');
}
function requireGenealogyId() {
@@ -347,11 +414,11 @@
});
if (!container) return memos;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">备忘录响应缺少稳定 MemoVo 字段,请联系后端核对。</div>';
setState(container, 'error', '备忘录响应缺少稳定 MemoVo 字段,请联系后端核对。');
return [];
}
if (!memos.length) {
container.innerHTML = '<div class="api-empty">暂无备忘录</div>';
setState(container, 'empty', '暂无备忘录');
return [];
}
container.innerHTML = memos.map(renderMemoRow).join('');
@@ -370,6 +437,7 @@
var memo;
if (!genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return null;
setState(query('[data-memo-detail]'), 'loading', '正在加载备忘录详情…');
try {
memo = normalizeMemo(await api.memoDetail(genealogyId, memoId));
if (!memo) throw new Error('备忘录详情响应缺少稳定 MemoVo 字段');
@@ -378,7 +446,11 @@
return memo;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
setState(
query('[data-memo-detail]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该备忘录。' : '备忘录详情加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该备忘录。' : (error.message || '备忘录详情加载失败'));
return null;
}
@@ -394,7 +466,9 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
setState(query('[data-memo-list]'), 'loading', '正在加载备忘录…');
try {
await loadCapability(api, genealogyId);
memos = renderMemos(await api.memos(genealogyId));
requestedMemoId = getCurrentMemoId();
if (requestedMemoId && memosById[requestedMemoId]) {
@@ -406,11 +480,12 @@
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-memo-list]')) {
query('[data-memo-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : '备忘录加载失败,请稍后重试。') +
'</div>';
}
setEditAccess(false);
setState(
query('[data-memo-list]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : '备忘录加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : (error.message || '备忘录加载失败'));
}
}
@@ -457,8 +532,11 @@
if (!genealogyId) return;
memoId = getCurrentMemoId();
syncLinks();
setEditorEnabled(false, '正在加载备忘录编辑信息…');
setEditorEnabled(false, '正在加载备忘录编辑信息…', 'loading');
setFormStatus('正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的备忘录。' };
if (memoId) {
memo = normalizeMemo(await api.memoDetail(genealogyId, memoId));
if (!memo) throw new Error('备忘录详情响应缺少稳定 MemoVo 字段');
@@ -466,12 +544,17 @@
if (query('[data-memo-editor-title]')) query('[data-memo-editor-title]').textContent = '编辑备忘录';
}
setEditorEnabled(true);
setFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditAccess(false);
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的备忘录。'
: (error.message || '备忘录编辑信息加载失败'));
setFormStatus(error.message || '备忘录编辑信息加载失败');
: (error.message || '备忘录编辑信息加载失败'), getApiStateType(error));
setFormStatus(
isForbidden(error) ? '当前账号无权维护该家谱的备忘录。' : (error.message || '备忘录编辑信息加载失败'),
getApiStateType(error)
);
}
}
@@ -486,16 +569,16 @@
var savedId;
var verifiedDetail;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
body = buildMemoBody(getFormValues(form));
validation = validateMemoBody(body);
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存备忘录…');
setWritePending(true, '正在保存…');
setFormStatus('正在保存备忘录…', 'loading');
try {
result = memoId
? await api.updateMemo(genealogyId, memoId, body)
@@ -508,7 +591,10 @@
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该备忘录。' : (error.message || '备忘录保存失败'));
setFormStatus(
isForbidden(error) ? '当前账号无权保存该备忘录。' : (error.message || '备忘录保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -519,7 +605,7 @@
var genealogyId = requireGenealogyId();
var memo = memosById[memoId];
if (writePending || !genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (memo ? memo.memoTitle : '该备忘录') + '”吗?删除后不可恢复,并会释放附件引用。')) return;
setWritePending(true);
try {
@@ -589,6 +675,7 @@
validateMemoBody: validateMemoBody,
normalizeMemo: normalizeMemo,
matchesSavedMemo: matchesSavedMemo,
canEditRecords: canEditRecords,
isEditableMemo: isEditableMemo,
renderMemoDetail: renderMemoDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
+126 -37
View File
@@ -15,6 +15,7 @@
var documentRef = root.document;
var recordsById = Object.create(null);
var canEditContent = false;
var writePending = false;
var meritTypes = {
donation: '捐赠',
@@ -284,17 +285,24 @@
return meritId ? url + '&meritId=' + encodeURIComponent(meritId) : url;
}
function canEditRecords(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function renderMeritRow(record) {
var summary = [meritTypes[record.meritType]];
var actions = '<button class="pill" type="button" data-merit-detail-id="' +
escapeHtml(record.meritId) + '">查看详情</button>';
if (record.meritTime) summary.push(record.meritTime);
if (record.amount) summary.push('金额:' + record.amount);
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.meritId)) +
'">编辑</a><button class="pill is-danger" type="button" data-merit-delete-id="' +
escapeHtml(record.meritId) + '">删除</button>';
}
return '<article class="module-row merit-row"><div><h3>' + escapeHtml(record.meritTitle) + '</h3><p>' +
escapeHtml(record.donorName + ' · ' + summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-merit-detail-id="' + escapeHtml(record.meritId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.meritId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-merit-delete-id="' + escapeHtml(record.meritId) + '">删除</button>' +
'</div></article>';
escapeHtml(record.donorName + ' · ' + summary.join(' · ')) + '</p></div><div class="row-actions">' + actions + '</div></article>';
}
function detailValue(label, value) {
@@ -302,7 +310,15 @@
}
function renderMeritDetail(record) {
if (!record) return '<div class="api-empty">请选择一条功德记录查看详情</div>';
var actions = '';
if (!record) return stateHtml('empty', '请选择一条功德记录查看详情');
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.meritId)) + '">编辑功德记录</a>' +
'<button class="btn ghost" type="button" data-merit-delete-id="' +
escapeHtml(record.meritId) + '">删除功德记录</button></div>';
}
return '<div class="form-like merit-detail">' +
detailValue('功德人', record.donorName) +
detailValue('功德类型', meritTypes[record.meritType]) +
@@ -312,9 +328,7 @@
detailValue('功德时间', record.meritTime) +
detailValue('记录人', record.appUserNickName) +
detailValue('备注', record.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.meritId)) + '">编辑功德记录</a>' +
'<button class="btn ghost" type="button" data-merit-delete-id="' + escapeHtml(record.meritId) + '">删除功德记录</button></div>';
'</div>' + actions;
}
function showMessage(message) {
@@ -332,6 +346,10 @@
return Number(error && (error.status || error.code)) === 403;
}
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
@@ -341,13 +359,38 @@
return true;
}
function setFormStatus(message) {
var target = query('[data-merit-form-status]');
if (target) target.textContent = message || '';
function stateHtml(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message);
return '<div class="api-state api-state--' + type + '" role="' +
(type === 'error' || type === 'forbidden' ? 'alert' : 'status') +
'" aria-live="polite">' + escapeHtml(message) + '</div>';
}
function setEditorEnabled(enabled, message) {
function setState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.innerHTML = stateHtml(type, message);
}
function setFormStatus(message, type) {
var target = query('[data-merit-form-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setEditorEnabled(enabled, message, type) {
var placeholder = query('[data-merit-editor-placeholder]');
queryAll('[data-merit-editor], [data-merit-editor-action]').forEach(function (element) {
@@ -355,15 +398,39 @@
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
if (!enabled && message) {
if (type) setState(placeholder, type, message);
else placeholder.textContent = message;
}
}
}
function setWritePending(value) {
function setEditAccess(enabled) {
canEditContent = Boolean(enabled);
queryAll('[data-merit-create-link]').forEach(function (element) {
element.hidden = !canEditContent;
});
}
async function loadCapability(api, genealogyId) {
var genealogy = await api.genealogyDetail(genealogyId);
setEditAccess(canEditRecords(genealogy));
return genealogy;
}
function setWritePending(value, label) {
var submit = query('[data-merit-form] [type="submit"]');
writePending = Boolean(value);
queryAll('[data-merit-form] button, [data-merit-form] input, [data-merit-form] textarea, [data-merit-form] select, [data-merit-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
if (submit) {
if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent;
submit.textContent = writePending && label ? label : submit.dataset.defaultLabel;
submit.setAttribute('aria-busy', writePending ? 'true' : 'false');
}
}
function syncLinks() {
@@ -375,10 +442,11 @@
var detail = query('[data-merit-detail]');
var message = '请先选择家谱,再查看或维护功德记录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
setState(list, 'empty', message);
setState(detail, 'empty', '当前没有家谱上下文。');
setEditAccess(false);
setEditorEnabled(false, message, 'empty');
setFormStatus(message, 'empty');
}
function requireGenealogyId() {
@@ -400,11 +468,11 @@
});
if (!container) return records;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">功德记录响应缺少稳定 MeritRecordVo 字段,请联系后端核对。</div>';
setState(container, 'error', '功德记录响应缺少稳定 MeritRecordVo 字段,请联系后端核对。');
return [];
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无功德记录</div>';
setState(container, 'empty', '暂无功德记录');
return [];
}
container.innerHTML = records.map(renderMeritRow).join('');
@@ -423,6 +491,7 @@
var record;
if (!genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return null;
setState(query('[data-merit-detail]'), 'loading', '正在加载功德记录详情…');
try {
record = normalizeMeritRecord(await api.meritRecordDetail(genealogyId, meritId));
if (!record) throw new Error('功德记录详情响应缺少稳定 MeritRecordVo 字段');
@@ -431,7 +500,11 @@
return record;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
setState(
query('[data-merit-detail]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该功德记录。' : '功德记录详情加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该功德记录。' : (error.message || '功德记录详情加载失败'));
return null;
}
@@ -447,7 +520,9 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
setState(query('[data-merit-list]'), 'loading', '正在加载功德记录…');
try {
await loadCapability(api, genealogyId);
records = renderMeritRecords(await api.meritRecords(genealogyId));
requestedMeritId = getCurrentMeritId();
if (requestedMeritId && recordsById[requestedMeritId]) {
@@ -459,11 +534,12 @@
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-merit-list]')) {
query('[data-merit-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : '功德记录加载失败,请稍后重试。') +
'</div>';
}
setEditAccess(false);
setState(
query('[data-merit-list]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : '功德记录加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : (error.message || '功德记录加载失败'));
}
}
@@ -517,8 +593,11 @@
if (!genealogyId) return;
meritId = getCurrentMeritId();
syncLinks();
setEditorEnabled(false, '正在加载功德记录编辑信息…');
setEditorEnabled(false, '正在加载功德记录编辑信息…', 'loading');
setFormStatus('正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的功德记录。' };
if (meritId) {
record = normalizeMeritRecord(await api.meritRecordDetail(genealogyId, meritId));
if (!record) throw new Error('功德记录详情响应缺少稳定 MeritRecordVo 字段');
@@ -526,12 +605,17 @@
if (query('[data-merit-editor-title]')) query('[data-merit-editor-title]').textContent = '编辑功德记录';
}
setEditorEnabled(true);
setFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditAccess(false);
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的功德记录。'
: (error.message || '功德记录编辑信息加载失败'));
setFormStatus(error.message || '功德记录编辑信息加载失败');
: (error.message || '功德记录编辑信息加载失败'), getApiStateType(error));
setFormStatus(
isForbidden(error) ? '当前账号无权维护该家谱的功德记录。' : (error.message || '功德记录编辑信息加载失败'),
getApiStateType(error)
);
}
}
@@ -546,18 +630,19 @@
var savedId;
var verifiedDetail;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll();
body = buildMeritRecordBody(getFormValues(form));
validation = validateMeritRecordBody(body);
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
delete body.invalidAmount;
delete body.invalidAmountPrecision;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存功德记录…');
setWritePending(true, '正在保存…');
setFormStatus('正在保存功德记录…', 'loading');
try {
result = meritId
? await api.updateMeritRecord(genealogyId, meritId, body)
@@ -570,7 +655,10 @@
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该功德记录。' : (error.message || '功德记录保存失败'));
setFormStatus(
isForbidden(error) ? '当前账号无权保存该功德记录。' : (error.message || '功德记录保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -581,7 +669,7 @@
var genealogyId = requireGenealogyId();
var record = recordsById[meritId];
if (writePending || !genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (record ? record.meritTitle : '该功德记录') + '”吗?删除后不可恢复。')) return;
setWritePending(true);
try {
@@ -635,6 +723,7 @@
validateMeritRecordBody: validateMeritRecordBody,
normalizeMeritRecord: normalizeMeritRecord,
matchesSavedMerit: matchesSavedMerit,
canEditRecords: canEditRecords,
isEditableMerit: isEditableMerit,
renderMeritDetail: renderMeritDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
+40 -15
View File
@@ -178,10 +178,23 @@
return true;
}
function updateStatus(message) {
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function setNotificationState(target, type, message) {
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message || '';
}
function updateStatus(type, message) {
var status = query('[data-notification-status]');
if (status) status.textContent = message || '';
setNotificationState(status, type, message);
}
function setWritePending(value) {
@@ -203,21 +216,27 @@
});
if (!container) return notifications;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">通知列表响应缺少稳定 NotificationView 字段,请联系后端核对。</div>';
setNotificationState(container, 'error', '通知列表响应无效,请稍后重试。');
return [];
}
if (!notifications.length) {
container.innerHTML = '<div class="api-empty">暂无通知</div>';
setNotificationState(container, 'empty', '当前暂无通知。');
return [];
}
container.innerHTML = notifications.map(renderNotificationRow).join('');
if (writePending) setWritePending(true);
return notifications;
}
function renderDetail(notification) {
var container = query('[data-notification-detail]');
if (container) container.innerHTML = renderNotificationDetail(notification);
if (!container) return;
if (!notification) {
setNotificationState(container, 'empty', '请选择一条通知查看详情。');
return;
}
container.innerHTML = renderNotificationDetail(notification);
}
async function loadNotificationDetail(notificationId) {
@@ -225,6 +244,7 @@
var notification;
if (!normalizeId(notificationId) || redirectUnauthorized(api)) return null;
setNotificationState(query('[data-notification-detail]'), 'loading', '正在读取通知详情…');
try {
notification = normalizeNotification(await api.notificationDetail(notificationId));
if (!notification || !matchesNotification(notification, notificationId)) {
@@ -236,8 +256,8 @@
return notification;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
updateStatus(error.message || '通知详情加载失败');
setNotificationState(query('[data-notification-detail]'), isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该通知详情。' : '通知详情加载失败,请稍后重试。');
updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该通知详情。' : (error.message || '通知详情加载失败,请稍后重试。'));
return null;
}
}
@@ -257,10 +277,12 @@
if (redirectUnauthorized(api)) return;
if (!api.notifications || !api.notificationDetail || !api.unreadNotificationCount) {
updateStatus('消息接口初始化失败,请刷新后重试');
setNotificationState(query('[data-notification-list]'), 'error', '消息服务初始化失败,请刷新后重试');
updateStatus('error', '消息服务初始化失败,请刷新后重试。');
return;
}
updateStatus('正在同步消息…');
setNotificationState(query('[data-notification-list]'), 'loading', '正在同步消息…');
updateStatus('loading', '正在同步消息…');
try {
result = await Promise.all([
api.notifications(buildNotificationQuery(currentFilter())),
@@ -273,12 +295,12 @@
: (notifications[0] && notifications[0].notificationId);
if (nextSelectedId) await loadNotificationDetail(nextSelectedId);
else renderDetail(null);
updateStatus('消息已同步');
updateStatus('', '消息已同步');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
renderNotifications(null);
renderDetail(null);
updateStatus(error.message || '消息同步失败');
setNotificationState(query('[data-notification-detail]'), isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看通知详情。' : '消息同步失败,请稍后重试。');
updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看通知列表。' : (error.message || '消息同步失败,请稍后重试。'));
}
}
@@ -288,6 +310,7 @@
if (writePending || !normalizeId(notificationId) || !canMarkRead(notification) || redirectUnauthorized(api)) return;
setWritePending(true);
updateStatus('loading', '正在标记通知为已读…');
try {
await api.markNotificationRead(notificationId);
selectedNotificationId = notificationId;
@@ -295,7 +318,7 @@
await loadNotifications();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
updateStatus(error.message || '标记通知已读失败');
updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限标记该通知为已读。' : (error.message || '标记通知已读失败,请稍后重试。'));
} finally {
setWritePending(false);
}
@@ -306,17 +329,18 @@
if (writePending || redirectUnauthorized(api)) return;
if (!api.markAllNotificationsRead) {
updateStatus('消息接口初始化失败,请刷新后重试');
updateStatus('error', '消息服务初始化失败,请刷新后重试');
return;
}
setWritePending(true);
updateStatus('loading', '正在将全部通知标记为已读…');
try {
await api.markAllNotificationsRead();
showMessage('已全部标记为已读');
await loadNotifications();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
updateStatus(error.message || '全部标记已读失败');
updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限标记全部通知为已读。' : (error.message || '全部标记已读失败,请稍后重试。'));
} finally {
setWritePending(false);
}
@@ -368,6 +392,7 @@
renderNotificationRow: renderNotificationRow,
renderNotificationDetail: renderNotificationDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
loadNotifications: loadNotifications,
init: init
};
+53 -7
View File
@@ -1,12 +1,43 @@
(function (window, $) {
'use strict';
if (!$) return;
var profileUI = window.ProfileUI || {};
var layuiReady = false;
var layerApi = null;
var genealogyContextStorageKey = 'genealogy_current_context_v1';
var mobileMenuId = 'profile-mobile-menu';
var apiStateTypes = ['loading', 'empty', 'error', 'forbidden'];
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderApiState(type, message) {
var role;
var busy;
if (apiStateTypes.indexOf(type) < 0) throw new TypeError('不支持的业务状态:' + type);
role = type === 'error' || type === 'forbidden' ? 'alert' : 'status';
busy = type === 'loading' ? ' aria-busy="true"' : '';
return '<div class="api-state api-state--' + type + '" data-api-state="' + type +
'" role="' + role + '" aria-live="polite"' + busy + '>' + escapeHtml(message) + '</div>';
}
function setApiState(element, type, message) {
if (!element) return;
element.innerHTML = renderApiState(type, message);
}
profileUI.renderApiState = renderApiState;
profileUI.setApiState = setApiState;
window.ProfileUI = profileUI;
if (!$) return;
function getSessionStorage() {
try {
@@ -231,13 +262,25 @@
if (!value || value.charAt(0) === '#' || /^(?:https?:|mailto:|tel:)/i.test(value)) return value;
if (value.indexOf('profile-families.html') === 0) return value;
return 'profile-families.html?next=' + encodeURIComponent(value.split('?')[0]);
return 'profile-families.html?next=' + encodeURIComponent(value);
}
var genealogyContextPagePattern = /(?:^|\/)profile-(?:admin-permissions|album(?:-detail|-edit)?|article(?:-edit)?|ceremony(?:-detail)?|content|family-admin|family-home|feed(?:-detail|-edit)?|generation|gift(?:-edit)?|growth(?:-edit)?|join-review|memo(?:-edit)?|merit(?:-edit)?|relative(?:-edit)?|tree|video(?:-edit)?)\.html(?:[?#]|$)/;
function isGenealogyContextHref(href) {
var value = String(href || '');
return !/^(?:https?:|mailto:|tel:)/i.test(value) && genealogyContextPagePattern.test(value);
}
function syncGenealogyContextLinks() {
var genealogyId = getGenealogyId();
$('[data-genealogy-context-link]').each(function () {
$('[data-genealogy-context-link], a[href]').filter(function () {
var $link = $(this);
return $link.is('[data-genealogy-context-link]') || isGenealogyContextHref($link.attr('href'));
}).each(function () {
var $link = $(this);
$link.attr('href', genealogyId ? withGenealogyId($link.attr('href'), genealogyId) : getGenealogyEntryUrl($link.attr('href')));
@@ -463,7 +506,7 @@
syncGenealogyContextLinks();
});
window.ProfileUI = {
Object.assign(profileUI, {
confirm: openConfirm,
closeLogout: closeLegacyLogout,
initLayui: initLayui,
@@ -471,10 +514,13 @@
normalizeGenealogyId: normalizeGenealogyId,
withGenealogyId: withGenealogyId,
getGenealogyEntryUrl: getGenealogyEntryUrl,
isGenealogyContextHref: isGenealogyContextHref,
getCurrentGenealogyContext: getCurrentGenealogyContext,
setCurrentGenealogyContext: setCurrentGenealogyContext,
clearCurrentGenealogyContext: clearCurrentGenealogyContext,
refreshCurrentGenealogyContext: refreshCurrentGenealogyContext,
syncGenealogyContextLinks: syncGenealogyContextLinks
};
syncGenealogyContextLinks: syncGenealogyContextLinks,
renderApiState: renderApiState,
setApiState: setApiState
});
})(window, window.jQuery || (window.layui && window.layui.$));
+60 -72
View File
@@ -33,25 +33,24 @@
return String(displayName).charAt(0) || '家';
}
function getFinalRegionCode(profile) {
var source = profile || {};
function getSexText(value) {
var labels = { '0': '男', '1': '女', '2': '未知' };
return source.districtCode || source.cityCode || source.provinceCode || '';
}
function buildRegionText(profile) {
return getFinalRegionCode(profile) ? '加载中...' : '待填写';
return labels[String(value)] || '待填写';
}
function buildProfileView(profile) {
// 页面展示模型和接口返回分开,后续改文案不影响接口契约。
return {
displayName: profileValue(profile, 'nickName', '未设置昵称'),
realName: profileValue(profile, 'realName', '待填写'),
avatarText: getAvatarText(profile),
avatarStatus: profile && profile.avatar ? '已设置' : '未设置',
phone: profileValue(profile, 'phone', '未绑定'),
sex: profileValue(profile, 'sex', '待填写'),
sex: getSexText(profile && profile.sex),
birthday: profileValue(profile, 'birthday', '待填写'),
regionText: buildRegionText(profile || {})
email: profileValue(profile, 'email', '待填写'),
regionText: '当前资料接口未提供'
};
}
@@ -82,6 +81,12 @@
return body;
}
function validateProfileUpdateBody(body) {
if (!body || Object.keys(body).length === 0) return '请至少填写一项资料';
if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) return '请输入正确的邮箱地址';
return '';
}
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
@@ -129,17 +134,22 @@
}
}
function setStatus(form, message) {
function setStatus(form, type, message) {
var statusNode = query('[data-profile-status]', form);
if (statusNode) {
statusNode.textContent = message || '';
if (!statusNode) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(statusNode, type, message);
return;
}
statusNode.textContent = message || '';
}
function setSubmitting(form, isSubmitting) {
queryAll('button[type="submit"]', form).forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = isSubmitting;
button.textContent = isSubmitting ? '保存中…' : button.dataset.defaultLabel;
});
}
@@ -155,18 +165,27 @@
});
}
function setProfileLoadStatus(message) {
function setProfileLoadStatus(type, message) {
var statusNode = query('[data-profile-load-status]');
if (statusNode) statusNode.textContent = message || '';
if (!statusNode) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(statusNode, type, message);
return;
}
statusNode.textContent = message || '';
}
function buildUnavailableProfileView() {
return {
displayName: '账户资料暂时无法读取',
realName: '暂无数据',
avatarText: '',
avatarStatus: '暂无数据',
phone: '暂无数据',
birthday: '暂无数据',
email: '暂无数据',
sex: '暂无数据',
regionText: '暂无数据'
};
}
@@ -194,53 +213,6 @@
});
}
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() {
@@ -252,40 +224,52 @@
if (!api || !documentRef || !documentRef.querySelector('[data-profile-page]')) return;
if (redirectUnauthorized(api)) return;
setProfileLoadStatus('正在读取账户资料…');
setProfileLoadStatus('loading', '正在读取账户资料…');
try {
profile = await api.currentProfile();
if (requestId !== latestProfileRequestId) return;
renderProfile(buildProfileView(profile));
fillProfileForm(profile);
fillRegionPicker(profile);
renderRegionText(profile, requestId);
setProfileLoadStatus('');
setProfileLoadStatus('', '');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
renderProfile(buildUnavailableProfileView());
setProfileLoadStatus('资料读取失败,请刷新页面后重试。');
setProfileLoadStatus('error', '资料读取失败,请刷新页面后重试。');
showMessage(error.message || '个人资料加载失败');
}
}
async function submitProfileForm(form) {
var api = getApi();
var body;
var validation;
if (!api || !form) return;
if (redirectUnauthorized(api)) return;
try {
body = buildProfileUpdateBody(getFormValues(form));
} catch (error) {
setStatus(form, 'error', error.message || '资料校验失败');
return;
}
validation = validateProfileUpdateBody(body);
if (validation) {
setStatus(form, 'error', validation);
return;
}
try {
setSubmitting(form, true);
setStatus(form, '保存中...');
await api.updateProfile(buildProfileUpdateBody(getFormValues(form)));
setStatus(form, '已保存');
setStatus(form, 'loading', '正在保存资料…');
await api.updateProfile(body);
setStatus(form, '', '已保存');
showMessage('个人资料已保存');
await loadProfile();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(form, '保存失败');
setStatus(form, 'error', '保存失败,请稍后重试。');
showMessage(error.message || '个人资料保存失败');
} finally {
setSubmitting(form, false);
@@ -299,6 +283,11 @@
form.addEventListener('submit', function (event) {
event.preventDefault();
if (!form.checkValidity()) {
setStatus(form, 'error', '请检查资料填写是否正确');
form.reportValidity();
return;
}
submitProfileForm(form);
});
}
@@ -310,11 +299,10 @@
return {
getAvatarText: getAvatarText,
getFinalRegionCode: getFinalRegionCode,
buildRegionText: buildRegionText,
buildProfileView: buildProfileView,
buildUnavailableProfileView: buildUnavailableProfileView,
buildProfileUpdateBody: buildProfileUpdateBody,
validateProfileUpdateBody: validateProfileUpdateBody,
shouldRedirectToLogin: shouldRedirectToLogin,
reloadProfile: loadProfile,
init: init
+84 -17
View File
@@ -107,6 +107,10 @@
return true;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function query(selector, rootNode) {
if (!documentRef && !rootNode) return null;
return (rootNode || documentRef).querySelector(selector);
@@ -136,6 +140,21 @@
return values;
}
function setApiState(target, type, message) {
if (!target) return;
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message || '';
}
function setPickerState(picker, type, message) {
var status = picker && query('[data-region-picker-status]', picker.parentNode);
setApiState(status, type, message);
}
function setOptions(select, items, placeholder) {
var list = normalizeList(items);
@@ -186,10 +205,14 @@
}
}
function handlePickerError(error) {
function handlePickerError(picker, error) {
var api = getApi();
var message;
if (!redirectUnauthorized(api, error)) showMessage(error.message || '地区加载失败');
if (redirectUnauthorized(api, error)) return;
message = isForbidden(error) ? '当前账号无权读取地区数据。' : (error.message || '地区加载失败');
setPickerState(picker, isForbidden(error) ? 'forbidden' : 'error', message);
showMessage(message);
}
function bindPickerEvents(picker, province, city, district) {
@@ -200,17 +223,33 @@
resetSelect(city, '请选择市');
resetSelect(district, '请选择区县');
updateTargetCode(picker);
if (!province.value) return;
if (!province.value) {
setPickerState(picker, '', '');
return;
}
loadChildrenInto(city, province.value, '请选择市').catch(handlePickerError);
setPickerState(picker, 'loading', '正在加载市…');
loadChildrenInto(city, province.value, '请选择市').then(function () {
setPickerState(picker, '', '');
}).catch(function (error) {
handlePickerError(picker, error);
});
});
city.addEventListener('change', function () {
resetSelect(district, '请选择区县');
updateTargetCode(picker);
if (!city.value) return;
if (!city.value) {
setPickerState(picker, '', '');
return;
}
loadChildrenInto(district, city.value, '请选择区县').catch(handlePickerError);
setPickerState(picker, 'loading', '正在加载区县');
loadChildrenInto(district, city.value, '请选择区县').then(function () {
setPickerState(picker, '', '');
}).catch(function (error) {
handlePickerError(picker, error);
});
});
district.addEventListener('change', function () {
@@ -229,7 +268,11 @@
resetSelect(city, '请选择市');
resetSelect(district, '请选择区县');
bindPickerEvents(picker, province, city, district);
picker.__regionInitPromise = loadChildrenInto(province, '0', '请选择省').catch(function (error) {
setPickerState(picker, 'loading', '正在加载省…');
picker.__regionInitPromise = loadChildrenInto(province, '0', '请选择省').then(function (result) {
setPickerState(picker, '', '');
return result;
}).catch(function (error) {
picker.__regionInitPromise = null;
throw error;
});
@@ -280,12 +323,16 @@
var list = normalizeList(items);
if (!container) return;
container.innerHTML = list.length ? list.map(function (item) {
if (!list.length) {
setApiState(container, 'empty', '未找到匹配地区');
return;
}
container.innerHTML = list.map(function (item) {
var code = getRegionCode(item);
var name = getRegionName(item);
return '<button class="btn ghost magnetic" type="button" data-region-search-result="' + escapeHtml(code) + '">' + escapeHtml(name) + ' ' + escapeHtml(code) + '</button>';
}).join('') : '<p class="api-empty">未找到匹配地区</p>';
}).join('');
}
async function submitRegionSearch(form) {
@@ -293,24 +340,35 @@
var values = getFormValues(form);
var results = query('[data-region-search-results]', form.parentNode);
var pathText = query('[data-region-search-path]', form.parentNode);
var status = query('[data-region-search-status]', form);
var items;
var message;
if (redirectUnauthorized(api)) return;
if (!values.regionKeyword || !values.regionKeyword.trim()) {
setApiState(status, 'error', '请输入地区名称或编码');
showMessage('请输入地区名称或编码');
return;
}
setApiState(status, 'loading', '正在搜索地区…');
setApiState(results, 'loading', '正在搜索地区…');
if (pathText) pathText.textContent = '搜索中...';
try {
renderSearchResults(results, await api.regionSearch({
items = await api.regionSearch({
keyword: values.regionKeyword.trim(),
limit: 20
}));
if (pathText) pathText.textContent = '请选择一个地区';
});
renderSearchResults(results, items);
setApiState(status, '', '');
if (pathText) pathText.textContent = normalizeList(items).length ? '请选择一个地区' : '未找到匹配地区';
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (pathText) pathText.textContent = error.message || '地区搜索失败';
showMessage(error.message || '地区搜索失败');
message = isForbidden(error) ? '当前账号无权搜索地区。' : (error.message || '地区搜索失败');
setApiState(results, isForbidden(error) ? 'forbidden' : 'error', message);
setApiState(status, isForbidden(error) ? 'forbidden' : 'error', message);
if (pathText) pathText.textContent = message;
showMessage(message);
}
}
@@ -320,21 +378,27 @@
var section = button.closest('.module-panel');
var picker = section && query('[data-region-picker]', section);
var pathText = section && query('[data-region-search-path]', section);
var status = section && query('[data-region-search-status]', section);
var detail;
var path;
var message;
if (!code || !picker || redirectUnauthorized(api)) return;
setApiState(status, 'loading', '正在定位地区…');
if (pathText) pathText.textContent = '正在定位地区...';
try {
detail = await api.regionDetail(code);
path = await api.regionPath(getRegionCode(detail) || code);
await applyRegionSelection(picker, buildRegionSelection(path));
if (pathText) pathText.textContent = buildPathText(path);
setApiState(status, '', '');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (pathText) pathText.textContent = error.message || '地区定位失败';
showMessage(error.message || '地区定位失败');
message = isForbidden(error) ? '当前账号无权定位地区。' : (error.message || '地区定位失败');
setApiState(status, isForbidden(error) ? 'forbidden' : 'error', message);
if (pathText) pathText.textContent = message;
showMessage(message);
}
}
@@ -364,7 +428,9 @@
if (redirectUnauthorized(api)) return;
queryAll('[data-region-picker]').forEach(function (picker) {
initPicker(picker).catch(handlePickerError);
initPicker(picker).catch(function (error) {
handlePickerError(picker, error);
});
});
bindSearchForms();
}
@@ -381,6 +447,7 @@
applyRegionSelection: applyRegionSelection,
applyProfileRegionSelection: applyProfileRegionSelection,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+124 -37
View File
@@ -15,6 +15,7 @@
var documentRef = root.document;
var recordsById = Object.create(null);
var canEditContent = false;
var writePending = false;
function getApi() {
@@ -280,19 +281,26 @@
return relativeId ? url + '&relativeId=' + encodeURIComponent(relativeId) : url;
}
function canEditRecords(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
function renderRelativeRow(record) {
var summary = [];
var actions = '<button class="pill" type="button" data-relative-detail-id="' +
escapeHtml(record.relativeId) + '">查看详情</button>';
if (record.relationName) summary.push(record.relationName);
if (record.eventName) summary.push(record.eventName);
if (record.eventTime) summary.push(record.eventTime);
if (!summary.length) summary.push('未填写关系和事件');
if (canEditContent) {
actions += '<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.relativeId)) +
'">编辑</a><button class="pill is-danger" type="button" data-relative-delete-id="' +
escapeHtml(record.relativeId) + '">删除</button>';
}
return '<article class="module-row relative-row"><div><h3>' + escapeHtml(record.relativeName) + '</h3><p>' +
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' +
'<button class="pill" type="button" data-relative-detail-id="' + escapeHtml(record.relativeId) + '">查看详情</button>' +
'<a class="pill" href="' + escapeHtml(buildEditUrl(record.genealogyId, record.relativeId)) + '">编辑</a>' +
'<button class="pill is-danger" type="button" data-relative-delete-id="' + escapeHtml(record.relativeId) + '">删除</button>' +
'</div></article>';
escapeHtml(summary.join(' · ')) + '</p></div><div class="row-actions">' + actions + '</div></article>';
}
function detailValue(label, value) {
@@ -301,9 +309,16 @@
function renderRelativeDetail(record) {
var count;
var actions = '';
if (!record) return '<div class="api-empty">请选择一条亲友记录查看详情</div>';
if (!record) return stateHtml('empty', '请选择一条亲友记录查看详情');
count = attachmentCount(record.mediaOssIds);
if (canEditContent) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.relativeId)) + '">编辑记录</a>' +
'<button class="btn ghost" type="button" data-relative-delete-id="' +
escapeHtml(record.relativeId) + '">删除记录</button></div>';
}
return '<div class="form-like relative-detail">' +
detailValue('亲友姓名', record.relativeName) +
detailValue('关系名称', record.relationName) +
@@ -315,9 +330,7 @@
detailValue('记录人', record.appUserNickName) +
detailValue('状态', record.status === '0' ? '正常' : '停用') +
detailValue('备注', record.remark) +
'</div><div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(record.genealogyId, record.relativeId)) + '">编辑记录</a>' +
'<button class="btn ghost" type="button" data-relative-delete-id="' + escapeHtml(record.relativeId) + '">删除记录</button></div>';
'</div>' + actions;
}
function showMessage(message) {
@@ -335,6 +348,10 @@
return Number(error && (error.status || error.code)) === 403;
}
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
@@ -344,13 +361,38 @@
return true;
}
function setFormStatus(message) {
var target = query('[data-relative-form-status]');
if (target) target.textContent = message || '';
function stateHtml(type, message) {
if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message);
return '<div class="api-state api-state--' + type + '" role="' +
(type === 'error' || type === 'forbidden' ? 'alert' : 'status') +
'" aria-live="polite">' + escapeHtml(message) + '</div>';
}
function setEditorEnabled(enabled, message) {
function setState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.innerHTML = stateHtml(type, message);
}
function setFormStatus(message, type) {
var target = query('[data-relative-form-status]');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message;
}
function setEditorEnabled(enabled, message, type) {
var placeholder = query('[data-relative-editor-placeholder]');
queryAll('[data-relative-editor], [data-relative-editor-action]').forEach(function (element) {
@@ -358,15 +400,39 @@
});
if (placeholder) {
placeholder.hidden = Boolean(enabled);
if (message) placeholder.textContent = message;
if (!enabled && message) {
if (type) setState(placeholder, type, message);
else placeholder.textContent = message;
}
}
}
function setWritePending(value) {
function setEditAccess(enabled) {
canEditContent = Boolean(enabled);
queryAll('[data-relative-create-link]').forEach(function (element) {
element.hidden = !canEditContent;
});
}
async function loadCapability(api, genealogyId) {
var genealogy = await api.genealogyDetail(genealogyId);
setEditAccess(canEditRecords(genealogy));
return genealogy;
}
function setWritePending(value, label) {
var submit = query('[data-relative-form] [type="submit"]');
writePending = Boolean(value);
queryAll('[data-relative-form] button, [data-relative-form] input, [data-relative-form] textarea, [data-relative-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
if (submit) {
if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent;
submit.textContent = writePending && label ? label : submit.dataset.defaultLabel;
submit.setAttribute('aria-busy', writePending ? 'true' : 'false');
}
}
function syncLinks() {
@@ -378,10 +444,11 @@
var detail = query('[data-relative-detail]');
var message = '请先选择家谱,再查看或维护亲友记录。';
if (list) list.innerHTML = '<div class="api-empty">' + message + '</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
setEditorEnabled(false, message);
setFormStatus(message);
setState(list, 'empty', message);
setState(detail, 'empty', '当前没有家谱上下文。');
setEditAccess(false);
setEditorEnabled(false, message, 'empty');
setFormStatus(message, 'empty');
}
function requireGenealogyId() {
@@ -403,11 +470,11 @@
});
if (!container) return records;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">亲友记录响应缺少稳定 RelativeRecordVo 字段,请联系后端核对。</div>';
setState(container, 'error', '亲友记录响应缺少稳定 RelativeRecordVo 字段,请联系后端核对。');
return [];
}
if (!records.length) {
container.innerHTML = '<div class="api-empty">暂无亲友往来记录</div>';
setState(container, 'empty', '暂无亲友往来记录');
return [];
}
container.innerHTML = records.map(renderRelativeRow).join('');
@@ -426,6 +493,7 @@
var record;
if (!genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return null;
setState(query('[data-relative-detail]'), 'loading', '正在加载亲友记录详情…');
try {
record = normalizeRelativeRecord(await api.relativeRecordDetail(genealogyId, relativeId));
if (!record) throw new Error('亲友记录详情响应缺少稳定 RelativeRecordVo 字段');
@@ -434,7 +502,11 @@
return record;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
setState(
query('[data-relative-detail]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该亲友记录。' : '亲友记录详情加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该亲友记录。' : (error.message || '亲友记录详情加载失败'));
return null;
}
@@ -450,7 +522,9 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncLinks();
setState(query('[data-relative-list]'), 'loading', '正在加载亲友记录…');
try {
await loadCapability(api, genealogyId);
records = renderRelativeRecords(await api.relativeRecords(genealogyId));
requestedRelativeId = getCurrentRelativeId();
if (requestedRelativeId && recordsById[requestedRelativeId]) {
@@ -462,11 +536,12 @@
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (query('[data-relative-list]')) {
query('[data-relative-list]').innerHTML = '<div class="api-empty">' +
(isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : '亲友记录加载失败,请稍后重试。') +
'</div>';
}
setEditAccess(false);
setState(
query('[data-relative-list]'),
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : '亲友记录加载失败,请稍后重试。'
);
showMessage(isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : (error.message || '亲友记录加载失败'));
}
}
@@ -515,8 +590,11 @@
if (!genealogyId) return;
relativeId = getCurrentRelativeId();
syncLinks();
setEditorEnabled(false, '正在加载亲友记录编辑信息…');
setEditorEnabled(false, '正在加载亲友记录编辑信息…', 'loading');
setFormStatus('正在读取家谱权限…', 'loading');
try {
await loadCapability(api, genealogyId);
if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的亲友记录。' };
if (relativeId) {
record = normalizeRelativeRecord(await api.relativeRecordDetail(genealogyId, relativeId));
if (!record) throw new Error('亲友记录详情响应缺少稳定 RelativeRecordVo 字段');
@@ -524,12 +602,17 @@
if (query('[data-relative-editor-title]')) query('[data-relative-editor-title]').textContent = '编辑亲友记录';
}
setEditorEnabled(true);
setFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setEditAccess(false);
setEditorEnabled(false, isForbidden(error)
? '当前账号无权维护该家谱的亲友记录。'
: (error.message || '亲友记录编辑信息加载失败'));
setFormStatus(error.message || '亲友记录编辑信息加载失败');
: (error.message || '亲友记录编辑信息加载失败'), getApiStateType(error));
setFormStatus(
isForbidden(error) ? '当前账号无权维护该家谱的亲友记录。' : (error.message || '亲友记录编辑信息加载失败'),
getApiStateType(error)
);
}
}
@@ -544,18 +627,18 @@
var savedId;
var verifiedDetail;
if (writePending || !genealogyId || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
body = buildRelativeRecordBody(getFormValues(form));
validation = validateRelativeRecordBody(body);
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
delete body.invalidGiftAmount;
delete body.invalidGiftPrecision;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存亲友记录…');
setWritePending(true, '正在保存…');
setFormStatus('正在保存亲友记录…', 'loading');
try {
result = relativeId
? await api.updateRelativeRecord(genealogyId, relativeId, body)
@@ -568,7 +651,10 @@
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号无权保存该亲友记录。' : (error.message || '亲友记录保存失败'));
setFormStatus(
isForbidden(error) ? '当前账号无权保存该亲友记录。' : (error.message || '亲友记录保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -579,7 +665,7 @@
var genealogyId = requireGenealogyId();
var record = recordsById[relativeId];
if (writePending || !genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return;
if (writePending || !canEditContent || !genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return;
if (root.confirm && !root.confirm('确认删除“' + (record ? record.relativeName : '该亲友记录') + '”吗?删除后不可恢复,并会释放附件引用。')) return;
setWritePending(true);
try {
@@ -649,6 +735,7 @@
validateRelativeRecordBody: validateRelativeRecordBody,
normalizeRelativeRecord: normalizeRelativeRecord,
matchesSavedRecord: matchesSavedRecord,
canEditRecords: canEditRecords,
isEditableRecord: isEditableRecord,
renderRelativeDetail: renderRelativeDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
+52 -19
View File
@@ -143,15 +143,32 @@
return values;
}
function setStatus(form, message) {
function setStatus(form, type, message) {
var status = query('[data-security-status]', form);
if (status) status.textContent = message;
if (!status) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(status, type, message);
return;
}
status.textContent = message || '';
}
function setSubmitting(form, isSubmitting) {
queryAll('button[type="submit"]', form).forEach(function (button) {
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = isSubmitting;
button.textContent = isSubmitting ? '提交中…' : button.dataset.defaultLabel;
});
}
function setBoundPhoneStatus(type, message) {
queryAll('[data-security-bound-phone]').forEach(function (node) {
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(node, type, message);
return;
}
node.textContent = message || '';
});
}
@@ -211,6 +228,7 @@
if (!api || !documentRef) return;
if (redirectUnauthorized(api)) return;
setBoundPhoneStatus('loading', '正在读取已绑定手机号…');
try {
profile = await api.currentProfile();
@@ -218,14 +236,10 @@
message = isPhone(boundPhone)
? '验证码将发送至 ' + maskPhone(boundPhone)
: '未获取到已绑定手机号';
queryAll('[data-security-bound-phone]').forEach(function (node) {
node.textContent = message;
});
setBoundPhoneStatus('', message);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
queryAll('[data-security-bound-phone]').forEach(function (node) {
node.textContent = '读取已绑定手机号失败';
});
setBoundPhoneStatus('error', '读取已绑定手机号失败,请刷新后重试。');
showMessage(error.message || '账号资料加载失败');
}
}
@@ -236,28 +250,31 @@
if (redirectUnauthorized(api)) return;
if (!values.oldPassword || !values.newPassword) {
setStatus(form, 'error', '请填写当前密码和新密码');
showMessage('请填写原密码和新密码');
return;
}
if (String(values.newPassword).length < 6) {
setStatus(form, 'error', '新密码至少需要 6 位');
showMessage('新密码至少需要 6 位');
return;
}
if (values.newPassword !== values.confirmPassword) {
setStatus(form, 'error', '两次新密码不一致');
showMessage('两次新密码不一致');
return;
}
try {
setSubmitting(form, true);
setStatus(form, '提交中...');
setStatus(form, 'loading', '正在修改密码…');
await api.changePassword(buildPasswordChangeBody(values));
setStatus(form, '密码已修改');
setStatus(form, '', '密码已修改');
showMessage('密码已修改');
form.reset();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(form, '修改失败');
setStatus(form, 'error', '修改失败,请稍后重试。');
showMessage(error.message || '密码修改失败');
} finally {
setSubmitting(form, false);
@@ -270,32 +287,33 @@
if (redirectUnauthorized(api)) return;
if (!body.phone || !body.smsCode) {
setStatus(form, 'error', '请填写新手机号和验证码');
showMessage('请填写新手机号和验证码');
return;
}
if (!isPhone(body.phone)) {
setStatus(form, 'error', '请输入正确的新手机号');
showMessage('请输入正确的新手机号');
return;
}
if (!isSmsCode(body.smsCode)) {
setStatus(form, 'error', '请输入正确的短信验证码');
showMessage('请输入正确的短信验证码');
return;
}
try {
setSubmitting(form, true);
setStatus(form, '提交中...');
setStatus(form, 'loading', '正在换绑手机号…');
await api.changePhone(body);
boundPhone = body.phone;
queryAll('[data-security-bound-phone]').forEach(function (node) {
node.textContent = '验证码将发送至 ' + maskPhone(boundPhone);
});
setStatus(form, '手机号已换绑');
setBoundPhoneStatus('', '验证码将发送至 ' + maskPhone(boundPhone));
setStatus(form, '', '手机号已换绑');
showMessage('手机号已换绑');
form.reset();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(form, '换绑失败');
setStatus(form, 'error', '换绑失败,请稍后重试。');
showMessage(error.message || '手机号换绑失败');
} finally {
setSubmitting(form, false);
@@ -311,6 +329,7 @@
if (!form || redirectUnauthorized(api)) return;
values = getFormValues(form);
if (!isPhone(values.phone)) {
setStatus(form, 'error', '请输入正确的新手机号');
showMessage('请输入正确的新手机号');
return;
}
@@ -318,16 +337,19 @@
values = getFormValues(form);
values.validToken = takeCaptchaToken(form);
button.disabled = true;
setStatus(form, 'loading', '正在发送验证码…');
try {
await api.sendSmsCode(getCaptchaOperation('phone'), {
phone: values.phone,
validToken: values.validToken || ''
});
setStatus(form, '', '验证码已发送,请查收短信。');
showMessage('验证码已发送');
startSmsCooldown(button);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(form, 'error', '验证码发送失败,请稍后重试。');
showMessage(error.message || '验证码发送失败');
button.disabled = false;
}
@@ -340,6 +362,7 @@
if (!form || redirectUnauthorized(api)) return;
if (!isPhone(boundPhone)) {
setStatus(form, 'error', '未获取到已绑定手机号,请刷新后重试。');
showMessage('未获取到已绑定手机号,请刷新后重试');
return;
}
@@ -347,16 +370,19 @@
values = getFormValues(form);
values.validToken = takeCaptchaToken(form);
button.disabled = true;
setStatus(form, 'loading', '正在发送验证码…');
try {
await api.sendSmsCode(getCaptchaOperation('deactivate'), {
phone: boundPhone,
validToken: values.validToken || ''
});
setStatus(form, '', '验证码已发送,请查收短信。');
showMessage('验证码已发送');
startSmsCooldown(button);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(form, 'error', '验证码发送失败,请稍后重试。');
showMessage(error.message || '验证码发送失败');
button.disabled = false;
}
@@ -368,10 +394,12 @@
if (redirectUnauthorized(api)) return;
if (!isSmsCode(values.smsCode)) {
setStatus(form, 'error', '请输入正确的短信验证码');
showMessage('请输入正确的短信验证码');
return;
}
if (!isDangerConfirmed(values.confirmText)) {
setStatus(form, 'error', '请完整输入“确认注销”');
showMessage('请完整输入“确认注销”');
return;
}
@@ -379,13 +407,13 @@
try {
setSubmitting(form, true);
setStatus(form, '提交中...');
setStatus(form, 'loading', '正在提交注销申请…');
await api.deactivateAccount(buildDeactivateBody(values));
showMessage('账号已提交注销');
root.location.href = 'index.html';
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setStatus(form, '注销失败');
setStatus(form, 'error', '注销失败,请稍后重试。');
showMessage(error.message || '账号注销失败');
} finally {
setSubmitting(form, false);
@@ -415,6 +443,11 @@
if (!form) return;
event.preventDefault();
if (!form.checkValidity()) {
setStatus(form, 'error', '请检查表单填写是否正确');
form.reportValidity();
return;
}
formType = form.getAttribute('data-security-form');
if (formType === 'password') submitPassword(form);
+51 -22
View File
@@ -91,7 +91,6 @@
}
function renderNormalizedArticleList(items) {
if (!items.length) return '<div class="api-empty">当前暂无资讯</div>';
return items.map(function (item) {
return '<a class="site-info-item" href="article-detail.html?articleId=' +
encodeURIComponent(item.articleId) + '">' +
@@ -131,9 +130,7 @@
function renderArticleDetail(item) {
var article = normalizeArticle(item);
return article
? renderNormalizedArticleDetail(article)
: '<div class="api-empty">资讯不存在或已下线</div>';
return article ? renderNormalizedArticleDetail(article) : '';
}
function readQueryValue(search, key) {
@@ -178,6 +175,27 @@
return match;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function isMissingArticleError(error) {
return error && error.message === '资讯不存在或已下线';
}
function setSiteState(container, type, message) {
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.textContent = message;
}
function setSiteStatus(container, message) {
if (container) container.textContent = message || '';
}
async function init() {
var document = root.document;
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
@@ -196,19 +214,24 @@
requestedType = readQueryValue(search, 'articleType');
if (!list) return;
if (requestedType && !normalizeArticleType(requestedType)) {
list.innerHTML = '<div class="api-empty">资讯分类无效</div>';
if (status) status.textContent = '资讯分类无效';
setSiteState(list, 'error', '资讯分类无效。');
setSiteStatus(status, '资讯分类无效');
return;
}
setSiteState(list, 'loading', '正在读取资讯…');
try {
list.classList.add('is-loading');
list.innerHTML = renderNormalizedArticleList(await loadArticles(api, requestedType));
if (status) status.textContent = '';
var items = await loadArticles(api, requestedType);
if (!items.length) setSiteState(list, 'empty', '当前暂无资讯。');
else list.innerHTML = renderNormalizedArticleList(items);
setSiteStatus(status, '');
} catch (error) {
list.innerHTML = '<div class="api-empty">资讯读取失败,请稍后重试</div>';
if (status) status.textContent = error.message || '资讯读取失败';
} finally {
list.classList.remove('is-loading');
setSiteState(
list,
isForbidden(error) ? 'forbidden' : 'error',
isForbidden(error) ? '当前账号无权查看资讯。' : '资讯读取失败,请稍后重试。'
);
setSiteStatus(status, error.message || '资讯读取失败,请稍后重试。');
}
return;
}
@@ -219,20 +242,23 @@
articleId = readArticleId(search);
if (!detail) return;
if (!articleId) {
detail.innerHTML = '<div class="api-empty">资讯编号无效</div>';
if (status) status.textContent = '资讯编号无效';
setSiteState(detail, 'error', '资讯编号无效。');
setSiteStatus(status, '资讯编号无效');
return;
}
setSiteState(detail, 'loading', '正在读取资讯详情…');
try {
detail.classList.add('is-loading');
detail.innerHTML = renderNormalizedArticleDetail(await loadArticleDetail(api, articleId));
if (status) status.textContent = '';
setSiteStatus(status, '');
} catch (error) {
detail.innerHTML = '<div class="api-empty">' +
escapeHtml(error.message || '资讯读取失败,请稍后重试') + '</div>';
if (status) status.textContent = error.message || '资讯读取失败';
} finally {
detail.classList.remove('is-loading');
setSiteState(
detail,
isForbidden(error) ? 'forbidden' : (isMissingArticleError(error) ? 'empty' : 'error'),
isForbidden(error)
? '当前账号无权查看该资讯。'
: (isMissingArticleError(error) ? '资讯不存在或已下线。' : '资讯详情读取失败,请稍后重试。')
);
setSiteStatus(status, error.message || '资讯详情读取失败,请稍后重试。');
}
}
}
@@ -247,6 +273,9 @@
renderArticleDetail: renderArticleDetail,
loadArticles: loadArticles,
loadArticleDetail: loadArticleDetail,
isForbidden: isForbidden,
isMissingArticleError: isMissingArticleError,
setSiteState: setSiteState,
init: init
};
});
+29 -7
View File
@@ -60,10 +60,29 @@
return true;
}
function isForbidden(error) {
return Number(error && (error.status || error.code)) === 403;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
}
function queryAll(selector, rootNode) {
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
}
function setUploadStatus(status, type, message) {
if (!status) return;
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(status, type, message);
return;
}
status.textContent = message || '';
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
@@ -214,22 +233,20 @@
try {
for (index = 0; index < files.length; index += 1) {
if (status) status.textContent = files[index].name + ' 正在上传...';
setUploadStatus(status, 'loading', files[index].name + ' 正在上传…');
result = normalizeUploadResult(await uploadFileForPage(api, files[index], {
onProgress: function (progress) {
if (status) {
status.textContent = '正在上传 ' + progress.uploadedChunks + '/' + progress.totalChunks + ' 分片';
}
setUploadStatus(status, 'loading', '正在上传 ' + progress.uploadedChunks + '/' + progress.totalChunks + ' 分片');
}
}));
if (!result.ossId) throw new Error('上传响应缺少 ossId');
target.value = mergeUploadTargetValue(target.value, result.ossId, multiple);
if (status) status.textContent = buildUploadStatus(result.fileName || files[index].name, result.ossId);
setUploadStatus(status, '', buildUploadStatus(result.fileName || files[index].name, result.ossId));
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (status) status.textContent = error.message || '上传失败';
showMessage(error.message || '上传失败');
setUploadStatus(status, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '当前账号无权上传文件。' : (error.message || '上传失败'));
showMessage(isForbidden(error) ? '当前账号无权上传文件。' : (error.message || '上传失败'));
} finally {
input.disabled = false;
input.value = '';
@@ -248,6 +265,10 @@
}
function init() {
queryAll('[data-upload-status]').forEach(function (status) {
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
});
bindActions();
}
@@ -259,6 +280,7 @@
uploadFileForPage: uploadFileForPage,
mergeUploadTargetValue: mergeUploadTargetValue,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
};
});
+107 -36
View File
@@ -232,7 +232,7 @@
function renderVideoDetail(video, managementAccess) {
var actions = '';
if (!video) return '<div class="api-empty">请选择一个视频查看详情</div>';
if (!video) return '';
if (managementAccess) {
actions = '<div class="bottom-actions"><a class="btn ghost" href="' +
escapeHtml(buildEditUrl(video.genealogyId, video.videoId)) + '">编辑视频</a>' +
@@ -287,15 +287,15 @@
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks();
}
function renderNoContext() {
var list = query('[data-video-list]');
var detail = query('[data-video-detail]');
var placeholder = query('[data-video-permission-placeholder]');
function canEditVideos(genealogy) {
return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true));
}
if (list) list.innerHTML = '<div class="api-empty">请先选择家谱,再查看家族视频。</div>';
if (detail) detail.innerHTML = '<div class="api-empty">当前没有家谱上下文。</div>';
if (placeholder) placeholder.textContent = '请先选择家谱,再发布视频。';
setFormStatus('请先选择家谱,再发布视频。');
function renderNoContext() {
setVideoListState('empty', '请先选择家谱,再查看家族视频。');
setVideoDetailState('empty', '当前没有家谱上下文。');
setPermissionPlaceholder('empty', '请先选择家谱,再发布视频。');
setFormStatus('请先选择家谱,再发布视频。', 'forbidden');
setManagementAccess(false);
}
@@ -316,17 +316,67 @@
if (placeholder) placeholder.hidden = canEditContent;
}
function setFormStatus(message) {
var status = query('[data-video-form-status]');
if (status) status.textContent = message || '';
function getApiStateType(error) {
return isForbidden(error) ? 'forbidden' : 'error';
}
function setWritePending(pending) {
function setVideoState(selector, type, message) {
var container = query(selector);
if (!container) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(container, type, message);
return;
}
container.textContent = message;
}
function setVideoListState(type, message) {
setVideoState('[data-video-list]', type, message);
}
function setVideoDetailState(type, message) {
setVideoState('[data-video-detail]', type, message);
}
function setPermissionPlaceholder(type, message) {
var placeholder = query('[data-video-permission-placeholder]');
if (!placeholder) return;
if (root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(placeholder, type, message);
return;
}
placeholder.textContent = message;
}
function setFormStatus(message, type) {
var status = query('[data-video-form-status]');
if (!status) return;
if (!message) {
status.innerHTML = '';
return;
}
if (type && root.ProfileUI && root.ProfileUI.setApiState) {
root.ProfileUI.setApiState(status, type, message);
return;
}
status.textContent = message;
}
function setWritePending(pending, label) {
var submit = query('[data-video-form] [type="submit"]');
writePending = Boolean(pending);
queryAll('[data-video-form] input, [data-video-form] textarea, [data-video-form] button, [data-video-delete-id]').forEach(function (control) {
control.disabled = writePending;
});
if (submit) {
if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent;
submit.textContent = writePending && label ? label : submit.dataset.defaultLabel;
submit.setAttribute('aria-busy', writePending ? 'true' : 'false');
}
}
function renderVideos(data) {
@@ -341,11 +391,11 @@
});
if (!container) return videos;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">视频响应缺少稳定 VideoVo 字段,请联系后端核对。</div>';
setVideoListState('error', '视频响应缺少稳定 VideoVo 字段,请联系后端核对。');
return [];
}
if (!videos.length) {
container.innerHTML = '<div class="api-empty">暂无家族视频</div>';
setVideoListState('empty', '暂无家族视频');
return [];
}
container.innerHTML = videos.map(function (video) {
@@ -357,7 +407,12 @@
function renderDetail(video) {
var container = query('[data-video-detail]');
if (container) container.innerHTML = renderVideoDetail(video, canEditContent);
if (!container) return;
if (!video) {
setVideoDetailState('empty', '请选择一个视频查看详情');
return;
}
container.innerHTML = renderVideoDetail(video, canEditContent);
}
async function loadVideoDetail(videoId) {
@@ -366,6 +421,7 @@
var video;
if (!genealogyId || !normalizeId(videoId) || redirectUnauthorized(api)) return null;
setVideoDetailState('loading', '正在加载视频详情…');
try {
video = normalizeVideo(await api.videoDetail(genealogyId, videoId));
if (!video) throw new Error('视频详情响应缺少稳定 VideoVo 字段');
@@ -374,7 +430,10 @@
return video;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
setVideoDetailState(
getApiStateType(error),
isForbidden(error) ? '当前账号无权查看该视频。' : (error.message || '视频详情加载失败')
);
showMessage(error.message || '视频详情加载失败');
return null;
}
@@ -391,9 +450,11 @@
genealogyId = requireGenealogyId();
if (!genealogyId) return;
syncGenealogyLinks();
setVideoListState('loading', '正在加载视频…');
setVideoDetailState('loading', '正在加载视频详情…');
try {
result = await Promise.all([api.genealogyDetail(genealogyId), api.videos(genealogyId)]);
setManagementAccess(Boolean(result[0] && result[0].canEditContent === true));
setManagementAccess(canEditVideos(result[0]));
videos = renderVideos(result[1]);
requestedVideoId = getCurrentVideoId();
if (requestedVideoId && videosById[requestedVideoId]) {
@@ -407,10 +468,12 @@
if (redirectUnauthorized(api, error)) return;
if (isForbidden(error)) {
setManagementAccess(false);
if (query('[data-video-list]')) query('[data-video-list]').innerHTML = '<div class="api-empty">当前账号无权查看该家谱视频。</div>';
setVideoListState('forbidden', '当前账号无权查看该家谱视频。');
setVideoDetailState('forbidden', '当前账号无权查看该家谱视频。');
return;
}
if (query('[data-video-list]')) query('[data-video-list]').innerHTML = '<div class="api-empty">视频加载失败,请稍后重试。</div>';
setVideoListState('error', '视频加载失败,请稍后重试。');
setVideoDetailState('error', '视频详情加载失败,请稍后重试。');
showMessage(error.message || '视频加载失败');
}
}
@@ -454,17 +517,17 @@
if (!genealogyId) return;
videoId = getCurrentVideoId();
syncGenealogyLinks();
setFormStatus('正在读取家谱权限…', 'loading');
setPermissionPlaceholder('loading', '正在核对视频维护权限…');
try {
result = await Promise.all([
api.genealogyDetail(genealogyId),
videoId ? api.videoDetail(genealogyId, videoId) : Promise.resolve(null)
]);
setManagementAccess(Boolean(result[0] && result[0].canEditContent === true));
setManagementAccess(canEditVideos(result[0]));
if (!canEditContent) {
if (query('[data-video-permission-placeholder]')) {
query('[data-video-permission-placeholder]').textContent = '当前账号没有视频维护权限。';
}
setFormStatus('当前账号没有视频维护权限。');
setPermissionPlaceholder('forbidden', '当前账号没有视频维护权限。');
setFormStatus('当前账号没有视频维护权限。', 'forbidden');
return;
}
if (videoId) {
@@ -473,15 +536,18 @@
fillVideoForm(video);
if (query('[data-video-editor-title]')) query('[data-video-editor-title]').textContent = '编辑视频';
}
setFormStatus('');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setManagementAccess(false);
if (query('[data-video-permission-placeholder]')) {
query('[data-video-permission-placeholder]').textContent = isForbidden(error)
? '当前账号没有视频维护权限。'
: (error.message || '视频编辑信息加载失败');
}
setFormStatus(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败'));
setPermissionPlaceholder(
getApiStateType(error),
isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败')
);
setFormStatus(
isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败'),
getApiStateType(error)
);
}
}
@@ -497,13 +563,13 @@
if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return;
if (validation) {
setFormStatus(validation);
setFormStatus(validation, 'error');
return;
}
delete body.invalidDuration;
delete body.invalidSortOrder;
setWritePending(true);
setFormStatus('正在保存视频…');
setWritePending(true, '正在保存…');
setFormStatus('正在保存视频…', 'loading');
try {
result = videoId ? await api.updateVideo(genealogyId, videoId, body) : await api.createVideo(genealogyId, body);
saved = normalizeVideo(result);
@@ -513,7 +579,10 @@
if (root.location) root.location.href = buildListUrl(genealogyId, savedId);
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setFormStatus(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频保存失败'));
setFormStatus(
isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频保存失败'),
getApiStateType(error)
);
} finally {
setWritePending(false);
}
@@ -607,8 +676,10 @@
validateVideoBody: validateVideoBody,
normalizeVideo: normalizeVideo,
normalizeDurationSeconds: normalizeDurationSeconds,
canEditVideos: canEditVideos,
renderVideoRow: renderVideoRow,
renderVideoDetail: renderVideoDetail,
setPermissionPlaceholder: setPermissionPlaceholder,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
init: init
+82 -22
View File
@@ -119,6 +119,20 @@
};
}
function normalizeGenealogyOptions(data) {
if (!Array.isArray(data)) return [];
return data.map(normalizeGenealogyOption).filter(Boolean);
}
function resolveContextGenealogyId(options, contextId) {
var normalizedContextId = normalizeId(contextId);
if (!normalizedContextId || !Array.isArray(options)) return '';
return options.some(function (option) {
return option.genealogyId === normalizedContextId;
}) ? normalizedContextId : '';
}
function buildVipOrderBody(values) {
var source = values || {};
var body = {};
@@ -214,7 +228,7 @@
}
function renderGenealogyOptions(data) {
var items = Array.isArray(data) ? data.map(normalizeGenealogyOption).filter(Boolean) : [];
var items = normalizeGenealogyOptions(data);
return '<option value="">不关联具体家谱</option>' + items.map(function (item) {
return '<option value="' + escapeHtml(item.genealogyId) + '">' +
@@ -271,6 +285,9 @@
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var packages = [];
var selectedPackageId = '';
var contextGenealogyId = root.ProfileUI && typeof root.ProfileUI.getGenealogyId === 'function'
? root.ProfileUI.getGenealogyId()
: '';
var writePending = false;
if (!page) return;
@@ -280,20 +297,52 @@
}
if (!packageList || !selectedText || !genealogySelect || !form || !orderList ||
!api.vipPackages || !api.genealogiesMine || !api.vipOrders || !api.createVipOrder) {
if (status) status.textContent = '会员服务接口初始化失败,请刷新后重试';
setListState(packageList, 'error', '会员套餐服务初始化失败,请刷新后重试');
setListState(orderList, 'error', '会员订单服务初始化失败,请刷新后重试。');
setStatus('error', '会员服务接口初始化失败,请刷新后重试。');
return;
}
function setStatus(message) {
if (status) status.textContent = message;
function setApiState(target, type, message) {
if (!target) return;
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
root.ProfileUI.setApiState(target, type, message);
return;
}
target.textContent = message || '';
}
function setStatus(type, message) {
setApiState(status, type, message);
}
function setListState(target, type, message) {
setApiState(target, type, message);
}
function setPendingButton(button, pending, label) {
if (!button) return;
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
button.disabled = pending;
button.textContent = pending ? label : button.dataset.defaultLabel;
}
function renderPackages() {
if (!packages.length) {
setListState(packageList, 'empty', '当前没有可用会员套餐。');
return;
}
packageList.innerHTML = renderVipPackages(packages, selectedPackageId);
}
function renderOrders(orders) {
orderList.innerHTML = renderVipOrders(orders);
var items = normalizeVipOrders(orders);
if (!items.length) {
setListState(orderList, 'empty', '当前没有会员订单。');
return;
}
orderList.innerHTML = renderVipOrders(items);
}
async function loadOrders() {
@@ -302,21 +351,30 @@
return orders;
}
setListState(packageList, 'loading', '正在读取会员套餐…');
setListState(orderList, 'loading', '正在读取会员订单…');
setStatus('loading', '正在读取会员服务…');
try {
var initial = await Promise.all([api.vipPackages(), api.genealogiesMine(), api.vipOrders()]);
var genealogyOptions = normalizeGenealogyOptions(initial[1]);
var selectedContextGenealogyId = resolveContextGenealogyId(genealogyOptions, contextGenealogyId);
packages = normalizeVipPackages(initial[0]);
renderPackages();
genealogySelect.innerHTML = renderGenealogyOptions(initial[1]);
genealogySelect.innerHTML = renderGenealogyOptions(genealogyOptions);
if (selectedContextGenealogyId) genealogySelect.value = selectedContextGenealogyId;
renderOrders(initial[2]);
setStatus(packages.length ? '请选择会员套餐' : '当前没有可用会员套餐');
setStatus('', packages.length
? (selectedContextGenealogyId ? '已关联当前家谱,请选择会员套餐' : '请选择会员套餐')
: '当前没有可用会员套餐');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
packageList.innerHTML = '<div class="api-empty">会员套餐读取失败,请稍后重试</div>';
orderList.innerHTML = '<div class="api-empty">会员订单读取失败,请稍后重试</div>';
setStatus(error.message || '会员服务读取失败,请稍后重试');
setListState(packageList, 'error', '会员套餐读取失败,请稍后重试。');
setListState(orderList, 'error', '会员订单读取失败,请稍后重试。');
setStatus('error', error.message || '会员服务读取失败,请稍后重试');
return;
}
@@ -332,25 +390,25 @@
selectedPackageId = selected.packageId;
selectedText.textContent = '已选择:' + selected.packageName + '(¥' + selected.price + '';
renderPackages();
setStatus('可以创建会员订单');
setStatus('', '可以创建会员订单');
});
if (refresh) {
refresh.addEventListener('click', async function () {
if (writePending) return;
refresh.disabled = true;
setStatus('正在刷新订单…');
setPendingButton(refresh, true, '正在刷新…');
setStatus('loading', '正在刷新会员订单…');
try {
await loadOrders();
setStatus('会员订单已刷新');
setStatus('', '会员订单已刷新');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '会员订单刷新失败,请稍后重试');
setStatus('error', error.message || '会员订单刷新失败,请稍后重试');
} finally {
refresh.disabled = false;
setPendingButton(refresh, false, '正在刷新…');
}
});
}
@@ -370,13 +428,13 @@
});
validation = validateVipOrderBody(body);
if (validation) {
setStatus(validation);
setStatus('error', validation);
return;
}
writePending = true;
if (submit) submit.disabled = true;
setStatus('正在创建会员订单…');
setPendingButton(submit, true, '正在创建订单…');
setStatus('loading', '正在创建会员订单…');
try {
created = normalizeVipOrder(await api.createVipOrder(body));
if (!created) throw new Error('创建响应缺少有效会员订单信息');
@@ -384,16 +442,16 @@
verified = refreshed.find(function (item) { return item.orderId === created.orderId; });
if (!verified) throw new Error('订单已提交,但重新读取未找到对应记录');
renderOrders(refreshed);
setStatus('订单已创建;当前 PC 暂未开放在线支付');
setStatus('', '订单已创建;当前 PC 暂未开放在线支付');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '会员订单创建失败,请稍后重试');
setStatus('error', error.message || '会员订单创建失败,请稍后重试');
} finally {
writePending = false;
if (submit) submit.disabled = false;
setPendingButton(submit, false, '正在创建订单…');
}
});
}
@@ -402,6 +460,8 @@
normalizeVipPackage: normalizeVipPackage,
normalizeVipPackages: normalizeVipPackages,
normalizeGenealogyOption: normalizeGenealogyOption,
normalizeGenealogyOptions: normalizeGenealogyOptions,
resolveContextGenealogyId: resolveContextGenealogyId,
buildVipOrderBody: buildVipOrderBody,
validateVipOrderBody: validateVipOrderBody,
normalizeVipOrder: normalizeVipOrder,
+26 -44
View File
@@ -20,52 +20,33 @@
>
<div class="eyebrow">Create Account</div>
<h1 id="register-title">注册账号</h1>
<p class="lead">注册后即可创建家谱或通过邀请码加入家族。</p>
<form id="register-form" class="form-grid">
<input
class="input"
name="phone"
type="tel"
inputmode="numeric"
autocomplete="tel"
maxlength="11"
placeholder="手机号"
/>
<div class="code-row">
<input
class="input"
name="smsCode"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="4"
pattern="\d{4}"
placeholder="短信验证码"
/>
<button class="btn ghost" type="button" data-api-send-code>
获取验证码
</button>
<p class="lead">注册后即可创建家谱或申请加入家族。</p>
<form id="register-form" class="form-grid" novalidate>
<label class="auth-field" for="register-phone">
<span>手机号 <b aria-hidden="true">*</b></span>
<input id="register-phone" class="input" name="phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="请输入手机号" required />
</label>
<div class="auth-field">
<label for="register-sms-code">短信验证码 <b aria-hidden="true">*</b></label>
<div class="code-row">
<input id="register-sms-code" class="input" name="smsCode" autocomplete="one-time-code" inputmode="numeric" maxlength="4" pattern="\d{4}" placeholder="请输入四位验证码" required />
<button class="btn ghost" type="button" data-api-send-code>获取验证码</button>
</div>
</div>
<input
class="input"
name="nickName"
autocomplete="nickname"
placeholder="昵称(选填)"
/>
<input
class="input"
name="password"
type="password"
autocomplete="new-password"
placeholder="设置密码"
/>
<input
class="input"
name="confirmPassword"
type="password"
autocomplete="new-password"
placeholder="确认密码"
/>
<label class="auth-field" for="register-nickname">
<span>昵称(选填)</span>
<input id="register-nickname" class="input" name="nickName" autocomplete="nickname" placeholder="请输入昵称" />
</label>
<label class="auth-field" for="register-password">
<span>设置密码 <b aria-hidden="true">*</b></span>
<input id="register-password" class="input" name="password" type="password" autocomplete="new-password" placeholder="至少六位" required />
</label>
<label class="auth-field" for="register-confirm-password">
<span>确认密码 <b aria-hidden="true">*</b></span>
<input id="register-confirm-password" class="input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="请再次输入密码" required />
</label>
<input type="hidden" name="validToken" />
<div id="register-status" class="auth-form-status" role="status" aria-live="polite" data-auth-form-status></div>
<button class="btn primary" type="submit">注册账号</button>
<div class="auth-links">
<a href="login.html">已有账号,去登录</a>
@@ -84,6 +65,7 @@
<script src="public/layui/layui.js"></script>
<script src="public/tac/js/tac.min.js"></script>
<script src="public/js/captcha-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/auth-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+8 -4
View File
@@ -46,21 +46,25 @@
</section>
<section class="section alt">
<div class="container ticket-layout">
<form class="card form-card ticket-form tilt-card" data-feedback-form>
<form class="card form-card ticket-form tilt-card" data-feedback-form novalidate>
<h2>工单信息</h2>
<input class="input" name="contactInfo" placeholder="手机号 / 邮箱" />
<select name="feedbackType">
<label for="ticketContactInfo">联系方式(可选)</label>
<input id="ticketContactInfo" class="input" name="contactInfo" placeholder="手机号 / 邮箱" />
<label for="ticketFeedbackType">反馈类型</label>
<select id="ticketFeedbackType" name="feedbackType">
<option value="advice">意见建议</option>
<option value="bug">问题反馈</option>
<option value="complaint">投诉反馈</option>
<option value="other">其他反馈</option>
</select>
<label for="ticketFeedbackContent">问题描述 <span class="field-required" aria-hidden="true">*</span></label>
<textarea
id="ticketFeedbackContent"
name="feedbackContent"
required
placeholder="请描述遇到的问题、页面位置和希望处理的结果"
></textarea>
<p class="form-note" aria-live="polite" data-feedback-status></p>
<div class="form-status" role="status" aria-live="polite" data-feedback-status></div>
<button class="btn primary magnetic" type="submit" data-feedback-submit>提交工单</button>
</form>
<aside class="ticket-side tilt-card">
+84
View File
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const AdminPermissionPages = require('../public/js/admin-permission-pages.js');
const root = path.resolve(__dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('管理员权限页只从当前家谱上下文读取安全编号', () => {
assert.equal(AdminPermissionPages.getCurrentGenealogyId('?genealogyId=2062179707935264769'), '2062179707935264769');
assert.equal(AdminPermissionPages.getCurrentGenealogyId('?genealogyId=0'), '');
assert.equal(AdminPermissionPages.getCurrentGenealogyId('?genealogyId=unsafe'), '');
});
test('管理员权限概览只接受当前家谱的角色与严格布尔能力字段', () => {
assert.deepEqual(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: true,
canEditContent: false,
ownerUserId: '2062179707935264701'
}, '2062179707935264769'), {
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: true,
canEditContent: false
});
assert.equal(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264768',
roleType: 'admin',
canManage: true,
canEditContent: false
}, '2062179707935264769'), null);
assert.equal(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: 'true',
canEditContent: false
}, '2062179707935264769').canManage, false);
assert.equal(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264769',
roleType: 'legacy-role',
canManage: true,
canEditContent: false
}, '2062179707935264769'), null);
});
test('权限概览依据当前能力展示可管理、审核与内容维护状态', () => {
const admin = {
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: true,
canEditContent: false
};
const member = {
genealogyId: '2062179707935264769',
roleType: 'member',
canManage: false,
canEditContent: false
};
assert.match(AdminPermissionPages.renderCurrentPermissions(admin), /当前角色[\s\S]*管理员/);
assert.match(AdminPermissionPages.renderCurrentPermissions(admin), /成员角色维护[\s\S]*可管理/);
assert.match(AdminPermissionPages.renderCurrentPermissions(admin), /内容维护[\s\S]*可维护/);
assert.match(AdminPermissionPages.renderCurrentPermissions(member), /入谱审核[\s\S]*不可审核/);
assert.equal(AdminPermissionPages.canMaintainContent({ canManage: 'true', canEditContent: false }), false);
});
test('管理员权限页使用既有家谱详情接口和统一状态,不提供 App 粒度授权表单', () => {
const page = read('profile-admin-permissions.html');
const script = read('public/js/admin-permission-pages.js');
assert.match(page, /data-admin-permission-current[\s\S]*data-api-state="loading"/);
assert.match(page, /src="public\/js\/admin-permission-pages\.js"/);
assert.match(script, /api\.genealogyDetail\(genealogyId\)/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /当前账号无权查看该家谱权限。/);
assert.doesNotMatch(page, /<input|<select|<form/);
assert.doesNotMatch(script, /addManageUser|getManagePower|auth_str/);
});
+40
View File
@@ -184,6 +184,9 @@ test('相册页面拆分列表、编辑和照片详情且不允许手填 OSS ID
});
assert.match(listPage, /data-album-list/);
assert.match(editPage, /name="albumName"/);
assert.match(editPage, /data-album-form[^>]+novalidate/);
assert.match(editPage, /class="field-required"/);
assert.match(editPage, /<div class="api-status" data-album-form-status/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-album-detail/);
@@ -191,5 +194,42 @@ test('相册页面拆分列表、编辑和照片详情且不允许手填 OSS ID
assert.match(detailPage, /name="ossId" type="hidden"/);
assert.match(detailPage, /name="shootTime" type="datetime-local"/);
assert.match(detailPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-album-photo-form novalidate/);
assert.match(detailPage, /<div class="api-status" data-album-photo-status/);
assert.doesNotMatch(editPage + detailPage, /type="text"[^>]+(?:coverOssId|ossId)|name="albumId"|name="photoId"/);
});
test('相册列表、详情和表单将加载、空数据、403 与请求失败写入统一状态组件', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-album.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-album-detail.html'), 'utf8');
const source = fs.readFileSync(modulePath, 'utf8');
assert.match(listPage, /data-album-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-album-detail[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-album-photo-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setStatus\('\[data-album-list\]', '正在加载相册…', 'loading'\)/);
assert.match(source, /setStatus\('\[data-album-list\]', '暂无相册', 'empty'\)/);
assert.match(source, /setStatus\('\[data-album-photo-list\]', '正在加载照片…', 'loading'\)/);
assert.match(source, /setStatus\('\[data-album-photo-list\]', '暂无照片', 'empty'\)/);
assert.match(source, /当前账号无权查看该相册照片。/);
assert.match(source, /正在读取家谱权限…', 'loading'/);
assert.match(source, /setStatus\('\[data-album-form-status\]', validation, 'error'\)/);
assert.match(source, /setStatus\('\[data-album-photo-status\]', validation, 'error'\)/);
assert.match(source, /getApiStateType\(error\)/);
});
test('相册与照片只接受后端布尔能力字段,并在所有写路径上门控', () => {
const source = fs.readFileSync(modulePath, 'utf8');
const canEditAlbums = requireFunction('canEditAlbums');
assert.equal(canEditAlbums({ canEditContent: true }), true);
assert.equal(canEditAlbums({ canManage: true }), true);
assert.equal(canEditAlbums({ canEditContent: 'true', canManage: false }), false);
assert.match(source, /canEditContent = canEditAlbums\(detail\)/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !albumId \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !normalizeId\(albumId\) \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !albumId \|\| !normalizeId\(photoId\) \|\| redirectUnauthorized\(api\)\) return;/);
});
+2 -2
View File
@@ -737,7 +737,7 @@ test('feedback methods use the PC collection path and AppFeedbackBody whitelist'
await client.submitFeedback({
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
contactInfo: '测试联系方式',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
});
@@ -747,7 +747,7 @@ test('feedback methods use the PC collection path and AppFeedbackBody whitelist'
['post', '/genealogy/pc/feedback', {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
contactInfo: '测试联系方式'
}],
['get', '/genealogy/pc/feedback', undefined]
]);
+12 -6
View File
@@ -119,17 +119,23 @@ test('推广区域只把 401 视为登录失效', () => {
assert.equal(AppPromotionPages.isUnauthorized(new Error('网络失败')), false);
});
test('推广区域提供明确的未登录和空列表状态', () => {
assert.match(
AppPromotionPages.renderPromotionLoginRequired(),
/<a href="login\.html">立即登录<\/a>/
);
assert.match(AppPromotionPages.renderPromotionList([]), /当前暂无应用推广/);
test('推广区域将空、无权限与请求失败交给统一状态组件呈现', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../public/js/app-promotion-pages.js'), 'utf8');
assert.equal(AppPromotionPages.renderPromotionList([]), '');
assert.equal(AppPromotionPages.isForbidden({ status: 403 }), true);
assert.equal(AppPromotionPages.isForbidden({ status: 401 }), false);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setPromotionState\(list, 'loading', '正在读取应用推广…'\)/);
assert.match(source, /setPromotionState\(list, 'empty', '当前暂无应用推广。'\)/);
assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/);
});
test('应用下载页只通过页面标记和新推广模块接入,不暴露业务 ID 输入', () => {
assert.match(appPage, /<body[^>]*data-promotion-page/);
assert.match(appPage, /data-promotion-list/);
assert.match(appPage, /data-api-state="loading"/);
assert.match(appPage, /src="public\/js\/profile-common\.js"/);
assert.match(appPage, /src="public\/js\/app-promotion-pages\.js"/);
assert.doesNotMatch(appPage, /src="public\/js\/promotion-pages\.js"/);
assert.doesNotMatch(appPage, /name="(?:promotionId|coverOssId)"/);

Some files were not shown because too many files have changed in this diff Show More