feat(api): 添加帮助中心反馈系统和VIP服务功能

- 在ApiClient中新增submitFeedback、myFeedback、helpArticles、helpArticleDetail、
  siteArticles、promotions、vipPackages、createVipOrder、vipOrders等方法
- 添加帮助文章和站点资讯的参数验证逻辑
- 更新测试文件添加新的API方法测试用例
- 在HTML页面中添加反馈、帮助和VIP服务相关页面的脚本引用
- 更新加入家谱页面为完整的申请流程界面
- 修改资讯详情页面为站点资讯展示页面
- 更新AxiosRequestUtil中认证处理逻辑
- 添加世系树渲染的HTML生成函数用于页面复用
- 更新文档中的API契约说明和页面规划
This commit is contained in:
fizzleaf
2026-07-30 16:04:48 +08:00
parent fb1743aa2a
commit 309598bfa6
53 changed files with 6740 additions and 282 deletions
+1
View File
@@ -148,5 +148,6 @@
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/page-effects.js"></script>
<script src="public/js/app-promotion-pages.js"></script>
</body>
</html>
+9 -36
View File
@@ -3,11 +3,11 @@
<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/css/article-detail.css" />
</head>
<body class="page-article-detail" data-article-detail-page>
<body class="page-article-detail" data-site-article-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
@@ -32,39 +32,11 @@
</div>
</header>
<main>
<section class="inner-hero article-hero">
<div class="container">
<div class="hero-copy">
<div class="eyebrow">Article Detail</div>
<h1 data-article-title>为什么家谱不只是一本名册</h1>
<p class="lead" data-article-summary>
家谱记录的是家族关系,也记录一个家庭如何理解自己的来处、秩序和传承。
</p>
<div class="article-meta" data-article-meta>
<span>家谱知识</span><span>2026.05</span><span>阅读 2,418</span>
</div>
</div>
<div class="hero-card article-cover-detail tilt-card">
<span></span>
<p>记录来处,也照见未来。</p>
</div>
</div>
</section>
<section class="section alt">
<div class="container article-layout">
<article class="article-content tilt-card" data-article-content>
<p>谱文详情加载中...</p>
</article>
<aside class="article-side tilt-card">
<h3>相关入口</h3>
<a href="genealogy.html">了解数字家谱</a>
<a href="create-genealogy.html">创建一本家谱</a>
<a href="culture.html">返回家族文化</a>
</aside>
</div>
</section>
<div class="container">
<p class="module-meta" data-site-article-status></p>
</div>
<main data-site-article-detail>
<div class="api-empty">资讯详情加载中</div>
</main>
<footer class="footer">
@@ -86,7 +58,7 @@
<h4>文化内容</h4>
<a href="culture.html">家族文化</a
><a href="surname.html">姓氏百科</a
><a href="article-detail.html">家谱知识</a
><a href="news.html">新闻资讯</a
><a href="about.html">平台公告</a>
</div>
<div>
@@ -108,5 +80,6 @@
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/page-effects.js"></script>
<script src="public/js/site-news-pages.js"></script>
</body>
</html>
+318 -4
View File
@@ -1090,7 +1090,7 @@ PC `GenealogyMemberVo` 响应字段及页面用途:
### 7.20 家谱创建与加入申请契约
页面:`profile-create-family.html`;加入申请和审核页面留到下一批开放
页面:`profile-create-family.html``join-genealogy.html``profile-join-family.html``profile-join-review.html`
`AppGenealogyCreateBody`
@@ -1109,9 +1109,323 @@ PC `GenealogyMemberVo` 响应字段及页面用途:
- 创建只调用 `POST /genealogy/pc/genealogies`,提交前读取 `GET /genealogy/pc/genealogies/quota`
- 地区选项复用 `/genealogy/pc/region/children?parentCode=...`,不手写地区编码;
- 创建成功后使用响应中的稳定 `genealogyId` 重读同一家谱,匹配后进入家谱主页;
- ApiClient 已同时冻结加入申请、我的申请、待审核、审核和撤销的 PC path/body 白名单,但本批未提前开放加入 UI
- 申请页先从 `GET /genealogy/pc/genealogies/options` 选择响应中的家谱,再调用 `POST /genealogy/pc/genealogies/{genealogyId}/join-applies`
- 我的申请页调用 `GET /genealogy/pc/genealogies/join-applies/mine`;只有 `status=0` 的响应项可以调用 `DELETE /genealogy/pc/genealogies/join-applies/{applyId}`
- 审核页只从当前家谱上下文取得 `genealogyId`,先读取家谱详情并要求 `canManage=true`,再读取 `GET /genealogy/pc/genealogies/{genealogyId}/join-applies/pending`
- 审核只调用 `PUT /genealogy/pc/genealogies/{genealogyId}/join-applies/{applyId}/audit``status=1` 通过、`status=2` 拒绝;
- `inviterUserId` 没有安全 PC 邀请来源,本轮加入申请契约不发送该字段;
- 创建页面无家谱 ID、申请 ID、用户 ID 或 OSS ID 文本输入,所有写操作防重复。
- 创建/申请/审核页面无家谱 ID、申请 ID、用户 ID 或 OSS ID 文本输入,所有写操作防重复并在成功后重读对应资源
`AppGenealogyJoinApplyBody`
| 字段 | 类型/约束 | 必填 | 来源 | 页面与提交时机 |
| --- | --- | --- | --- | --- |
| `applicantName` | string,最长 50 | 否 | U | 申请页姓名;非空时提交 |
| `phone` | string,最长 30 | 否 | U | 申请页联系电话;非空时提交 |
| `relationDesc` | string,最长 100 | 否 | U | 申请页关系说明;非空时提交 |
| `applyReason` | string,最长 500 | 否 | U | 申请页申请理由;非空时提交 |
| `inviterUserId` | int64/string | 邀请模式条件必填 | I | PC 无安全来源,不展示、不发送;邀请模式继续阻断 |
`GenealogyJoinApplyVo` 与审核:
| 字段 | 类型/枚举 | 来源 | 页面用途 |
| --- | --- | --- | --- |
| `applyId``genealogyId` | int64/string | R/I | 响应按钮属性和写入 path;不显示、不手填 |
| `genealogyName``surname` | string | R | 我的申请列表只读展示 |
| `applicantName``phone``relationDesc``applyReason` | string | R | 管理员待审核列表;只展示申请人主动提交的 `phone` |
| `auditTime``auditRemark` | datetime/string | R | 我的申请审核结果展示 |
| `status` | string `0/1/2/3` | R | 待审核/通过/拒绝/撤销;仅 `0` 可撤销 |
| `appUserId``inviterUserId``auditUserId` | int64/string | I | 普通列表和审核列表均不展示 |
| `appUserPhone``inviterPhone``auditPhone` | string | I | 账号/邀请/审核人员隐私字段均不展示 |
| 审核 `status` | string `1/2` | S | 管理员点击通过或拒绝时提交 |
| 审核 `auditRemark` | string,最长 500 | U | 拒绝时可选填写,非空时提交 |
### 7.21 意见反馈与工单契约
页面:`profile-feedback.html``submit-ticket.html``my-tickets.html``ticket-detail.html`
后端只提供统一反馈集合:
- `POST /genealogy/pc/feedback`:当前业务用户提交反馈;
- `GET /genealogy/pc/feedback`:读取当前业务用户自己的反馈列表;
- 没有独立 ticket path;“工单”只是帮助中心对同一反馈记录的页面名称;
- 没有用户侧回复、追问、关闭或删除接口,页面不制造这些操作。
`AppFeedbackBody`
| 字段 | 类型/约束 | 必填 | 来源 | 页面与提交时机 |
| --- | --- | --- | --- | --- |
| `feedbackType` | string enum `advice/bug/complaint/other` | 否 | S | 两个提交页选择;空值省略并由后端默认 `advice` |
| `feedbackContent` | string,非空 | 是 | U | 问题或建议正文;提交时发送 |
| `contactInfo` | string | 否 | U | 用户主动提供的联系方式;非空时发送 |
| `feedbackTitle` | — | — | V | 旧页面字段不属于 DTO,已从表单移除且不发送 |
`FeedbackVo`
| 字段 | 类型/枚举 | 来源 | 页面用途 |
| --- | --- | --- | --- |
| `feedbackId` | int64/string | R/I | 提交后重读匹配、列表详情链接和 URL;不展示、不手填 |
| `feedbackType``feedbackContent``contactInfo` | string | R | 列表或详情只读展示 |
| `handleStatus` | string `0/1/2/3` | R | 待处理/处理中/已处理/已关闭 |
| `handleResult``handleTime``remark` | string/datetime | R | 后端存在时在详情展示 |
| `status` | string `0/1` | I | 响应有效性校验,不提供用户修改入口 |
| `appUserId``handlerId` | int64/string | I | 内部用户/处理人编号,不展示 |
| `appUserNickName``appUserPhone` | string | I | 后台 enrichment,不在用户页面展示 |
实现边界:
- ApiClient 是 GET/POST path 和三字段 body 白名单唯一 owner
- 提交使用防重复锁;成功响应必须包含稳定字符串 `feedbackId`
- 提交后重读我的反馈列表并精确找到同一 `feedbackId`,否则不宣称成功;
- 工单详情从 URL 读取列表响应产生的 `feedbackId`,只在我的列表中精确匹配,不回退第一条;
- 401 清理登录态;403 保留登录态并显示后端权限错误;页面不展示原始 JSON。
### 7.22 VIP 套餐与会员订单契约
页面:`profile-services.html`
PC 端只开放三条业务用户接口:
- `GET /genealogy/pc/vip/packages`:读取可购买套餐,成功响应 `List<VipPackageVo>`,无分页结构;
- `POST /genealogy/pc/vip/orders`:创建当前业务用户的会员订单,body 为 `AppVipOrderBody`,成功响应 `VipOrderVo`
- `GET /genealogy/pc/vip/orders`:读取当前业务用户的订单,成功响应 `List<VipOrderVo>`,无分页结构;
- 三条接口均要求 APP_USER 登录态;创建接口有后端防重复提交;
- PC Controller 没有支付发起、支付回调、取消、关闭或退款接口,页面不得制造这些动作。
`AppVipOrderBody`
| 字段 | 类型/约束 | 必填 | 来源 | 页面与提交时机 |
| --- | --- | --- | --- | --- |
| `packageId` | int64,稳定字符串 ID | 是 | S/I | 用户点击套餐响应生成的卡片时选择;创建订单时发送,禁止手填 |
| `genealogyId` | int64,稳定字符串 ID | 否 | S/I | 用户从“我的家谱”响应生成的下拉项中选择;非空时创建订单发送,禁止手填 |
| `payType` | string;后端空值默认 `wechat` | 否 | A | PC 页面不提供支付方式选择且默认省略;等待后端开放真实支付能力后再确认枚举 |
`VipPackageVo`
| 字段 | 类型/枚举 | 来源 | 页面用途 |
| --- | --- | --- | --- |
| `packageId` | int64/string | R/I | 套餐卡片选择和订单 body;不展示、不手填 |
| `packageName``packageDesc` | string | R | 套餐名称和说明 |
| `packageType` | string `vip/storage` | R | 显示为会员套餐/存储扩容;未知值整条过滤 |
| `price``originalPrice` | decimal,非负 | R | 价格展示;不参与前端金额计算 |
| `durationValue` | integer,非负,可空 | R | 与期限单位组合展示 |
| `durationUnit` | string `permanent/day/month/year` | R | 永久/天/月/年 |
| `genealogyLimit``memberLimit``storageLimitMb` | integer,非负,可空 | R | 套餐额度只读展示 |
| `featureJson` | string/JSON | I | 契约未说明用户侧展示结构,当前不解析、不展示 |
| `sortOrder` | integer | I | 后端列表顺序 owner,前端不重新排序 |
| `status` | string `0/1` | I | 只接受正常 `0` 套餐;不提供修改入口 |
| `remark` | string | R | 非空时作为套餐补充说明展示 |
`VipOrderVo`
| 字段 | 类型/枚举 | 来源 | 页面用途 |
| --- | --- | --- | --- |
| `orderId` | int64/string | R/I | 创建后重读精确匹配;不展示、不手填 |
| `orderNo` | string | R | 订单列表展示 |
| `packageId` | int64/string | R/I | 响应有效性校验,不展示 |
| `packageName` | string | R | 订单套餐名称 |
| `genealogyId` | int64/string,可空 | R/I | 响应有效性校验,不展示 |
| `genealogyNo``genealogyName` | string,可空 | R | 关联家谱信息展示 |
| `orderAmount``payAmount` | decimal,非负 | R | 订单金额和应付金额展示;以后端值为准 |
| `payType` | string,可空 | R | 后端返回时只读展示,不作为支付入口 |
| `payStatus` | string `0/1/2/3` | R | 待支付/已支付/已关闭/已退款 |
| `payTime``expireTime` | datetime,可空 | R | 支付和有效期信息展示 |
| `status` | string `0/1` | I | 响应有效性校验,不提供修改入口 |
| `remark` | string | R | 非空时展示 |
| `appUserId``appUserNickName``appUserPhone` | int64/string | I | 当前账号及 enrichment 字段,不在页面展示 |
实现边界:
- `ApiClient` 是三条 path 与三字段请求白名单唯一 owner;
- 页面启动时并行读取套餐、我的家谱和订单;所有业务 ID 仅来自响应;
- 创建使用前端防重复锁;创建响应必须是完整有效 `VipOrderVo`,随后重读订单列表并精确找到同一 `orderId` 才宣称成功;
- 页面省略 `payType`,由后端使用已确认的默认值;不展示后端尚未提供的支付、取消或退款按钮;
- 401 清理登录态;403 保留登录态并展示后端错误;非法套餐、订单、金额、枚举或不安全数字 ID 整条过滤。
### 7.23 家谱主页只读概览契约
页面:`profile-family-home.html`
只使用两条现有 PC 读取接口:
- `GET /genealogy/pc/genealogies/{genealogyId}/overview`:返回当前家谱 `AppGenealogyVo`
- `GET /genealogy/pc/genealogies/{genealogyId}/lineage/tree`:返回 `List<LineagePersonTreeView>`
- `genealogyId` 只从 `profile-common.js` 的当前家谱上下文取得,两条请求使用同一个稳定字符串 ID;
- 后端 `overview` 当前实际复用家谱详情服务,不包含文章、相册、视频、祭祀或动态聚合统计。
`AppGenealogyVo` 主页字段:
| 字段 | 类型/约束 | 来源 | 页面用途 |
| --- | --- | --- | --- |
| `genealogyId` | int64/string | I/A/R | 当前上下文、两条请求 path、响应一致性校验;不展示、不手填 |
| `genealogyNo` | string | R | 家谱编号只读展示 |
| `genealogyName` | string,非空 | R | 页面标题和概览名称 |
| `surname` | string | R | 姓氏只读展示 |
| `ancestralHall` | string,可空 | R | 堂号非空时展示 |
| `originPlace` | string,可空 | R | 祖籍非空时展示 |
| `regionFullName` | string,可空 | R | 完整地区非空时展示 |
| `memberCount``personCount` | integer,非负 | R | 家谱成员数和世系人物数;不在前端计算 |
| `status` | string `0/1` | I | 只接受正常 `0` 响应 |
| `canManage` | boolean | I | 严格为 `true` 时显示家谱管理入口 |
| `canEditContent` | boolean | I | 保留为内容权限上下文,主页不据此制造写操作 |
| `ownerUserId``coverOssId` | int64/string | I | 当前主页不展示、不手填 |
| 其余地址、角色、成员状态字段 | 对应 DTO 类型 | I | 当前主页不消费,不输出原始 JSON |
`LineagePersonTreeView`
| 字段 | 类型/结构 | 来源 | 页面用途 |
| --- | --- | --- | --- |
| `personId``genealogyId` | int64/string | R/I | 树节点稳定标识和响应校验;不直接展示 |
| `name``generationName`、人物摘要字段 | string | R | 复用世系页的安全节点渲染 |
| `relationType` | string `father/mother/spouse/child/adoptive` | R | 关系数据;主页不提供关系写操作 |
| `relationName` | string | R | 非空时作为关系说明 |
| `spouses``children` | `LineagePersonTreeView[]` | R | 递归世系预览 |
实现边界:
- `lineage-pages.js` 是世系节点规范化和树 HTML 的唯一 owner;主页不得复制一套树字段解释;
- 页面并行读取概览和世系树,只展示基础信息、后端已有计数和真实树;
- `/genealogy/dashboard/overview` 是后台权限接口,不属于 PC 家谱主页契约,前端不调用;
- PC 没有成员邀请创建或分享接口,“邀请家人”只显示阻断说明,不提供可点击操作;
- 主页没有写请求;401 清理登录态,403 保留登录态并显示读取错误。
### 7.24 帮助中心文章契约
页面:`help.html`。契约 owner`utils/ApiClient.js``helpArticles` / `helpArticleDetail``public/js/help-pages.js`
接口:
| method | 完整 path | 目录 | 鉴权 | 页面时机 |
| --- | --- | --- | --- | --- |
| GET | `/genealogy/pc/help-articles` | 内容文章 / PC 帮助文章列表 | APP_USER 登录态 | 帮助中心初始化或用户点击刷新 |
| GET | `/genealogy/pc/help-articles/{helpId}` | 内容文章 / PC 帮助文章详情 | APP_USER 登录态 | 用户从列表点击“查看完整解答” |
请求字段:
| 位置 | 字段 | 必填 | 类型/约束 | 分类 | 页面来源与提交时机 |
| --- | --- | --- | --- | --- | --- |
| Query | `helpCategory` | 否 | string;后端按完全相等过滤 | S | 当前页面不提供猜测分类选项,因此初始化时省略;后端提供正式分类字典后才能开放选择 |
| Path | `helpId` | 是 | int64;前端始终按非零十进制字符串处理 | I | 只能来自列表响应;用户点击该文章详情时进入 path |
| Header | `clientid` | 基础请求自动 | string | A | `AxiosRequestUtil` 统一填写;页面不输入 |
| Header | `Authorization` | 是 | `Bearer <access_token>` | A | `AxiosRequestUtil` 从登录态自动填写;页面不读取、不展示 token |
| Body | — | — | 两个接口均无 Body | — | — |
`HelpArticleVo` 响应字段:
| 字段 | 类型 | 分类 | 页面使用 |
| --- | --- | --- | --- |
| `helpId` | int64/string | I | 校验稳定 ID、绑定列表项并精确重读详情;不向用户显示 |
| `helpCategory` | string | R | 列表和详情分类文字,可空 |
| `helpTitle` | string | R | 列表标题,空值使整条响应失败 |
| `helpContent` | string | R | 详情正文;转义并保留换行,不执行响应 HTML |
| `coverOssId` | int64/string | I | 当前响应没有文件 URL,页面不显示、不拼接、不允许手填 |
| `sortOrder` | int64 | I | 仅后端排序,不展示 |
| `viewCount` | int64/string | R | 非负十进制计数;详情 GET 会由后端累计浏览量 |
| `status` | string | I | 只接受正常值 `0`;停用文章整条拒绝 |
| `remark` | string | I | 后台备注,不展示 |
响应与错误边界:
- 列表响应必须是直接数组,不接受分页 `rows` 猜测;任一元素缺少稳定 ID、标题、正文或正常状态时整批失败;
- 详情必须返回与 path 中相同的 `helpId`,不回退到列表第一条;
- YAML 把两条接口标为 `security: []`,但 `PcHelpArticleController` 没有 `@SaIgnore`,部署环境无 token 实测返回“认证失败”;按用户指定的后端项目为准,当前客户端必须携带登录 token,此项作为 Apifox/后端冲突保留;
- 后端只声明统一成功包装,未提供帮助文章专属失败码;详情不存在或停用时按业务错误展示,不制造 404 枚举;
- 无登录态或 401 清理登录状态并进入登录页;403 保留登录态并展示读取错误;
- 页面只有读取动作,不提供编辑、发布、分类管理、封面 OSS ID 或成员邀请操作。
### 7.25 应用推广契约
页面:`app.html` 的“应用推广”区域。契约 owner:`utils/ApiClient.js``promotions``public/js/app-promotion-pages.js`
接口:
| method | 完整 path | 目录 | 鉴权 | 页面时机 |
| --- | --- | --- | --- | --- |
| GET | `/genealogy/pc/promotions` | 应用推广 / PC 应用推广列表 | APP_USER 登录态 | 应用下载页初始化;仅在本地已有登录 token 时请求一次 |
请求字段:
| 位置 | 字段 | 必填 | 类型/约束 | 分类 | 页面来源与提交时机 |
| --- | --- | --- | --- | --- | --- |
| Query | `platform` | 否 | stringSQL 字典 `gen_promotion_platform``all` 全部(默认)、`app` APP、`pc` PC、`wechat` 小程序 | A | 应用下载页初始化时固定发送 `pc`;服务端返回 `platform=pc``platform=all` 的正常记录,不让用户选择或手填 |
| Header | `clientid` | 基础请求自动 | string | A | `AxiosRequestUtil` 统一填写;页面不输入 |
| Header | `Authorization` | 是 | `Bearer <access_token>` | A | `AxiosRequestUtil` 从登录态自动填写;页面不读取、不展示 token |
| Path | — | — | 无 Path 参数 | — | — |
| Body | — | — | 无 Body | — | — |
`AppPromotionVo` 列表元素字段:
| 字段 | 类型 | 必填/约束 | 分类 | 页面使用 |
| --- | --- | --- | --- | --- |
| `promotionId` | int64/string | SQL `BIGINT NOT NULL` 主键;前端按非零十进制字符串处理 | I | 稳定绑定卡片,不展示、不允许手填 |
| `promotionKey` | string | SQL `varchar(80) NOT NULL DEFAULT 'banner'` | I | 后台识别字段,页面不展示、不提交 |
| `promotionTitle` | string | SQL `varchar(200) NOT NULL`;页面要求非空 | R | 推广卡片标题,HTML 转义后展示 |
| `promotionDesc` | string | SQL `varchar(500) DEFAULT ''`,可空 | R | 推广卡片说明,HTML 转义后展示 |
| `coverOssId` | int64/string | 可空;响应没有可访问文件 URL | I | 不显示、不拼接 URL、不允许用户手填 OSS ID |
| `targetUrl` | string | SQL `varchar(500) DEFAULT ''`,可空;页面只接受绝对 `http`/`https` URL | R | 合法时作为新窗口外链;危险、相对或无效地址降级为不可点击卡片 |
| `platform` | string | SQL `varchar(30) NOT NULL DEFAULT 'all'`;字典枚举 `all/app/pc/wechat` | I | 仅后端筛选,当前页面不展示 |
| `sortOrder` | int64 | SQL `BIGINT NOT NULL DEFAULT 0`;后端按升序排序,同序按 `promotionId` 降序 | I | 仅后端排序,页面不二次解释或展示 |
| `status` | string | SQL `char(1) NOT NULL DEFAULT '0'``0` 正常、`1` 停用,页面只接受 `0` | I | 停用或缺失状态的记录使整批响应失败 |
| `remark` | string | SQL `varchar(500) DEFAULT NULL`,可空 | I | 后台备注,不展示 |
响应、权限与错误边界:
- 成功响应经统一请求层解包后必须是直接 `AppPromotionVo[]`,不是分页结构;任一元素缺少稳定 ID、标题或正常状态时整批失败;
- 空数组显示“当前暂无应用推广”;未登录时页面本身仍可浏览,但推广区域只显示登录入口且不发请求;
- YAML 把该接口标为 `security: []`,并且未导出真实 Query `platform`;后端 `PcPromotionApiController` 没有 `@SaIgnore`,全局拦截器要求 APP_USER 登录态。SQL 字典已确认 `all/app/pc/wechat`,服务层按“请求平台或 `all`”过滤,因此当前实现携带登录 token 并固定发送 `platform=pc`
- SQL `gen_app_promotion` 另有 `(tenant_id, platform, status, del_flag)``(tenant_id, sort_order)` 索引;`del_flag`、租户及审计字段不属于 `AppPromotionVo`,页面不接收、不展示;
- 401 清理失效 token 并把推广区域切回登录提示;403、网络失败或畸形响应只在推广区域显示读取失败,不跳转、不清理有效登录态;
- 后端只声明统一成功包装,未提供推广专属失败响应和错误码;字段长度、默认值及平台枚举已由后端 SQL 冻结,不再作为阻断项;
- 页面没有推广新增、编辑、上下架、排序、封面上传能力,不调用 APP 或后台管理接口,也不向用户暴露推广 ID、推广键或 OSS ID。
### 7.26 官网资讯契约
页面:`news.html``article-detail.html`。契约 owner`utils/ApiClient.js``siteArticles``utils/AxiosRequestUtil.js` 的公开请求鉴权语义和 `public/js/site-news-pages.js`
接口:
| method | 完整 path | 目录 | 鉴权 | 页面时机 |
| --- | --- | --- | --- | --- |
| GET | `/genealogy/pc/site/articles` | 站点内容 / PC 站点文章列表 | 公开,`auth: false` | 资讯列表初始化、分类切换后的页面初始化,以及详情页按列表响应 ID 精确重读 |
请求字段:
| 位置 | 字段 | 必填 | 类型/约束 | 分类 | 页面来源与提交时机 |
| --- | --- | --- | --- | --- | --- |
| Query | `articleType` | 否 | stringSQL 字典 `gen_site_article_type``news` 新闻(默认)、`notice` 公告、`download` 下载 | S | 用户点击固定分类链接后从 URL Query 取得;“全部”省略该字段,非法值不发请求 |
| Query | `limit` | 否 | integer,客户端只允许 1–100;服务端正数最大取 100 | A | 列表和详情重读均由页面固定提交 `100` |
| Header | `clientid` | 基础请求自动 | string | A | `AxiosRequestUtil` 统一填写;页面不输入 |
| Header | `Authorization` | 不发送 | — | — | 方法设置 `auth: false`;即使本地已有 token 也不发送,公开请求 401/403 不清理既有登录态 |
| Path | — | — | 无 Path 参数 | — | — |
| Body | — | — | 无 Body | — | — |
`SiteArticleVo` 列表元素字段:
| 字段 | 类型 | 必填/约束 | 分类 | 页面使用 |
| --- | --- | --- | --- | --- |
| `articleId` | int64/string | SQL `BIGINT NOT NULL` 主键;前端按非零十进制字符串处理 | I | 只用于生成详情链接和精确匹配,不展示、不允许手填 |
| `articleType` | string | SQL `varchar(50) NOT NULL DEFAULT 'news'`;枚举 `news/notice/download` | R | 转换为新闻、公告、下载标签;枚举外记录使整批失败 |
| `articleTitle` | string | SQL `varchar(200) NOT NULL`;页面要求非空 | R | 列表和详情标题,HTML 转义 |
| `articleSummary` | string | SQL `varchar(500) DEFAULT ''`,可空 | R | 列表摘要和详情导语,HTML 转义 |
| `articleContent` | string | SQL `text`,可空 | R | 详情正文;HTML 转义并只保留换行,不执行响应 HTML |
| `coverOssId` | int64/string | SQL `BIGINT`,可空;没有文件 URL 契约 | I | 不显示、不拼接 URL、不允许手填 |
| `externalUrl` | string | SQL `varchar(500) DEFAULT ''`,可空 | R | 只接受绝对 HTTP/HTTPS;合法时显示安全新窗口外链 |
| `publishTime` | string/date | SQL `datetime`,可空 | R | 列表和详情发布时间文本,HTML 转义 |
| `sortOrder` | int64 | SQL `BIGINT NOT NULL DEFAULT 0` | I | 仅后端排序,不展示 |
| `status` | string | SQL `char(1) NOT NULL DEFAULT '0'``0` 正常、`1` 停用 | I | 页面只接受 `0`,其他值使整批失败 |
| `remark` | string | SQL `varchar(500) DEFAULT NULL`,可空 | I | 后台备注,不展示 |
响应、详情与错误边界:
- 成功响应经统一请求层解包后必须是直接 `SiteArticleVo[]`;不接受分页 `rows`、旧字段别名或任一非法元素;
- 服务端只查正常状态,按 `sortOrder ASC``publishTime DESC``articleId DESC` 排序;页面保持服务端顺序;
- 后端没有站点文章单条详情接口;详情 ID 只能来自列表链接,详情页重读 `{ limit: 100 }` 后精确匹配同一字符串 ID,不存在时不得回退第一条;
- 标题、摘要、正文、类型和时间全部转义;正文只保留换行,危险或相对外链不渲染;
- 空数组显示“当前暂无资讯”;非法分类/详情 ID 不发请求;网络、业务或畸形响应只更新内容区域,不跳转登录;
- YAML 已导出 method/path、`articleType``limit``security: []`,但响应只引用通用列表结果,未导出 `SiteArticleVo` 11 个字段;完整字段、长度、默认值和枚举由后端 VO、SQL 与字典补齐;
- 2026-07-30 使用本地页面对当前部署环境做匿名实测时,接口返回“认证失败,无法访问系统资源”;页面保持在资讯页且不清理已有 token。该行为与 YAML 的 `security: []` 冲突,后端需确认部署环境是否遗漏公开放行;前端不通过附带登录 token 绕过公开契约;
- `coverOssId` 的文件 URL 和站点文章单条详情接口继续作为后端契约阻断;页面不猜 URL、不制造详情路径;
- 本批不修改 `about/culture/privacy/terms/surname`,也不复用登录后的家谱谱文 CRUD 脚本。
## 8. 分阶段执行顺序
@@ -1247,7 +1561,7 @@ PC `GenealogyMemberVo` 响应字段及页面用途:
### 阶段 7:剩余 PC 页面分批开放
首批已完成家谱生命周期 ApiClient 契约和创建家谱页面:额度、三级地区、封面上传派生、严格 DTO 校验、防重复与创建后重读均已接入。加入申请/审核、反馈/工单、VIP 和家谱主页按独立后续批次实施;成员邀请、分享和资料完善提醒没有 PC Controller,继续阻断。
首批已完成家谱生命周期 ApiClient 契约和创建家谱页面:额度、三级地区、封面上传派生、严格 DTO 校验、防重复与创建后重读均已接入。第二批已完成申请加入、我的申请、撤销和管理员审核页面;家谱/申请 ID 均来自响应或当前上下文,写后重读确认。第三批已完成意见反馈工单展示:四页共用 PC 反馈集合、提交后三字段白名单重读、详情精确匹配和隐私过滤。第四批已完成 VIP 套餐、我的家谱选项和会员订单:页面不手填业务 ID,不伪造支付能力,创建后重读同一订单确认。第五批已完成家谱主页只读概览:复用 PC overview 与世系树,权限控制管理入口,不制造聚合统计或邀请能力。第六批已完成帮助中心:登录后读取 PC 帮助文章列表,列表响应产生详情 ID,详情精确重读并安全展示正文;Apifox 的匿名标记与后端实际鉴权冲突已记录。第七批已完成应用下载页的 PC 推广列表:未登录不发请求,登录后固定发送 SQL 字典确认的 `platform=pc`,只读取服务端返回的 `pc + all` 正常记录,外链仅允许绝对 HTTP/HTTPS;推广 ID、后台键和 OSS ID 均不向用户开放。第八批已完成官网资讯列表与详情:公开请求不发送或清理 token,分类使用 SQL 字典 `news/notice/download`,详情 ID 只来自列表响应并通过重读列表精确匹配;YAML 通用响应缺失完整 `SiteArticleVo`,且当前部署环境匿名访问仍返回认证失败,两项差异均已记录。成员邀请、分享和资料完善提醒没有 PC Controller,继续阻断。
## 9. 文件级施工建议
@@ -0,0 +1,391 @@
# 应用下载页 PC 推广列表 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`app.html` 现有推广区域读取并安全展示后端 PC 推广列表,同时保持应用下载页公开可访问。
**Architecture:** `utils/ApiClient.js` 独占 method/path/query 契约;新建 `public/js/app-promotion-pages.js` 独占 `AppPromotionVo` 规范化、链接安全、区域状态和渲染。页面只提供容器与脚本加载,不复制接口逻辑。
**Tech Stack:** 原生 JavaScript UMD、HTML/CSS、Node.js `node:test`、现有 `GenealogyApi` / `AxiosRequestUtil`
## Global Constraints
- 只调用 `GET /genealogy/pc/promotions`,不得调用 APP 或后台管理接口。
- 后端控制器实际要求 PC 登录态;请求携带当前 token 和基础 `clientid`
- 页面初始化不发送 `platform`;客户端即使被其它调用方使用,也只允许可选 Query `platform`
- 页面不展示或拼接 `coverOssId`,不允许用户输入业务 ID 或 OSS ID。
- `targetUrl` 只允许绝对 HTTP(S) URL;其它值按无链接卡片展示。
- 页面本身保持公开;未登录和 401 只改变推广区域,不跳走整个下载页。
-`promotion-pages.js` 不恢复,不保留兼容入口。
- 后端项目 `D:\WorkSpace\Java\Genealogy` 只读。
- 当前共享 `main` 工作区包含多批未提交改动;本计划不执行 Git stage、commit、merge 或 push。
---
### Task 1: 冻结 PC 推广客户端契约
**Files:**
- Modify: `tests/api-client-contract.test.js`
- Modify: `utils/ApiClient.js`
**Interfaces:**
- Consumes: 私有 `request(method, path, options)``pickDefined(source, allowedFields)`
- Produces: `client.promotions(query?: { platform?: string }): Promise<Array<AppPromotionVo>>`
- [ ] **Step 1: 在客户端允许方法集合中加入 `promotions`,并写失败测试**
测试使用带 `access-token` 的真实客户端边界,传入:
```js
await client.promotions({
platform: 'pc',
keyword: 'must-drop'
});
```
手工断言请求为:
```js
[
'get',
'/genealogy/pc/promotions',
{ platform: 'pc' },
'Bearer access-token'
]
```
该测试捕获错误 path、错误 method、契约外 Query 被透传或误设 `auth: false`
- [ ] **Step 2: 运行客户端测试观察 RED**
Run:
```powershell
node --test tests/api-client-contract.test.js
```
Expected: FAIL,提示 `promotions` 未导出或不是函数。
- [ ] **Step 3: 添加最小客户端实现**
`helpArticleDetail` 和 VIP 方法附近添加:
```js
promotions: function (query) {
return request('GET', '/genealogy/pc/promotions', {
query: pickDefined(query, ['platform'])
});
},
```
不得增加 `auth: false`、默认 `platform` 或旧路径 fallback。
- [ ] **Step 4: 运行客户端测试观察 GREEN**
Run:
```powershell
node --test tests/api-client-contract.test.js
```
Expected: 全部通过。
---
### Task 2: 实现推广响应和安全渲染边界
**Files:**
- Create: `tests/app-promotion-pages.test.js`
- Create: `public/js/app-promotion-pages.js`
**Interfaces:**
- Consumes: `GenealogyApi.defaultClient.promotions()``getToken()``clearToken()`
- Produces:
- `normalizeTargetUrl(value): string`
- `normalizePromotion(item): PromotionView | null`
- `normalizePromotionList(data): PromotionView[]`
- `renderPromotionList(data): string`
- `renderPromotionLoginRequired(): string`
- `loadPromotions(api): Promise<PromotionView[]>`
- `isUnauthorized(error): boolean`
- `init(): Promise<void>`
`PromotionView` 的唯一形状:
```js
{
promotionId: '2062179707935264769',
promotionTitle: '下载移动端',
promotionDesc: '随时查看家谱内容',
targetUrl: 'https://example.com/download'
}
```
- [ ] **Step 1: 写完整真实 DTO fixture 和失败测试**
Fixture 必须包含后端全部 10 个字段:
```js
{
promotionId: '2062179707935264769',
promotionKey: 'app-download',
promotionTitle: '下载移动端',
promotionDesc: '随时查看家谱内容',
coverOssId: '2062179707935264701',
targetUrl: 'https://example.com/download',
platform: 'all',
sortOrder: 10,
status: '0',
remark: 'internal-only'
}
```
分别覆盖:
1. 规范化结果只保留 `PromotionView` 四字段;
2. 不安全数字长 ID、空标题和 `status=1` 返回 `null`
3. 直接数组正常,`{ rows: [...] }` 和混入非法元素返回空数组;
4. `https://``http://` 保留,`javascript:``data:`、相对路径和空值返回空字符串;
5. 渲染转义标题与说明,不出现 `coverOssId``promotionKey``sortOrder``status``remark`
6. 合法 URL 使用 `target="_blank"``rel="noopener noreferrer"`,非法 URL 不生成 `<a>`
7. `loadPromotions(api)` 只调用一次 `api.promotions()`,不传 `platform`
8. `isUnauthorized` 仅把 HTTP/业务 401 视为登录失效,403 为普通错误。
- [ ] **Step 2: 运行模块测试观察 RED**
Run:
```powershell
node --test tests/app-promotion-pages.test.js
```
Expected: FAIL,提示 `public/js/app-promotion-pages.js` 不存在。
- [ ] **Step 3: 实现 UMD 模块的纯函数**
实现稳定 ID
```js
function normalizeId(value) {
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
var result = String(value == null ? '' : value).trim();
return /^[1-9][0-9]*$/.test(result) ? result : '';
}
```
实现安全 URL
```js
function normalizeTargetUrl(value) {
var text = String(value == null ? '' : value).trim();
var parsed;
if (!text) return '';
try {
parsed = new URL(text);
} catch (error) {
return '';
}
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
}
```
`normalizePromotion` 必须要求稳定 `promotionId`、非空 `promotionTitle``status === '0'`,然后只返回 `PromotionView``normalizePromotionList` 必须拒绝非直接数组以及包含任一非法元素的数组。
- [ ] **Step 4: 实现渲染和读取函数**
有链接时结构:
```html
<a class="promotion-card" data-promotion-id="..." href="..." target="_blank" rel="noopener noreferrer">
<div><h3>...</h3><p>...</p><span>了解详情</span></div>
</a>
```
无链接时使用 `<article class="promotion-card">`,不输出 `href``target``rel`。空列表固定返回:
```html
<div class="api-empty">当前暂无应用推广</div>
```
未登录固定返回:
```html
<div class="api-empty">登录后可查看应用推广。<a href="login.html">去登录</a></div>
```
`loadPromotions(api)` 必须执行 `await api.promotions()`,并通过“原数组长度等于规范化后数组长度”确认响应完整。
- [ ] **Step 5: 实现区域级初始化**
`init()` 只在 `[data-promotion-page]``[data-promotion-list]` 同时存在时运行:
```js
if (!api || !api.getToken || !api.getToken()) {
list.innerHTML = renderPromotionLoginRequired();
return;
}
```
有 token 时加载一次。401 调用 `api.clearToken()` 并渲染登录入口;403、网络失败或非法响应渲染“应用推广读取失败,请稍后重试”。不得修改 `root.location`
- [ ] **Step 6: 运行模块测试观察 GREEN**
Run:
```powershell
node --test tests/app-promotion-pages.test.js tests/api-client-contract.test.js
```
Expected: 全部通过。
---
### Task 3: 开放应用下载页推广区域
**Files:**
- Modify: `tests/app-promotion-pages.test.js`
- Modify: `tests/pc-scope.test.js`
- Modify: `app.html`
- Modify only if required by rendered markup: `public/css/app.css`
**Interfaces:**
- Consumes: `window.AppPromotionPages.init()` 的 DOMContentLoaded 自动初始化。
- Produces: `app.html` 的真实推广列表入口。
- [ ] **Step 1: 写页面开放状态失败测试**
`tests/app-promotion-pages.test.js` 读取 `app.html`,断言:
```js
assert.match(page, /data-promotion-page/);
assert.match(page, /data-promotion-list/);
assert.match(page, /public\/js\/app-promotion-pages\.js/);
assert.doesNotMatch(page, /public\/js\/promotion-pages\.js/);
assert.doesNotMatch(page, /name="(?:promotionId|coverOssId|platform)"/);
```
`tests/pc-scope.test.js` 保留旧 `promotion-pages.js` deny-list,并新增 `app.html` 必须加载 `app-promotion-pages.js` 的断言。
- [ ] **Step 2: 运行页面测试观察 RED**
Run:
```powershell
node --test tests/app-promotion-pages.test.js tests/pc-scope.test.js
```
Expected: FAIL,提示 `app.html` 尚未加载新脚本。
- [ ] **Step 3: 修改页面加载脚本**
`page-effects.js` 后加载:
```html
<script src="public/js/app-promotion-pages.js"></script>
```
保留现有 `data-promotion-page``data-promotion-list` 和初始加载文案。不得添加筛选器、刷新按钮、示例推广或隐藏 ID 输入。
- [ ] **Step 4: 仅在需要时补充无图片卡片样式**
如果 `<a class="promotion-card">` 不能继承卡片文字颜色与块级点击区域,只增加:
```css
.promotion-card {
display: block;
color: inherit;
text-decoration: none;
}
```
不得调整首页广告样式或重做应用下载页布局。
- [ ] **Step 5: 运行页面与模块组合测试观察 GREEN**
Run:
```powershell
node --test tests/app-promotion-pages.test.js tests/api-client-contract.test.js tests/pc-scope.test.js tests/public-static-pages.test.js
```
Expected: 全部通过。
---
### Task 4: 更新规划并完成验证
**Files:**
- Modify: `docs/PC接口对接规划.md`
- Modify: `docs/superpowers/plans/2026-07-29-app-promotions.md`
**Interfaces:**
- Consumes: 已实现的 ApiClient、模块、页面和测试证据。
- Produces: 阶段 7 第七批契约记录和验收报告。
- [ ] **Step 1: 在规划中新增推广字段表**
记录:
- method/path/内容推广目录;
- `platform` 为可选 Query、当前页面省略;
- Authorization 与 clientid 为 A
- Body 为空;
- `promotionId``promotionKey``coverOssId``platform``sortOrder``status``remark` 为 I
- `promotionTitle``promotionDesc`、安全 `targetUrl` 为 R
- YAML `security: []` / 未导出 Query 与后端实际鉴权 / `platform` 的冲突;
- 页面初始化、未登录、401、403、空数组、非法响应和真实列表时机。
- [ ] **Step 2: 运行专项测试**
Run:
```powershell
node --test tests/app-promotion-pages.test.js tests/api-client-contract.test.js tests/pc-scope.test.js tests/public-static-pages.test.js
```
Expected: 0 failures。
- [ ] **Step 3: 运行语法和差异检查**
Run:
```powershell
node --check public/js/app-promotion-pages.js
node --check utils/ApiClient.js
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 两个语法检查 exit 0`diff --check` 无错误,CRLF warning 可记录但不算失败。
- [ ] **Step 4: 运行全量测试**
Run:
```powershell
npm test
```
Expected: 0 failures。
- [ ] **Step 5: 浏览器验证真实分支**
只读验证:
1. 未登录打开 `app.html`,确认下载内容保留、推广区域显示登录入口、未发生推广请求;
2. 使用已授权测试账号登录后打开 `app.html`
3. 后端返回空数组时显示“当前暂无应用推广”;有数据时检查标题、说明、安全外链和内部字段隐藏;
4. 检查页面控制台;
5. 不点击外部推广链接,不创建或修改后端数据;
6. 浏览器控制不稳定时停止自动关闭标签页,只报告已取得的验证结果并清理本地临时服务。
- [ ] **Step 6: 标记计划状态并按阶段格式汇报**
报告必须包含:
```text
Changed: ApiClient、推广模块、app.html 和规划新增内容。
Verified: RED/GREEN、专项、全量和浏览器覆盖数量。
Conflicts: YAML 匿名/无 Query 与后端真实鉴权/platform 的差异。
Blocked: 缺少封面 URL、platform 枚举或真实推广数据时的剩余联调项。
Next: 官网内容或公开家谱接口的下一最小批次。
```
@@ -0,0 +1,376 @@
# 家谱主页 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`profile-family-home.html` 从静态预览开放为真实 PC 家谱主页,展示当前家谱概览和世系树预览,并保留已接入业务页入口。
**Architecture:** `profile-common.js` 继续唯一拥有当前 `genealogyId`;页面只调用已有 `genealogyOverview(genealogyId)``lineageTree(genealogyId)``lineage-pages.js` 继续唯一拥有世系树节点规范化与 HTML 生成,新建 `family-home-pages.js` 只负责概览 DTO、权限显隐、并行读取和页面状态。
**Tech Stack:** 静态 HTML、原生 JavaScript UMD、Axios、Node `node:test`
**Status:** 2026-07-29 已按 Task 1–4 实施;自动化与无家谱真实账号分支已验证,有家谱数据态等待具备真实家谱的账号复核。
## Global Constraints
- 后端 `D:\WorkSpace\Java\Genealogy` 全程只读。
- 只使用 PC 接口;不得调用 `/genealogy/dashboard/overview`、APP 或管理后台接口。
- `genealogyId` 只能来自 `ProfileUI.getGenealogyId()`,始终按字符串处理。
- `GET /genealogy/pc/genealogies/{genealogyId}/overview` 返回 `AppGenealogyVo`,它不是内容统计接口;不得制造文章、相册、视频或活动数量。
- 世系树只读调用 `GET /genealogy/pc/genealogies/{genealogyId}/lineage/tree`
- 家谱管理入口仅在响应 `canManage=true` 时显示;内容页自身继续负责更细权限。
- PC 没有成员邀请创建/分享接口;主页不得开放“邀请家人”操作。
- 本批次没有写接口,不创建、修改或删除任何真实业务数据。
---
### Task 1: 家谱主页 DTO 与页面契约
**Files:**
- Create: `public/js/family-home-pages.js`
- Create: `tests/family-home-pages.test.js`
**Interfaces:**
- Consumes: `ProfileUI.getGenealogyId()``GenealogyApi.defaultClient`
- Produces:
- `normalizeFamilyOverview(item, expectedGenealogyId)`
- `renderFamilyOverview(overview)`
- `shouldShowFamilyManagement(overview)`
- `shouldRedirectToLogin(api, error)`
- [ ] **Step 1: 写失败数据边界测试**
```js
const overview = FamilyHomePages.normalizeFamilyOverview({
genealogyId: '2062179707935264769',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
ancestralHall: '南阳堂',
originPlace: '四川成都',
regionFullName: '四川省 成都市',
memberCount: 12,
personCount: 36,
status: '0',
canManage: true,
canEditContent: true,
ownerUserId: 'must-not-render',
coverOssId: 'must-not-render'
}, '2062179707935264769');
assert.deepEqual(overview, {
genealogyId: '2062179707935264769',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
ancestralHall: '南阳堂',
originPlace: '四川成都',
regionFullName: '四川省 成都市',
memberCount: 12,
personCount: 36,
canManage: true,
canEditContent: true
});
assert.equal(
FamilyHomePages.normalizeFamilyOverview(
{ genealogyId: Number.MAX_SAFE_INTEGER + 1, genealogyName: '非法', status: '0' },
'2062179707935264769'
),
null
);
assert.equal(
FamilyHomePages.normalizeFamilyOverview(
{ genealogyId: '2', genealogyName: '串谱', status: '0' },
'2062179707935264769'
),
null
);
```
同时断言:
- `genealogyName` 为空、`status!='0'`、负数/非安全计数均拒绝;
- 渲染转义全部文本,不出现 `ownerUserId``coverOssId` 或原始 JSON
- 只有布尔值 `canManage===true` 才开放管理入口;
- 401 清理登录态,403 不清理登录态。
- [ ] **Step 2: 运行 RED**
Run: `node --test tests/family-home-pages.test.js`
Expected: FAIL`family-home-pages.js` 不存在。
- [ ] **Step 3: 最小实现概览规范化**
```js
function normalizeFamilyOverview(item, expectedGenealogyId) {
var source = item || {};
var genealogyId = normalizeId(source.genealogyId);
var expectedId = normalizeId(expectedGenealogyId);
var memberCount = normalizeCount(source.memberCount);
var personCount = normalizeCount(source.personCount);
if (!genealogyId || genealogyId !== expectedId ||
!text(source.genealogyName) || String(source.status) !== '0' ||
memberCount === null || personCount === null) return null;
return {
genealogyId: genealogyId,
genealogyNo: text(source.genealogyNo),
genealogyName: text(source.genealogyName),
surname: text(source.surname),
ancestralHall: text(source.ancestralHall),
originPlace: text(source.originPlace),
regionFullName: text(source.regionFullName),
memberCount: memberCount,
personCount: personCount,
canManage: source.canManage === true,
canEditContent: source.canEditContent === true
};
}
```
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/family-home-pages.test.js
node --check public/js/family-home-pages.js
```
Expected: PASS。
---
### Task 2: 复用世系树唯一渲染 owner
**Files:**
- Modify: `public/js/lineage-pages.js`
- Modify: `tests/lineage-pages.test.js`
- Modify: `tests/family-home-pages.test.js`
**Interfaces:**
- Produces: `LineagePages.renderLineageTreeHtml(data)`
- Consumes: 现有 `normalizeLineagePerson(item)``renderTreeNode(item, ancestry)`
- [ ] **Step 1: 写失败共享渲染测试**
```js
const tree = [{
personId: '2062179707935264770',
genealogyId: '2062179707935264769',
name: '<始祖>',
status: '0',
spouses: [],
children: []
}];
const html = LineagePages.renderLineageTreeHtml(tree);
assert.match(html, /&lt;始祖&gt;/);
assert.match(html, /data-lineage-person="2062179707935264770"/);
assert.doesNotMatch(html, /2062179707935264769/);
assert.match(LineagePages.renderLineageTreeHtml([]), /暂无世系树/);
```
- [ ] **Step 2: 运行 RED**
Run: `node --test tests/lineage-pages.test.js tests/family-home-pages.test.js`
Expected: FAIL`renderLineageTreeHtml` 未导出。
- [ ] **Step 3: 从现有 `renderTree` 提取纯 HTML owner**
```js
function renderLineageTreeHtml(data) {
var nodes = normalizeList(data)
.map(function (item) { return renderTreeNode(item, {}); })
.filter(Boolean);
return nodes.length
? '<ul class="lineage-tree">' + nodes.join('') + '</ul>'
: '<div class="api-empty">暂无世系树</div>';
}
function renderTree(data) {
var container = query('[data-lineage-tree]');
if (container) container.innerHTML = renderLineageTreeHtml(data);
}
```
在 UMD 导出对象加入:
```js
renderLineageTreeHtml: renderLineageTreeHtml
```
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/lineage-pages.test.js tests/family-home-pages.test.js
node --check public/js/lineage-pages.js
```
Expected: PASS,现有世系管理页输出不变。
---
### Task 3: 开放真实家谱主页
**Files:**
- Modify: `profile-family-home.html`
- Modify: `public/js/family-home-pages.js`
- Modify: `tests/family-home-pages.test.js`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/stage6-navigation.test.js`
**Interfaces:**
- Consumes:
- `api.genealogyOverview(genealogyId)`
- `api.lineageTree(genealogyId)`
- `LineagePages.renderLineageTreeHtml(data)`
- Produces: `initFamilyHomePage()``init()`
- [ ] **Step 1: 写失败页面测试**
断言:
- `profile-family-home.html` 不再包含 `data-feature-status="pending"``pending-pages.js`
- 加载顺序为 `profile-common.js``lineage-pages.js``family-home-pages.js`
- 标题、摘要、计数、管理入口、世系预览均有稳定 `data-*` hook
- 硬编码“四川武胜汤氏族”被删除;
- “邀请家人”不再是可点击业务入口,并明确提示“PC 暂未开放邀请”;
- 谱文、相册、视频、功德、祭祀、世系、动态入口继续携带 `data-genealogy-context-link`
- 初始化只并行调用:
```js
Promise.all([
api.genealogyOverview(genealogyId),
api.lineageTree(genealogyId)
])
```
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/family-home-pages.test.js tests/pending-pages.test.js tests/stage6-navigation.test.js
```
Expected: FAIL,主页仍为 pending 且包含硬编码家谱。
- [ ] **Step 3: 实现只读初始化流程**
```js
async function initFamilyHomePage() {
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var genealogyId = root.ProfileUI && root.ProfileUI.getGenealogyId();
var results;
var overview;
if (!genealogyId) {
root.location.replace('profile-families.html?next=profile-family-home.html');
return;
}
try {
results = await Promise.all([
api.genealogyOverview(genealogyId),
api.lineageTree(genealogyId)
]);
overview = normalizeFamilyOverview(results[0], genealogyId);
if (!overview) throw new Error('家谱概览响应无效');
renderFamilyOverview(overview);
renderManagementAccess(overview.canManage);
query('[data-lineage-home-tree]').innerHTML =
root.LineagePages.renderLineageTreeHtml(results[1]);
} catch (error) {
if (shouldRedirectToLogin(api, error)) return redirectToLogin(api);
renderFamilyHomeError(error);
}
}
```
页面只展示:
- 家谱名称、编号、姓氏、堂号、祖籍/地区;
- `memberCount``personCount`
- 真实世系树及进入完整世系页的链接;
- 后端已经接入的内容模块入口。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/family-home-pages.test.js tests/pending-pages.test.js tests/stage6-navigation.test.js
node --check public/js/family-home-pages.js
```
Expected: PASS。
---
### Task 4: 规划记录与完整验收
**Files:**
- Modify: `docs/PC接口对接规划.md`
- Modify: `docs/superpowers/plans/2026-07-29-family-home.md`
**Interfaces:**
- Consumes: Task 13 的只读家谱主页闭环。
- [ ] **Step 1: 更新规划字段表**
新增家谱主页小节,逐字段记录:
| 字段 | 分类 | 页面用途 |
| --- | --- | --- |
| `genealogyId` | I/A | 当前上下文、两条请求 path、响应一致性校验 |
| `genealogyNo``genealogyName``surname` | R | 标题和基础信息 |
| `ancestralHall``originPlace``regionFullName` | R | 非空时展示 |
| `memberCount``personCount` | R | 非负只读计数 |
| `canManage``canEditContent` | I | 权限显隐,不作为用户输入 |
| `ownerUserId``coverOssId` | I | 当前主页不展示、不手填 |
| `LineagePersonTreeView.spouses/children` | R | 递归世系预览 |
同时记录:
- `/overview` 实际是详情别名,不包含内容聚合统计;
- `/genealogy/dashboard/overview` 是后台权限接口,不进入 PC 前端;
- 成员邀请没有 PC 接口,继续阻断。
- [ ] **Step 2: 聚焦验证**
Run:
```powershell
node --test tests/family-home-pages.test.js tests/lineage-pages.test.js tests/pending-pages.test.js tests/stage6-navigation.test.js tests/api-client-contract.test.js
node --check public/js/family-home-pages.js
node --check public/js/lineage-pages.js
```
Expected: 全部 PASS。
- [ ] **Step 3: 全量和差异验证**
Run:
```powershell
npm test
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 0 failed,差异检查无错误。
- [ ] **Step 4: 浏览器真实只读验证**
使用真实登录态验证:
1. 无家谱上下文时只跳转选择页,不发家谱业务请求;
2. 有真实家谱时标题、概览计数和世系树来自 PC 响应;
3. 普通成员看不到管理按钮,管理者可见;
4. 所有入口透传同一 `genealogyId`
5. 空世系、403、404、网络错误都有明确页面状态;
6. 控制台无错误;
7. 不创建或修改任何真实数据。
@@ -0,0 +1,310 @@
# 反馈与工单 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 开放意见反馈、提交工单、我的工单和工单详情,以同一套 PC 反馈记录形成提交、列表和详情闭环。
**Architecture:** `utils/ApiClient.js` 唯一拥有 `/genealogy/pc/feedback` 的 GET/POST 契约;新增 UMD 模块 `feedback-pages.js` 负责 DTO 构造、完整 VO 规范化、列表/详情渲染和四类页面初始化。“工单”只是反馈记录的帮助中心展示名称,详情通过 URL 中由列表响应产生的 `feedbackId` 在我的反馈列表中精确匹配。
**Tech Stack:** 静态 HTML、原生 JavaScript UMD、Axios、Node `node:test`
## Global Constraints
- 后端 `D:\WorkSpace\Java\Genealogy` 全程只读。
- 只调用 `POST /genealogy/pc/feedback``GET /genealogy/pc/feedback`
- 请求只发送 `feedbackType``feedbackContent``contactInfo`;不发送 `feedbackTitle`
- `feedbackType` 只允许后端已确认字典值 `advice/bug/complaint/other`;空值省略并由后端默认 `advice`
- `feedbackId` 只能来自提交或列表响应,不提供文本输入。
- 页面不展示 `appUserId``handlerId``appUserPhone`、原始 JSON。
- 所有提交防重复;提交成功后必须重读列表并匹配同一 `feedbackId`
---
### Task 1: ApiClient 反馈契约
**Files:**
- Modify: `utils/ApiClient.js`
- Modify: `tests/api-client-contract.test.js`
**Interfaces:**
- Produces: `submitFeedback(body)``myFeedback()`
- [ ] **Step 1: 写失败的契约测试**
增加测试,使用完整请求字面量断言:
```js
await client.submitFeedback({
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
});
await client.myFeedback();
assert.deepEqual(calls, [
{
method: 'POST',
url: '/genealogy/pc/feedback',
body: {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
}
},
{ method: 'GET', url: '/genealogy/pc/feedback' }
]);
```
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/api-client-contract.test.js
```
Expected: FAIL`submitFeedback``myFeedback` 不存在。
- [ ] **Step 3: 最小实现**
`createClient()` 返回对象中增加:
```js
submitFeedback: function (body) {
return request('POST', '/genealogy/pc/feedback', {
body: pickDefined(body, ['feedbackType', 'feedbackContent', 'contactInfo'])
});
},
myFeedback: function () {
return request('GET', '/genealogy/pc/feedback');
}
```
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/api-client-contract.test.js
```
Expected: PASS。
---
### Task 2: 反馈 DTO、VO、列表和详情边界
**Files:**
- Create: `public/js/feedback-pages.js`
- Create: `tests/feedback-pages.test.js`
**Interfaces:**
- Produces:
- `getFeedbackId(search)`
- `buildFeedbackBody(values)`
- `validateFeedbackBody(body)`
- `normalizeFeedback(item)`
- `normalizeFeedbackList(data)`
- `findFeedbackById(data, feedbackId)`
- `buildFeedbackDetailUrl(feedbackId)`
- `renderFeedbackList(data, options)`
- `renderFeedbackDetail(item)`
- [ ] **Step 1: 写失败的纯行为测试**
用完整 `FeedbackVo` 字面量验证:
```js
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: ' bug ',
feedbackContent: ' 页面按钮无响应 ',
contactInfo: ' 19181970173 ',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
}), {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
});
```
同时断言:
- `feedbackContent` 为空时报错;
- 类型只允许 `advice/bug/complaint/other`,空值省略;
- 不安全 number ID、空内容、`handleStatus``0/1/2/3``status``0/1` 的响应拒绝;
- 数组包含一个非法元素时整批返回空数组;
- 详情只精确匹配同一字符串 `feedbackId`,不存在时不回退第一条;
- 列表和详情转义可见文本,不出现内部用户/处理人 ID、账号手机号或原始 JSON;
- 详情 URL 使用 `ticket-detail.html?feedbackId=...`
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/feedback-pages.test.js
```
Expected: FAIL,模块不存在。
- [ ] **Step 3: 最小实现纯函数**
`normalizeFeedback()` 返回且只返回:
```js
{
feedbackId,
feedbackType,
feedbackContent,
contactInfo,
handleStatus,
handleResult,
handleTime,
status,
remark
}
```
状态展示固定为:
```js
{ '0': '待处理', '1': '处理中', '2': '已处理', '3': '已关闭' }
```
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/feedback-pages.test.js
node --check public/js/feedback-pages.js
```
Expected: PASS。
---
### Task 3: 四个页面的真实反馈闭环
**Files:**
- Modify: `profile-feedback.html`
- Modify: `submit-ticket.html`
- Modify: `my-tickets.html`
- Modify: `ticket-detail.html`
- Modify: `public/js/feedback-pages.js`
- Modify: `tests/feedback-pages.test.js`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/public-static-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Produces: `initFeedbackFormPage()``initFeedbackListPage()``initFeedbackDetailPage()``init()`
- [ ] **Step 1: 写失败的页面行为测试**
断言四个页面:
- 不再包含 `data-feature-status="pending"``pending-pages.js`
- 均加载 `feedback-pages.js` 和 ApiClient 依赖;
- 两个提交页只提供 `feedbackType/feedbackContent/contactInfo`,不存在 `feedbackTitle` 或任意业务 ID 输入;
- 列表页有 `data-feedback-list` 和刷新按钮;
- 详情页只有 `data-feedback-detail`,不提供回复、追问、删除或关闭操作。
断言脚本提交后调用 `myFeedback()`,必须按提交响应的 `feedbackId` 重读匹配;详情按 URL `feedbackId` 精确匹配。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/feedback-pages.test.js tests/pending-pages.test.js tests/public-static-pages.test.js tests/pc-scope.test.js
```
Expected: FAIL,四个页面仍为 pending。
- [ ] **Step 3: 实现页面初始化**
提交页:
1. 构造并校验请求;
2. `writePending` 锁和按钮禁用;
3. 调用 `submitFeedback(body)`
4. 规范化提交响应;
5. 重读 `myFeedback()`,精确找到同一 `feedbackId`
6. 个人反馈页刷新历史列表;工单页进入 `ticket-detail.html?feedbackId=...`
列表页读取 `myFeedback()` 并生成详情链接。详情页从 URL 读取 ID、重读列表并精确匹配;未匹配显示“反馈记录不存在或无权查看”。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/feedback-pages.test.js tests/pending-pages.test.js tests/public-static-pages.test.js tests/pc-scope.test.js
node --check public/js/feedback-pages.js
```
Expected: PASS。
---
### Task 4: 导航、规划和收尾验证
**Files:**
- Modify: `help.html`
- Modify: `profile-services.html`
- Modify: `docs/PC接口对接规划.md`
- Modify: `tests/stage6-navigation.test.js`
- Modify: `docs/superpowers/plans/2026-07-29-feedback-tickets.md`
**Interfaces:**
- Consumes: Task 13 的真实反馈闭环。
- [ ] **Step 1: 写失败的导航测试**
断言:
- 帮助中心可直接进入提交工单和我的工单;
- 服务中心可进入意见反馈;
- 所有入口不再被 pending 状态拦截;
- 不存在独立 ticket API、反馈标题或手填 `feedbackId` 的入口。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/stage6-navigation.test.js tests/public-static-pages.test.js
```
Expected: FAIL,帮助中心尚未开放我的工单入口或旧 pending 断言仍存在。
- [ ] **Step 3: 更新入口和规划**
规划记录:
- `AppFeedbackBody` 三个字段来源与提交时机;
- `FeedbackVo` 可见字段、内部隐藏字段和四种处理状态;
- 无独立工单 path,四个页面共用反馈记录;
- 提交后重读、详情精确匹配、401/403 和隐私边界;
- 后端没有用户侧回复、追问、关闭或删除接口,继续阻断。
- [ ] **Step 4: 聚焦和全量验证**
Run:
```powershell
node --test tests/feedback-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js tests/public-static-pages.test.js tests/pc-scope.test.js tests/stage6-navigation.test.js
npm test
node --check public/js/feedback-pages.js
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 全部 PASS。
- [ ] **Step 5: 浏览器只读验证**
使用真实登录态验证四个页面、我的反馈列表、无记录详情、隐私和控制台错误。未经用户本轮明确授权,不提交真实反馈。
@@ -0,0 +1,266 @@
# 家谱加入申请与审核 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 开放家谱申请加入、我的申请、撤销待审核申请,以及家谱管理员审核申请的完整 PC 页面闭环。
**Architecture:** `utils/ApiClient.js` 已拥有本批全部 PC path/body。新增 `join-pages.js` 负责可申请家谱选择、申请表单、我的申请和撤销;新增 `join-review-pages.js` 负责当前家谱待审核列表和通过/拒绝。两个 UMD 模块只使用响应中的字符串 ID,写入后重读对应列表。
**Tech Stack:** 静态 HTML、原生 JavaScript UMD、Axios、Node `node:test`
## Global Constraints
- 后端 `D:\WorkSpace\Java\Genealogy` 全程只读。
- 只调用 `/genealogy/pc/**`
- 家谱 ID 和申请 ID 必须来自 PC 响应或当前家谱上下文,不能手填。
- `inviterUserId` 没有安全 PC 来源,不展示、不发送。
- 所有写操作防重复,401 清登录态,403 保留登录态。
- 每项生产行为先运行失败测试,再最小实现。
---
### Task 1: 申请数据边界
**Files:**
- Create: `tests/join-pages.test.js`
- Create: `public/js/join-pages.js`
**Interfaces:**
- Consumes: `genealogyOptions(query)``applyToGenealogy(genealogyId, body)``myGenealogyJoinApplies()``cancelGenealogyJoinApply(applyId)`
- Produces:
- `normalizeJoinGenealogy(item)`
- `buildJoinApplyBody(values)`
- `validateJoinApplyBody(body)`
- `normalizeJoinApply(item)`
- `normalizeJoinApplies(data)`
- `renderJoinGenealogyOptions(data)`
- `renderMyJoinApplies(data)`
- [ ] **Step 1: 写失败的纯行为测试**
用完整 `AppGenealogyVo``GenealogyJoinApplyVo` 字面量验证:
```js
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' 叶子 ',
phone: ' 19181970173 ',
relationDesc: ' 族亲 ',
applyReason: ' 申请加入 ',
inviterUserId: 'must-drop'
}), {
applicantName: '叶子',
phone: '19181970173',
relationDesc: '族亲',
applyReason: '申请加入'
});
```
断言:
- `applicantName <= 50``phone <= 30``relationDesc <= 100``applyReason <= 500`
- 可申请家谱必须有稳定 `genealogyId/genealogyName/surname`,停用家谱拒绝。
- 申请必须有稳定 `applyId/genealogyId``status`;状态只允许 `0/1/2/3`
- 安全数字长 ID 只接受字符串;不安全 number 拒绝。
- 普通用户列表不展示内部用户 ID、邀请人 ID、审核人 ID或原始 JSON。
- 只有 `status=0` 渲染撤销按钮。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/join-pages.test.js
```
Expected: FAIL,模块不存在。
- [ ] **Step 3: 最小实现纯函数**
所有可见文本使用 `escapeHtml`;响应任一必需字段无效时拒绝该条,数组包含非法元素时整批返回空数组。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/join-pages.test.js
node --check public/js/join-pages.js
```
Expected: PASS。
---
### Task 2: 申请页面与我的申请
**Files:**
- Modify: `join-genealogy.html`
- Modify: `profile-join-family.html`
- Modify: `public/js/join-pages.js`
- Modify: `tests/join-pages.test.js`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Produces: `initJoinApplyPage()``initMyJoinAppliesPage()`
- [ ] **Step 1: 写失败的页面行为测试**
断言:
- 两页均不再 pending,并加载 `join-pages.js`
- `join-genealogy.html` 有关键词搜索、后端家谱选项、申请资料表单;不存在 `genealogyId/inviterUserId/applyId` 文本输入。
- `profile-join-family.html` 有我的申请列表和刷新入口;申请 ID 只存在于响应渲染按钮属性。
- 页面没有邀请码输入或“分享码”伪语义。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/join-pages.test.js tests/pending-pages.test.js tests/pc-scope.test.js
```
Expected: FAIL,页面仍 pending/缺少真实行为。
- [ ] **Step 3: 实现申请和撤销**
`join-genealogy.html`
1. 读取 `genealogyOptions({keyword})`
2. 用户只能点击响应卡片选择家谱,内部保存字符串 `genealogyId`
3. 构造并校验申请 body。
4. 提交后重读 `myGenealogyJoinApplies()`,必须找到同一响应 `applyId`
5. 跳转 `profile-join-family.html`
`profile-join-family.html`
1. 读取并渲染我的申请。
2. 只有待审核申请显示撤销。
3. 二次确认后调用撤销接口并重读列表,确保该申请不再为待审核。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/join-pages.test.js tests/pending-pages.test.js tests/pc-scope.test.js
node --check public/js/join-pages.js
```
Expected: PASS。
---
### Task 3: 管理员审核
**Files:**
- Create: `tests/join-review-pages.test.js`
- Create: `public/js/join-review-pages.js`
- Modify: `profile-join-review.html`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Consumes: `genealogyDetail(genealogyId)``pendingGenealogyJoinApplies(genealogyId)``auditGenealogyJoinApply(genealogyId, applyId, body)`
- Produces:
- `getCurrentGenealogyId(search)`
- `buildJoinAuditBody(values)`
- `validateJoinAuditBody(body)`
- `renderPendingJoinApplies(data, canManage)`
- `initJoinReviewPage()`
- [ ] **Step 1: 写失败的审核行为测试**
```js
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({
status: '2',
auditRemark: ' 资料不一致 ',
applyId: 'must-drop'
}), {
status: '2',
auditRemark: '资料不一致'
});
```
断言:
- `status` 只允许 `1/2``auditRemark <= 500`
- 页面只从 URL/`ProfileUI` 读取家谱 ID。
- 只有 `genealogyDetail.canManage=true` 显示审核动作。
- 待审核列表显示申请人名称、申请手机号、关系和原因,但隐藏内部用户 ID、邀请人/审核人 ID。
- 通过和拒绝按钮使用响应中的稳定 `applyId`
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/join-review-pages.test.js
```
Expected: FAIL,模块不存在且页面仍 pending。
- [ ] **Step 3: 实现审核页面**
加载当前家谱详情和待审核列表;没有上下文时阻止请求并返回家谱选择入口。点击通过使用 `{status:'1'}`;拒绝要求输入可选审核说明并使用 `{status:'2', auditRemark}`。审核成功后重读待审核列表,确保同一 `applyId` 不再存在。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/join-review-pages.test.js tests/pending-pages.test.js tests/pc-scope.test.js
node --check public/js/join-review-pages.js
```
Expected: PASS。
---
### Task 4: 导航、规划与验证
**Files:**
- Modify: `profile-families.html`
- Modify: `profile-family-admin.html`
- Modify: `profile.html`
- Modify: `tests/stage6-navigation.test.js`
- Modify: `docs/PC接口对接规划.md`
- Modify: `docs/superpowers/plans/2026-07-29-genealogy-join-review.md`
- [ ] **Step 1: 写失败的导航测试**
断言我的家谱入口可达申请页,家谱管理入口携带上下文进入审核页;不存在指向手填家谱 ID 或邀请码页面的入口。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/stage6-navigation.test.js tests/pending-pages.test.js
```
Expected: FAIL,审核导航尚未稳定携带家谱上下文。
- [ ] **Step 3: 开放导航并更新规划**
记录申请/审核 DTO、VO、权限、状态、字段来源、写后重读和 `inviterUserId` 阻断。
- [ ] **Step 4: 聚焦和全量验证**
Run:
```powershell
node --test tests/join-pages.test.js tests/join-review-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js tests/pc-scope.test.js tests/stage6-navigation.test.js
npm test
node --check public/js/join-pages.js
node --check public/js/join-review-pages.js
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 全部 PASS。
- [ ] **Step 5: 浏览器验证并报告**
真实账号验证可申请家谱列表、我的申请空状态、无家谱上下文审核分支、隐私和控制台错误。未经用户明确授权,不提交或审核真实申请。
@@ -0,0 +1,39 @@
# 帮助中心 PC 接口对接计划
目标:把现有 `help.html` 的示例问答替换为后端正式 PC 帮助文章列表和详情,不混入官网文章、推广或家谱业务。
## 全局约束
- 只调用 `GET /genealogy/pc/help-articles``GET /genealogy/pc/help-articles/{helpId}`
- 后端控制器未标记匿名访问,部署环境要求登录;两个接口发送当前 PC token。
- `helpCategory` 是唯一 Query 字段;`helpId` 只能来自列表响应。
- 展示字段仅为 `helpCategory``helpTitle``helpContent``viewCount`
- `coverOssId``sortOrder``status``remark` 不展示,不允许用户输入 ID。
- 正文转义后展示,不执行响应中的 HTML。
- 后端项目只读。
## 任务 1:冻结客户端契约
-`tests/api-client-contract.test.js` 先增加失败测试。
-`utils/ApiClient.js` 增加 `helpArticles(query)``helpArticleDetail(helpId)`
- 验证 method、path、Query 白名单、登录鉴权和长 ID 字符串。
## 任务 2:实现帮助文章边界
- 新增 `tests/help-pages.test.js` 并先观察失败。
- 新增 `public/js/help-pages.js`
- 校验完整 `HelpArticleVo` 输入,只向页面返回安全展示字段。
- 列表任一元素非法时整批失败;详情必须与请求 ID 一致。
- 转义标题、分类和正文。
## 任务 3:开放帮助中心
- 先用页面契约测试证明现有示例内容不符合真实接口状态。
- 修改 `help.html`,提供加载、空、成功和失败状态。
- 展开文章时调用详情接口,不制造编辑、邀请或后台操作。
## 任务 4:规划与验证
- 更新 `docs/PC接口对接规划.md` 的字段来源、页面时机与阶段 7 进度。
- 运行帮助中心专项测试、全量测试、语法检查和 `diff --check`
- 浏览器验证真实列表/空状态和控制台。
@@ -0,0 +1,249 @@
# VIP 套餐与订单 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在服务中心开放 VIP 套餐查看、响应选项创建订单和我的订单刷新闭环,不制造支付或取消能力。
**Architecture:** `utils/ApiClient.js` 唯一拥有 `/genealogy/pc/vip/**` 的三个接口;新增 `vip-pages.js` 规范化套餐、家谱选项和订单,使用响应中的字符串 ID 构造 `AppVipOrderBody``profile-services.html` 作为唯一 VIP 页面,只提供套餐选择、可选家谱选择、创建订单和订单刷新。
**Tech Stack:** 静态 HTML、原生 JavaScript UMD、Axios、Node `node:test`
## Global Constraints
- 后端 `D:\WorkSpace\Java\Genealogy` 全程只读。
- 只调用 `GET /genealogy/pc/vip/packages``POST /genealogy/pc/vip/orders``GET /genealogy/pc/vip/orders`
- `packageId` 只能来自套餐响应;`genealogyId` 只能来自 `genealogiesMine()` 响应,不提供文本输入。
- 页面不提供未经确认的支付方式选择;请求省略 `payType`,由后端默认 `wechat`
- 不显示立即支付、模拟支付成功、取消订单、退款或关闭订单操作。
- 创建成功后重读订单列表并精确匹配同一 `orderId`
- 不展示 `appUserId`、用户手机号、内部原始 JSON。
---
### Task 1: ApiClient VIP 契约
**Files:**
- Modify: `utils/ApiClient.js`
- Modify: `tests/api-client-contract.test.js`
**Interfaces:**
- Produces: `vipPackages()``createVipOrder(body)``vipOrders()`
- [ ] **Step 1: 写失败契约测试**
```js
await client.vipPackages();
await client.createVipOrder({
packageId: '2062179707935264769',
genealogyId: '2062179707935264770',
payType: 'wechat',
appUserId: 'must-drop',
payStatus: 'must-drop'
});
await client.vipOrders();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/vip/packages', undefined],
['post', '/genealogy/pc/vip/orders', {
packageId: '2062179707935264769',
genealogyId: '2062179707935264770',
payType: 'wechat'
}],
['get', '/genealogy/pc/vip/orders', undefined]
]);
```
- [ ] **Step 2: 运行 RED**
Run: `node --test tests/api-client-contract.test.js`
Expected: FAILVIP 方法不存在。
- [ ] **Step 3: 最小实现**
```js
vipPackages: function () {
return request('GET', '/genealogy/pc/vip/packages');
},
createVipOrder: function (body) {
return request('POST', '/genealogy/pc/vip/orders', {
body: pickDefined(body, ['packageId', 'genealogyId', 'payType'])
});
},
vipOrders: function () {
return request('GET', '/genealogy/pc/vip/orders');
}
```
- [ ] **Step 4: 运行 GREEN**
Run: `node --test tests/api-client-contract.test.js`
Expected: PASS。
---
### Task 2: 套餐、家谱选项和订单数据边界
**Files:**
- Create: `public/js/vip-pages.js`
- Create: `tests/vip-pages.test.js`
**Interfaces:**
- Produces:
- `normalizeVipPackage(item)`
- `normalizeVipPackages(data)`
- `normalizeGenealogyOption(item)`
- `buildVipOrderBody(values)`
- `validateVipOrderBody(body)`
- `normalizeVipOrder(item)`
- `normalizeVipOrders(data)`
- `renderVipPackages(data, selectedPackageId)`
- `renderGenealogyOptions(data)`
- `renderVipOrders(data)`
- [ ] **Step 1: 写失败纯行为测试**
使用完整 `VipPackageVo``VipOrderVo` 字面量断言:
- 套餐要求稳定字符串 `packageId`、非空名称、`packageType=vip/storage``durationUnit=permanent/day/month/year`、非负价格、`status=0`
- 停用套餐和不安全数字长 ID 不渲染;
- 家谱选项要求稳定字符串 `genealogyId` 和非空 `genealogyName`
- body 只保留 `packageId/genealogyId/payType`,页面默认构造不包含 `payType`
- 必须选择有效套餐;可选家谱必须是安全字符串 ID;若显式提供 `payType` 只允许已确认 `wechat`
- 订单要求稳定 `orderId/packageId`、订单号、套餐名、非负金额、`payStatus=0/1/2/3``status=0/1`
- 渲染不出现用户 ID、手机号、原始 JSON,不出现支付/取消/退款按钮。
- [ ] **Step 2: 运行 RED**
Run: `node --test tests/vip-pages.test.js`
Expected: FAIL,模块不存在。
- [ ] **Step 3: 最小实现纯函数**
支付状态:
```js
{ '0': '待支付', '1': '已支付', '2': '已关闭', '3': '已退款' }
```
套餐类型:
```js
{ vip: '会员套餐', storage: '存储扩容' }
```
套餐和订单金额保留后端字符串语义,不进行浮点运算。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/vip-pages.test.js
node --check public/js/vip-pages.js
```
Expected: PASS。
---
### Task 3: 服务中心真实 VIP 闭环
**Files:**
- Modify: `profile-services.html`
- Modify: `public/js/vip-pages.js`
- Modify: `tests/vip-pages.test.js`
- Modify: `tests/pending-pages.test.js`
- Modify: `tests/pc-scope.test.js`
**Interfaces:**
- Produces: `initVipPage()``init()`
- [ ] **Step 1: 写失败页面行为测试**
断言:
- 服务中心不再 pending,加载 `vip-pages.js`
- 有套餐列表、只读选中提示、可选家谱下拉、订单表单、刷新订单和订单列表;
- 不存在 `packageId/genealogyId/orderId` 文本或数字输入;
- 不存在 `payType` 选择器以及支付、取消、退款、模拟成功按钮;
- 脚本并行读取套餐、我的家谱和订单;
- 创建后重读订单并精确匹配提交响应 `orderId`
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/vip-pages.test.js tests/pending-pages.test.js tests/pc-scope.test.js
```
Expected: FAIL,服务中心仍为 pending 且允许手填 ID。
- [ ] **Step 3: 实现页面初始化**
1. 401 跳转登录,403 保留登录态;
2. `Promise.all([vipPackages(), genealogiesMine(), vipOrders()])` 读取页面数据;
3. 点击套餐卡保存响应中的字符串 `packageId`
4. 家谱下拉只使用 `genealogiesMine()` 选项,空值表示不关联家谱;
5. 表单提交使用 `writePending` 锁;
6. 创建响应必须规范化;
7. 重读 `vipOrders()` 并找到同一 `orderId`
8. 刷新订单列表并展示“订单已创建;当前 PC 暂未开放在线支付”。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/vip-pages.test.js tests/pending-pages.test.js tests/pc-scope.test.js
node --check public/js/vip-pages.js
```
Expected: PASS。
---
### Task 4: 导航、规划和收尾验证
**Files:**
- Modify: `profile.html`
- Modify: `docs/PC接口对接规划.md`
- Modify: `tests/stage6-navigation.test.js`
- Modify: `docs/superpowers/plans/2026-07-29-vip-packages-orders.md`
**Interfaces:**
- Consumes: Task 13 的 VIP 套餐与订单闭环。
- [ ] **Step 1: 写失败导航测试**
断言个人中心服务入口可进入 `profile-services.html`,服务页真实开放并且所有 VIP 业务都留在该唯一 owner 页面。
- [ ] **Step 2: 运行 RED**
Run: `node --test tests/stage6-navigation.test.js tests/pending-pages.test.js`
Expected: FAIL,服务页仍 pending 或未加载真实脚本。
- [ ] **Step 3: 更新规划**
记录 `AppVipOrderBody``VipPackageVo``VipOrderVo` 的字段来源、枚举、隐藏字段、写后重读和支付能力阻断;阶段 7 标记 VIP 批次完成。
- [ ] **Step 4: 聚焦和全量验证**
Run:
```powershell
node --test tests/vip-pages.test.js tests/api-client-contract.test.js tests/pending-pages.test.js tests/pc-scope.test.js tests/stage6-navigation.test.js
npm test
node --check public/js/vip-pages.js
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 全部 PASS。
- [ ] **Step 5: 浏览器只读验证**
真实登录态验证套餐、家谱下拉、订单空/有数据态、无伪支付动作和控制台错误。未经用户本轮明确授权,不创建真实订单。
@@ -0,0 +1,424 @@
# 官网资讯 PC 接口对接实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`news.html``article-detail.html` 接入公开 PC 站点文章列表接口,交付可筛选列表与精确详情闭环。
**Architecture:** `utils/ApiClient.js` 独占 PC path、公开鉴权标记和 Query 校验;`public/js/site-news-pages.js` 独占 `SiteArticleVo` 规范化、渲染和页面初始化。后端没有单条详情接口,详情页使用 URL 中由列表响应生成的字符串 ID 重读列表并精确匹配。
**Tech Stack:** 原生 HTML/CSS/JavaScript、现有 Axios 请求层、Node `node:test`
## Global Constraints
- 后端 `D:\WorkSpace\Java\Genealogy` 只读,不得修改。
- 只使用 `/genealogy/pc/site/articles`,不得调用 APP、后台管理或家谱谱文 CRUD 接口。
- 本批只修改 `news.html``article-detail.html`,不混改其他静态内容页。
- 业务 ID 始终保持字符串,不提供手填 ID 或 OSS ID。
- `articleType` 只允许 `news/notice/download``limit` 固定为 100 且客户端只允许 1–100 的整数。
- 公开请求设置 `auth: false`;其 401/403 不清理既有 token、不跳转登录。
- 响应只接受直接 `SiteArticleVo[]`;旧字段、分页对象和任一非法元素均使整批失败。
- 所有响应文本 HTML 转义;正文仅保留换行;外链只允许绝对 HTTP/HTTPS。
- 当前工作区由用户明确授权直接施工;不得 stage、commit、push 或创建 PR。
- 子代理仅用于探索和只读复核;业务代码修改、取舍与最终验证由主代理完成。
---
### Task 1: 修正公开请求的登录态隔离
**Files:**
- Modify: `tests/request-auth-state.test.js`
- Modify: `utils/AxiosRequestUtil.js`
**Interfaces:**
- Consumes: `createRequester({ getToken, onUnauthorized, axiosInstance })`
- Produces: `request(method, path, { auth: false })` 不发送 Authorization,且 HTTP/业务 401 均不调用 `onUnauthorized`
- [ ] **Step 1: 写失败测试**
`tests/request-auth-state.test.js` 引入 `../utils/AxiosRequestUtil.js`,新增两个真实请求层用例:
```js
test('public HTTP 401 neither sends nor clears the stored login token', async () => {
let storedToken = 'access-token';
let seenRequest;
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'web-pc',
getToken() { return storedToken; },
onUnauthorized() { storedToken = ''; },
axiosInstance: {
request(config) {
seenRequest = config;
return Promise.reject({
message: 'Request failed',
response: { status: 401, data: { code: 401, msg: '认证失败' } }
});
}
}
});
await assert.rejects(requester('GET', '/public', { auth: false }));
assert.equal(seenRequest.headers.Authorization, undefined);
assert.equal(storedToken, 'access-token');
});
test('public business 401 preserves the stored login token', async () => {
let storedToken = 'access-token';
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'web-pc',
getToken() { return storedToken; },
onUnauthorized() { storedToken = ''; },
axiosInstance: {
request() {
return Promise.resolve({
data: { code: 401, msg: '认证失败' }
});
}
}
});
await assert.rejects(requester('GET', '/public', { auth: false }));
assert.equal(storedToken, 'access-token');
});
```
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/request-auth-state.test.js
```
Expected: 新增用例因 `handleUnauthorized` 未区分 `auth: false` 而失败。
- [ ] **Step 3: 写最小实现**
`utils/AxiosRequestUtil.js` 的单次 `request` 闭包内,使 HTTP catch 与业务解包 catch 只在 `req.auth !== false` 时调用 `handleUnauthorized`
```js
if (req.auth !== false) handleUnauthorized(status);
```
不得改变默认鉴权请求的现有行为。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/request-auth-state.test.js
```
Expected: 现有默认 401 清 token、403 保留 token,以及新增公开请求用例全部通过。
---
### Task 2: 增加 PC 站点文章 ApiClient 契约
**Files:**
- Modify: `tests/api-client-contract.test.js`
- Modify: `utils/ApiClient.js`
**Interfaces:**
- Produces: `siteArticles(query?: { articleType?: 'news'|'notice'|'download', limit?: number }): Promise<Array>`
- Request: `GET /genealogy/pc/site/articles`, `{ auth: false, query }`
- [ ] **Step 1: 写失败测试**
在公开方法清单中增加 `siteArticles`,并新增契约用例:
```js
await client.siteArticles({
articleType: 'notice',
limit: 100,
keyword: 'must-drop'
});
assert.deepEqual(seen, {
method: 'get',
url: '/genealogy/pc/site/articles',
params: { articleType: 'notice', limit: 100 },
authorization: undefined
});
assert.throws(
() => client.siteArticles({ articleType: 'culture', limit: 100 }),
/不支持的资讯类型/
);
assert.throws(
() => client.siteArticles({ limit: 101 }),
/资讯数量限制/
);
```
同时覆盖 `limit``0`、小数、字符串和负数,确保只接受 1–100 的 Number 整数;省略 `articleType` 时只发送 `{ limit: 100 }`
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/api-client-contract.test.js
```
Expected: `siteArticles` 不存在。
- [ ] **Step 3: 写最小实现**
`utils/ApiClient.js` 增加 `siteArticles`
```js
siteArticles: function (query) {
var source = query || {};
var articleType = source.articleType === undefined || source.articleType === null
? ''
: String(source.articleType).trim();
var limit = source.limit;
var params = {};
if (articleType && ['news', 'notice', 'download'].indexOf(articleType) === -1) {
throw new Error('不支持的资讯类型:' + articleType);
}
if (limit !== undefined &&
(typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)) {
throw new Error('资讯数量限制必须是 1 到 100 的整数');
}
if (articleType) params.articleType = articleType;
if (limit !== undefined) params.limit = limit;
return request('GET', '/genealogy/pc/site/articles', {
auth: false,
query: params
});
},
```
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/api-client-contract.test.js tests/request-auth-state.test.js
```
Expected: 全部通过,且公开请求没有 Authorization Header。
---
### Task 3: 实现站点资讯页面 owner
**Files:**
- Create: `tests/site-news-pages.test.js`
- Create: `public/js/site-news-pages.js`
**Interfaces:**
- Consumes: `GenealogyApi.defaultClient.siteArticles({ articleType?, limit: 100 })`
- Produces:
- `normalizeArticle(item)`
- `normalizeArticleList(data)`
- `normalizeArticleType(value)`
- `normalizeExternalUrl(value)`
- `readArticleId(search)`
- `renderArticleList(data)`
- `renderArticleDetail(item)`
- `loadArticles(api, articleType?)`
- `loadArticleDetail(api, articleId)`
- `init()`
- [ ] **Step 1: 写完整 RED 测试**
创建 `tests/site-news-pages.test.js`,使用完整 `SiteArticleVo` fixture
```js
{
articleId: '2062179707935264769',
articleType: 'notice',
articleTitle: '平台公告',
articleSummary: '公告摘要',
articleContent: '第一行\n第二行',
coverOssId: '2062179707935264701',
externalUrl: 'https://example.com/notice',
publishTime: '2026-07-30 09:00:00',
sortOrder: 1,
status: '0',
remark: 'internal-only'
}
```
必须覆盖:
- 安全长 ID 字符串保留;不安全 Number、零、空 ID 拒绝。
- `articleType` 只接受 `news/notice/download`
- 标题必填、状态必须为 `0`
- 直接数组全有或全无;`{ rows: [...] }` 拒绝。
- 规范化结果不包含 `coverOssId/sortOrder/status/remark`
- 列表 HTML 指向 `article-detail.html?articleId=2062179707935264769`
- 标题、摘要、正文转义;正文换行变为 `<br />`
- 危险、相对和 `data:` 外链不渲染;合法 HTTP/HTTPS 使用 `noopener noreferrer`
- `loadArticles(api, 'notice')` 调用 `{ articleType: 'notice', limit: 100 }`
- `loadArticles(api, '')` 调用 `{ limit: 100 }`
- `loadArticleDetail` 只返回同 ID;未匹配抛出“资讯不存在或已下线”。
- 非法详情 ID 在调用 API 前抛出“资讯编号无效”。
- `readArticleId('?articleId=...')` 只返回安全字符串。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/site-news-pages.test.js
```
Expected: 模块不存在。
- [ ] **Step 3: 写 UMD 模块最小实现**
创建 UMD 模块:Node 环境导出 `module.exports = factory(root)`;浏览器环境挂到 `root.SiteNewsPages`,并在 `DOMContentLoaded` 调用 `root.SiteNewsPages.init()`。关键行为:
```js
async function loadArticles(api, articleType) {
var type = normalizeArticleType(articleType);
var query = { limit: 100 };
var data;
var items;
if (articleType && !type) throw new Error('资讯分类无效');
if (type) query.articleType = type;
data = await api.siteArticles(query);
items = normalizeArticleList(data);
if (!Array.isArray(data) || items.length !== data.length) {
throw new Error('资讯列表响应无效');
}
return items;
}
```
`init()`
- `[data-site-news-page]`:从 `location.search` 读取 `articleType`,加载并渲染 `[data-site-news-list]`
- `[data-site-article-page]`:读取 `articleId`;非法时不发请求;合法时精确重读并渲染 `[data-site-article-detail]`
- 所有失败只更新对应区域和 `[data-site-*-status]`,不跳转、不清 token。
- [ ] **Step 4: 运行 GREEN**
Run:
```powershell
node --test tests/site-news-pages.test.js tests/api-client-contract.test.js tests/request-auth-state.test.js
```
Expected: 全部通过。
---
### Task 4: 接入 HTML、规划并完成验收
**Files:**
- Modify: `news.html`
- Modify: `article-detail.html`
- Modify: `tests/site-news-pages.test.js`
- Modify: `tests/public-static-pages.test.js`
- Modify: `tests/pc-scope.test.js`
- Modify: `docs/PC接口对接规划.md`
**Interfaces:**
- `news.html`: `[data-site-news-page]`, `[data-site-news-list]`, `[data-site-news-status]`
- `article-detail.html`: `[data-site-article-page]`, `[data-site-article-detail]`, `[data-site-article-status]`
- [ ] **Step 1: 写 HTML 接入 RED 测试**
断言:
```js
assert.match(newsPage, /data-site-news-page/);
assert.match(newsPage, /data-site-news-list/);
assert.match(newsPage, /news\.html\?articleType=news/);
assert.match(newsPage, /news\.html\?articleType=notice/);
assert.match(newsPage, /news\.html\?articleType=download/);
assert.match(newsPage, /src="public\/js\/site-news-pages\.js"/);
assert.match(detailPage, /data-site-article-page/);
assert.match(detailPage, /data-site-article-detail/);
assert.match(detailPage, /src="public\/js\/site-news-pages\.js"/);
assert.doesNotMatch(detailPage, /src="public\/js\/article-pages\.js"/);
assert.doesNotMatch(newsPage + detailPage, /name="(?:articleId|coverOssId)"/);
```
更新 `public-static-pages.test.js`,移除旧的固定 `#platform/#culture` 链接断言,改为真实分类入口和详情目标文件断言。
- [ ] **Step 2: 运行 RED**
Run:
```powershell
node --test tests/site-news-pages.test.js tests/public-static-pages.test.js tests/pc-scope.test.js
```
Expected: HTML 尚未加载新模块、旧固定链接断言需迁移。
- [ ] **Step 3: 修改 HTML**
`news.html`
- `<body data-site-news-page>`
- 删除“当前为静态展示”文案和两条硬编码文章。
- 列表容器初始化为“资讯内容加载中”。
- 分类链接使用规格中的四个固定 URL。
- 按顺序加载 `config.js``StorageUtil.js``axios.js``AxiosRequestUtil.js``ApiClient.js``page-effects.js``site-news-pages.js`
`article-detail.html`
- body 标记改为 `data-site-article-page`,避免与家谱谱文语义混用。
- 将 hero 和正文合并到 `data-site-article-detail` 可替换容器。
- 增加 `data-site-article-status`
- 加载 `site-news-pages.js`,不加载 `article-pages.js`
- [ ] **Step 4: 更新规划**
`docs/PC接口对接规划.md` 第 7 节新增“官网资讯契约”,记录:
- method/path、公开鉴权、Query 枚举和 limit 范围。
- `SiteArticleVo` 11 个字段的 R/I/S/A 分类、SQL 长度和默认值。
- 列表直接数组、服务端排序、详情重读匹配。
- YAML 只给通用响应且没有导出 `SiteArticleVo` Schema 的差异。
- `coverOssId` 文件 URL 和站点文章单条详情接口仍阻断。
在阶段 7 增加第八批完成说明。
- [ ] **Step 5: 运行自动化验收**
Run:
```powershell
node --check public/js/site-news-pages.js
node --check utils/ApiClient.js
node --check utils/AxiosRequestUtil.js
node --test tests/site-news-pages.test.js tests/api-client-contract.test.js tests/request-auth-state.test.js tests/public-static-pages.test.js tests/pc-scope.test.js
node --test tests/*.test.js
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
```
Expected: 所有命令退出码 0。
- [ ] **Step 6: 浏览器验收**
启动临时本地静态服务器,验证:
1. 未登录打开 `news.html`,页面不跳登录。
2. 真实列表、分类或空状态正确。
3. 有数据时点击一个由响应生成的详情链接,详情 ID 精确匹配;无数据时记录真实空状态。
4. 控制台没有业务脚本错误。
5. 不点击外部链接。
6. 停止服务器并删除临时辅助文件。
- [ ] **Step 7: 阶段报告**
按以下格式报告并停下:
```text
Changed: 官网资讯列表、详情、公开请求语义和规划补充。
Verified: 专项/全量测试数量及真实浏览器覆盖。
Conflicts: YAML 通用响应与后端完整 VO/SQL 的差异。
Blocked: 单条详情接口、封面文件 URL、仍无 PC Controller 的 pending 页面。
```
@@ -0,0 +1,71 @@
# 应用下载页 PC 推广列表设计
## 目标与范围
只在 `app.html` 现有“应用推广”区域接入:
`GET /genealogy/pc/promotions`
本批不修改首页广告位,不接入官网文章、帮助文章或公开家谱,不新增推广详情、编辑、发布和统计功能。
## 契约来源与冲突
后端 `PcPromotionApiController` 继承 `BusinessPromotionControllerSupport`,实际支持可选 Query `platform`,返回直接数组 `List<AppPromotionVo>`。服务端只返回 `status=0` 的记录,并按 `sortOrder` 升序、`promotionId` 降序排列。
Apifox YAML 将接口标记为 `security: []` 且未导出 `platform`;后端控制器没有 `@SaIgnore`,全局安全拦截器实际要求 PC 登录态和匹配的 `clientid`。本批以后端代码和部署行为为准:
- 请求携带当前 PC 登录 token;
- 不发送 `platform`,因为后端没有提供可核验的枚举和值说明;
- 页面不自行排序或筛选。
## 页面行为
`app.html` 本身保持公开,不因推广接口需要登录而强制跳转:
- 未登录:不发请求,推广区域显示“登录后查看应用推广”和登录链接;
- 已登录:初始化时读取一次推广列表;
- 返回空数组:显示“当前暂无应用推广”;
- 返回有效记录:渲染现有三列推广卡片;
- 401:清理失效登录态,推广区域改为登录入口;
- 403、网络失败或响应非法:只在推广区域显示错误,不影响下载页其它内容;
- 页面不提供手动刷新、筛选、编辑或业务 ID 输入。
## 字段边界
| 字段 | 分类 | 页面行为 |
| --- | --- | --- |
| `promotionId` | I | 要求为稳定非零十进制字符串,用作节点标识,不展示 |
| `promotionKey` | I | 后台键,不展示 |
| `promotionTitle` | R | 必填,转义后作为卡片标题 |
| `promotionDesc` | R | 可空,转义后作为说明 |
| `coverOssId` | I | 没有文件 URL 契约,不拼接、不展示、不手填 |
| `targetUrl` | R | 可空;只允许绝对 `http`/`https` URL,合法时整张卡片可点击,并使用 `target="_blank"``rel="noopener noreferrer"` |
| `platform` | I | 响应字段只用于边界保留,不显示、不作为前端筛选依据 |
| `sortOrder` | I | 服务端排序 owner,前端不重新排序 |
| `status` | I | 只接受 `0`;其它值使整条响应非法 |
| `remark` | I | 后台备注,不展示 |
列表必须是直接数组。任一元素缺少稳定 `promotionId`、标题或正常状态时,整批响应失败,避免部分错误数据被误当成完整结果。
## 文件与所有权
- `utils/ApiClient.js`method、完整 path 和 Query 白名单唯一 owner,新增 `promotions(query)`;虽然当前页面省略 Query,但客户端只允许 `platform`
- `public/js/app-promotion-pages.js`:响应规范化、URL 安全校验、卡片渲染、登录态和区域状态 owner。
- `app.html`:保留现有 `data-promotion-list` 容器,加载推广脚本。
- `public/css/app.css`:只在现有样式确实无法支持无封面卡片时做最小调整。
- `tests/api-client-contract.test.js``tests/app-promotion-pages.test.js``tests/pc-scope.test.js`:契约、渲染、安全和页面开放状态。
- `docs/PC接口对接规划.md`:记录字段来源、提交时机、鉴权冲突和阶段进度。
旧的 `promotion-pages.js` 不恢复;新的 owner 只服务应用下载页,避免与历史未定义脚本形成兼容分支。
## 测试与验收
必须按 RED → GREEN 验证:
1. 客户端只调用 `GET /genealogy/pc/promotions`,可选 Query 仅保留 `platform`,并携带登录 token。
2. 长 ID 始终按字符串处理;不安全数字、停用记录、空标题和非法列表结构失败。
3. 标题、说明和 URL 转义;`javascript:`、相对 URL 和非 HTTP(S) URL 不生成链接。
4. 页面不显示 `coverOssId``promotionKey``sortOrder``status``remark` 或原始 JSON。
5. 未登录不发推广请求;401、403、空列表和成功列表均有独立区域状态。
6. 专项测试、全量测试、语法检查和 `git diff --check` 通过。
7. 浏览器使用测试账号验证真实列表或空状态;没有推广数据时,不制造详情或示例推广。
@@ -0,0 +1,170 @@
# 官网资讯 PC 接口对接设计
## 1. 目标
把现有静态 `news.html``article-detail.html` 接入后端已经存在的 PC 站点文章接口,形成“资讯列表 → 文章详情”的公开只读闭环。
本批只处理官网资讯,不修改关于我们、家族文化、隐私政策、用户协议、姓氏百科及个人中心谱文 CRUD。
## 2. 契约来源
后端只读项目:`D:\WorkSpace\Java\Genealogy`
接口:
| method | path | 参数 | 响应 |
| --- | --- | --- | --- |
| GET | `/genealogy/pc/site/articles` | Query `articleType?``limit?` | 直接 `SiteArticleVo[]` |
该操作在 YAML 中标记为 `security: []`,后端 `PcSiteContentController` 也没有页面侧登录要求。本批按公开接口处理:`ApiClient` 为该请求设置 `auth: false`,不携带或读取登录 token;公共请求层仍自动携带基础 `clientid` Header。
后端没有站点文章单条详情接口。详情页必须重新读取文章列表,再按 URL 中由列表链接产生的 `articleId` 精确匹配;不存在时显示明确的未找到状态,不得回退到第一条文章。
## 3. SQL 冻结值
文章类型字典 `gen_site_article_type`
| 值 | 含义 |
| --- | --- |
| `news` | 新闻,默认 |
| `notice` | 公告 |
| `download` | 下载 |
`gen_site_article` 的页面相关约束:
| 字段 | SQL 约束 |
| --- | --- |
| `article_id` | `BIGINT NOT NULL`,主键 |
| `article_type` | `varchar(50) NOT NULL DEFAULT 'news'` |
| `article_title` | `varchar(200) NOT NULL` |
| `article_summary` | `varchar(500) DEFAULT ''` |
| `article_content` | `text`,可空 |
| `cover_oss_id` | `BIGINT`,可空 |
| `external_url` | `varchar(500) DEFAULT ''` |
| `publish_time` | `datetime`,可空 |
| `sort_order` | `BIGINT NOT NULL DEFAULT 0` |
| `status` | `char(1) NOT NULL DEFAULT '0'``0` 正常、`1` 停用 |
| `remark` | `varchar(500) DEFAULT NULL` |
服务端只返回正常状态记录,按 `sortOrder ASC``publishTime DESC``articleId DESC` 排序。正数 `limit` 最大按 100 执行;本批固定发送 `limit=100`
## 4. 前端 owner
| 文件 | 职责 |
| --- | --- |
| `utils/ApiClient.js` | 唯一拥有 `/genealogy/pc/site/articles` 请求路径、Query 白名单与枚举校验 |
| `utils/AxiosRequestUtil.js` | 确保 `auth: false` 的公开请求不携带 token,且其 401 不清理用户在其他页面建立的登录态 |
| `public/js/site-news-pages.js` | 唯一拥有站点文章响应规范化、列表/详情渲染、URL ID 解析和页面初始化 |
| `news.html` | 只提供资讯列表、分类入口及加载/空/失败挂载点 |
| `article-detail.html` | 只提供公开文章详情挂载点 |
不得复用 `public/js/article-pages.js`。该脚本属于登录后的家谱谱文 CRUD,契约、权限和数据结构均不同。
## 5. 页面数据流
### 5.1 资讯列表
1. `news.html` 初始化读取 URL Query `articleType`
2. Query 为空时不发送 `articleType`,读取全部三类文章;非空时只接受 `news/notice/download`
URL 中出现其他值时显示“资讯分类无效”且不发请求。
3. 请求固定携带 `limit=100`
4. 每个列表项使用响应中的字符串 `articleId` 生成 `article-detail.html?articleId=...`
5. 分类入口只生成本地固定链接:
- 全部:`news.html`
- 新闻:`news.html?articleType=news`
- 公告:`news.html?articleType=notice`
- 下载:`news.html?articleType=download`
6. 空数组显示“当前暂无资讯”;畸形响应或网络失败显示区域错误,不跳转。
### 5.2 文章详情
1. `articleId` 只能来自 URL Query,并按非零十进制字符串校验,禁止转成 JavaScript Number。
2. 页面调用同一个文章列表接口,固定 `limit=100`,不制造不存在的单条详情路径。
3. 只接受 `articleId` 完全相同的文章。
4. 缺失或非法 ID 显示“资讯编号无效”;合法但未匹配显示“资讯不存在或已下线”。
5. 页面不提供 ID 输入框、编辑、发布、删除、状态切换或 OSS ID 输入。
## 6. 字段分类与展示
| 字段 | 分类 | 页面使用 |
| --- | --- | --- |
| `articleId` | I | 列表链接和详情精确匹配;不直接展示 |
| `articleType` | R | 显示中文类型标签;只接受 SQL 字典枚举 |
| `articleTitle` | R | 列表和详情标题,必须非空 |
| `articleSummary` | R | 列表摘要和详情导语,可空 |
| `articleContent` | R | 详情正文,可空 |
| `coverOssId` | I | 没有文件 URL 契约,不显示、不拼接、不手填 |
| `externalUrl` | R | 仅接受绝对 HTTP/HTTPS;合法时显示“查看外部内容” |
| `publishTime` | R | 可空;按后端 `yyyy-MM-dd HH:mm:ss` 文本安全展示 |
| `sortOrder` | I | 仅后端排序,不展示 |
| `status` | I | 只接受 `0`,其他值使整批响应失败 |
| `remark` | I | 后台备注,不展示 |
`articleType` 是列表页 Query 时分类为 S,由用户点击固定分类入口产生;`limit=100` 分类为 A,由系统自动提交。
## 7. 安全与渲染
- 所有业务 ID 始终保持字符串。
- 标题、摘要、正文、类型和时间全部 HTML 转义。
- 正文只保留换行,不执行后端返回的 HTML、脚本或事件属性。
- `externalUrl` 只允许绝对 `http:``https:`,并使用 `target="_blank"``rel="noopener noreferrer"`
- 任一列表元素缺少稳定 ID、合法类型、标题或正常状态时,整批响应失败。
- 响应不接受分页对象、`rows` 包装或旧字段别名。
- 页面公开访问,请求显式使用 `auth: false`;公开请求的 401/403 不清理既有 token,也不跳转登录页。
## 8. HTML 迁移
`news.html` 删除两条硬编码新闻和固定分类卡片,改为:
- `data-site-news-page`
- `data-site-news-list`
- `data-site-news-status`
- 固定四个分类链接
- 加载完整 API 基础脚本和 `public/js/site-news-pages.js`
`article-detail.html` 保留现有视觉结构,将硬编码示例正文改为:
- `data-site-article-page`
- `data-site-article-detail`
- `data-site-article-status`
- 加载 `public/js/site-news-pages.js`
现有 `notice-detail.html` 本批不改。后端的公告也是 `SiteArticleVo`,统一进入 `article-detail.html`,避免维护两个详情模板。
## 9. 错误与空状态
| 场景 | 行为 |
| --- | --- |
| 列表空数组 | 显示“当前暂无资讯” |
| 列表响应畸形 | 显示“资讯列表响应无效” |
| 网络或业务失败 | 显示“资讯读取失败,请稍后重试” |
| 详情 ID 缺失或非法 | 显示“资讯编号无效”且不请求 |
| 详情未匹配 | 显示“资讯不存在或已下线” |
| 危险外链 | 不渲染外链入口,正文仍可查看 |
| 401/403 | 作为公开内容读取失败处理,不清理登录态、不跳转 |
## 10. 测试与验收
必须以 TDD 覆盖:
1. `ApiClient` 使用公开 PC path,只发送 `articleType/limit`,设置 `auth: false`,拒绝非法类型与超范围 limit。
2. 公共请求即使收到 401 也不清理既有登录 token。
3. 长 ID 保持字符串,非法安全整数被拒绝。
4. `SiteArticleVo` 全字段规范化与直接数组校验。
5. 任一非法元素使整批失败。
6. 列表链接只使用响应 ID,页面无手填 ID/OSS ID。
7. 详情精确匹配,不回退。
8. 正文转义、换行保留、危险外链拒绝。
9. `news.html``article-detail.html` 加载新脚本,且不加载家谱谱文 CRUD 脚本。
10. 先运行资讯专项测试,再运行全量 `node --test tests/*.test.js`
11. 使用浏览器验证公开列表、分类链接、详情或真实空状态,控制台无业务错误。
## 11. 明确不做
- 不修改后端。
- 不新增文章管理能力。
- 不接 APP 或后台管理接口。
- 不把 `coverOssId` 猜成文件 URL。
- 不接 `about/privacy/agreement` 固定页面。
- 不修改 `culture.html` 和首页文章卡片;它们作为后续独立批次。
- 不修改法律页“待法务确认”状态。
+9 -22
View File
@@ -39,10 +39,10 @@
<div class="eyebrow">Help Center</div>
<h1>从创建到协作,快速解决常见问题</h1>
<p class="lead">
覆盖注册登录、创建家谱、邀请成员、资料审核、隐私设置和内容发布等流程
帮助内容由平台持续维护,覆盖账号、家谱和内容使用中的常见问题
</p>
<div class="help-tags">
<span>创建家谱</span><span>邀请成员</span><span>隐私权限</span>
<span>账号安全</span><span>家谱使用</span><span>内容管理</span>
</div>
</div>
<div class="hero-card help-card tilt-card">
@@ -55,6 +55,7 @@
<a class="btn primary magnetic" href="submit-ticket.html"
>提交工单</a
>
<a class="btn ghost magnetic" href="my-tickets.html">我的工单</a>
</div>
</div>
</section>
@@ -63,27 +64,12 @@
<div class="container">
<div class="section-head">
<h2>常见问题</h2>
<p>把复杂流程拆成清楚步骤,便于用户第一次使用时快速上手</p>
<p>选择问题后读取平台发布的完整解答</p>
<button class="btn ghost" type="button" data-help-refresh>刷新帮助</button>
</div>
<div class="faq-list" data-help-list>
<details open class="tilt-card">
<summary>如何创建第一本家谱?</summary>
<p>
进入创建家谱页面,填写家族名称、姓氏、地区和简介,即可生成家谱空间。
</p>
</details>
<details class="tilt-card">
<summary>如何邀请亲人一起维护?</summary>
<p>在家谱管理中生成邀请码,亲人可通过加入家谱页面提交申请。</p>
</details>
<details class="tilt-card">
<summary>公开家谱会展示哪些内容?</summary>
<p>仅展示管理员允许公开的家族介绍、动态和部分资料。</p>
</details>
<details class="tilt-card">
<summary>资料填错了怎么办?</summary>
<p>管理员可以编辑成员资料、调整世系关系,也可以设置审核流程。</p>
</details>
<p class="muted" data-help-status aria-live="polite">正在读取帮助文章…</p>
<div class="faq-list is-loading" data-help-list>
<div class="api-empty">正在读取帮助文章…</div>
</div>
</div>
</section>
@@ -125,5 +111,6 @@
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/page-effects.js"></script>
<script src="public/js/help-pages.js"></script>
</body>
</html>
+33 -6
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/join-genealogy.css" />
</head>
<body class="page-join-genealogy">
<body class="page-join-genealogy" data-join-apply-page>
<main class="auth-layout">
<a class="brand auth-page-brand" href="index.html"
><img class="brand-logo" src="public/images/logo.png" alt="我们的家谱,代代相传" /></a
@@ -18,11 +18,37 @@
>
<div class="eyebrow">Join Family</div>
<h1 id="join-title">加入家谱</h1>
<p class="lead">请从家谱广场或亲人分享的家谱详情进入申请流程,家谱编号由系统自动带入</p>
<div class="form-grid">
<a class="btn primary" href="plaza.html">前往家谱广场</a>
<a class="btn ghost" href="profile-families.html">返回我的家谱</a>
</div>
<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>
</section>
</main>
<script src="config.js"></script>
@@ -30,5 +56,6 @@
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/join-pages.js"></script>
</body>
</html>
+17 -15
View File
@@ -7,10 +7,7 @@
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body
data-feature-status="pending"
data-feature-message="在线工单查询服务正在开发中,当前页面仅保留设计预览。"
>
<body data-feedback-list-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
@@ -33,23 +30,23 @@
<div>
<div class="site-info-kicker">My Tickets</div>
<h1>我的工单</h1>
<p>工单列表、处理进度与客服回复将在后续工单接口交付后接入</p>
<p>查看通过 PC 反馈接口提交的问题说明和处理状态</p>
</div>
<div class="site-info-mark"></div>
</div>
</section>
<section class="site-info-section alt">
<div class="container">
<div class="site-info-status">
<h2>暂未开放在线查询</h2>
<p>当前不能读取或展示工单数据,也不会伪造历史记录</p>
<div class="site-info-list">
<div class="module-title-row">
<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="site-info-actions">
<a
class="btn ghost magnetic"
href="help.html"
data-feature-link="available"
>返回帮助中心</a
>
<a class="btn primary magnetic" href="submit-ticket.html">提交新工单</a>
<a class="btn ghost magnetic" href="help.html">返回帮助中心</a>
</div>
</div>
</div>
@@ -63,7 +60,12 @@
</div>
</div>
</footer>
<script src="public/js/pending-pages.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/feedback-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+17 -19
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body>
<body data-site-news-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
@@ -34,7 +34,7 @@
<div class="site-info-kicker">News Center</div>
<h1>新闻资讯</h1>
<p>
集中阅读平台公告与家谱文化内容。当前为静态展示,后续由新闻内容接口统一提供列表、分类和详情
集中阅读平台新闻、公告与下载内容
</p>
</div>
<div class="site-info-mark"></div>
@@ -42,27 +42,19 @@
</section>
<section class="site-info-section">
<div class="container site-info-layout">
<div class="site-info-list">
<a class="site-info-item" id="platform" href="notice-detail.html"
><small>平台公告</small>
<h2>平台内容公开展示规范说明</h2>
<p>
说明公开展示的家谱与文化内容范围,以及资料维护时应注意的边界。
</p></a
><a class="site-info-item" id="culture" href="article-detail.html"
><small>家谱文化</small>
<h2>为什么家谱不只是一本名册</h2>
<p>
从成员关系、家风记忆与共同维护三个角度理解数字家谱的价值。
</p></a
>
<div>
<p class="module-meta" data-site-news-status></p>
<div class="site-info-list" data-site-news-list>
<div class="api-empty">资讯内容加载中</div>
</div>
</div>
<aside class="site-info-panel">
<h2>新闻分类</h2>
<div class="site-info-links">
<a href="#platform">平台公告</a><a href="#culture">家谱文化</a
><a href="notice-detail.html">公告详情</a
><a href="article-detail.html">文化文章</a>
<a href="news.html">全部</a
><a href="news.html?articleType=news">新闻</a
><a href="news.html?articleType=notice">公告</a
><a href="news.html?articleType=download">下载</a>
</div>
</aside>
</div>
@@ -78,6 +70,12 @@
</div>
</div>
</footer>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/page-effects.js"></script>
<script src="public/js/site-news-pages.js"></script>
</body>
</html>
+1 -1
View File
@@ -79,7 +79,7 @@
<a class="module-card tilt-card" href="profile-join-family.html"
><span class="icon"></span>
<h3>加入家谱</h3>
<p>输入邀请码或由管理员邀请,加入已有家谱协作</p></a
<p>搜索并选择可加入的家谱,提交关系和申请说明等待审核</p></a
>
</div>
</section>
+1
View File
@@ -37,6 +37,7 @@
<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-ceremony.html" data-genealogy-context-link><span class="icon"></span><h3>祭祀活动</h3><p>维护活动、祭品与受邀名单。</p></a>
<a class="module-card tilt-card" href="profile-article.html" data-genealogy-context-link><span class="icon"></span><h3>谱文</h3><p>查看或维护当前家谱谱文。</p></a>
<a class="module-card tilt-card" href="profile-join-review.html" data-genealogy-context-link><span class="icon"></span><h3>入谱审核</h3><p>审核当前家谱的待处理加入申请。</p></a>
</div>
</section>
<section class="module-panel">
+26 -18
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-family-home-page data-feature-status="pending" data-feature-message="家谱概览与管理服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-family-home-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -17,7 +17,7 @@
<nav class="nav-links">
<a class="active" href="profile-families.html">我的家谱</a
><a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a
><a href="profile-content.html">内容发布</a
><a href="profile-content.html" data-genealogy-context-link>内容发布</a
><a href="profile-data.html">个人资料</a
><a href="profile-services.html">帮助与服务</a>
</nav>
@@ -33,12 +33,12 @@
<div class="module-kicker">Family Home</div>
<div class="module-title-row">
<div>
<h1 data-family-home-title>四川武胜汤氏族</h1>
<h1 data-family-home-title>家谱主页</h1>
<p>
后台家谱主页,用于进入世系、相册、家族圈、内容管理和成员协作。
</p>
</div>
<a class="btn ghost magnetic" href="profile-family-admin.html" data-genealogy-context-link
<a class="btn ghost magnetic" href="profile-family-admin.html" data-family-home-management data-genealogy-context-link hidden
>管理家谱</a
>
</div>
@@ -50,29 +50,36 @@
<a class="active" href="profile-family-home.html" data-genealogy-context-link>家谱主页</a
><a href="profile-tree.html" data-genealogy-context-link>世系图</a
><a href="profile-album.html" data-feature-link="available" data-genealogy-context-link>相册</a
><a href="profile-content.html">内容发布</a
><a href="profile-family-admin.html" data-genealogy-context-link>家族管理</a
><a href="profile-content.html" data-genealogy-context-link>内容发布</a
><a href="profile-family-admin.html" data-family-home-management data-genealogy-context-link hidden>家族管理</a
><a href="profile-messages.html">消息中心</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>家谱概览</h2>
<div class="module-grid">
<div class="module-card">
<div class="module-card" data-family-home-overview>
<span class="icon"></span>
<h3>家谱编号</h3>
<p data-family-home-summary>家谱信息加载中...</p>
<h3>家谱信息</h3>
<p>家谱概览加载中...</p>
</div>
<a class="module-card tilt-card" href="profile-tree.html" data-genealogy-context-link
><span class="icon"></span>
<h3>世系图</h3>
<p>查看树谱模式和成员关系。</p></a
><a class="module-card tilt-card" href="profile-invite.html"
><span class="icon"></span>
<div class="module-card">
<span class="icon"></span>
<h3>家谱成员</h3>
<p><strong data-family-home-member-count>--</strong></p>
</div>
<div class="module-card">
<span class="icon"></span>
<h3>世系人物</h3>
<p><strong data-family-home-person-count>--</strong></p>
</div>
<div class="module-card">
<span class="icon"></span>
<h3>邀请家人</h3>
<p>生成邀请入口,邀请亲人协作</p></a
>
<p>PC 暂未开放邀请,请等待后端提供正式接口</p>
</div>
</div>
<p class="api-status" data-family-home-status>正在读取家谱概览</p>
</section>
<section class="module-panel">
<h2>世系预览</h2>
@@ -120,7 +127,8 @@
<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/lineage-pages.js"></script>
<script src="public/js/family-home-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+13 -21
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-feature-status="pending" data-feature-message="意见反馈服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-feedback-page="profile">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -19,8 +19,7 @@
><a class="active" href="profile-feedback.html">意见反馈</a>
</nav>
<div class="nav-actions">
<a href="profile-services.html">返回服务中心</a
><button class="btn primary magnetic" type="submit" form="profileFeedbackForm">提交</button>
<a href="profile-services.html">返回服务中心</a>
</div>
</div>
</header>
@@ -52,9 +51,10 @@
<div class="editor-field">
<label for="feedbackType">反馈类型</label>
<select id="feedbackType" name="feedbackType">
<option value="suggestion">改进建议</option>
<option value="advice">意见建议</option>
<option value="bug">问题反馈</option>
<option value="data">资料异常</option>
<option value="complaint">投诉反馈</option>
<option value="other">其他反馈</option>
</select>
</div>
<div class="editor-field">
@@ -62,30 +62,22 @@
<input id="contactInfo" name="contactInfo" type="text" placeholder="手机号 / 邮箱" />
</div>
</div>
<div class="editor-field">
<label for="feedbackTitle">反馈标题</label>
<input id="feedbackTitle" name="feedbackTitle" type="text" placeholder="请填写反馈标题" />
</div>
<div class="editor-field">
<label for="feedbackContent">反馈描述</label>
<textarea id="feedbackContent" name="feedbackContent" placeholder="请描述问题、页面位置和期望结果"></textarea>
<textarea id="feedbackContent" name="feedbackContent" required placeholder="请描述问题、页面位置和期望结果"></textarea>
</div>
<p class="form-note" aria-live="polite" data-feedback-status></p>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">提交反馈</button>
<button class="btn primary magnetic" type="submit" data-feedback-submit>提交反馈</button>
</div>
</form>
</section>
<section class="module-panel">
<h2>历史反馈</h2>
<div class="module-list" data-feedback-list>
<div class="module-row">
<div>
<h3>资料导入建议</h3>
<p>已受理 · 2026.05</p>
</div>
<span class="pill">查看</span>
</div>
<div class="module-title-row">
<h2>历史反馈</h2>
<button class="btn ghost" type="button" data-feedback-refresh>刷新</button>
</div>
<div class="module-list" data-feedback-list></div>
</section>
</div>
</div>
@@ -99,7 +91,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/feedback-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+8 -14
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-feature-status="pending" data-feature-message="加入家谱服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-my-join-applies-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -30,9 +30,7 @@
<div class="module-title-row">
<div>
<h1>加入家谱</h1>
<p>
输入亲人分享的邀请码,提交后进入个人中心消息流程等待管理员审核。
</p>
<p>查看加入申请进度;待审核申请可以撤销后重新提交。</p>
</div>
<a
class="btn primary magnetic"
@@ -51,16 +49,12 @@
</aside>
<div class="module-main">
<section class="module-panel">
<h2>我的加入申请</h2>
<div class="module-list" data-join-apply-list="mine">
<a class="module-row" href="join-genealogy.html">
<div>
<h3>提交新的加入申请</h3>
<p>输入家谱 ID 和关系说明,等待管理员审核。</p>
</div>
<span class="pill">去申请</span>
</a>
<div class="module-title-row">
<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="module-list" data-join-apply-list="mine"></div>
</section>
</div>
</div>
@@ -75,7 +69,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/join-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+10 -48
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-feature-status="pending" data-feature-message="入谱审核服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-join-review-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -20,14 +20,7 @@
><a class="active" href="profile-join-review.html">入谱审核</a>
</nav>
<div class="nav-actions">
<a href="profile-messages.html">返回消息</a
><a
class="btn primary magnetic"
href="#"
data-layer-confirm="确认批量处理当前审核申请?"
data-confirm-title="全部处理"
>全部处理</a
>
<a href="profile-family-admin.html" data-genealogy-context-link>返回家族管理</a>
</div>
</div>
</header>
@@ -38,9 +31,7 @@
<div class="module-title-row">
<div>
<h1>入谱审核</h1>
<p>
个人中心专用审核页,处理加入家谱、绑定账号和成员资料补全申请。
</p>
<p>审核当前家谱的加入申请。只有家谱管理者可以执行通过或拒绝。</p>
</div>
</div>
</div>
@@ -49,47 +40,18 @@
<div class="container module-layout">
<aside class="module-nav">
<a href="profile-messages.html">消息中心</a
><a class="active" href="profile-join-review.html">入谱审核</a
><a class="active" href="profile-join-review.html" data-genealogy-context-link>入谱审核</a
><a href="profile-invite.html">邀请家人</a
><a href="profile-admin-permissions.html">管理员权限</a>
</aside>
<div class="module-main">
<section class="module-panel">
<h2>待审核申请</h2>
<div class="module-list" data-join-apply-list="pending">
<a class="module-row" href="profile-join-review.html"
><div>
<h3>某某人申请加入四川武胜汤氏族</h3>
<p>提交时间:2026.05.26 · 关系说明待确认</p>
</div>
<span class="pill">审核</span></a
><a class="module-row" href="profile-join-review.html"
><div>
<h3>张三申请绑定成员账号</h3>
<p>需要核对手机号和成员资料</p>
</div>
<span class="pill">处理</span></a
>
</div>
</section>
<section class="module-panel">
<h2>审核操作</h2>
<div class="bottom-actions">
<a
class="btn primary magnetic"
href="#"
data-layer-confirm="同意当前入谱申请?"
data-confirm-title="审核通过"
>同意</a
><a
class="btn ghost magnetic"
href="#"
data-layer-prompt="请输入拒绝原因"
data-prompt-title="拒绝申请"
data-prompt-type="textarea"
>拒绝</a
>
<div class="module-title-row">
<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>
</section>
</div>
</div>
@@ -104,7 +66,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/join-review-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+14 -22
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-vip-page data-feature-status="pending" data-feature-message="会员与在线服务正在开发中,当前页面仅保留设计预览。">
<body class="page-profile-module" data-vip-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="profile.html"
@@ -43,7 +43,7 @@
“我的”页中的应用推广、意见反馈、安全设置、帮助中心和应用分享。
</p>
</div>
<a class="btn primary magnetic" href="profile-feedback.html"
<a class="btn primary magnetic" href="profile-feedback.html" data-feature-link="available"
>提交反馈</a
>
</div>
@@ -68,7 +68,7 @@
<h3>应用分享</h3>
<p>邀请好友使用,了解应用下载和分享奖励。</p></a
>
<a class="module-card tilt-card" href="profile-feedback.html"
<a class="module-card tilt-card" href="profile-feedback.html" data-feature-link="available"
><span class="icon"></span>
<h3>意见反馈</h3>
<p>提交产品问题、资料异常和改进建议。</p></a
@@ -97,37 +97,29 @@
</section>
<section class="module-panel">
<h2>会员服务</h2>
<p>选择适合当前家谱的会员套餐创建订单后继续完成支付。</p>
<p>选择适合的会员套餐创建订单。当前 PC 暂未开放在线支付。</p>
<div class="vip-package-list" data-vip-package-list>
<div class="api-empty">会员套餐加载中</div>
</div>
<form class="editor-form vip-order-form" data-vip-order-form>
<div class="editor-two">
<div class="editor-field">
<label for="packageId">套餐 ID</label>
<input id="packageId" name="packageId" type="number" placeholder="请先选择套餐" required />
</div>
<div class="editor-field">
<label for="genealogyId">家谱 ID</label>
<input id="genealogyId" name="genealogyId" type="number" placeholder="可选,指定要开通的家谱" />
</div>
</div>
<p class="api-status" data-vip-selected-package>尚未选择套餐</p>
<div class="editor-field">
<label for="payType">支付方式</label>
<select id="payType" name="payType">
<option value="wechat">微信支付</option>
<option value="alipay">支付宝</option>
<option value="balance">余额支付</option>
<label for="vipGenealogy">关联家谱(可选)</label>
<select id="vipGenealogy" data-vip-genealogy-options>
<option value="">家谱选项加载中</option>
</select>
</div>
<div class="bottom-actions">
<button class="btn primary magnetic" type="submit">创建会员订单</button>
<button class="btn primary magnetic" type="submit" data-vip-order-submit>创建会员订单</button>
<span class="api-status" data-vip-order-status>等待选择套餐</span>
</div>
</form>
</section>
<section class="module-panel">
<h2>会员订单</h2>
<div class="module-title-row">
<h2>会员订单</h2>
<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>
@@ -162,7 +154,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/vip-pages.js"></script>
<script src="public/js/profile-common.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
+4 -4
View File
@@ -87,8 +87,8 @@
<span>7 条</span>
</div>
<div class="todo-list">
<a href="profile-join-review.html"
><b>入谱申请</b><span>3 人等待审核</span></a
<a href="profile-join-review.html" data-genealogy-context-link
><b>入谱申请</b><span>进入当前家谱查看</span></a
>
<a href="profile-gift.html"
><b>贺礼邀请</b><span>2 条需要确认</span></a
@@ -168,10 +168,10 @@
<h3>管理世代</h3>
<p>设置世代、调整始祖世代、维护排行。</p></a
>
<a class="feature-tile tilt-card" href="profile-messages.html"
<a class="feature-tile tilt-card" href="profile-join-review.html" data-genealogy-context-link
><span></span>
<h3>入谱审核</h3>
<p>处理加入家谱、成员绑定和资料审核</p></a
<p>处理当前家谱的待审核加入申请</p></a
>
<a
class="feature-tile tilt-card"
+3
View File
@@ -183,10 +183,13 @@
}
.promotion-card {
display: block;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #fffaf0;
color: inherit;
text-decoration: none;
box-shadow: 0 18px 42px rgba(57, 48, 36, .08);
transition: transform .24s ease, border-color .24s ease, box-shadow .24s ease;
}
+150
View File
@@ -0,0 +1,150 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.AppPromotionPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.AppPromotionPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
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 normalizeId(value) {
var normalized;
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
if (value === undefined || value === null) return '';
normalized = String(value).trim();
return /^[1-9][0-9]*$/.test(normalized) ? normalized : '';
}
function text(value) {
return value === undefined || value === null ? '' : String(value).trim();
}
function normalizeTargetUrl(value) {
var normalized = text(value);
var parsed;
if (!normalized) return '';
try {
parsed = new URL(normalized);
} catch (error) {
return '';
}
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
}
function normalizePromotion(item) {
var source = item || {};
var promotionId = normalizeId(source.promotionId);
var promotionTitle = text(source.promotionTitle);
if (!promotionId || !promotionTitle || String(source.status) !== '0') return null;
return {
promotionId: promotionId,
promotionTitle: promotionTitle,
promotionDesc: text(source.promotionDesc),
targetUrl: normalizeTargetUrl(source.targetUrl)
};
}
function normalizePromotionList(data) {
var items;
if (!Array.isArray(data)) return [];
items = data.map(normalizePromotion);
return items.some(function (item) { return !item; }) ? [] : items;
}
function renderPromotionCard(item) {
var content = '<div><h3>' + escapeHtml(item.promotionTitle) + '</h3>' +
'<p>' + escapeHtml(item.promotionDesc) + '</p></div>';
if (!item.targetUrl) {
return '<article class="promotion-card" data-promotion-id="' +
escapeHtml(item.promotionId) + '">' + content + '</article>';
}
return '<a class="promotion-card" data-promotion-id="' + escapeHtml(item.promotionId) +
'" href="' + escapeHtml(item.targetUrl) +
'" target="_blank" rel="noopener noreferrer">' + content + '</a>';
}
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>';
}
async function loadPromotions(api) {
var data;
var items;
if (!api || typeof api.promotions !== 'function') throw new Error('应用推广接口不可用');
data = await api.promotions({ platform: 'pc' });
items = normalizePromotionList(data);
if (!Array.isArray(data) || items.length !== data.length) {
throw new Error('推广列表响应无效');
}
return items;
}
function isUnauthorized(error) {
return Number(error && (error.status || error.code)) === 401;
}
async function init() {
var page = root.document && root.document.querySelector('[data-promotion-page]');
var list = root.document && root.document.querySelector('[data-promotion-list]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
if (!page || !list) return;
if (!api || !api.getToken || !api.getToken()) {
list.innerHTML = renderPromotionLoginRequired();
return;
}
try {
list.classList.add('is-loading');
list.innerHTML = renderPromotionList(await loadPromotions(api));
} catch (error) {
if (isUnauthorized(error)) {
if (api.clearToken) api.clearToken();
list.innerHTML = renderPromotionLoginRequired();
return;
}
list.innerHTML = '<div class="api-empty">应用推广读取失败,请稍后重试</div>';
} finally {
list.classList.remove('is-loading');
}
}
return {
normalizePromotion: normalizePromotion,
normalizePromotionList: normalizePromotionList,
normalizeTargetUrl: normalizeTargetUrl,
renderPromotionList: renderPromotionList,
renderPromotionLoginRequired: renderPromotionLoginRequired,
loadPromotions: loadPromotions,
isUnauthorized: isUnauthorized,
init: init
};
});
+202
View File
@@ -0,0 +1,202 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.FamilyHomePages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.FamilyHomePages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
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 normalizeId(value) {
var result;
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
if (value === undefined || value === null) return '';
result = String(value).trim();
return /^[1-9][0-9]*$/.test(result) ? result : '';
}
function text(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function normalizeCount(value) {
var result = Number(value);
return Number.isSafeInteger(result) && result >= 0 ? result : null;
}
function normalizeFamilyOverview(item, expectedGenealogyId) {
var source = item || {};
var genealogyId = normalizeId(source.genealogyId);
var expectedId = normalizeId(expectedGenealogyId);
var memberCount = normalizeCount(source.memberCount);
var personCount = normalizeCount(source.personCount);
if (!genealogyId || genealogyId !== expectedId ||
!text(source.genealogyName) || String(source.status) !== '0' ||
memberCount === null || personCount === null) {
return null;
}
return {
genealogyId: genealogyId,
genealogyNo: text(source.genealogyNo),
genealogyName: text(source.genealogyName),
surname: text(source.surname),
ancestralHall: text(source.ancestralHall),
originPlace: text(source.originPlace),
regionFullName: text(source.regionFullName),
memberCount: memberCount,
personCount: personCount,
canManage: source.canManage === true,
canEditContent: source.canEditContent === true
};
}
function renderFamilyOverview(overview) {
var source = overview || {};
var details = [
source.genealogyNo ? '家谱编号 ' + source.genealogyNo : '',
source.surname ? '姓氏 ' + source.surname : '',
source.ancestralHall ? '堂号 ' + source.ancestralHall : '',
source.originPlace ? '祖籍 ' + source.originPlace : '',
source.regionFullName ? '地区 ' + source.regionFullName : ''
].filter(Boolean).join(' · ');
return '<h3>' + escapeHtml(source.genealogyName) + '</h3><p>' +
escapeHtml(details) + '</p><p>成员 ' + escapeHtml(source.memberCount) +
' 人 · 世系人物 ' + escapeHtml(source.personCount) + ' 人</p>';
}
function shouldShowFamilyManagement(overview) {
return !!overview && overview.canManage === true;
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
async function loadFamilyHome(api, genealogyId) {
var normalizedId = normalizeId(genealogyId);
var results;
var overview;
if (!normalizedId || !api || !api.genealogyOverview || !api.lineageTree) {
throw new Error('家谱主页接口初始化失败');
}
results = await Promise.all([
api.genealogyOverview(normalizedId),
api.lineageTree(normalizedId)
]);
overview = normalizeFamilyOverview(results[0], normalizedId);
if (!overview) throw new Error('家谱概览响应无效');
return {
overview: overview,
tree: results[1]
};
}
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 query(selector) {
return root.document && root.document.querySelector(selector);
}
function queryAll(selector) {
return root.document ? Array.prototype.slice.call(root.document.querySelectorAll(selector)) : [];
}
function setText(selector, value) {
var element = query(selector);
if (element) element.textContent = value;
}
async function initFamilyHomePage() {
var page = query('[data-family-home-page]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var genealogyId;
var result;
if (!page) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
genealogyId = root.ProfileUI && root.ProfileUI.getGenealogyId();
if (!genealogyId) {
if (root.location && typeof root.location.replace === 'function') {
root.location.replace('profile-families.html?next=profile-family-home.html');
}
return;
}
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
root.ProfileUI.syncGenealogyContextLinks();
}
if (!root.LineagePages || !root.LineagePages.renderLineageTreeHtml) {
setText('[data-family-home-status]', '世系预览初始化失败,请刷新后重试');
return;
}
try {
result = await loadFamilyHome(api, genealogyId);
setText('[data-family-home-title]', 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));
queryAll('[data-family-home-management]').forEach(function (element) {
element.hidden = !shouldShowFamilyManagement(result.overview);
});
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 || '家谱主页读取失败,请稍后重试');
}
}
function init() {
return initFamilyHomePage();
}
return {
normalizeFamilyOverview: normalizeFamilyOverview,
renderFamilyOverview: renderFamilyOverview,
shouldShowFamilyManagement: shouldShowFamilyManagement,
shouldRedirectToLogin: shouldRedirectToLogin,
loadFamilyHome: loadFamilyHome,
initFamilyHomePage: initFamilyHomePage,
init: init
};
});
+385
View File
@@ -0,0 +1,385 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.FeedbackPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.FeedbackPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var FEEDBACK_TYPES = {
advice: '意见建议',
bug: '问题反馈',
complaint: '投诉反馈',
other: '其他反馈'
};
var HANDLE_STATUS = {
'0': '待处理',
'1': '处理中',
'2': '已处理',
'3': '已关闭'
};
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 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 optionalText(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function getFeedbackId(search) {
var params = new URLSearchParams(String(search === undefined && root.location ? root.location.search : search || '').replace(/^\?/, ''));
return normalizeId(params.get('feedbackId'));
}
function buildFeedbackBody(values) {
var source = values || {};
var body = {};
var feedbackType = optionalText(source.feedbackType);
var feedbackContent = optionalText(source.feedbackContent);
var contactInfo = optionalText(source.contactInfo);
if (feedbackType) body.feedbackType = feedbackType;
if (feedbackContent) body.feedbackContent = feedbackContent;
if (contactInfo) body.contactInfo = contactInfo;
return body;
}
function validateFeedbackBody(body) {
var source = body || {};
if (!optionalText(source.feedbackContent)) return '请填写反馈内容';
if (source.feedbackType && !Object.prototype.hasOwnProperty.call(FEEDBACK_TYPES, source.feedbackType)) {
return '反馈类型无效';
}
return '';
}
function normalizeFeedback(item) {
var source = item || {};
var feedbackId = normalizeId(source.feedbackId);
var feedbackType = optionalText(source.feedbackType);
var feedbackContent = optionalText(source.feedbackContent);
var handleStatus = String(source.handleStatus === undefined || source.handleStatus === null ? '' : source.handleStatus);
var status = String(source.status === undefined || source.status === null ? '' : source.status);
if (!feedbackId || !feedbackContent ||
!Object.prototype.hasOwnProperty.call(FEEDBACK_TYPES, feedbackType) ||
!Object.prototype.hasOwnProperty.call(HANDLE_STATUS, handleStatus) ||
['0', '1'].indexOf(status) < 0) {
return null;
}
return {
feedbackId: feedbackId,
feedbackType: feedbackType,
feedbackContent: feedbackContent,
contactInfo: optionalText(source.contactInfo),
handleStatus: handleStatus,
handleResult: optionalText(source.handleResult),
handleTime: optionalText(source.handleTime),
status: status,
remark: optionalText(source.remark)
};
}
function normalizeFeedbackList(data) {
var items;
if (!Array.isArray(data)) return [];
items = data.map(normalizeFeedback);
return items.some(function (item) { return !item; }) ? [] : items;
}
function findFeedbackById(data, feedbackId) {
var targetId = normalizeId(feedbackId);
var items = normalizeFeedbackList(data);
var match;
if (!targetId) return null;
match = items.find(function (item) { return item.feedbackId === targetId; });
return match || null;
}
function buildFeedbackDetailUrl(feedbackId, detailPage) {
var id = normalizeId(feedbackId);
var params;
if (!id) return '';
params = new URLSearchParams();
params.set('feedbackId', id);
return (detailPage || 'ticket-detail.html') + '?' + params.toString();
}
function renderFeedbackList(data, options) {
var settings = options || {};
var items = normalizeFeedbackList(data);
var detailPage = settings.detailPage || 'ticket-detail.html';
if (!items.length) return '<div class="api-empty">当前没有反馈记录</div>';
return items.map(function (item) {
return '<a class="module-row" href="' +
escapeHtml(buildFeedbackDetailUrl(item.feedbackId, detailPage)) +
'"><div><h3>' + escapeHtml(FEEDBACK_TYPES[item.feedbackType]) +
'</h3><p>' + escapeHtml(item.feedbackContent) +
'</p></div><span class="pill">' + HANDLE_STATUS[item.handleStatus] +
'</span></a>';
}).join('');
}
function renderFeedbackDetail(item) {
var feedback = normalizeFeedback(item);
var details;
if (!feedback) return '<div class="api-empty">反馈记录不存在或无权查看</div>';
details = [
feedback.contactInfo ? '<dt>提交联系方式</dt><dd>' + escapeHtml(feedback.contactInfo) + '</dd>' : '',
'<dt>处理状态</dt><dd>' + HANDLE_STATUS[feedback.handleStatus] + '</dd>',
feedback.handleResult ? '<dt>处理结果</dt><dd>' + escapeHtml(feedback.handleResult) + '</dd>' : '',
feedback.handleTime ? '<dt>处理时间</dt><dd>' + escapeHtml(feedback.handleTime) + '</dd>' : '',
feedback.remark ? '<dt>备注</dt><dd>' + escapeHtml(feedback.remark) + '</dd>' : ''
].filter(Boolean).join('');
return '<article class="module-panel"><div class="module-kicker">' +
escapeHtml(FEEDBACK_TYPES[feedback.feedbackType]) +
'</div><h2>问题说明</h2><p>' + escapeHtml(feedback.feedbackContent) +
'</p><dl class="detail-list">' + details + '</dl></article>';
}
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 getFormValues(form) {
var values = {};
Array.prototype.forEach.call(form.querySelectorAll('[name]'), function (field) {
values[field.name] = field.value;
});
return values;
}
function setStatus(message) {
var status = root.document && root.document.querySelector('[data-feedback-status]');
if (status) status.textContent = message || '';
}
async function readMyFeedback(api) {
var data = await api.myFeedback();
var items = normalizeFeedbackList(data);
if (!Array.isArray(data) || items.length !== data.length) {
throw new Error('反馈列表响应无效');
}
return items;
}
async function initFeedbackFormPage() {
var form = root.document && root.document.querySelector('[data-feedback-form]');
var list = root.document && root.document.querySelector('[data-feedback-list]');
var refresh = root.document && root.document.querySelector('[data-feedback-refresh]');
var page = root.document && root.document.querySelector('[data-feedback-page]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var pageMode = page && page.getAttribute('data-feedback-page');
var writePending = false;
if (!form) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
async function loadList() {
var items;
if (!list) return [];
setStatus('正在读取反馈记录…');
try {
items = await readMyFeedback(api);
list.innerHTML = renderFeedbackList(items, { detailPage: '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 || '读取反馈记录失败,请稍后重试');
return [];
}
}
if (refresh) refresh.addEventListener('click', loadList);
form.addEventListener('submit', async function (event) {
var body;
var validation;
var created;
var refreshed;
var button = form.querySelector('[data-feedback-submit]');
event.preventDefault();
if (writePending) return;
body = buildFeedbackBody(getFormValues(form));
validation = validateFeedbackBody(body);
if (validation) {
setStatus(validation);
return;
}
writePending = true;
if (button) button.disabled = true;
setStatus('正在提交反馈…');
try {
created = normalizeFeedback(await api.submitFeedback(body));
if (!created) throw new Error('提交响应缺少有效反馈信息');
refreshed = await readMyFeedback(api);
if (!refreshed.some(function (item) { return item.feedbackId === created.feedbackId; })) {
throw new Error('提交后无法读取同一反馈记录');
}
if (pageMode === 'ticket') {
if (root.location) root.location.href = buildFeedbackDetailUrl(created.feedbackId);
return;
}
if (list) list.innerHTML = renderFeedbackList(refreshed, { detailPage: 'ticket-detail.html' });
form.reset();
setStatus('反馈已提交');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '提交反馈失败,请稍后重试');
} finally {
writePending = false;
if (button) button.disabled = false;
}
});
await loadList();
}
async function initFeedbackListPage() {
var page = root.document && root.document.querySelector('[data-feedback-list-page]');
var list = root.document && root.document.querySelector('[data-feedback-list]');
var refresh = root.document && root.document.querySelector('[data-feedback-refresh]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
if (!page || !list) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
async function loadList() {
try {
setStatus('正在读取反馈记录…');
list.innerHTML = renderFeedbackList(await readMyFeedback(api), {
detailPage: 'ticket-detail.html'
});
setStatus('');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
list.innerHTML = '<div class="api-empty">读取反馈记录失败</div>';
setStatus(error.message || '读取反馈记录失败,请稍后重试');
}
}
if (refresh) refresh.addEventListener('click', loadList);
await loadList();
}
async function initFeedbackDetailPage() {
var page = root.document && root.document.querySelector('[data-feedback-detail-page]');
var detail = root.document && root.document.querySelector('[data-feedback-detail]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var feedbackId = getFeedbackId();
var item;
if (!page || !detail) return;
if (!feedbackId) {
detail.innerHTML = '<div class="api-empty">反馈记录不存在或无权查看</div>';
return;
}
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
try {
setStatus('正在读取反馈详情…');
item = findFeedbackById(await api.myFeedback(), feedbackId);
detail.innerHTML = renderFeedbackDetail(item);
setStatus(item ? '' : '反馈记录不存在或无权查看');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
detail.innerHTML = '<div class="api-empty">反馈详情读取失败</div>';
setStatus(error.message || '反馈详情读取失败,请稍后重试');
}
}
async function init() {
if (root.document && root.document.querySelector('[data-feedback-form]')) {
await initFeedbackFormPage();
return;
}
if (root.document && root.document.querySelector('[data-feedback-list-page]')) {
await initFeedbackListPage();
return;
}
if (root.document && root.document.querySelector('[data-feedback-detail-page]')) {
await initFeedbackDetailPage();
}
}
return {
getFeedbackId: getFeedbackId,
buildFeedbackBody: buildFeedbackBody,
validateFeedbackBody: validateFeedbackBody,
normalizeFeedback: normalizeFeedback,
normalizeFeedbackList: normalizeFeedbackList,
findFeedbackById: findFeedbackById,
buildFeedbackDetailUrl: buildFeedbackDetailUrl,
renderFeedbackList: renderFeedbackList,
renderFeedbackDetail: renderFeedbackDetail,
initFeedbackFormPage: initFeedbackFormPage,
initFeedbackListPage: initFeedbackListPage,
initFeedbackDetailPage: initFeedbackDetailPage,
init: init
};
});
+206
View File
@@ -0,0 +1,206 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.HelpPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.HelpPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
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 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 normalizeCount(value) {
var text;
if (value === undefined || value === null || value === '') return '0';
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value < 0)) return '';
text = String(value).trim();
return /^(?:0|[1-9][0-9]*)$/.test(text) ? text : '';
}
function text(value) {
return value === undefined || value === null ? '' : String(value).trim();
}
function normalizeHelpArticle(item) {
var source = item || {};
var helpId = normalizeId(source.helpId);
var helpTitle = text(source.helpTitle);
var helpContent = text(source.helpContent);
var viewCount = normalizeCount(source.viewCount);
if (!helpId || !helpTitle || !helpContent || !viewCount || String(source.status) !== '0') {
return null;
}
return {
helpId: helpId,
helpCategory: text(source.helpCategory),
helpTitle: helpTitle,
helpContent: helpContent,
viewCount: viewCount
};
}
function normalizeHelpArticleList(data) {
var items;
if (!Array.isArray(data)) return [];
items = data.map(normalizeHelpArticle);
return items.some(function (item) { return !item; }) ? [] : items;
}
function renderHelpArticleList(data) {
var items = normalizeHelpArticleList(data);
if (!items.length) return '<div class="api-empty">当前暂无帮助文章</div>';
return items.map(function (item) {
var meta = [item.helpCategory, '浏览 ' + item.viewCount + ' 次'].filter(Boolean).join(' · ');
return '<details class="tilt-card" data-help-article-id="' + escapeHtml(item.helpId) + '">' +
'<summary>' + escapeHtml(item.helpTitle) + '</summary>' +
'<p>' + escapeHtml(meta) + '</p>' +
'<div class="help-detail" data-help-detail-panel="' + escapeHtml(item.helpId) + '">' +
'<button class="btn ghost" type="button" data-help-detail-id="' +
escapeHtml(item.helpId) + '">查看完整解答</button></div></details>';
}).join('');
}
function renderHelpArticleDetail(item) {
var article = normalizeHelpArticle(item);
var meta;
if (!article) return '<div class="api-empty">帮助文章不存在或已停用</div>';
meta = [article.helpCategory, '浏览 ' + article.viewCount + ' 次'].filter(Boolean).join(' · ');
return '<div class="help-detail-content"><p class="module-meta">' + escapeHtml(meta) +
'</p><p>' + escapeHtml(article.helpContent).replace(/\r?\n/g, '<br />') + '</p></div>';
}
async function loadHelpArticleDetail(api, helpId) {
var targetId = normalizeId(helpId);
var article;
if (!api || typeof api.helpArticleDetail !== 'function') throw new Error('帮助文章接口不可用');
if (!targetId) throw new Error('帮助文章编号无效');
article = normalizeHelpArticle(await api.helpArticleDetail(targetId));
if (!article) throw new Error('帮助文章详情响应无效');
if (article.helpId !== targetId) throw new Error('详情响应与请求编号不一致');
return article;
}
async function loadHelpArticles(api) {
var data;
var items;
if (!api || typeof api.helpArticles !== 'function') throw new Error('帮助文章接口不可用');
data = await api.helpArticles();
items = normalizeHelpArticleList(data);
if (!Array.isArray(data) || items.length !== data.length) throw new Error('帮助文章列表响应无效');
return items;
}
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';
}
async function init() {
var list = root.document && root.document.querySelector('[data-help-list]');
var status = root.document && root.document.querySelector('[data-help-status]');
var refresh = root.document && root.document.querySelector('[data-help-refresh]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
if (!list) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
function setStatus(message) {
if (status) status.textContent = message || '';
}
async function refreshList() {
try {
list.classList.add('is-loading');
setStatus('正在读取帮助文章…');
list.innerHTML = renderHelpArticleList(await loadHelpArticles(api));
setStatus('');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
list.innerHTML = '<div class="api-empty">帮助文章读取失败,请稍后重试</div>';
setStatus(error.message || '帮助文章读取失败');
} finally {
list.classList.remove('is-loading');
}
}
list.addEventListener('click', async function (event) {
var button = event.target.closest('[data-help-detail-id]');
var helpId;
var panel;
if (!button || button.disabled) return;
helpId = button.getAttribute('data-help-detail-id');
panel = list.querySelector('[data-help-detail-panel="' + helpId + '"]');
if (!panel) return;
button.disabled = true;
panel.textContent = '正在读取完整解答…';
try {
panel.innerHTML = renderHelpArticleDetail(await loadHelpArticleDetail(api, helpId));
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
panel.textContent = error.message || '帮助文章详情读取失败';
}
});
if (refresh) refresh.addEventListener('click', refreshList);
await refreshList();
}
return {
normalizeHelpArticle: normalizeHelpArticle,
normalizeHelpArticleList: normalizeHelpArticleList,
renderHelpArticleList: renderHelpArticleList,
renderHelpArticleDetail: renderHelpArticleDetail,
loadHelpArticleDetail: loadHelpArticleDetail,
loadHelpArticles: loadHelpArticles,
shouldRedirectToLogin: shouldRedirectToLogin,
init: init
};
});
+407
View File
@@ -0,0 +1,407 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.JoinPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.JoinPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var APPLY_STATUS = {
'0': '待审核',
'1': '已通过',
'2': '已拒绝',
'3': '已撤销'
};
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 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 optionalText(value) {
var text;
if (value === undefined || value === null) return '';
text = String(value).trim();
return text;
}
function buildJoinApplyBody(values) {
var source = values || {};
var body = {};
['applicantName', 'phone', 'relationDesc', 'applyReason'].forEach(function (name) {
var value = optionalText(source[name]);
if (value) body[name] = value;
});
return body;
}
function validateJoinApplyBody(body) {
var source = body || {};
var limits = {
applicantName: [50, '申请人姓名不能超过 50 个字符'],
phone: [30, '联系电话不能超过 30 个字符'],
relationDesc: [100, '关系说明不能超过 100 个字符'],
applyReason: [500, '申请理由不能超过 500 个字符']
};
var names = Object.keys(limits);
var index;
for (index = 0; index < names.length; index += 1) {
if (String(source[names[index]] || '').length > limits[names[index]][0]) {
return limits[names[index]][1];
}
}
return '';
}
function normalizeJoinGenealogy(item) {
var source = item || {};
var genealogyId = normalizeId(source.genealogyId);
var genealogyName = optionalText(source.genealogyName);
var surname = optionalText(source.surname);
var status = source.status === undefined || source.status === null ? '' : String(source.status);
if (!genealogyId || !genealogyName || !surname || (status && status !== '0')) return null;
return {
genealogyId: genealogyId,
genealogyName: genealogyName,
surname: surname,
regionFullName: optionalText(source.regionFullName),
memberCount: source.memberCount,
joinMode: source.joinMode === undefined || source.joinMode === null ? '' : String(source.joinMode)
};
}
function normalizeJoinApply(item) {
var source = item || {};
var applyId = normalizeId(source.applyId);
var genealogyId = normalizeId(source.genealogyId);
var genealogyName = optionalText(source.genealogyName);
var status = source.status === undefined || source.status === null ? '' : String(source.status);
if (!applyId || !genealogyId || !genealogyName || !Object.prototype.hasOwnProperty.call(APPLY_STATUS, status)) {
return null;
}
return {
applyId: applyId,
genealogyId: genealogyId,
genealogyName: genealogyName,
surname: optionalText(source.surname),
relationDesc: optionalText(source.relationDesc),
applyReason: optionalText(source.applyReason),
auditRemark: optionalText(source.auditRemark),
auditTime: optionalText(source.auditTime),
status: status
};
}
function normalizeJoinApplies(data) {
var items;
if (!Array.isArray(data)) return [];
items = data.map(normalizeJoinApply);
return items.some(function (item) { return !item; }) ? [] : items;
}
function renderJoinGenealogyOptions(data) {
var items;
if (!Array.isArray(data)) return '<div class="api-empty">家谱列表数据无效,请刷新重试</div>';
items = data.map(normalizeJoinGenealogy).filter(Boolean);
if (!items.length) return '<div class="api-empty">没有找到可申请加入的家谱</div>';
return items.map(function (item) {
var meta = [
item.surname + '氏',
item.regionFullName,
item.memberCount === undefined || item.memberCount === null ? '' : item.memberCount + ' 位成员'
].filter(Boolean).join(' · ');
return '<button class="module-row" type="button" data-join-genealogy-id="' +
escapeHtml(item.genealogyId) + '" data-join-genealogy-name="' +
escapeHtml(item.genealogyName) + '"><span><strong>' +
escapeHtml(item.genealogyName) + '</strong><small>' +
escapeHtml(meta) + '</small></span><span class="pill">选择</span></button>';
}).join('');
}
function renderMyJoinApplies(data) {
var items = normalizeJoinApplies(data);
if (!items.length) return '<div class="api-empty">当前没有加入申请</div>';
return items.map(function (item) {
var details = [
item.surname ? item.surname + '氏' : '',
item.relationDesc,
item.applyReason,
item.auditRemark ? '审核说明:' + item.auditRemark : '',
item.auditTime ? '审核时间:' + item.auditTime : ''
].filter(Boolean).join(' · ');
var action = item.status === '0'
? '<button class="btn ghost" type="button" data-join-cancel-id="' +
escapeHtml(item.applyId) + '">撤销申请</button>'
: '';
return '<article class="module-row"><div><h3>' + escapeHtml(item.genealogyName) +
'</h3><p>' + escapeHtml(details || '等待申请状态更新') +
'</p></div><div><span class="pill">' + APPLY_STATUS[item.status] +
'</span>' + action + '</div></article>';
}).join('');
}
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 setText(selector, message) {
var element = root.document && root.document.querySelector(selector);
if (element) element.textContent = message || '';
}
function getFormValues(form) {
var values = {};
Array.prototype.forEach.call(form.querySelectorAll('[name]'), function (field) {
values[field.name] = field.value;
});
return values;
}
function canJoinGenealogy(quota) {
var remaining = Number(quota && quota.joinRemaining);
if (quota && typeof quota.canJoin === 'boolean') return quota.canJoin;
return Number.isInteger(remaining) && (remaining === -1 || remaining > 0);
}
async function initJoinApplyPage() {
var page = root.document && root.document.querySelector('[data-join-apply-page]');
var searchForm = root.document && root.document.querySelector('[data-join-genealogy-search]');
var options = root.document && root.document.querySelector('[data-join-genealogy-options]');
var applyForm = root.document && root.document.querySelector('[data-join-apply-form]');
var selected = root.document && root.document.querySelector('[data-join-selected-genealogy]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var selectedGenealogyId = '';
var writePending = false;
if (!page) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
async function loadOptions(keyword) {
var data;
setText('[data-join-apply-status]', '正在读取可申请家谱…');
try {
data = await api.genealogyOptions(keyword ? { keyword: keyword } : {});
options.innerHTML = renderJoinGenealogyOptions(data);
setText('[data-join-apply-status]', '');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
options.innerHTML = '<div class="api-empty">读取家谱列表失败</div>';
setText('[data-join-apply-status]', error.message || '读取家谱列表失败,请稍后重试');
}
}
try {
if (!canJoinGenealogy(await api.genealogyQuota())) {
setText('[data-join-apply-status]', '当前没有可用的家谱加入额度');
applyForm.querySelector('[data-join-apply-submit]').disabled = true;
return;
}
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setText('[data-join-apply-status]', error.message || '无法读取家谱加入额度');
applyForm.querySelector('[data-join-apply-submit]').disabled = true;
return;
}
searchForm.addEventListener('submit', function (event) {
event.preventDefault();
loadOptions(optionalText(searchForm.querySelector('[name="keyword"]').value));
});
options.addEventListener('click', function (event) {
var button = event.target.closest('[data-join-genealogy-id]');
if (!button || !options.contains(button)) return;
selectedGenealogyId = normalizeId(button.getAttribute('data-join-genealogy-id'));
if (!selectedGenealogyId) return;
selected.textContent = '已选择:' + button.getAttribute('data-join-genealogy-name');
Array.prototype.forEach.call(options.querySelectorAll('[data-join-genealogy-id]'), function (item) {
item.classList.toggle('is-selected', item === button);
});
});
applyForm.addEventListener('submit', async function (event) {
var body;
var validation;
var created;
var refreshed;
event.preventDefault();
if (writePending) return;
if (!selectedGenealogyId) {
setText('[data-join-apply-status]', '请先从家谱列表中选择要加入的家谱');
return;
}
body = buildJoinApplyBody(getFormValues(applyForm));
validation = validateJoinApplyBody(body);
if (validation) {
setText('[data-join-apply-status]', validation);
return;
}
writePending = true;
applyForm.querySelector('[data-join-apply-submit]').disabled = true;
setText('[data-join-apply-status]', '正在提交申请…');
try {
created = normalizeJoinApply(await api.applyToGenealogy(selectedGenealogyId, body));
if (!created) throw new Error('申请响应缺少有效申请信息');
refreshed = normalizeJoinApplies(await api.myGenealogyJoinApplies());
if (!refreshed.some(function (item) { return item.applyId === created.applyId; })) {
throw new Error('提交后无法读取同一申请');
}
if (root.location) root.location.href = 'profile-join-family.html';
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setText('[data-join-apply-status]', error.message || '提交加入申请失败,请稍后重试');
} finally {
writePending = false;
applyForm.querySelector('[data-join-apply-submit]').disabled = false;
}
});
await loadOptions('');
}
async function initMyJoinAppliesPage() {
var page = root.document && root.document.querySelector('[data-my-join-applies-page]');
var list = root.document && root.document.querySelector('[data-join-apply-list="mine"]');
var refresh = root.document && root.document.querySelector('[data-join-apply-refresh]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var writePending = false;
if (!page) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
async function loadApplies() {
var data;
setText('[data-my-join-applies-status]', '正在读取申请记录…');
try {
data = await api.myGenealogyJoinApplies();
list.innerHTML = renderMyJoinApplies(data);
setText('[data-my-join-applies-status]', '');
return normalizeJoinApplies(data);
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return [];
}
list.innerHTML = '<div class="api-empty">读取加入申请失败</div>';
setText('[data-my-join-applies-status]', error.message || '读取加入申请失败,请稍后重试');
return [];
}
}
refresh.addEventListener('click', function () {
loadApplies();
});
list.addEventListener('click', async function (event) {
var button = event.target.closest('[data-join-cancel-id]');
var applyId;
var refreshed;
if (!button || !list.contains(button) || writePending) return;
applyId = normalizeId(button.getAttribute('data-join-cancel-id'));
if (!applyId) return;
if (root.confirm && !root.confirm('确认撤销这条加入申请吗?')) return;
writePending = true;
button.disabled = true;
setText('[data-my-join-applies-status]', '正在撤销申请…');
try {
await api.cancelGenealogyJoinApply(applyId);
refreshed = await loadApplies();
if (refreshed.some(function (item) { return item.applyId === applyId && item.status === '0'; })) {
throw new Error('撤销后申请仍处于待审核状态');
}
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setText('[data-my-join-applies-status]', error.message || '撤销加入申请失败,请稍后重试');
} finally {
writePending = false;
}
});
await loadApplies();
}
async function init() {
if (root.document && root.document.querySelector('[data-join-apply-page]')) {
await initJoinApplyPage();
return;
}
if (root.document && root.document.querySelector('[data-my-join-applies-page]')) {
await initMyJoinAppliesPage();
}
}
return {
buildJoinApplyBody: buildJoinApplyBody,
validateJoinApplyBody: validateJoinApplyBody,
normalizeJoinGenealogy: normalizeJoinGenealogy,
normalizeJoinApply: normalizeJoinApply,
normalizeJoinApplies: normalizeJoinApplies,
renderJoinGenealogyOptions: renderJoinGenealogyOptions,
renderMyJoinApplies: renderMyJoinApplies,
canJoinGenealogy: canJoinGenealogy,
initJoinApplyPage: initJoinApplyPage,
initMyJoinAppliesPage: initMyJoinAppliesPage,
init: init
};
});
+250
View File
@@ -0,0 +1,250 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.JoinReviewPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.JoinReviewPages.initJoinReviewPage();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
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 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 optionalText(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function getCurrentGenealogyId(search) {
var contextId;
var params;
if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) {
contextId = root.ProfileUI.getGenealogyId();
return normalizeId(contextId);
}
params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return normalizeId(params.get('genealogyId'));
}
function buildJoinAuditBody(values) {
var source = values || {};
var body = { status: String(source.status === undefined || source.status === null ? '' : source.status).trim() };
var auditRemark = optionalText(source.auditRemark);
if (auditRemark) body.auditRemark = auditRemark;
return body;
}
function validateJoinAuditBody(body) {
if (!body || ['1', '2'].indexOf(body.status) < 0) return '审核状态无效';
if (String(body.auditRemark || '').length > 500) return '审核说明不能超过 500 个字符';
return '';
}
function normalizePendingApply(item) {
var source = item || {};
var applyId = normalizeId(source.applyId);
var genealogyId = normalizeId(source.genealogyId);
if (!applyId || !genealogyId || String(source.status) !== '0') return null;
return {
applyId: applyId,
genealogyId: genealogyId,
genealogyName: optionalText(source.genealogyName),
applicantName: optionalText(source.applicantName) || optionalText(source.appUserNickName) || '未填写姓名',
phone: optionalText(source.phone),
relationDesc: optionalText(source.relationDesc),
applyReason: optionalText(source.applyReason)
};
}
function normalizePendingApplies(data) {
var items;
if (!Array.isArray(data)) return [];
items = data.map(normalizePendingApply);
return items.some(function (item) { return !item; }) ? [] : items;
}
function renderPendingJoinApplies(data, canManage) {
var items = normalizePendingApplies(data);
if (!items.length) return '<div class="api-empty">当前没有待审核申请</div>';
return items.map(function (item) {
var details = [
item.phone ? '申请联系电话:' + item.phone : '',
item.relationDesc ? '关系:' + item.relationDesc : '',
item.applyReason ? '理由:' + item.applyReason : ''
].filter(Boolean).join(' · ');
var actions = canManage
? '<div class="bottom-actions"><button class="btn primary" type="button" data-join-audit-id="' +
escapeHtml(item.applyId) + '" data-join-audit-status="1">通过</button>' +
'<button class="btn ghost" type="button" data-join-audit-id="' +
escapeHtml(item.applyId) + '" data-join-audit-status="2">拒绝</button></div>'
: '';
return '<article class="module-row"><div><h3>' + escapeHtml(item.applicantName) +
'申请加入' + escapeHtml(item.genealogyName || '当前家谱') + '</h3><p>' +
escapeHtml(details || '申请人未补充说明') + '</p></div>' + actions + '</article>';
}).join('');
}
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 setStatus(message) {
var element = root.document && root.document.querySelector('[data-join-review-status]');
if (element) element.textContent = message || '';
}
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 api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var genealogyId = getCurrentGenealogyId();
var canManage = false;
var writePending = false;
if (!page) return;
if (!genealogyId) {
setStatus('请先从“我的家谱”选择要管理的家谱');
list.innerHTML = '<a class="module-row" href="profile-families.html?next=profile-join-review.html">选择家谱后继续</a>';
return;
}
if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) {
root.ProfileUI.syncGenealogyContextLinks();
}
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
async function loadPending() {
var data;
setStatus('正在读取待审核申请…');
try {
data = await api.pendingGenealogyJoinApplies(genealogyId);
list.innerHTML = renderPendingJoinApplies(data, canManage);
setStatus(canManage ? '' : '当前账号没有审核该家谱申请的权限');
return normalizePendingApplies(data);
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return [];
}
list.innerHTML = '<div class="api-empty">读取待审核申请失败</div>';
setStatus(error.message || '读取待审核申请失败,请稍后重试');
return [];
}
}
try {
var detail = await api.genealogyDetail(genealogyId);
canManage = detail && detail.canManage === true;
if (!canManage) {
list.innerHTML = '<div class="api-empty">当前账号没有审核该家谱申请的权限</div>';
setStatus('当前账号没有审核该家谱申请的权限');
return;
}
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '无法核验当前家谱管理权限');
return;
}
refresh.addEventListener('click', function () {
loadPending();
});
list.addEventListener('click', async 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 个字符)', '') || '';
}
body = buildJoinAuditBody({ status: status, auditRemark: auditRemark });
validation = validateJoinAuditBody(body);
if (validation) {
setStatus(validation);
return;
}
writePending = true;
button.disabled = true;
setStatus('正在提交审核结果…');
try {
await api.auditGenealogyJoinApply(genealogyId, applyId, body);
refreshed = await loadPending();
if (refreshed.some(function (item) { return item.applyId === applyId; })) {
throw new Error('审核后申请仍在待审核列表');
}
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '提交审核结果失败,请稍后重试');
} finally {
writePending = false;
}
});
await loadPending();
}
return {
getCurrentGenealogyId: getCurrentGenealogyId,
buildJoinAuditBody: buildJoinAuditBody,
validateJoinAuditBody: validateJoinAuditBody,
normalizePendingApply: normalizePendingApply,
renderPendingJoinApplies: renderPendingJoinApplies,
initJoinReviewPage: initJoinReviewPage
};
});
+10 -6
View File
@@ -341,16 +341,19 @@
'<strong>' + escapeHtml(person.name) + '</strong><span>' + escapeHtml(getPersonSummary(person)) + '</span></button>' + branches + '</li>';
}
function renderTree(data) {
var container = query('[data-lineage-tree]');
function renderLineageTreeHtml(data) {
var nodes = normalizeList(data).map(function (item) { return renderTreeNode(item, {}); }).filter(Boolean);
if (!container) return;
if (!nodes.length) {
container.innerHTML = '<div class="api-empty">暂无世系树</div>';
return;
return '<div class="api-empty">暂无世系树</div>';
}
container.innerHTML = '<ul class="lineage-tree">' + nodes.join('') + '</ul>';
return '<ul class="lineage-tree">' + nodes.join('') + '</ul>';
}
function renderTree(data) {
var container = query('[data-lineage-tree]');
if (container) container.innerHTML = renderLineageTreeHtml(data);
}
function renderOptions(data) {
@@ -962,6 +965,7 @@
normalizeLineageBinding: normalizeLineageBinding,
buildLineagePageQuery: buildLineagePageQuery,
normalizeList: normalizeList,
renderLineageTreeHtml: renderLineageTreeHtml,
relationLabel: relationLabel,
shouldRedirectToLogin: shouldRedirectToLogin,
isForbidden: isForbidden,
+252
View File
@@ -0,0 +1,252 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.SiteNewsPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.SiteNewsPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var ARTICLE_TYPE_LABELS = {
news: '新闻',
notice: '公告',
download: '下载'
};
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 text(value) {
return value === undefined || value === null ? '' : String(value).trim();
}
function normalizeId(value) {
var normalized;
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
if (value === undefined || value === null) return '';
normalized = String(value).trim();
return /^[1-9][0-9]*$/.test(normalized) ? normalized : '';
}
function normalizeArticleType(value) {
var normalized = text(value);
return Object.prototype.hasOwnProperty.call(ARTICLE_TYPE_LABELS, normalized)
? normalized
: '';
}
function normalizeExternalUrl(value) {
var normalized = text(value);
var parsed;
if (!normalized) return '';
try {
parsed = new URL(normalized);
} catch (error) {
return '';
}
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
}
function normalizeArticle(item) {
var source = item || {};
var articleId = normalizeId(source.articleId);
var articleType = normalizeArticleType(source.articleType);
var articleTitle = text(source.articleTitle);
if (!articleId || !articleType || !articleTitle || String(source.status) !== '0') {
return null;
}
return {
articleId: articleId,
articleType: articleType,
articleTitle: articleTitle,
articleSummary: text(source.articleSummary),
articleContent: text(source.articleContent),
externalUrl: normalizeExternalUrl(source.externalUrl),
publishTime: text(source.publishTime)
};
}
function normalizeArticleList(data) {
var items;
if (!Array.isArray(data)) return [];
items = data.map(normalizeArticle);
return items.some(function (item) { return !item; }) ? [] : items;
}
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) + '">' +
'<small>' + escapeHtml(ARTICLE_TYPE_LABELS[item.articleType]) + '</small>' +
'<h2>' + escapeHtml(item.articleTitle) + '</h2>' +
'<p>' + escapeHtml(item.articleSummary) + '</p>' +
'<span>' + escapeHtml(item.publishTime) + '</span></a>';
}).join('');
}
function renderArticleList(data) {
return renderNormalizedArticleList(normalizeArticleList(data));
}
function renderNormalizedArticleDetail(item) {
var externalLink = item.externalUrl
? '<a class="btn ghost" data-external-link href="' + escapeHtml(item.externalUrl) +
'" target="_blank" rel="noopener noreferrer">查看外部内容</a>'
: '';
var content = escapeHtml(item.articleContent).replace(/\r?\n/g, '<br />');
return '<section class="inner-hero article-hero"><div class="container">' +
'<div class="hero-copy"><div class="eyebrow">' +
escapeHtml(ARTICLE_TYPE_LABELS[item.articleType]) + '</div><h1>' +
escapeHtml(item.articleTitle) + '</h1><p class="lead">' +
escapeHtml(item.articleSummary) + '</p><div class="article-meta"><span>' +
escapeHtml(item.publishTime) + '</span></div></div>' +
'<div class="hero-card article-cover-detail tilt-card"><span>闻</span><p>' +
escapeHtml(item.articleSummary) + '</p></div></div></section>' +
'<section class="section alt"><div class="container article-layout">' +
'<article class="article-content tilt-card"><p>' + content + '</p>' +
externalLink + '</article><aside class="article-side tilt-card"><h3>相关入口</h3>' +
'<a href="news.html">返回新闻资讯</a><a href="genealogy.html">了解数字家谱</a>' +
'<a href="create-genealogy.html">创建一本家谱</a></aside></div></section>';
}
function renderArticleDetail(item) {
var article = normalizeArticle(item);
return article
? renderNormalizedArticleDetail(article)
: '<div class="api-empty">资讯不存在或已下线</div>';
}
function readQueryValue(search, key) {
var params = new URLSearchParams(String(search || '').replace(/^\?/, ''));
return text(params.get(key));
}
function readArticleId(search) {
return normalizeId(readQueryValue(search, 'articleId'));
}
async function loadArticles(api, articleType) {
var requestedType = text(articleType);
var normalizedType = normalizeArticleType(requestedType);
var query = { limit: 100 };
var data;
var items;
if (!api || typeof api.siteArticles !== 'function') throw new Error('资讯接口不可用');
if (requestedType && !normalizedType) throw new Error('资讯分类无效');
if (normalizedType) query.articleType = normalizedType;
data = await api.siteArticles(query);
items = normalizeArticleList(data);
if (!Array.isArray(data) || items.length !== data.length) {
throw new Error('资讯列表响应无效');
}
return items;
}
async function loadArticleDetail(api, targetArticleId) {
var normalizedId = normalizeId(targetArticleId);
var items;
var match;
if (!normalizedId) throw new Error('资讯编号无效');
items = await loadArticles(api, '');
match = items.find(function (item) {
return item.articleId === normalizedId;
});
if (!match) throw new Error('资讯不存在或已下线');
return match;
}
async function init() {
var document = root.document;
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var search = root.location && root.location.search || '';
var listPage = document && document.querySelector('[data-site-news-page]');
var detailPage = document && document.querySelector('[data-site-article-page]');
var list;
var status;
var requestedType;
var detail;
var articleId;
if (listPage) {
list = document.querySelector('[data-site-news-list]');
status = document.querySelector('[data-site-news-status]');
requestedType = readQueryValue(search, 'articleType');
if (!list) return;
if (requestedType && !normalizeArticleType(requestedType)) {
list.innerHTML = '<div class="api-empty">资讯分类无效</div>';
if (status) status.textContent = '资讯分类无效';
return;
}
try {
list.classList.add('is-loading');
list.innerHTML = renderNormalizedArticleList(await loadArticles(api, requestedType));
if (status) status.textContent = '';
} catch (error) {
list.innerHTML = '<div class="api-empty">资讯读取失败,请稍后重试</div>';
if (status) status.textContent = error.message || '资讯读取失败';
} finally {
list.classList.remove('is-loading');
}
return;
}
if (detailPage) {
detail = document.querySelector('[data-site-article-detail]');
status = document.querySelector('[data-site-article-status]');
articleId = readArticleId(search);
if (!detail) return;
if (!articleId) {
detail.innerHTML = '<div class="api-empty">资讯编号无效</div>';
if (status) status.textContent = '资讯编号无效';
return;
}
try {
detail.classList.add('is-loading');
detail.innerHTML = renderNormalizedArticleDetail(await loadArticleDetail(api, articleId));
if (status) status.textContent = '';
} catch (error) {
detail.innerHTML = '<div class="api-empty">' +
escapeHtml(error.message || '资讯读取失败,请稍后重试') + '</div>';
if (status) status.textContent = error.message || '资讯读取失败';
} finally {
detail.classList.remove('is-loading');
}
}
}
return {
normalizeArticle: normalizeArticle,
normalizeArticleList: normalizeArticleList,
normalizeArticleType: normalizeArticleType,
normalizeExternalUrl: normalizeExternalUrl,
readArticleId: readArticleId,
renderArticleList: renderArticleList,
renderArticleDetail: renderArticleDetail,
loadArticles: loadArticles,
loadArticleDetail: loadArticleDetail,
init: init
};
});
+414
View File
@@ -0,0 +1,414 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.VipPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.VipPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var PACKAGE_TYPES = {
vip: '会员套餐',
storage: '存储扩容'
};
var DURATION_UNITS = {
permanent: '永久',
day: '天',
month: '个月',
year: '年'
};
var PAY_STATUS = {
'0': '待支付',
'1': '已支付',
'2': '已关闭',
'3': '已退款'
};
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 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 optionalText(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function moneyText(value, optional) {
var text;
if ((value === undefined || value === null || value === '') && optional) return '';
if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) return null;
text = String(value === undefined || value === null ? '' : value).trim();
return /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(text) ? text : null;
}
function validLimit(value) {
return value === undefined || value === null ||
(Number.isSafeInteger(Number(value)) && Number(value) >= 0);
}
function normalizeVipPackage(item) {
var source = item || {};
var packageId = normalizeId(source.packageId);
var packageName = optionalText(source.packageName);
var packageType = optionalText(source.packageType);
var durationUnit = optionalText(source.durationUnit);
var price = moneyText(source.price, false);
var originalPrice = moneyText(source.originalPrice, true);
var status = String(source.status === undefined || source.status === null ? '' : source.status);
if (!packageId || !packageName ||
!Object.prototype.hasOwnProperty.call(PACKAGE_TYPES, packageType) ||
!Object.prototype.hasOwnProperty.call(DURATION_UNITS, durationUnit) ||
price === null || originalPrice === null || status !== '0' ||
!validLimit(source.durationValue) || !validLimit(source.genealogyLimit) ||
!validLimit(source.memberLimit) || !validLimit(source.storageLimitMb)) {
return null;
}
return {
packageId: packageId,
packageName: packageName,
packageType: packageType,
packageDesc: optionalText(source.packageDesc),
price: price,
originalPrice: originalPrice,
durationValue: source.durationValue === undefined || source.durationValue === null ? null : Number(source.durationValue),
durationUnit: durationUnit,
genealogyLimit: source.genealogyLimit === undefined || source.genealogyLimit === null ? null : Number(source.genealogyLimit),
memberLimit: source.memberLimit === undefined || source.memberLimit === null ? null : Number(source.memberLimit),
storageLimitMb: source.storageLimitMb === undefined || source.storageLimitMb === null ? null : Number(source.storageLimitMb),
remark: optionalText(source.remark)
};
}
function normalizeVipPackages(data) {
if (!Array.isArray(data)) return [];
return data.map(normalizeVipPackage).filter(Boolean);
}
function normalizeGenealogyOption(item) {
var source = item || {};
var genealogyId = normalizeId(source.genealogyId);
var genealogyName = optionalText(source.genealogyName);
if (!genealogyId || !genealogyName) return null;
return {
genealogyId: genealogyId,
genealogyName: genealogyName,
surname: optionalText(source.surname)
};
}
function buildVipOrderBody(values) {
var source = values || {};
var body = {};
var packageId = normalizeId(source.packageId);
var genealogyId = normalizeId(source.genealogyId);
var payType = optionalText(source.payType);
if (packageId) body.packageId = packageId;
else if (source.packageId !== undefined) body.invalidPackageId = true;
if (genealogyId) body.genealogyId = genealogyId;
else if (source.genealogyId) body.invalidGenealogyId = true;
if (payType) body.payType = payType;
return body;
}
function validateVipOrderBody(body) {
if (!body || !normalizeId(body.packageId) || body.invalidPackageId) return '请选择有效会员套餐';
if (body.invalidGenealogyId ||
(body.genealogyId !== undefined && body.genealogyId !== null &&
String(body.genealogyId).trim() !== '' && !normalizeId(body.genealogyId))) {
return '家谱选项无效';
}
if (body.payType && body.payType !== 'wechat') return '支付方式无效';
return '';
}
function normalizeVipOrder(item) {
var source = item || {};
var orderId = normalizeId(source.orderId);
var packageId = normalizeId(source.packageId);
var genealogyId = source.genealogyId === undefined || source.genealogyId === null
? ''
: normalizeId(source.genealogyId);
var orderAmount = moneyText(source.orderAmount, false);
var payAmount = moneyText(source.payAmount, false);
var payStatus = String(source.payStatus === undefined || source.payStatus === null ? '' : source.payStatus);
var status = String(source.status === undefined || source.status === null ? '' : source.status);
if (!orderId || !packageId || !optionalText(source.orderNo) ||
!optionalText(source.packageName) ||
(source.genealogyId !== undefined && source.genealogyId !== null && !genealogyId) ||
orderAmount === null || payAmount === null ||
!Object.prototype.hasOwnProperty.call(PAY_STATUS, payStatus) ||
['0', '1'].indexOf(status) < 0) {
return null;
}
return {
orderId: orderId,
orderNo: optionalText(source.orderNo),
packageId: packageId,
packageName: optionalText(source.packageName),
genealogyId: genealogyId,
genealogyNo: optionalText(source.genealogyNo),
genealogyName: optionalText(source.genealogyName),
orderAmount: orderAmount,
payAmount: payAmount,
payType: optionalText(source.payType),
payStatus: payStatus,
payTime: optionalText(source.payTime),
expireTime: optionalText(source.expireTime),
status: status,
remark: optionalText(source.remark)
};
}
function normalizeVipOrders(data) {
if (!Array.isArray(data)) return [];
return data.map(normalizeVipOrder).filter(Boolean);
}
function renderVipPackages(data, selectedPackageId) {
var items = Array.isArray(data) ? data : [];
var selectedId = normalizeId(selectedPackageId);
if (!items.length) return '<div class="api-empty">当前没有可用会员套餐</div>';
return items.map(function (item) {
var duration = item.durationUnit === 'permanent'
? DURATION_UNITS[item.durationUnit]
: (item.durationValue === null ? '' : item.durationValue) + DURATION_UNITS[item.durationUnit];
var limits = [
item.genealogyLimit === null ? '' : '家谱 ' + item.genealogyLimit + ' 部',
item.memberLimit === null ? '' : '成员 ' + item.memberLimit + ' 人',
item.storageLimitMb === null ? '' : '存储 ' + item.storageLimitMb + ' MB'
].filter(Boolean).join(' · ');
return '<button class="module-card' + (item.packageId === selectedId ? ' is-selected' : '') +
'" type="button" data-vip-package-id="' + escapeHtml(item.packageId) +
'" data-vip-package-name="' + escapeHtml(item.packageName) + '"><span class="icon">会</span><h3>' +
escapeHtml(item.packageName) + '</h3><p>' +
escapeHtml([PACKAGE_TYPES[item.packageType], item.packageDesc, duration, limits].filter(Boolean).join(' · ')) +
'</p><strong>¥' + escapeHtml(item.price) + '</strong></button>';
}).join('');
}
function renderGenealogyOptions(data) {
var items = Array.isArray(data) ? data.map(normalizeGenealogyOption).filter(Boolean) : [];
return '<option value="">不关联具体家谱</option>' + items.map(function (item) {
return '<option value="' + escapeHtml(item.genealogyId) + '">' +
escapeHtml(item.genealogyName + (item.surname ? ' · ' + item.surname + '氏' : '')) +
'</option>';
}).join('');
}
function renderVipOrders(data) {
var items = normalizeVipOrders(data);
if (!items.length) return '<div class="api-empty">当前没有会员订单</div>';
return items.map(function (item) {
var details = [
item.orderNo,
item.genealogyName,
'订单金额 ¥' + item.orderAmount,
'应付 ¥' + item.payAmount,
item.payType ? '支付方式 ' + item.payType : '',
item.payTime ? '支付时间 ' + item.payTime : '',
item.expireTime ? '有效期至 ' + item.expireTime : '',
item.remark
].filter(Boolean).join(' · ');
return '<article class="module-row"><div><h3>' + escapeHtml(item.packageName) +
'</h3><p>' + escapeHtml(details) + '</p></div><span class="pill">' +
PAY_STATUS[item.payStatus] + '</span></article>';
}).join('');
}
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';
}
async function init() {
var page = root.document && root.document.querySelector('[data-vip-page]');
var packageList = root.document && root.document.querySelector('[data-vip-package-list]');
var selectedText = root.document && root.document.querySelector('[data-vip-selected-package]');
var genealogySelect = root.document && root.document.querySelector('[data-vip-genealogy-options]');
var form = root.document && root.document.querySelector('[data-vip-order-form]');
var submit = root.document && root.document.querySelector('[data-vip-order-submit]');
var status = root.document && root.document.querySelector('[data-vip-order-status]');
var refresh = root.document && root.document.querySelector('[data-vip-order-refresh]');
var orderList = root.document && root.document.querySelector('[data-vip-order-list]');
var api = root.GenealogyApi && root.GenealogyApi.defaultClient;
var packages = [];
var selectedPackageId = '';
var writePending = false;
if (!page) return;
if (shouldRedirectToLogin(api)) {
redirectToLogin(api);
return;
}
if (!packageList || !selectedText || !genealogySelect || !form || !orderList ||
!api.vipPackages || !api.genealogiesMine || !api.vipOrders || !api.createVipOrder) {
if (status) status.textContent = '会员服务接口初始化失败,请刷新后重试';
return;
}
function setStatus(message) {
if (status) status.textContent = message;
}
function renderPackages() {
packageList.innerHTML = renderVipPackages(packages, selectedPackageId);
}
function renderOrders(orders) {
orderList.innerHTML = renderVipOrders(orders);
}
async function loadOrders() {
var orders = normalizeVipOrders(await api.vipOrders());
renderOrders(orders);
return orders;
}
try {
var initial = await Promise.all([api.vipPackages(), api.genealogiesMine(), api.vipOrders()]);
packages = normalizeVipPackages(initial[0]);
renderPackages();
genealogySelect.innerHTML = renderGenealogyOptions(initial[1]);
renderOrders(initial[2]);
setStatus(packages.length ? '请选择会员套餐' : '当前没有可用会员套餐');
} 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 || '会员服务读取失败,请稍后重试');
return;
}
packageList.addEventListener('click', function (event) {
var button = event.target.closest('[data-vip-package-id]');
var packageId;
var selected;
if (!button || !packageList.contains(button)) return;
packageId = normalizeId(button.getAttribute('data-vip-package-id'));
selected = packages.find(function (item) { return item.packageId === packageId; });
if (!selected) return;
selectedPackageId = selected.packageId;
selectedText.textContent = '已选择:' + selected.packageName + '(¥' + selected.price + '';
renderPackages();
setStatus('可以创建会员订单');
});
if (refresh) {
refresh.addEventListener('click', async function () {
if (writePending) return;
refresh.disabled = true;
setStatus('正在刷新订单…');
try {
await loadOrders();
setStatus('会员订单已刷新');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '会员订单刷新失败,请稍后重试');
} finally {
refresh.disabled = false;
}
});
}
form.addEventListener('submit', async function (event) {
var body;
var validation;
var created;
var refreshed;
var verified;
event.preventDefault();
if (writePending) return;
body = buildVipOrderBody({
packageId: selectedPackageId,
genealogyId: genealogySelect.value
});
validation = validateVipOrderBody(body);
if (validation) {
setStatus(validation);
return;
}
writePending = true;
if (submit) submit.disabled = true;
setStatus('正在创建会员订单…');
try {
created = normalizeVipOrder(await api.createVipOrder(body));
if (!created) throw new Error('创建响应缺少有效会员订单信息');
refreshed = normalizeVipOrders(await api.vipOrders());
verified = refreshed.find(function (item) { return item.orderId === created.orderId; });
if (!verified) throw new Error('订单已提交,但重新读取未找到对应记录');
renderOrders(refreshed);
setStatus('订单已创建;当前 PC 暂未开放在线支付');
} catch (error) {
if (shouldRedirectToLogin(api, error)) {
redirectToLogin(api);
return;
}
setStatus(error.message || '会员订单创建失败,请稍后重试');
} finally {
writePending = false;
if (submit) submit.disabled = false;
}
});
}
return {
normalizeVipPackage: normalizeVipPackage,
normalizeVipPackages: normalizeVipPackages,
normalizeGenealogyOption: normalizeGenealogyOption,
buildVipOrderBody: buildVipOrderBody,
validateVipOrderBody: validateVipOrderBody,
normalizeVipOrder: normalizeVipOrder,
normalizeVipOrders: normalizeVipOrders,
renderVipPackages: renderVipPackages,
renderGenealogyOptions: renderGenealogyOptions,
renderVipOrders: renderVipOrders,
init: init
};
});
+11 -9
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/submit-ticket.css" />
</head>
<body class="page-submit-ticket" data-feature-status="pending" data-feature-message="在线提交工单服务正在开发中,请先通过帮助中心了解常见问题。">
<body class="page-submit-ticket" data-feedback-page="ticket">
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html"
@@ -48,24 +48,26 @@
<div class="container ticket-layout">
<form class="card form-card ticket-form tilt-card" data-feedback-form>
<h2>工单信息</h2>
<input class="input" name="feedbackTitle" placeholder="联系人姓名" />
<input class="input" name="contactInfo" placeholder="手机号 / 邮箱" />
<select name="feedbackType">
<option value="account">账号登录问题</option>
<option value="genealogy">家谱资料问题</option>
<option value="privacy">权限与隐私问题</option>
<option value="display">页面展示问题</option>
<option value="advice">意见建议</option>
<option value="bug">问题反馈</option>
<option value="complaint">投诉反馈</option>
<option value="other">其他反馈</option>
</select>
<textarea
name="feedbackContent"
required
placeholder="请描述遇到的问题、页面位置和希望处理的结果"
></textarea>
<button class="btn primary magnetic" type="submit">提交工单</button>
<p class="form-note" aria-live="polite" data-feedback-status></p>
<button class="btn primary magnetic" type="submit" data-feedback-submit>提交工单</button>
</form>
<aside class="ticket-side tilt-card">
<h3>提交前可准备</h3>
<p>家谱名称、问题页面、账号联系方式、截图说明和期望处理结果。</p>
<a href="help.html" data-feature-link="available">返回帮助中心</a>
<a href="help.html">返回帮助中心</a>
<a href="my-tickets.html">查看我的工单</a>
</aside>
</div>
</section>
@@ -110,7 +112,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/feedback-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+174
View File
@@ -32,6 +32,10 @@ test('API client exposes latest document-defined PC operations', () => {
'genealogiesMine', 'genealogyOptions', 'genealogyDetail', 'genealogyOverview', 'genealogyQuota',
'createGenealogy', 'applyToGenealogy', 'myGenealogyJoinApplies',
'pendingGenealogyJoinApplies', 'auditGenealogyJoinApply', 'cancelGenealogyJoinApply',
'submitFeedback', 'myFeedback',
'helpArticles', 'helpArticleDetail', 'siteArticles',
'promotions',
'vipPackages', 'createVipOrder', 'vipOrders',
'notifications', 'notificationDetail', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
@@ -506,6 +510,110 @@ test('genealogy quota uses the current PC path without manufacturing a genealogy
await client.genealogyQuota();
});
test('help article methods use authenticated PC paths and whitelist the optional category', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.helpArticles({ helpCategory: ' account ', keyword: 'must-drop' });
await client.helpArticleDetail('2062179707935264769');
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.params,
config.headers.Authorization
]), [
['get', '/genealogy/pc/help-articles', { helpCategory: ' account ' }, 'Bearer access-token'],
['get', '/genealogy/pc/help-articles/2062179707935264769', undefined, 'Bearer access-token']
]);
});
test('promotion method uses the authenticated PC path and whitelists platform query', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.promotions({
platform: 'pc',
keyword: 'must-drop'
});
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.params,
config.headers.Authorization
]), [
['get', '/genealogy/pc/promotions', { platform: 'pc' }, 'Bearer access-token']
]);
assert.throws(
() => client.promotions({ platform: 'desktop' }),
/不支持的推广平台/
);
});
test('site article method uses the public PC path and validates SQL query values', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: {
getItem() { return 'access-token'; },
setItem() {},
removeItem() {}
},
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.siteArticles({
articleType: 'notice',
limit: 100,
keyword: 'must-drop'
});
await client.siteArticles({ limit: 100 });
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.params,
config.headers.Authorization
]), [
['get', '/genealogy/pc/site/articles', { articleType: 'notice', limit: 100 }, undefined],
['get', '/genealogy/pc/site/articles', { limit: 100 }, undefined]
]);
assert.throws(
() => client.siteArticles({ articleType: 'culture', limit: 100 }),
/不支持的资讯类型/
);
[0, -1, 1.5, 101, '100'].forEach((limit) => {
assert.throws(
() => client.siteArticles({ limit }),
/资讯数量限制/
);
});
});
test('genealogy context methods obtain IDs from the real PC list and detail paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -613,6 +721,72 @@ test('genealogy lifecycle methods use PC paths and strict request bodies', async
);
});
test('feedback methods use the PC collection path and AppFeedbackBody whitelist', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.submitFeedback({
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
});
await client.myFeedback();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['post', '/genealogy/pc/feedback', {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
}],
['get', '/genealogy/pc/feedback', undefined]
]);
});
test('VIP methods use PC package and order paths with AppVipOrderBody whitelist', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.vipPackages();
await client.createVipOrder({
packageId: '2062179707935264769',
genealogyId: '2062179707935264770',
payType: 'wechat',
appUserId: 'must-drop',
payStatus: 'must-drop'
});
await client.vipOrders();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/vip/packages', undefined],
['post', '/genealogy/pc/vip/orders', {
packageId: '2062179707935264769',
genealogyId: '2062179707935264770',
payType: 'wechat'
}],
['get', '/genealogy/pc/vip/orders', undefined]
]);
});
test('notification methods use the current PC list, unread and read paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
+136
View File
@@ -0,0 +1,136 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const AppPromotionPages = require('../public/js/app-promotion-pages.js');
const appPage = fs.readFileSync(path.resolve(__dirname, '../app.html'), 'utf8');
const promotionId = '2062179707935264769';
function promotionFixture(overrides) {
return Object.assign({
promotionId,
promotionKey: 'app-download',
promotionTitle: '下载移动端',
promotionDesc: '随时查看家谱内容',
coverOssId: '2062179707935264701',
targetUrl: 'https://example.com/download',
platform: 'all',
sortOrder: 10,
status: '0',
remark: 'internal-only'
}, overrides);
}
test('推广响应只保留 AppPromotionVo 的安全展示字段并保持长 ID 字符串', () => {
assert.deepEqual(AppPromotionPages.normalizePromotion(promotionFixture()), {
promotionId,
promotionTitle: '下载移动端',
promotionDesc: '随时查看家谱内容',
targetUrl: 'https://example.com/download'
});
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({
promotionId: Number.MAX_SAFE_INTEGER + 1
})), null);
});
test('推广响应拒绝停用记录和空标题', () => {
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({ status: '1' })), null);
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({ promotionTitle: ' ' })), null);
});
test('推广列表只接受直接数组且任一非法元素使整批失败', () => {
assert.equal(AppPromotionPages.normalizePromotionList([promotionFixture()]).length, 1);
assert.deepEqual(AppPromotionPages.normalizePromotionList([]), []);
assert.deepEqual(AppPromotionPages.normalizePromotionList({
rows: [promotionFixture()]
}), []);
assert.deepEqual(AppPromotionPages.normalizePromotionList([
promotionFixture(),
promotionFixture({ promotionId: '' })
]), []);
});
test('推广链接只允许绝对 HTTP 或 HTTPS 地址', () => {
assert.equal(
AppPromotionPages.normalizeTargetUrl(' https://example.com/download '),
'https://example.com/download'
);
assert.equal(AppPromotionPages.normalizeTargetUrl('http://example.com'), 'http://example.com/');
['javascript:alert(1)', 'data:text/html,test', '/download', 'download.html', ''].forEach((value) => {
assert.equal(AppPromotionPages.normalizeTargetUrl(value), '');
});
});
test('推广卡片转义响应并隐藏 OSS ID、后台键、排序、状态和备注', () => {
const html = AppPromotionPages.renderPromotionList([promotionFixture({
promotionTitle: '<script>alert(1)</script>',
promotionDesc: '<img src=x>',
coverOssId: 'secret-oss',
promotionKey: 'secret-key',
remark: 'secret-remark'
})]);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /&lt;img src=x&gt;/);
assert.match(html, /target="_blank"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.doesNotMatch(
html,
/<script|<img|secret-oss|secret-key|secret-remark|coverOssId|promotionKey|sortOrder|status|remark/
);
});
test('危险或缺失的推广链接渲染为不可点击卡片', () => {
const html = AppPromotionPages.renderPromotionList([
promotionFixture({ targetUrl: 'javascript:alert(1)' })
]);
assert.match(html, /^<article class="promotion-card"/);
assert.doesNotMatch(html, /<a(?:\s|>)|href=|target=|rel=/);
});
test('PC 推广列表只请求 SQL 字典确认的 pc 平台', async () => {
const calls = [];
const api = {
async promotions() {
calls.push(Array.from(arguments));
return [promotionFixture()];
}
};
assert.equal((await AppPromotionPages.loadPromotions(api)).length, 1);
assert.deepEqual(calls, [[{ platform: 'pc' }]]);
await assert.rejects(
AppPromotionPages.loadPromotions({
async promotions() {
return { rows: [promotionFixture()] };
}
}),
/推广列表响应无效/
);
});
test('推广区域只把 401 视为登录失效', () => {
assert.equal(AppPromotionPages.isUnauthorized({ status: 401 }), true);
assert.equal(AppPromotionPages.isUnauthorized({ code: '401' }), true);
assert.equal(AppPromotionPages.isUnauthorized({ status: 403 }), false);
assert.equal(AppPromotionPages.isUnauthorized(new Error('网络失败')), false);
});
test('推广区域提供明确的未登录和空列表状态', () => {
assert.match(
AppPromotionPages.renderPromotionLoginRequired(),
/<a href="login\.html">立即登录<\/a>/
);
assert.match(AppPromotionPages.renderPromotionList([]), /当前暂无应用推广/);
});
test('应用下载页只通过页面标记和新推广模块接入,不暴露业务 ID 输入', () => {
assert.match(appPage, /<body[^>]*data-promotion-page/);
assert.match(appPage, /data-promotion-list/);
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)"/);
});
+186
View File
@@ -0,0 +1,186 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const FamilyHomePages = require('../public/js/family-home-pages.js');
const genealogyId = '2062179707935264769';
const root = path.resolve(__dirname, '..');
function overviewFixture(overrides) {
return Object.assign({
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
ancestralHall: '南阳堂',
originPlace: '四川成都',
regionCode: '510100',
regionName: '成都市',
regionFullName: '四川省 成都市',
addressDetail: '高新区',
coverOssId: '2062179707935264700',
intro: '叶氏源流',
ownerUserId: '2062179707935264701',
visibility: '1',
joinMode: '1',
memberCount: 12,
personCount: 36,
status: '0',
roleType: 'owner',
canManage: true,
canEditContent: true,
memberStatus: '0',
joinTime: '2026-07-29 12:00:00'
}, overrides);
}
test('家谱主页只保留 AppGenealogyVo 的安全展示字段并匹配当前上下文', () => {
assert.deepEqual(FamilyHomePages.normalizeFamilyOverview(
overviewFixture(),
genealogyId
), {
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
ancestralHall: '南阳堂',
originPlace: '四川成都',
regionFullName: '四川省 成都市',
memberCount: 12,
personCount: 36,
canManage: true,
canEditContent: true
});
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ genealogyId: '2062179707935264770' }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ genealogyId: Number.MAX_SAFE_INTEGER + 1 }),
genealogyId
), null);
});
test('家谱主页拒绝停用、空名称和非法计数响应', () => {
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ status: '1' }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ genealogyName: '' }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ memberCount: -1 }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ personCount: Number.MAX_SAFE_INTEGER + 1 }),
genealogyId
), null);
});
test('家谱概览渲染转义文本并隐藏内部用户与文件编号', () => {
const overview = FamilyHomePages.normalizeFamilyOverview(
overviewFixture({
genealogyName: '<叶氏家谱>',
ancestralHall: '<南阳堂>'
}),
genealogyId
);
const html = FamilyHomePages.renderFamilyOverview(overview);
assert.match(html, /&lt;叶氏家谱&gt;/);
assert.match(html, /&lt;南阳堂&gt;/);
assert.match(html, /成员 12 人/);
assert.match(html, /世系人物 36 人/);
assert.doesNotMatch(html, /ownerUserId|coverOssId|206217970793526470[01]/);
});
test('家谱管理入口只接受后端布尔能力字段', () => {
assert.equal(FamilyHomePages.shouldShowFamilyManagement(
FamilyHomePages.normalizeFamilyOverview(overviewFixture({ canManage: true }), genealogyId)
), true);
assert.equal(FamilyHomePages.shouldShowFamilyManagement(
FamilyHomePages.normalizeFamilyOverview(overviewFixture({ canManage: 'true' }), genealogyId)
), false);
});
test('家谱主页只把缺失登录态和 401 视为登录失效', () => {
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return 'token'; }
}, { status: 403 }), false);
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return 'token'; }
}, { status: 401 }), true);
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return ''; }
}), true);
});
test('家谱主页只读取当前家谱概览和世系树', async () => {
const calls = [];
const tree = [{
personId: '2062179707935264770',
genealogyId,
name: '始祖',
spouses: [],
children: []
}];
const result = await FamilyHomePages.loadFamilyHome({
genealogyOverview(receivedId) {
calls.push(['overview', receivedId]);
return Promise.resolve(overviewFixture());
},
lineageTree(receivedId) {
calls.push(['tree', receivedId]);
return Promise.resolve(tree);
}
}, genealogyId);
assert.deepEqual(calls, [
['overview', genealogyId],
['tree', genealogyId]
]);
assert.deepEqual(result.overview, FamilyHomePages.normalizeFamilyOverview(
overviewFixture(),
genealogyId
));
assert.deepEqual(result.tree, tree);
});
test('家谱主页开放真实只读概览且不制造邀请或聚合统计入口', () => {
const source = fs.readFileSync(path.join(root, 'profile-family-home.html'), 'utf8');
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.doesNotMatch(source, /四川武胜汤氏族/);
assert.doesNotMatch(source, /href="profile-invite\.html"/);
assert.match(source, /PC 暂未开放邀请/);
assert.match(source, /data-family-home-title/);
assert.match(source, /data-family-home-overview/);
assert.match(source, /data-family-home-member-count/);
assert.match(source, /data-family-home-person-count/);
assert.match(source, /data-family-home-management/);
assert.match(source, /data-lineage-home-tree/);
assert.match(source, /src="public\/js\/lineage-pages\.js"/);
assert.match(source, /src="public\/js\/family-home-pages\.js"/);
[
'profile-feed.html',
'profile-tree.html',
'profile-article.html',
'profile-album.html',
'profile-video.html',
'profile-merit.html',
'profile-ceremony.html'
].forEach((href) => {
assert.match(
source,
new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'),
`${href} 缺少家谱上下文`
);
});
});
+169
View File
@@ -0,0 +1,169 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const FeedbackPages = require('../public/js/feedback-pages.js');
const feedbackId = '2062179707935264769';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
function feedbackFixture(overrides) {
return Object.assign({
feedbackId,
appUserId: '2062179707935264701',
appUserNickName: '叶子',
appUserPhone: '19100000000',
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
handleStatus: '2',
handleResult: '已经修复',
handlerId: '2062179707935264702',
handleTime: '2026-07-29 13:00:00',
status: '0',
remark: '感谢反馈'
}, overrides);
}
test('反馈请求只构造 AppFeedbackBody 并裁剪文本', () => {
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: ' bug ',
feedbackContent: ' 页面按钮无响应 ',
contactInfo: ' 19181970173 ',
feedbackTitle: 'must-drop',
appUserId: 'must-drop',
handlerId: 'must-drop'
}), {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
});
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: '',
feedbackContent: ' 建议增加导出 ',
contactInfo: ' '
}), {
feedbackContent: '建议增加导出'
});
});
test('反馈内容必填且类型只允许后端确认的四种值', () => {
assert.equal(FeedbackPages.validateFeedbackBody({ feedbackContent: '建议增加导出' }), '');
assert.equal(FeedbackPages.validateFeedbackBody({
feedbackType: 'advice',
feedbackContent: '建议增加导出'
}), '');
assert.match(FeedbackPages.validateFeedbackBody({ feedbackContent: '' }), /反馈内容/);
assert.match(FeedbackPages.validateFeedbackBody({
feedbackType: 'suggestion',
feedbackContent: '建议增加导出'
}), /反馈类型/);
});
test('FeedbackVo 只保留用户可见字段并保持长 ID 字符串', () => {
assert.deepEqual(FeedbackPages.normalizeFeedback(feedbackFixture()), {
feedbackId,
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
handleStatus: '2',
handleResult: '已经修复',
handleTime: '2026-07-29 13:00:00',
status: '0',
remark: '感谢反馈'
});
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
feedbackId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
feedbackContent: ''
})), null);
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
handleStatus: '9'
})), null);
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
status: '9'
})), null);
});
test('反馈数组包含非法记录时整批拒绝', () => {
assert.equal(FeedbackPages.normalizeFeedbackList([feedbackFixture()]).length, 1);
assert.deepEqual(FeedbackPages.normalizeFeedbackList([
feedbackFixture(),
feedbackFixture({ feedbackId: '' })
]), []);
assert.deepEqual(FeedbackPages.normalizeFeedbackList({ rows: [feedbackFixture()] }), []);
});
test('详情只精确匹配 URL 中的反馈 ID 且不存在时不回退', () => {
const secondId = '2062179707935264770';
const list = [feedbackFixture(), feedbackFixture({ feedbackId: secondId, feedbackContent: '第二条' })];
assert.equal(FeedbackPages.getFeedbackId('?feedbackId=' + secondId), secondId);
assert.equal(FeedbackPages.getFeedbackId('?feedbackId=unsafe'), '');
assert.equal(FeedbackPages.findFeedbackById(list, secondId).feedbackContent, '第二条');
assert.equal(FeedbackPages.findFeedbackById(list, '2062179707935264999'), null);
assert.equal(
FeedbackPages.buildFeedbackDetailUrl(secondId),
'ticket-detail.html?feedbackId=2062179707935264770'
);
});
test('反馈列表和详情转义文本且不暴露内部账号信息', () => {
const item = feedbackFixture({
feedbackContent: '<script>alert(1)</script>',
handleResult: '<img src=x>',
appUserId: 'secret-user',
handlerId: 'secret-handler',
appUserPhone: 'secret-account-phone'
});
const listHtml = FeedbackPages.renderFeedbackList([item], {
detailPage: 'ticket-detail.html'
});
const detailHtml = FeedbackPages.renderFeedbackDetail(item);
assert.match(listHtml, /&lt;script&gt;/);
assert.match(detailHtml, /&lt;img src=x&gt;/);
assert.doesNotMatch(listHtml + detailHtml, /<script|<img|secret-user|secret-handler|secret-account-phone|appUserId|handlerId/);
});
test('两个提交页只提供 AppFeedbackBody 字段并加载真实脚本', () => {
['profile-feedback.html', 'submit-ticket.html'].forEach((page) => {
const source = read(page);
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /data-feedback-form/);
assert.match(source, /name="feedbackType"/);
assert.match(source, /name="feedbackContent"/);
assert.match(source, /name="contactInfo"/);
assert.match(source, /public\/js\/feedback-pages\.js/);
assert.doesNotMatch(source, /name="(?:feedbackTitle|feedbackId|appUserId|handlerId)"/);
});
});
test('我的工单和详情页读取同一反馈集合且不制造处理动作', () => {
const listPage = read('my-tickets.html');
const detailPage = read('ticket-detail.html');
[listPage, detailPage].forEach((source) => {
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /public\/js\/feedback-pages\.js/);
});
assert.match(listPage, /data-feedback-list/);
assert.match(listPage, /data-feedback-refresh/);
assert.match(detailPage, /data-feedback-detail/);
assert.doesNotMatch(detailPage, /回复|追问|删除|关闭工单|name="feedbackId"/);
});
test('反馈运行时提交后重读同一 ID,详情不存在时不回退', () => {
const source = read('public/js/feedback-pages.js');
assert.match(source, /await api\.submitFeedback\(/);
assert.match(source, /await api\.myFeedback\(\)/);
assert.match(source, /提交后无法读取同一反馈记录/);
assert.match(source, /反馈记录不存在或无权查看/);
});
+116
View File
@@ -0,0 +1,116 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const HelpPages = require('../public/js/help-pages.js');
const helpId = '2062179707935264769';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
function helpFixture(overrides) {
return Object.assign({
helpId,
helpCategory: '账号与安全',
helpTitle: '如何修改登录密码?',
helpContent: '进入账号安全页,完成验证后修改密码。',
coverOssId: '2062179707935264701',
sortOrder: 10,
viewCount: 12,
status: '0',
remark: 'internal-only'
}, overrides);
}
test('帮助文章只保留 HelpArticleVo 的安全展示字段并保持长 ID 字符串', () => {
assert.deepEqual(HelpPages.normalizeHelpArticle(helpFixture()), {
helpId,
helpCategory: '账号与安全',
helpTitle: '如何修改登录密码?',
helpContent: '进入账号安全页,完成验证后修改密码。',
viewCount: '12'
});
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({
helpId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({
viewCount: '9007199254740993123'
})).viewCount, '9007199254740993123');
});
test('帮助文章拒绝停用、空标题、空正文和非法浏览量', () => {
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ status: '1' })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ helpTitle: ' ' })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ helpContent: '' })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ viewCount: -1 })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ viewCount: '1.5' })), null);
});
test('帮助文章列表只接受直接数组且任一非法元素使整批失败', () => {
assert.equal(HelpPages.normalizeHelpArticleList([helpFixture()]).length, 1);
assert.deepEqual(HelpPages.normalizeHelpArticleList([]), []);
assert.deepEqual(HelpPages.normalizeHelpArticleList({
rows: [helpFixture()]
}), []);
assert.deepEqual(HelpPages.normalizeHelpArticleList([
helpFixture(),
helpFixture({ helpId: '' })
]), []);
});
test('帮助文章列表和详情转义响应且不暴露 OSS ID、排序、状态和备注', () => {
const article = helpFixture({
helpTitle: '<script>alert(1)</script>',
helpContent: '<img src=x onerror=alert(1)>\n第二行',
helpCategory: '<b>分类</b>',
coverOssId: 'secret-oss',
remark: 'secret-remark'
});
const listHtml = HelpPages.renderHelpArticleList([article]);
const detailHtml = HelpPages.renderHelpArticleDetail(article);
assert.match(listHtml, /&lt;script&gt;/);
assert.match(detailHtml, /&lt;img src=x onerror=alert\(1\)&gt;<br \/>第二行/);
assert.doesNotMatch(listHtml + detailHtml, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
});
test('帮助文章详情必须与列表中点击的真实 helpId 一致', async () => {
const calls = [];
const api = {
async helpArticleDetail(id) {
calls.push(id);
return helpFixture();
}
};
assert.equal((await HelpPages.loadHelpArticleDetail(api, helpId)).helpId, helpId);
assert.deepEqual(calls, [helpId]);
await assert.rejects(
HelpPages.loadHelpArticleDetail({
async helpArticleDetail() {
return helpFixture({ helpId: '2062179707935264770' });
}
}, helpId),
/详情响应与请求编号不一致/
);
});
test('帮助中心只把缺失登录态和 401 视为登录失效', () => {
assert.equal(HelpPages.shouldRedirectToLogin(null), true);
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return ''; } }), true);
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 401 }), true);
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
});
test('帮助中心使用真实 PC 列表和详情,不再展示硬编码问答', () => {
const page = read('help.html');
assert.match(page, /data-help-list/);
assert.match(page, /data-help-status/);
assert.match(page, /public\/js\/help-pages\.js/);
assert.doesNotMatch(page, /如何创建第一本家谱|如何邀请亲人一起维护/);
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
});
+189
View File
@@ -0,0 +1,189 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const JoinPages = require('../public/js/join-pages.js');
const genealogyId = '2062179707935264769';
const applyId = '2062179707935264770';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
test('加入申请只构造 AppGenealogyJoinApplyBody 并裁剪可选文本', () => {
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' 叶子 ',
phone: ' 19181970173 ',
relationDesc: ' 族亲 ',
applyReason: ' 申请加入 ',
inviterUserId: 'must-drop',
genealogyId: 'must-drop'
}), {
applicantName: '叶子',
phone: '19181970173',
relationDesc: '族亲',
applyReason: '申请加入'
});
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' ',
phone: '',
relationDesc: null
}), {});
});
test('加入申请按后端长度约束校验四个可选字段', () => {
assert.equal(JoinPages.validateJoinApplyBody({ applicantName: '叶'.repeat(50) }), '');
assert.match(JoinPages.validateJoinApplyBody({ applicantName: '叶'.repeat(51) }), /50/);
assert.equal(JoinPages.validateJoinApplyBody({ phone: '1'.repeat(30) }), '');
assert.match(JoinPages.validateJoinApplyBody({ phone: '1'.repeat(31) }), /30/);
assert.equal(JoinPages.validateJoinApplyBody({ relationDesc: '亲'.repeat(100) }), '');
assert.match(JoinPages.validateJoinApplyBody({ relationDesc: '亲'.repeat(101) }), /100/);
assert.equal(JoinPages.validateJoinApplyBody({ applyReason: '因'.repeat(500) }), '');
assert.match(JoinPages.validateJoinApplyBody({ applyReason: '因'.repeat(501) }), /500/);
});
test('可申请家谱必须提供稳定字符串 ID、谱名和姓氏,并拒绝停用家谱', () => {
assert.deepEqual(JoinPages.normalizeJoinGenealogy({
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
regionFullName: '四川省',
memberCount: 12,
joinMode: '0',
status: '0',
ownerUserId: 'must-drop'
}), {
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
regionFullName: '四川省',
memberCount: 12,
joinMode: '0'
});
assert.equal(JoinPages.normalizeJoinGenealogy({
genealogyId: Number.MAX_SAFE_INTEGER + 1,
genealogyName: '叶氏家谱',
surname: '叶',
status: '0'
}), null);
assert.equal(JoinPages.normalizeJoinGenealogy({
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
status: '1'
}), null);
});
test('我的申请只接收稳定 ID 和 0/1/2/3 状态', () => {
assert.deepEqual(JoinPages.normalizeJoinApply({
applyId,
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
relationDesc: '族亲',
applyReason: '申请加入',
auditRemark: '已核实',
auditTime: '2026-07-29 12:00:00',
status: '1',
appUserId: 'must-drop',
inviterUserId: 'must-drop',
auditUserId: 'must-drop',
appUserPhone: 'must-drop'
}), {
applyId,
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
relationDesc: '族亲',
applyReason: '申请加入',
auditRemark: '已核实',
auditTime: '2026-07-29 12:00:00',
status: '1'
});
assert.equal(JoinPages.normalizeJoinApply({
applyId: Number.MAX_SAFE_INTEGER + 1,
genealogyId,
genealogyName: '叶氏家谱',
status: '0'
}), null);
assert.equal(JoinPages.normalizeJoinApply({
applyId,
genealogyId,
genealogyName: '叶氏家谱',
status: '9'
}), null);
});
test('申请数组只要包含非法元素就整批拒绝', () => {
const valid = { applyId, genealogyId, genealogyName: '叶氏家谱', status: '0' };
assert.equal(JoinPages.normalizeJoinApplies([valid]).length, 1);
assert.deepEqual(JoinPages.normalizeJoinApplies([valid, { status: '0' }]), []);
assert.deepEqual(JoinPages.normalizeJoinApplies({ rows: [valid] }), []);
});
test('普通用户列表隐藏内部字段且只有待审核申请可以撤销', () => {
const html = JoinPages.renderMyJoinApplies([{
applyId,
genealogyId,
genealogyName: '<叶氏家谱>',
surname: '叶',
relationDesc: '族亲',
applyReason: '申请加入',
status: '0',
appUserId: 'secret-user',
inviterUserId: 'secret-inviter',
auditUserId: 'secret-auditor',
appUserPhone: 'secret-phone'
}]);
const approvedHtml = JoinPages.renderMyJoinApplies([{
applyId,
genealogyId,
genealogyName: '叶氏家谱',
status: '1'
}]);
assert.match(html, /&lt;叶氏家谱&gt;/);
assert.match(html, new RegExp('data-join-cancel-id="' + applyId + '"'));
assert.doesNotMatch(approvedHtml, /data-join-cancel-id/);
assert.doesNotMatch(html, /secret-user|secret-inviter|secret-auditor|secret-phone|must-drop/);
});
test('申请加入页由后端家谱选项驱动且不允许手填业务 ID 或邀请码', () => {
const source = read('join-genealogy.html');
assert.match(source, /data-join-apply-page/);
assert.match(source, /data-join-genealogy-search/);
assert.match(source, /data-join-genealogy-options/);
assert.match(source, /data-join-apply-form/);
assert.match(source, /name="applicantName"/);
assert.match(source, /name="phone"/);
assert.match(source, /name="relationDesc"/);
assert.match(source, /name="applyReason"/);
assert.match(source, /public\/js\/join-pages\.js/);
assert.doesNotMatch(source, /name="(?:genealogyId|applyId|inviterUserId|inviteCode)"/);
assert.doesNotMatch(source, /邀请码|分享码|手工.*ID|输入.*ID/);
});
test('我的加入申请页加载真实列表和刷新入口', () => {
const source = read('profile-join-family.html');
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /data-my-join-applies-page/);
assert.match(source, /data-join-apply-list="mine"/);
assert.match(source, /data-join-apply-refresh/);
assert.match(source, /public\/js\/join-pages\.js/);
assert.doesNotMatch(source, /邀请码|分享码|输入.*ID/);
});
test('加入申请运行时提交与撤销后都重新读取我的申请', () => {
const source = read('public/js/join-pages.js');
assert.match(source, /await api\.applyToGenealogy\(/);
assert.match(source, /await api\.myGenealogyJoinApplies\(\)/);
assert.match(source, /await api\.cancelGenealogyJoinApply\(/);
assert.match(source, /提交后无法读取同一申请/);
assert.match(source, /撤销后申请仍处于待审核状态/);
});
+82
View File
@@ -0,0 +1,82 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const JoinReviewPages = require('../public/js/join-review-pages.js');
const genealogyId = '2062179707935264769';
const applyId = '2062179707935264770';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
test('审核页只从当前家谱上下文读取稳定字符串 ID', () => {
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=' + genealogyId), genealogyId);
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=unsafe'), '');
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=1e21'), '');
});
test('审核请求只构造 status 和可选审核说明', () => {
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({
status: '2',
auditRemark: ' 资料不一致 ',
applyId: 'must-drop',
genealogyId: 'must-drop'
}), {
status: '2',
auditRemark: '资料不一致'
});
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({ status: '1', auditRemark: ' ' }), {
status: '1'
});
assert.equal(JoinReviewPages.validateJoinAuditBody({ status: '1' }), '');
assert.equal(JoinReviewPages.validateJoinAuditBody({ status: '2', auditRemark: '因'.repeat(500) }), '');
assert.match(JoinReviewPages.validateJoinAuditBody({ status: '0' }), /审核状态/);
assert.match(JoinReviewPages.validateJoinAuditBody({ status: '2', auditRemark: '因'.repeat(501) }), /500/);
});
test('待审核列表隐藏内部 ID 和账号手机号,只有管理者可见操作', () => {
const data = [{
applyId,
genealogyId,
genealogyName: '叶氏家谱',
applicantName: '<叶子>',
phone: '19181970173',
relationDesc: '族亲',
applyReason: '申请加入',
status: '0',
appUserId: 'secret-user',
appUserPhone: 'secret-account-phone',
inviterUserId: 'secret-inviter',
auditUserId: 'secret-auditor'
}];
const managerHtml = JoinReviewPages.renderPendingJoinApplies(data, true);
const memberHtml = JoinReviewPages.renderPendingJoinApplies(data, false);
assert.match(managerHtml, /&lt;叶子&gt;/);
assert.match(managerHtml, /19181970173/);
assert.match(managerHtml, new RegExp('data-join-audit-id="' + applyId + '"'));
assert.match(managerHtml, /data-join-audit-status="1"/);
assert.match(managerHtml, /data-join-audit-status="2"/);
assert.doesNotMatch(memberHtml, /data-join-audit-id|data-join-audit-status/);
assert.doesNotMatch(managerHtml, /secret-user|secret-account-phone|secret-inviter|secret-auditor/);
});
test('审核页开放真实 PC 管理行为且不允许手填业务 ID', () => {
const page = read('profile-join-review.html');
const script = read('public/js/join-review-pages.js');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
assert.match(page, /data-join-review-page/);
assert.match(page, /data-join-apply-list="pending"/);
assert.match(page, /data-join-review-refresh/);
assert.match(page, /public\/js\/join-review-pages\.js/);
assert.doesNotMatch(page, /name="(?:genealogyId|applyId|appUserId|inviterUserId|auditUserId)"/);
assert.match(script, /await api\.genealogyDetail\(/);
assert.match(script, /detail\.canManage === true/);
assert.match(script, /await api\.auditGenealogyJoinApply\(/);
assert.match(script, /await api\.pendingGenealogyJoinApplies\(/);
assert.match(script, /审核后申请仍在待审核列表/);
});
+16
View File
@@ -10,6 +10,22 @@ test('世系页只从 URL 读取真实家谱编号', () => {
assert.equal(LineagePages.getCurrentGenealogyId(''), '');
});
test('世系管理页和家谱主页复用同一棵安全树的 HTML', () => {
const html = LineagePages.renderLineageTreeHtml([{
personId: '2062179707935264770',
genealogyId: '2062179707935264769',
name: '<始祖>',
generationName: '始',
spouses: [],
children: []
}]);
assert.match(html, /&lt;始祖&gt;/);
assert.match(html, /data-lineage-person="2062179707935264770"/);
assert.doesNotMatch(html, /2062179707935264769/);
assert.match(LineagePages.renderLineageTreeHtml([]), /暂无世系树/);
});
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
assert.deepEqual(
LineagePages.buildLineagePersonBody({
+15 -3
View File
@@ -36,9 +36,9 @@ test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
test('pages do not load scripts for unavailable business APIs', () => {
const removedScripts = [
'feedback-pages.js', 'genealogy-pages.js',
'help-pages.js', 'join-apply-pages.js',
'promotion-pages.js', 'vip-pages.js'
'genealogy-pages.js',
'join-apply-pages.js',
'promotion-pages.js'
];
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
@@ -53,6 +53,17 @@ test('pages do not load scripts for unavailable business APIs', () => {
assert.equal(read(page).includes('src="public/js/feed-pages.js"'), true, `${page} must load feed-pages.js`);
});
assert.equal(read('profile-generation.html').includes('src="public/js/generation-pages.js"'), true, 'profile-generation.html must load generation-pages.js');
assert.equal(read('join-genealogy.html').includes('src="public/js/join-pages.js"'), true, 'join-genealogy.html must load join-pages.js');
assert.equal(read('profile-join-family.html').includes('src="public/js/join-pages.js"'), true, 'profile-join-family.html must load join-pages.js');
assert.equal(read('profile-join-review.html').includes('src="public/js/join-review-pages.js"'), true, 'profile-join-review.html must load join-review-pages.js');
['profile-feedback.html', 'submit-ticket.html', 'my-tickets.html', 'ticket-detail.html'].forEach((page) => {
assert.equal(read(page).includes('src="public/js/feedback-pages.js"'), true, `${page} must load feedback-pages.js`);
});
assert.equal(read('help.html').includes('src="public/js/help-pages.js"'), true, 'help.html must load help-pages.js');
assert.equal(read('app.html').includes('src="public/js/app-promotion-pages.js"'), true, 'app.html must load app-promotion-pages.js');
['news.html', 'article-detail.html'].forEach((page) => {
assert.equal(read(page).includes('src="public/js/site-news-pages.js"'), true, `${page} must load site-news-pages.js`);
});
assert.equal(read('profile-tree.html').includes('src="public/js/lineage-pages.js"'), true, 'profile-tree.html must load lineage-pages.js');
assert.equal(read('profile-family-admin.html').includes('src="public/js/member-admin-pages.js"'), true, 'profile-family-admin.html must load member-admin-pages.js');
assert.equal(read('profile-video.html').includes('src="public/js/video-pages.js"'), true, 'profile-video.html must load video-pages.js');
@@ -60,4 +71,5 @@ test('pages do not load scripts for unavailable business APIs', () => {
assert.equal(read('profile-ceremony.html').includes('src="public/js/ceremony-admin-pages.js"'), true, 'profile-ceremony.html must load ceremony-admin-pages.js');
assert.equal(read('profile-relative.html').includes('src="public/js/relative-pages.js"'), true, 'profile-relative.html must load relative-pages.js');
assert.equal(read('profile-memo.html').includes('src="public/js/memo-pages.js"'), true, 'profile-memo.html must load memo-pages.js');
assert.equal(read('profile-services.html').includes('src="public/js/vip-pages.js"'), true, 'profile-services.html must load vip-pages.js');
});
+8 -8
View File
@@ -7,21 +7,16 @@ const PageAvailability = require('../public/js/pending-pages.js');
const root = path.resolve(__dirname, '..');
const familyPendingPages = [
'profile-join-family.html',
'profile-join-review.html',
'profile-invite.html',
];
const contentPendingPages = [
'profile-feedback.html',
'profile-admin-permissions.html',
'profile-data-reminders.html'
];
const residualPendingPages = [
'profile-family-home.html',
'profile-share.html',
'profile-services.html'
'profile-share.html'
];
const pendingPages = familyPendingPages.concat(contentPendingPages, residualPendingPages);
@@ -53,12 +48,17 @@ test('待开发页面显式加载状态脚本', () => {
});
test('已接入的家谱业务页面保持真实功能状态', () => {
['profile-families.html', 'profile-create-family.html', 'profile-family-admin.html', 'profile-content.html', 'profile-feed.html', 'profile-feed-edit.html', 'profile-feed-detail.html', 'profile-generation.html', 'profile-tree.html', 'profile-article.html', 'profile-article-edit.html', 'profile-album.html', 'profile-album-edit.html', 'profile-album-detail.html', 'profile-video.html', 'profile-video-edit.html', 'profile-gift.html', 'profile-gift-edit.html', 'profile-ceremony.html', 'profile-ceremony-detail.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html', 'profile-merit-edit.html', 'profile-messages.html'].forEach((page) => {
['profile-families.html', 'profile-create-family.html', 'profile-join-family.html', 'profile-join-review.html', 'profile-family-home.html', 'profile-family-admin.html', 'profile-feedback.html', 'profile-services.html', 'profile-content.html', 'profile-feed.html', 'profile-feed-edit.html', 'profile-feed-detail.html', 'profile-generation.html', 'profile-tree.html', 'profile-article.html', 'profile-article-edit.html', 'profile-album.html', 'profile-album-edit.html', 'profile-album-detail.html', 'profile-video.html', 'profile-video-edit.html', 'profile-gift.html', 'profile-gift-edit.html', 'profile-ceremony.html', 'profile-ceremony-detail.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html', 'profile-merit-edit.html', 'profile-messages.html'].forEach((page) => {
assert.doesNotMatch(read(page), /data-feature-status="pending"/, `${page} 不应标记为待开发`);
});
assert.match(read('profile-generation.html'), /src="public\/js\/generation-pages\.js"/);
assert.match(read('profile-families.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-create-family.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-join-family.html'), /src="public\/js\/join-pages\.js"/);
assert.match(read('profile-join-review.html'), /src="public\/js\/join-review-pages\.js"/);
assert.match(read('profile-feedback.html'), /src="public\/js\/feedback-pages\.js"/);
assert.match(read('profile-services.html'), /src="public\/js\/vip-pages\.js"/);
assert.match(read('profile-family-home.html'), /src="public\/js\/family-home-pages\.js"/);
assert.match(read('profile-tree.html'), /src="public\/js\/lineage-pages\.js"/);
assert.match(read('profile-family-admin.html'), /src="public\/js\/member-admin-pages\.js"/);
assert.match(read('profile-article.html'), /src="public\/js\/article-pages\.js"/);
@@ -93,7 +93,7 @@ test('待开发的家谱管理页明确放行已接入的字辈入口并透传
});
test('待开发页面仅放行显式标记的可用功能入口', () => {
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feedback.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-share.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feed.html', 'data-feature-link': 'available' })), false);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
});
+11 -9
View File
@@ -37,14 +37,16 @@ test('官网静态信息页面均有独立入口', () => {
});
});
test('新闻页提供分类与现有详情跳转', () => {
test('新闻页提供真实 PC 文章分类与详情入口', () => {
if (!exists('news.html')) return;
const source = read('news.html');
assert.match(source, /href="#platform"/);
assert.match(source, /href="#culture"/);
assert.match(source, /href="notice-detail\.html"/);
assert.match(source, /href="article-detail\.html"/);
assert.match(source, /href="news\.html"/);
assert.match(source, /href="news\.html\?articleType=news"/);
assert.match(source, /href="news\.html\?articleType=notice"/);
assert.match(source, /href="news\.html\?articleType=download"/);
assert.match(source, /data-site-news-list/);
assert.match(source, /src="public\/js\/site-news-pages\.js"/);
});
test('法律页面明确等待法务确认', () => {
@@ -54,14 +56,14 @@ test('法律页面明确等待法务确认', () => {
});
});
test('工单功能入口显示待开发状态并保留帮助中心跳转', () => {
test('工单功能入口开放真实反馈记录并保留帮助中心跳转', () => {
['submit-ticket.html', 'my-tickets.html', 'ticket-detail.html'].forEach((page) => {
if (!exists(page)) return;
const source = read(page);
assert.match(source, /data-feature-status="pending"/);
assert.match(source, /src="public\/js\/pending-pages\.js"/);
assert.match(source, /href="help\.html"\s+data-feature-link="available"/);
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /src="public\/js\/feedback-pages\.js"/);
assert.match(source, /href="help\.html"/);
});
});
+62
View File
@@ -2,6 +2,7 @@ const assert = require('node:assert/strict');
const test = require('node:test');
const GenealogyApi = require('../utils/ApiClient.js');
const AxiosRequestUtil = require('../utils/AxiosRequestUtil.js');
function createFailingClient(status) {
let storedToken = 'access-token';
@@ -96,3 +97,64 @@ test('business response code 401 clears the stored login token', async () => {
assert.equal(client.getToken(), '');
});
test('public HTTP 401 neither sends nor clears the stored login token', async () => {
let storedToken = 'access-token';
let seenRequest;
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'web-pc',
getToken() {
return storedToken;
},
onUnauthorized() {
storedToken = '';
},
axiosInstance: {
request(config) {
seenRequest = config;
return Promise.reject({
message: 'Request failed',
response: {
status: 401,
data: {
code: 401,
msg: '认证失败'
}
}
});
}
}
});
await assert.rejects(requester('GET', '/public', { auth: false }));
assert.equal(seenRequest.headers.Authorization, undefined);
assert.equal(storedToken, 'access-token');
});
test('public business 401 preserves the stored login token', async () => {
let storedToken = 'access-token';
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'web-pc',
getToken() {
return storedToken;
},
onUnauthorized() {
storedToken = '';
},
axiosInstance: {
request() {
return Promise.resolve({
data: {
code: 401,
msg: '认证失败'
}
});
}
}
});
await assert.rejects(requester('GET', '/public', { auth: false }));
assert.equal(storedToken, 'access-token');
});
+179
View File
@@ -0,0 +1,179 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const SiteNewsPages = require('../public/js/site-news-pages.js');
const newsPage = fs.readFileSync(path.resolve(__dirname, '../news.html'), 'utf8');
const detailPage = fs.readFileSync(path.resolve(__dirname, '../article-detail.html'), 'utf8');
const articleId = '2062179707935264769';
function articleFixture(overrides) {
return Object.assign({
articleId,
articleType: 'notice',
articleTitle: '平台公告',
articleSummary: '公告摘要',
articleContent: '第一行\n第二行',
coverOssId: '2062179707935264701',
externalUrl: 'https://example.com/notice',
publishTime: '2026-07-30 09:00:00',
sortOrder: 1,
status: '0',
remark: 'internal-only'
}, overrides);
}
test('站点文章只保留完整 SiteArticleVo 的安全展示字段和字符串长 ID', () => {
assert.deepEqual(SiteNewsPages.normalizeArticle(articleFixture()), {
articleId,
articleType: 'notice',
articleTitle: '平台公告',
articleSummary: '公告摘要',
articleContent: '第一行\n第二行',
externalUrl: 'https://example.com/notice',
publishTime: '2026-07-30 09:00:00'
});
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({
articleId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleId: '0' })), null);
});
test('站点文章拒绝 SQL 字典外类型、停用记录和空标题', () => {
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleType: 'culture' })), null);
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ status: '1' })), null);
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleTitle: ' ' })), null);
assert.equal(SiteNewsPages.normalizeArticleType('news'), 'news');
assert.equal(SiteNewsPages.normalizeArticleType('notice'), 'notice');
assert.equal(SiteNewsPages.normalizeArticleType('download'), 'download');
assert.equal(SiteNewsPages.normalizeArticleType('culture'), '');
});
test('站点文章列表只接受直接数组且任一非法元素使整批失败', () => {
assert.equal(SiteNewsPages.normalizeArticleList([articleFixture()]).length, 1);
assert.deepEqual(SiteNewsPages.normalizeArticleList([]), []);
assert.deepEqual(SiteNewsPages.normalizeArticleList({ rows: [articleFixture()] }), []);
assert.deepEqual(SiteNewsPages.normalizeArticleList([
articleFixture(),
articleFixture({ articleId: '' })
]), []);
});
test('站点资讯列表使用响应 ID 生成详情链接并隐藏内部字段', () => {
const html = SiteNewsPages.renderArticleList([articleFixture({
articleTitle: '<script>alert(1)</script>',
articleSummary: '<img src=x>',
coverOssId: 'secret-oss',
remark: 'secret-remark'
})]);
assert.match(html, /article-detail\.html\?articleId=2062179707935264769/);
assert.match(html, /公告/);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /&lt;img src=x&gt;/);
assert.doesNotMatch(html, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
assert.match(SiteNewsPages.renderArticleList([]), /当前暂无资讯/);
});
test('站点资讯详情转义正文、保留换行并只开放安全外链', () => {
const html = SiteNewsPages.renderArticleDetail(articleFixture({
articleContent: '<script>alert(1)</script>\n第二行'
}));
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;<br \/>第二行/);
assert.match(html, /href="https:\/\/example\.com\/notice"/);
assert.match(html, /target="_blank"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.doesNotMatch(html, /<script|coverOssId|secret-remark/);
['javascript:alert(1)', 'data:text/html,test', '/notice', 'notice.html', ''].forEach((value) => {
const unsafeHtml = SiteNewsPages.renderArticleDetail(articleFixture({ externalUrl: value }));
assert.doesNotMatch(unsafeHtml, /data-external-link/);
});
});
test('站点文章外链只接受绝对 HTTP 或 HTTPS', () => {
assert.equal(
SiteNewsPages.normalizeExternalUrl(' https://example.com/notice '),
'https://example.com/notice'
);
assert.equal(SiteNewsPages.normalizeExternalUrl('http://example.com'), 'http://example.com/');
assert.equal(SiteNewsPages.normalizeExternalUrl('javascript:alert(1)'), '');
assert.equal(SiteNewsPages.normalizeExternalUrl('/notice'), '');
});
test('资讯列表只提交已确认分类和系统 limit', async () => {
const calls = [];
const api = {
async siteArticles(query) {
calls.push(query);
return [articleFixture()];
}
};
assert.equal((await SiteNewsPages.loadArticles(api, 'notice')).length, 1);
assert.equal((await SiteNewsPages.loadArticles(api, '')).length, 1);
assert.deepEqual(calls, [
{ articleType: 'notice', limit: 100 },
{ limit: 100 }
]);
await assert.rejects(SiteNewsPages.loadArticles(api, 'culture'), /资讯分类无效/);
await assert.rejects(
SiteNewsPages.loadArticles({
async siteArticles() {
return { rows: [articleFixture()] };
}
}, ''),
/资讯列表响应无效/
);
});
test('资讯详情只精确匹配 URL 中由列表产生的文章 ID', async () => {
const api = {
async siteArticles(query) {
assert.deepEqual(query, { limit: 100 });
return [
articleFixture(),
articleFixture({ articleId: '2062179707935264770', articleTitle: '另一条' })
];
}
};
assert.equal((await SiteNewsPages.loadArticleDetail(api, articleId)).articleTitle, '平台公告');
await assert.rejects(
SiteNewsPages.loadArticleDetail(api, '2062179707935264771'),
/资讯不存在或已下线/
);
await assert.rejects(
SiteNewsPages.loadArticleDetail(api, Number.MAX_SAFE_INTEGER + 1),
/资讯编号无效/
);
});
test('详情 URL 只读取安全字符串文章 ID', () => {
assert.equal(SiteNewsPages.readArticleId('?articleId=' + articleId), articleId);
assert.equal(SiteNewsPages.readArticleId('?articleId=0'), '');
assert.equal(SiteNewsPages.readArticleId('?articleId=unsafe'), '');
assert.equal(SiteNewsPages.readArticleId(''), '');
});
test('官网资讯列表与详情页加载同一个公开内容 owner 且不暴露业务 ID', () => {
assert.match(newsPage, /<body[^>]*data-site-news-page/);
assert.match(newsPage, /data-site-news-list/);
assert.match(newsPage, /data-site-news-status/);
assert.match(newsPage, /news\.html\?articleType=news/);
assert.match(newsPage, /news\.html\?articleType=notice/);
assert.match(newsPage, /news\.html\?articleType=download/);
assert.match(newsPage, /src="public\/js\/site-news-pages\.js"/);
assert.match(detailPage, /<body[^>]*data-site-article-page/);
assert.match(detailPage, /<title>资讯详情 - 代代相传<\/title>/);
assert.match(detailPage, /data-site-article-detail/);
assert.match(detailPage, /data-site-article-status/);
assert.match(detailPage, /src="public\/js\/site-news-pages\.js"/);
assert.doesNotMatch(detailPage, /src="public\/js\/article-pages\.js"/);
assert.doesNotMatch(detailPage, /href="article-detail\.html"/);
assert.doesNotMatch(newsPage + detailPage, /name="(?:articleId|coverOssId)"/);
});
+21
View File
@@ -33,3 +33,24 @@ test('个人中心功能入口没有把家谱业务页当作无上下文普通
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少个人中心上下文入口`);
});
});
test('加入申请与管理员审核入口使用真实页面和家谱上下文', () => {
const families = read('profile-families.html');
const familyAdmin = read('profile-family-admin.html');
const profile = read('profile.html');
assert.match(families, /href="profile-join-family\.html"/);
assert.doesNotMatch(families, /邀请码|分享码|手填.*ID/);
assert.match(familyAdmin, /href="profile-join-review\.html"[^>]*data-genealogy-context-link/);
assert.match(profile, /href="profile-join-review\.html"[^>]*data-genealogy-context-link/);
});
test('帮助中心和服务中心开放同一反馈记录闭环', () => {
const help = read('help.html');
const services = read('profile-services.html');
assert.match(help, /href="submit-ticket\.html"/);
assert.match(help, /href="my-tickets\.html"/);
assert.match(services, /href="profile-feedback\.html"[^>]*data-feature-link="available"/);
assert.doesNotMatch(help + services, /手填.*feedbackId|独立.*ticket.*接口/i);
});
+231
View File
@@ -0,0 +1,231 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const VipPages = require('../public/js/vip-pages.js');
const root = path.resolve(__dirname, '..');
const packageId = '2062179707935264769';
const genealogyId = '2062179707935264770';
const orderId = '2062179707935264771';
function packageFixture(overrides) {
return Object.assign({
packageId,
packageName: '家谱年度会员',
packageType: 'vip',
packageDesc: '适合持续维护家谱',
price: '99.00',
originalPrice: '129.00',
durationValue: 1,
durationUnit: 'year',
genealogyLimit: 3,
memberLimit: 500,
storageLimitMb: 10240,
featureJson: '{"export":true}',
sortOrder: 1,
status: '0',
remark: '年度套餐'
}, overrides);
}
function orderFixture(overrides) {
return Object.assign({
orderId,
orderNo: 'VIP202607291200001234',
packageId,
packageName: '家谱年度会员',
appUserId: '2062179707935264701',
appUserNickName: '叶子',
appUserPhone: '19100000000',
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
orderAmount: '99.00',
payAmount: '99.00',
payType: 'wechat',
payStatus: '0',
payTime: null,
expireTime: null,
status: '0',
remark: ''
}, overrides);
}
test('VIP 套餐只保留可展示字段并保持金额和长 ID 字符串', () => {
assert.deepEqual(VipPages.normalizeVipPackage(packageFixture()), {
packageId,
packageName: '家谱年度会员',
packageType: 'vip',
packageDesc: '适合持续维护家谱',
price: '99.00',
originalPrice: '129.00',
durationValue: 1,
durationUnit: 'year',
genealogyLimit: 3,
memberLimit: 500,
storageLimitMb: 10240,
remark: '年度套餐'
});
assert.equal(VipPages.normalizeVipPackage(packageFixture({
packageId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
packageType: 'unknown'
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
durationUnit: 'week'
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
price: '-1.00'
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
status: '1'
})), null);
});
test('套餐列表过滤停用或非法记录', () => {
assert.equal(VipPages.normalizeVipPackages([
packageFixture(),
packageFixture({ packageId: '2062179707935264772', status: '1' })
]).length, 1);
assert.deepEqual(VipPages.normalizeVipPackages({ rows: [packageFixture()] }), []);
});
test('家谱选择项只接受响应中的稳定 ID 和名称', () => {
assert.deepEqual(VipPages.normalizeGenealogyOption({
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
ownerUserId: 'must-drop'
}), {
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶'
});
assert.equal(VipPages.normalizeGenealogyOption({
genealogyId: Number.MAX_SAFE_INTEGER + 1,
genealogyName: '叶氏家谱'
}), null);
assert.equal(VipPages.normalizeGenealogyOption({
genealogyId,
genealogyName: ''
}), null);
});
test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式', () => {
assert.deepEqual(VipPages.buildVipOrderBody({
packageId: ' ' + packageId + ' ',
genealogyId: ' ' + genealogyId + ' ',
payType: '',
orderId: 'must-drop',
appUserId: 'must-drop',
payStatus: 'must-drop'
}), {
packageId,
genealogyId
});
assert.deepEqual(VipPages.buildVipOrderBody({
packageId,
payType: ' wechat '
}), {
packageId,
payType: 'wechat'
});
});
test('订单请求要求安全套餐 ID,并只接受已确认的可选值', () => {
assert.equal(VipPages.validateVipOrderBody({ packageId }), '');
assert.equal(VipPages.validateVipOrderBody({ packageId, genealogyId }), '');
assert.match(VipPages.validateVipOrderBody({ packageId: '' }), /套餐/);
assert.match(VipPages.validateVipOrderBody({
packageId,
genealogyId: 'unsafe'
}), /家谱/);
assert.match(VipPages.validateVipOrderBody({
packageId,
payType: 'alipay'
}), /支付方式/);
});
test('VIP 订单隐藏账号字段并校验金额和状态', () => {
assert.deepEqual(VipPages.normalizeVipOrder(orderFixture()), {
orderId,
orderNo: 'VIP202607291200001234',
packageId,
packageName: '家谱年度会员',
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
orderAmount: '99.00',
payAmount: '99.00',
payType: 'wechat',
payStatus: '0',
payTime: '',
expireTime: '',
status: '0',
remark: ''
});
assert.equal(VipPages.normalizeVipOrder(orderFixture({
orderId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(VipPages.normalizeVipOrder(orderFixture({
payAmount: '-1'
})), null);
assert.equal(VipPages.normalizeVipOrder(orderFixture({
payStatus: '9'
})), null);
assert.equal(VipPages.normalizeVipOrder(orderFixture({
status: '9'
})), null);
});
test('套餐和订单渲染不出现内部账号信息或伪支付操作', () => {
const packagesHtml = VipPages.renderVipPackages([packageFixture({
packageName: '<年度会员>'
})], packageId);
const ordersHtml = VipPages.renderVipOrders([orderFixture({
appUserId: 'secret-user',
appUserPhone: 'secret-phone'
})]);
const combined = packagesHtml + ordersHtml;
assert.match(packagesHtml, /&lt;年度会员&gt;/);
assert.match(packagesHtml, new RegExp('data-vip-package-id="' + packageId + '"'));
assert.match(ordersHtml, /待支付/);
assert.doesNotMatch(combined, /secret-user|secret-phone|appUserId|appUserPhone|立即支付|模拟|取消订单|退款/);
});
test('套餐渲染接受初始化流程已经规范化的数据', () => {
const normalized = VipPages.normalizeVipPackages([packageFixture()]);
const html = VipPages.renderVipPackages(normalized, packageId);
assert.match(html, /家谱年度会员/);
assert.match(html, /data-vip-package-id=/);
assert.doesNotMatch(html, /当前没有可用会员套餐/);
});
test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 ID 或未开放支付操作', () => {
const source = fs.readFileSync(path.join(root, 'profile-services.html'), 'utf8');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.match(source, /src="public\/js\/vip-pages\.js"/);
assert.match(source, /data-vip-package-list/);
assert.match(source, /data-vip-selected-package/);
assert.match(source, /<select[^>]+data-vip-genealogy-options/);
assert.match(source, /data-vip-order-refresh/);
assert.match(source, /data-vip-order-list/);
assert.doesNotMatch(source, /<input[^>]+name="(?:packageId|genealogyId|orderId)"/);
assert.doesNotMatch(source, /name="payType"/);
assert.doesNotMatch(source, /立即支付|模拟支付|取消订单|退款/);
});
test('会员服务运行时并行读取套餐、我的家谱和订单,并在创建后重新读取核验', () => {
const source = fs.readFileSync(path.join(root, 'public/js/vip-pages.js'), 'utf8');
assert.match(source, /Promise\.all\(\[api\.vipPackages\(\), api\.genealogiesMine\(\), api\.vipOrders\(\)\]\)/);
assert.match(source, /created = normalizeVipOrder\(await api\.createVipOrder\(body\)\)/);
assert.match(source, /refreshed = normalizeVipOrders\(await api\.vipOrders\(\)\)/);
assert.match(source, /item\.orderId === created\.orderId/);
});
+13 -15
View File
@@ -7,10 +7,7 @@
<link rel="stylesheet" href="public/css/public.css" />
<link rel="stylesheet" href="public/css/site-info.css" />
</head>
<body
data-feature-status="pending"
data-feature-message="在线工单详情服务正在开发中,请先通过帮助中心了解常见问题。"
>
<body data-feedback-detail-page>
<header class="site-header">
<div class="container nav">
<a class="brand magnetic" href="index.html">
@@ -34,23 +31,19 @@
<div>
<div class="site-info-kicker">Ticket Detail</div>
<h1>工单详情</h1>
<p>工单处理记录、客服回复与进度状态将在工单接口交付后接入</p>
<p>详情来自当前账号的 PC 反馈列表,并按反馈编号精确匹配</p>
</div>
<div class="site-info-mark"></div>
</div>
</section>
<section class="site-info-section alt">
<div class="container">
<div class="site-info-status">
<h2>暂未开放在线详情查询</h2>
<p>当前页面不会读取或展示工单数据,也不会伪造处理进度。</p>
<div class="site-info-list">
<p class="form-note" aria-live="polite" data-feedback-status></p>
<div data-feedback-detail></div>
<div class="site-info-actions">
<a
class="btn ghost magnetic"
href="help.html"
data-feature-link="available"
>返回帮助中心</a
>
<a class="btn primary magnetic" href="my-tickets.html">返回我的工单</a>
<a class="btn ghost magnetic" href="help.html">返回帮助中心</a>
</div>
</div>
</div>
@@ -64,7 +57,12 @@
</div>
</div>
</footer>
<script src="public/js/pending-pages.js"></script>
<script src="config.js"></script>
<script src="utils/StorageUtil.js"></script>
<script src="utils/axios.js"></script>
<script src="utils/AxiosRequestUtil.js"></script>
<script src="utils/ApiClient.js"></script>
<script src="public/js/feedback-pages.js"></script>
<script src="public/js/page-effects.js"></script>
</body>
</html>
+63
View File
@@ -511,6 +511,69 @@
return request('DELETE', '/genealogy/pc/genealogies/join-applies/' +
toRequiredPathId(applyId, '申请编号'));
},
submitFeedback: function (body) {
return request('POST', '/genealogy/pc/feedback', {
body: pickDefined(body, ['feedbackType', 'feedbackContent', 'contactInfo'])
});
},
myFeedback: function () {
return request('GET', '/genealogy/pc/feedback');
},
helpArticles: function (query) {
return request('GET', '/genealogy/pc/help-articles', {
query: pickDefined(query, ['helpCategory'])
});
},
helpArticleDetail: function (helpId) {
return request('GET', '/genealogy/pc/help-articles/' +
toRequiredPathId(helpId, '帮助文章编号'));
},
siteArticles: function (query) {
var source = query || {};
var articleType = source.articleType === undefined || source.articleType === null
? ''
: String(source.articleType).trim();
var limit = source.limit;
var params = {};
if (articleType && ['news', 'notice', 'download'].indexOf(articleType) === -1) {
throw new Error('不支持的资讯类型:' + articleType);
}
if (limit !== undefined &&
(typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)) {
throw new Error('资讯数量限制必须是 1 到 100 的整数');
}
if (articleType) params.articleType = articleType;
if (limit !== undefined) params.limit = limit;
return request('GET', '/genealogy/pc/site/articles', {
auth: false,
query: params
});
},
promotions: function (query) {
var source = query || {};
var platform = source.platform === undefined || source.platform === null
? ''
: String(source.platform).trim();
if (platform && ['all', 'app', 'pc', 'wechat'].indexOf(platform) === -1) {
throw new Error('不支持的推广平台:' + platform);
}
return request('GET', '/genealogy/pc/promotions', {
query: platform ? { platform: platform } : {}
});
},
vipPackages: function () {
return request('GET', '/genealogy/pc/vip/packages');
},
createVipOrder: function (body) {
return request('POST', '/genealogy/pc/vip/orders', {
body: pickDefined(body, ['packageId', 'genealogyId', 'payType'])
});
},
vipOrders: function () {
return request('GET', '/genealogy/pc/vip/orders');
},
notifications: function (query) {
return request('GET', '/genealogy/pc/notifications', {
query: pickDefined(query, ['readStatus'])
+2 -2
View File
@@ -96,7 +96,7 @@
var responseData = error.response && error.response.data;
var status = error.response && error.response.status;
handleUnauthorized(status);
if (req.auth !== false) handleUnauthorized(status);
throw createHttpError((responseData && responseData.msg) || error.message || '接口请求失败', {
status: status,
@@ -108,7 +108,7 @@
try {
return unwrapResponse(response.data);
} catch (error) {
handleUnauthorized(error.code);
if (req.auth !== false) handleUnauthorized(error.code);
throw error;
}
};